diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates.ts b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates.ts index 11b0b8e2fc..45e691f3e3 100644 --- a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates.ts +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates.ts @@ -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"), diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts index 36c7a386f3..12cb17c302 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts @@ -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] diff --git a/apps/web/app/lib/templates.ts b/apps/web/app/lib/templates.ts index 401f2ebee5..684d4f73f4 100644 --- a/apps/web/app/lib/templates.ts +++ b/apps/web/app/lib/templates.ts @@ -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"), diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 82e23f9434..93baa48b1b 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -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 diff --git a/apps/web/lib/i18n/i18n.mock.ts b/apps/web/lib/i18n/i18n.mock.ts index 215a24b3ae..604f73c779 100644 --- a/apps/web/lib/i18n/i18n.mock.ts +++ b/apps/web/lib/i18n/i18n.mock.ts @@ -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 = { diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index de83abecac..4e20957667 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -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.", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index f4b6eaccd9..33b0747ea1 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -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.", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index b6bc7f75a3..3635bda963 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -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.", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index df1df79ede..d5b43515d4 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -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": "言語が見つかりません。始めるには、最初のものを追加してください。", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index c8040c52ff..fe6f243c50 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -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.", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index a426f08ba6..2ab037929e 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -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.", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 38a291c1da..268f380f9f 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -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.", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index a4eacaf46e..8ce1865968 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -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": "没有找到语言。添加第一个以开始。", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 387fafcd34..19168a285c 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -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": "找不到語言。新增第一個語言以開始使用。", diff --git a/apps/web/modules/email/components/preview-email-template.tsx b/apps/web/modules/email/components/preview-email-template.tsx index 1b7c389de3..6c92c61a10 100644 --- a/apps/web/modules/email/components/preview-email-template.tsx +++ b/apps/web/modules/email/components/preview-email-template.tsx @@ -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({ ); - case TSurveyElementTypeEnum.CTA: + case TSurveyElementTypeEnum.CTA: { + const ctaElement = firstQuestion as TSurveyCTAElement; return ( - - {!firstQuestion.required && ( + {ctaElement.buttonExternal && ctaElement.ctaButtonLabel && ctaElement.buttonUrl && ( + - {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}> + + {getLocalizedValue(ctaElement.ctaButtonLabel, defaultLanguageCode)}{" "} + + - )} - - {getLocalizedValue(block?.buttonLabel, defaultLanguageCode)} - - + + )} ); + } case TSurveyElementTypeEnum.Rating: return ( diff --git a/apps/web/modules/survey/components/question-form-input/index.tsx b/apps/web/modules/survey/components/question-form-input/index.tsx index e473690e34..90caa43ec1 100644 --- a/apps/web/modules/survey/components/question-form-input/index.tsx +++ b/apps/web/modules/survey/components/question-form-input/index.tsx @@ -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) => void; updateSurvey?: (data: Partial | Partial) => void; - updateChoice?: (choiceIdx: number, data: Partial) => void; + updateChoice?: (choiceIdx: number, data: Partial) => 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, diff --git a/apps/web/modules/survey/components/question-form-input/utils.ts b/apps/web/modules/survey/components/question-form-input/utils.ts index 1246183e03..2afa5cf621 100644 --- a/apps/web/modules/survey/components/question-form-input/utils.ts +++ b/apps/web/modules/survey/components/question-form-input/utils.ts @@ -108,7 +108,6 @@ export const isValueIncomplete = ( "buttonLabel", "placeholder", "backButtonLabel", - "dismissButtonLabel", ]; // If value is not provided, immediately return false as it cannot be incomplete. diff --git a/apps/web/modules/survey/editor/components/add-question-to-block-button.tsx b/apps/web/modules/survey/editor/components/add-question-to-block-button.tsx index e5425165ff..6e82ec4205 100644 --- a/apps/web/modules/survey/editor/components/add-question-to-block-button.tsx +++ b/apps/web/modules/survey/editor/components/add-question-to-block-button.tsx @@ -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"; diff --git a/apps/web/modules/survey/editor/components/block-card.tsx b/apps/web/modules/survey/editor/components/block-card.tsx index 222650ab28..8b9b45e9d0 100644 --- a/apps/web/modules/survey/editor/components/block-card.tsx +++ b/apps/web/modules/survey/editor/components/block-card.tsx @@ -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 ( + + ); + case TSurveyElementTypeEnum.MultipleChoiceSingle: + return ( + + ); + case TSurveyElementTypeEnum.MultipleChoiceMulti: + return ( + + ); + case TSurveyElementTypeEnum.NPS: + return ( + + ); + case TSurveyElementTypeEnum.CTA: + return ( + + ); + case TSurveyElementTypeEnum.Rating: + return ( + + ); + case TSurveyElementTypeEnum.Consent: + return ( + + ); + case TSurveyElementTypeEnum.Date: + return ( + + ); + case TSurveyElementTypeEnum.PictureSelection: + return ( + + ); + case TSurveyElementTypeEnum.FileUpload: + return ( + + ); + case TSurveyElementTypeEnum.Cal: + return ( + + ); + case TSurveyElementTypeEnum.Matrix: + return ( + + ); + case TSurveyElementTypeEnum.Address: + return ( + + ); + case TSurveyElementTypeEnum.Ranking: + return ( + + ); + case TSurveyElementTypeEnum.ContactInfo: + return ( + + ); + 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 ( @@ -186,11 +477,11 @@ export const BlockCard = ({ - e.stopPropagation()}> + 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 ( 0 && "border-t border-slate-200")}> { if (activeQuestionId !== element.id) { setActiveQuestionId(element.id); @@ -229,7 +519,7 @@ export const BlockCard = ({ @@ -246,25 +536,9 @@ export const BlockCard = ({ )} - {recallToHeadline( - element.headline, - localSurvey, - true, - selectedLanguageCode - )[selectedLanguageCode] - ? formatTextWithSlashes( - getTextContent( - recallToHeadline( - element.headline, - localSurvey, - true, - selectedLanguageCode - )[selectedLanguageCode] ?? "" - ) - ) - : getTSurveyQuestionTypeEnumName(element.type, t)} + {getElementHeadline(element, selectedLanguageCode)} - {!open && ( + {!isOpen && element.type !== TSurveyElementTypeEnum.CTA && ( {element?.required ? t("environments.surveys.edit.required") @@ -302,226 +576,16 @@ export const BlockCard = ({ - - {responseCount > 0 && - [ - TSurveyElementTypeEnum.MultipleChoiceSingle, - TSurveyElementTypeEnum.MultipleChoiceMulti, - TSurveyElementTypeEnum.PictureSelection, - TSurveyElementTypeEnum.Rating, - TSurveyElementTypeEnum.NPS, - TSurveyElementTypeEnum.Ranking, - TSurveyElementTypeEnum.Matrix, - ].includes(element.type) ? ( + + {shouldShowCautionAlert(element.type) && ( {t("environments.surveys.edit.caution_text")} onAlertTrigger()}> {t("common.learn_more")} - ) : null} - {element.type === TSurveyElementTypeEnum.OpenText ? ( - - ) : element.type === TSurveyElementTypeEnum.MultipleChoiceSingle ? ( - - ) : element.type === TSurveyElementTypeEnum.MultipleChoiceMulti ? ( - - ) : element.type === TSurveyElementTypeEnum.NPS ? ( - - ) : element.type === TSurveyElementTypeEnum.CTA ? ( - - ) : element.type === TSurveyElementTypeEnum.Rating ? ( - - ) : element.type === TSurveyElementTypeEnum.Consent ? ( - - ) : element.type === TSurveyElementTypeEnum.Date ? ( - - ) : element.type === TSurveyElementTypeEnum.PictureSelection ? ( - - ) : element.type === TSurveyElementTypeEnum.FileUpload ? ( - - ) : element.type === TSurveyElementTypeEnum.Cal ? ( - - ) : element.type === TSurveyElementTypeEnum.Matrix ? ( - - ) : element.type === TSurveyElementTypeEnum.Address ? ( - - ) : element.type === TSurveyElementTypeEnum.Ranking ? ( - - ) : element.type === TSurveyElementTypeEnum.ContactInfo ? ( - - ) : null} + )} + {renderElementForm(element, questionIdx)} 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 = ({ { - e.stopPropagation(); - onDelete(); + if (!isOnlyBlock) { + e.stopPropagation(); + onDelete(); + } }} className="h-8 w-8"> diff --git a/apps/web/modules/survey/editor/components/block-settings.tsx b/apps/web/modules/survey/editor/components/block-settings.tsx index c56327b866..88b5ef7fcd 100644 --- a/apps/web/modules/survey/editor/components/block-settings.tsx +++ b/apps/web/modules/survey/editor/components/block-settings.tsx @@ -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; diff --git a/apps/web/modules/survey/editor/components/editor-card-menu.tsx b/apps/web/modules/survey/editor/components/editor-card-menu.tsx index 565555b06c..70a68af983 100644 --- a/apps/web/modules/survey/editor/components/editor-card-menu.tsx +++ b/apps/web/modules/survey/editor/components/editor-card-menu.tsx @@ -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 diff --git a/apps/web/modules/survey/editor/components/logic-editor-actions.tsx b/apps/web/modules/survey/editor/components/logic-editor-actions.tsx index f52a8dc5e7..997d5f11e9 100644 --- a/apps/web/modules/survey/editor/components/logic-editor-actions.tsx +++ b/apps/web/modules/survey/editor/components/logic-editor-actions.tsx @@ -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, { diff --git a/apps/web/modules/survey/editor/components/logic-editor.tsx b/apps/web/modules/survey/editor/components/logic-editor.tsx index 569eb3c548..9c74711791 100644 --- a/apps/web/modules/survey/editor/components/logic-editor.tsx +++ b/apps/web/modules/survey/editor/components/logic-editor.tsx @@ -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(); - - // 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 ( @@ -133,15 +115,12 @@ export function LogicEditor({ - {t("environments.surveys.edit.next_question")} + {t("environments.surveys.edit.next_block")} {fallbackOptions.map((option) => ( - - {option.icon} - {option.label} - + {option.label} ))} diff --git a/apps/web/modules/survey/editor/components/open-question-form.tsx b/apps/web/modules/survey/editor/components/open-question-form.tsx index 9512e70cbd..a467681acf 100644 --- a/apps/web/modules/survey/editor/components/open-question-form.tsx +++ b/apps/web/modules/survey/editor/components/open-question-form.tsx @@ -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"} diff --git a/apps/web/modules/survey/editor/components/question-option-choice.tsx b/apps/web/modules/survey/editor/components/question-option-choice.tsx index 6e7f4a910a..126a5cf972 100644 --- a/apps/web/modules/survey/editor/components/question-option-choice.tsx +++ b/apps/web/modules/survey/editor/components/question-option-choice.tsx @@ -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; diff --git a/apps/web/modules/survey/editor/components/questions-view.tsx b/apps/web/modules/survey/editor/components/questions-view.tsx index b9a2ec9243..5b392ea56c 100644 --- a/apps/web/modules/survey/editor/components/questions-view.tsx +++ b/apps/web/modules/survey/editor/components/questions-view.tsx @@ -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; } }; diff --git a/apps/web/modules/survey/editor/components/rating-question-form.tsx b/apps/web/modules/survey/editor/components/rating-question-form.tsx index ce1161da54..0e221d9aeb 100644 --- a/apps/web/modules/survey/editor/components/rating-question-form.tsx +++ b/apps/web/modules/survey/editor/components/rating-question-form.tsx @@ -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 = ({ - - {!question.required && ( - - - - )} - - {question.scale !== "star" && ( { - const translations: Record = { - "environments.surveys.edit.untitled_block": "Untitled Block", - }; - return translations[key] || key; -}) as TFunction; +vi.mock("@paralleldrive/cuid2", () => ({ + createId: vi.fn(() => "test-cuid-" + Math.random().toString(36).substring(7)), +})); -// Helper to create a mock survey -const createMockSurvey = (): TSurvey => ({ - id: "test-survey-id", - name: "Test Survey", - type: "link", - environmentId: "test-env-id", - createdBy: null, - status: "draft", - welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false }, - questions: [], - endings: [], - hiddenFields: { enabled: false }, - variables: [], - displayOption: "displayOnce", - recontactDays: null, - displayLimit: null, - autoClose: null, - delay: 0, - displayPercentage: null, - autoComplete: null, - isVerifyEmailEnabled: false, - isSingleResponsePerEmailEnabled: false, - projectOverwrites: null, - styling: null, - surveyClosedMessage: null, - singleUse: null, - pin: null, - languages: [], - showLanguageSwitch: null, - segment: null, - triggers: [], +const mockT = ((key: string) => key) as never; + +const createMockElement = (id: string): TSurveyElement => ({ + id, + type: TSurveyElementTypeEnum.OpenText, + headline: { default: "Test Question" }, + required: false, + inputType: "text", + longAnswer: true, + charLimit: { enabled: false }, +}); + +const createMockBlock = (id: string, name: string, elements: TSurveyElement[] = []): TSurveyBlock => ({ + id, + name, + elements, +}); + +const createMockSurvey = (blocks: TSurveyBlock[] = []): TSurvey => ({ + id: "survey-1", createdAt: new Date(), updatedAt: new Date(), - blocks: [ - { - id: "block-1", - name: "Block 1", - elements: [ - { - id: "elem-1", - type: TSurveyElementTypeEnum.OpenText, - headline: { default: "Question 1" }, - required: true, - inputType: "text", - } as any, - { - id: "elem-2", - type: TSurveyElementTypeEnum.OpenText, - headline: { default: "Question 2" }, - required: false, - inputType: "email", - } as any, - ], - }, - { - id: "block-2", - name: "Block 2", - elements: [ - { - id: "elem-3", - type: TSurveyElementTypeEnum.Rating, - headline: { default: "Rate us" }, - required: true, - scale: "star", - range: 5, - } as any, - ], - }, - ], + name: "Test Survey", + type: "link", + environmentId: "env-1", + createdBy: null, + status: "draft", + displayOption: "respondMultiple", + autoClose: null, + triggers: [], + recontactDays: null, + displayLimit: null, + welcomeCard: { + enabled: false, + headline: { default: "Welcome" }, + timeToFinish: false, + showResponseCount: false, + }, + questions: [], + blocks, + endings: [], + hiddenFields: { enabled: false, fieldIds: [] }, + variables: [], + styling: null, + segment: null, + languages: [], + displayPercentage: null, + isVerifyEmailEnabled: false, + isSingleResponsePerEmailEnabled: false, + singleUse: null, + pin: null, + projectOverwrites: null, + surveyClosedMessage: null, followUps: [], + delay: 0, + autoComplete: null, + showLanguageSwitch: null, recaptcha: null, isBackButtonHidden: false, metadata: {}, }); -describe("Block Utility Functions", () => { - describe("isElementIdUnique", () => { - test("should return true for unique element ID", () => { - const survey = createMockSurvey(); - const isUnique = isElementIdUnique("new-elem", survey.blocks); - expect(isUnique).toBe(true); - }); +describe("isElementIdUnique", () => { + test("should return true for a unique element ID", () => { + const blocks = [ + createMockBlock("block-1", "Block 1", [createMockElement("q1")]), + createMockBlock("block-2", "Block 2", [createMockElement("q2")]), + ]; - test("should return false for duplicate element ID", () => { - const survey = createMockSurvey(); - const isUnique = isElementIdUnique("elem-1", survey.blocks); - expect(isUnique).toBe(false); - }); + expect(isElementIdUnique("q3", blocks)).toBe(true); + }); + + test("should return false for a duplicate element ID", () => { + const blocks = [ + createMockBlock("block-1", "Block 1", [createMockElement("q1")]), + createMockBlock("block-2", "Block 2", [createMockElement("q2")]), + ]; + + expect(isElementIdUnique("q1", blocks)).toBe(false); + expect(isElementIdUnique("q2", blocks)).toBe(false); + }); + + test("should return true for empty blocks", () => { + expect(isElementIdUnique("q1", [])).toBe(true); }); }); -describe("Block Operations", () => { - describe("addBlock", () => { - test("should add a block to the end by default", () => { - const survey = createMockSurvey(); - const result = addBlock(mockT, survey, { name: "Block 3", elements: [] }); +describe("findElementLocation", () => { + test("should find element location correctly", () => { + const element1 = createMockElement("q1"); + const element2 = createMockElement("q2"); + const block1 = createMockBlock("block-1", "Block 1", [element1]); + const block2 = createMockBlock("block-2", "Block 2", [element2]); + const survey = createMockSurvey([block1, block2]); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks.length).toBe(3); - expect(result.data.blocks[2].name).toBe("Block 3"); - expect(result.data.blocks[2].id).toBeTruthy(); - } - }); + const result = findElementLocation(survey, "q2"); - test("should add a block at specific index", () => { - const survey = createMockSurvey(); - const result = addBlock(mockT, survey, { name: "Block 1.5", elements: [] }, 1); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks.length).toBe(3); - expect(result.data.blocks[1].name).toBe("Block 1.5"); - } - }); - - test("should return error for invalid index", () => { - const survey = createMockSurvey(); - const result = addBlock(mockT, survey, { name: "Block X", elements: [] }, 10); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("Invalid index"); - } - }); - - test("should use default name if not provided", () => { - const survey = createMockSurvey(); - const result = addBlock(mockT, survey, { elements: [] }); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks[2].name).toBe("Untitled Block"); - } - }); + expect(result.blockId).toBe("block-2"); + expect(result.blockIndex).toBe(1); + expect(result.elementIndex).toBe(0); + expect(result.block).toEqual(block2); }); - describe("updateBlock", () => { - test("should update block attributes", () => { - const survey = createMockSurvey(); - const result = updateBlock(survey, "block-1", { name: "Updated Block 1" }); + test("should return null values when element is not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks[0].name).toBe("Updated Block 1"); - } - }); + const result = findElementLocation(survey, "nonexistent"); - test("should return error for non-existent block", () => { - const survey = createMockSurvey(); - const result = updateBlock(survey, "non-existent", { name: "Updated" }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("not found"); - } - }); + expect(result.blockId).toBe(null); + expect(result.blockIndex).toBe(-1); + expect(result.elementIndex).toBe(-1); + expect(result.block).toBe(null); }); - describe("deleteBlock", () => { - test("should delete a block", () => { - const survey = createMockSurvey(); - const result = deleteBlock(survey, "block-1"); + test("should find element in the middle of multiple elements", () => { + const elements = [createMockElement("q1"), createMockElement("q2"), createMockElement("q3")]; + const block = createMockBlock("block-1", "Block 1", elements); + const survey = createMockSurvey([block]); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks.length).toBe(1); - expect(result.data.blocks[0].id).toBe("block-2"); - } - }); + const result = findElementLocation(survey, "q2"); - test("should return error for non-existent block", () => { - const survey = createMockSurvey(); - const result = deleteBlock(survey, "non-existent"); + expect(result.blockId).toBe("block-1"); + expect(result.blockIndex).toBe(0); + expect(result.elementIndex).toBe(1); + }); +}); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("not found"); - } - }); +describe("addBlock", () => { + test("should add a block to empty survey", () => { + const survey = createMockSurvey([]); + const result = addBlock(mockT, survey, { name: "New Block" }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks).toHaveLength(1); + expect(result.data.blocks[0].name).toBe("New Block"); + expect(result.data.blocks[0].elements).toEqual([]); + } }); - describe("duplicateBlock", () => { - test("should duplicate a block with new IDs", () => { - const survey = createMockSurvey(); - const result = duplicateBlock(survey, "block-1"); + test("should append block to end by default", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const result = addBlock(mockT, survey, { name: "Block 2" }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks.length).toBe(3); - const duplicated = result.data.blocks[1]; - expect(duplicated.name).toBe("Block 1 (copy)"); - expect(duplicated.id).not.toBe("block-1"); - expect(duplicated.elements.length).toBe(2); - // Element IDs should be different - expect(duplicated.elements[0].id).not.toBe("elem-1"); - } + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks).toHaveLength(2); + expect(result.data.blocks[1].name).toBe("Block 2"); + } + }); + + test("should insert block at specific index", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1"), + createMockBlock("block-2", "Block 2"), + ]); + const result = addBlock(mockT, survey, { name: "Block 1.5" }, 1); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks).toHaveLength(3); + expect(result.data.blocks[1].name).toBe("Block 1.5"); + expect(result.data.blocks[0].name).toBe("Block 1"); + expect(result.data.blocks[2].name).toBe("Block 2"); + } + }); + + test("should use default name if not provided", () => { + const survey = createMockSurvey([]); + const result = addBlock(mockT, survey, {}); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].name).toBe("environments.surveys.edit.untitled_block"); + } + }); + + test("should return error for invalid index", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const result = addBlock(mockT, survey, { name: "Invalid" }, 10); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain("Invalid index"); + } + }); + + test("should return error for negative index", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const result = addBlock(mockT, survey, { name: "Invalid" }, -1); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain("Invalid index"); + } + }); +}); + +describe("updateBlock", () => { + test("should update block name", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Old Name")]); + const result = updateBlock(survey, "block-1", { name: "New Name" }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].name).toBe("New Name"); + } + }); + + test("should update multiple block attributes", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const result = updateBlock(survey, "block-1", { + name: "Updated", + buttonLabel: { default: "Next" }, }); - test("should clear logic on duplicated block", () => { - const survey = createMockSurvey(); - survey.blocks[0].logic = [ - { - id: "logic-1", - conditions: { connector: "and", conditions: [] }, - actions: [], + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].name).toBe("Updated"); + expect(result.data.blocks[0].buttonLabel).toEqual({ default: "Next" }); + } + }); + + test("should return error when block not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const result = updateBlock(survey, "nonexistent", { name: "Updated" }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Block with ID "nonexistent" not found'); + } + }); + + test("should return error when trying to update id", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const result = updateBlock(survey, "block-1", { id: "new-id" } as any); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe("Block ID cannot be updated"); + } + }); +}); + +describe("deleteBlock", () => { + test("should delete a block", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1"), + createMockBlock("block-2", "Block 2"), + ]); + const result = deleteBlock(survey, "block-1"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks).toHaveLength(1); + expect(result.data.blocks[0].id).toBe("block-2"); + } + }); + + test("should return error when trying to delete the last block", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const result = deleteBlock(survey, "block-1"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe("Cannot delete the last block in the survey"); + } + }); + + test("should return error when block not found", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1"), + createMockBlock("block-2", "Block 2"), + ]); + const result = deleteBlock(survey, "nonexistent"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Block with ID "nonexistent" not found'); + } + }); + + test("should handle deleting from empty survey", () => { + const survey = createMockSurvey([]); + const result = deleteBlock(survey, "block-1"); + + expect(result.ok).toBe(false); + }); +}); + +describe("duplicateBlock", () => { + test("should duplicate a block with new IDs", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [createMockElement("q1"), createMockElement("q2")]), + ]); + const result = duplicateBlock(survey, "block-1"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks).toHaveLength(2); + expect(result.data.blocks[1].name).toBe("Block 1 (copy)"); + expect(result.data.blocks[1].id).not.toBe("block-1"); + expect(result.data.blocks[1].elements[0].id).not.toBe("q1"); + expect(result.data.blocks[1].elements[1].id).not.toBe("q2"); + expect(result.data.blocks[1].elements[0].isDraft).toBe(true); + expect(result.data.blocks[1].elements[1].isDraft).toBe(true); + } + }); + + test("should clear logic when duplicating", () => { + const blockWithLogic = createMockBlock("block-1", "Block 1", [createMockElement("q1")]); + blockWithLogic.logic = [ + { + id: "logic-1", + conditions: { + id: "cond-1", + connector: "and", + conditions: [], }, - ] as any; + actions: [], + }, + ]; + blockWithLogic.logicFallback = "block-2"; - const result = duplicateBlock(survey, "block-1"); + const survey = createMockSurvey([blockWithLogic]); + const result = duplicateBlock(survey, "block-1"); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks[1].logic).toBeUndefined(); - } - }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[1].logic).toBeUndefined(); + expect(result.data.blocks[1].logicFallback).toBeUndefined(); + } }); - describe("moveBlock", () => { - test("should move block down", () => { - const survey = createMockSurvey(); - const result = moveBlock(survey, "block-1", "down"); + test("should insert duplicate after original block", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1"), + createMockBlock("block-2", "Block 2"), + createMockBlock("block-3", "Block 3"), + ]); + const result = duplicateBlock(survey, "block-2"); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks[0].id).toBe("block-2"); - expect(result.data.blocks[1].id).toBe("block-1"); - } - }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks).toHaveLength(4); + expect(result.data.blocks[2].name).toBe("Block 2 (copy)"); + expect(result.data.blocks[1].id).toBe("block-2"); + expect(result.data.blocks[3].id).toBe("block-3"); + } + }); - test("should move block up", () => { - const survey = createMockSurvey(); - const result = moveBlock(survey, "block-2", "up"); + test("should return error when block not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const result = duplicateBlock(survey, "nonexistent"); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks[0].id).toBe("block-2"); - expect(result.data.blocks[1].id).toBe("block-1"); - } - }); - - test("should return unchanged survey when moving first block up", () => { - const survey = createMockSurvey(); - const result = moveBlock(survey, "block-1", "up"); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks[0].id).toBe("block-1"); - } - }); - - test("should return unchanged survey when moving last block down", () => { - const survey = createMockSurvey(); - const result = moveBlock(survey, "block-2", "down"); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks[1].id).toBe("block-2"); - } - }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Block with ID "nonexistent" not found'); + } }); }); -describe("Element Operations", () => { - describe("addElementToBlock", () => { - test("should add element to block", () => { - const survey = createMockSurvey(); - const newElement = { - id: "elem-new", - type: TSurveyElementTypeEnum.OpenText, - headline: { default: "New Question" }, - required: false, - inputType: "text", - } as any; +describe("moveBlock", () => { + test("should move block up", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1"), + createMockBlock("block-2", "Block 2"), + createMockBlock("block-3", "Block 3"), + ]); + const result = moveBlock(survey, "block-2", "up"); - const result = addElementToBlock(survey, "block-1", newElement); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks[0].elements.length).toBe(3); - expect(result.data.blocks[0].elements[2].id).toBe("elem-new"); - expect(result.data.blocks[0].elements[2].isDraft).toBe(true); - } - }); - - test("should return error for duplicate element ID", () => { - const survey = createMockSurvey(); - const duplicateElement = { - id: "elem-1", // Already exists - type: TSurveyElementTypeEnum.OpenText, - headline: { default: "Duplicate" }, - required: false, - inputType: "text", - } as any; - - const result = addElementToBlock(survey, "block-2", duplicateElement); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("already exists"); - } - }); - - test("should return error for duplicate element ID within same block", () => { - const survey = createMockSurvey(); - const duplicateElement = { - id: "elem-1", // Already exists in block-1 - type: TSurveyElementTypeEnum.Rating, - headline: { default: "Duplicate in same block" }, - required: false, - range: 5, - scale: "star", - } as any; - - // Try to add to the same block where elem-1 already exists - const result = addElementToBlock(survey, "block-1", duplicateElement); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("already exists"); - } - }); - - test("should return error for non-existent block", () => { - const survey = createMockSurvey(); - const element = { - id: "elem-new", - type: TSurveyElementTypeEnum.OpenText, - headline: { default: "Question" }, - required: false, - inputType: "text", - } as any; - - const result = addElementToBlock(survey, "non-existent", element); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("not found"); - } - }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].id).toBe("block-2"); + expect(result.data.blocks[1].id).toBe("block-1"); + expect(result.data.blocks[2].id).toBe("block-3"); + } }); - describe("updateElementInBlock", () => { - test("should update element attributes", () => { - const survey = createMockSurvey(); - const result = updateElementInBlock(survey, "block-1", "elem-1", { - headline: { default: "Updated Question" }, - }); + test("should move block down", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1"), + createMockBlock("block-2", "Block 2"), + createMockBlock("block-3", "Block 3"), + ]); + const result = moveBlock(survey, "block-2", "down"); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks![0].elements[0].headline.default).toBe("Updated Question"); - } - }); - - test("should allow updating element ID to a unique ID", () => { - const survey = createMockSurvey(); - const result = updateElementInBlock(survey, "block-1", "elem-1", { - id: "elem-new-id", - }); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks![0].elements[0].id).toBe("elem-new-id"); - } - }); - - test("should return error when updating element ID to duplicate within same block", () => { - const survey = createMockSurvey(); - const result = updateElementInBlock(survey, "block-1", "elem-1", { - id: "elem-2", // elem-2 already exists in block-1 - }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("already exists"); - } - }); - - test("should return error when updating element ID to duplicate in another block", () => { - const survey = createMockSurvey(); - const result = updateElementInBlock(survey, "block-1", "elem-1", { - id: "elem-3", // elem-3 exists in block-2 - }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("already exists"); - } - }); - - test("should return error for non-existent element", () => { - const survey = createMockSurvey(); - const result = updateElementInBlock(survey, "block-1", "non-existent", { - headline: { default: "Updated" }, - }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("not found"); - } - }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].id).toBe("block-1"); + expect(result.data.blocks[1].id).toBe("block-3"); + expect(result.data.blocks[2].id).toBe("block-2"); + } }); - describe("deleteElementFromBlock", () => { - test("should delete element from block", () => { - const survey = createMockSurvey(); - const result = deleteElementFromBlock(survey, "block-1", "elem-2"); + test("should not move first block up", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1"), + createMockBlock("block-2", "Block 2"), + ]); + const result = moveBlock(survey, "block-1", "up"); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks[0].elements.length).toBe(1); - expect(result.data.blocks[0].elements[0].id).toBe("elem-1"); - } - }); - - test("should return error for non-existent element", () => { - const survey = createMockSurvey(); - const result = deleteElementFromBlock(survey, "block-1", "non-existent"); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("not found"); - } - }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].id).toBe("block-1"); + expect(result.data.blocks[1].id).toBe("block-2"); + } }); - describe("duplicateElementInBlock", () => { - test("should duplicate element with new ID", () => { - const survey = createMockSurvey(); - const result = duplicateElementInBlock(survey, "block-1", "elem-1"); + test("should not move last block down", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1"), + createMockBlock("block-2", "Block 2"), + ]); + const result = moveBlock(survey, "block-2", "down"); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks[0].elements.length).toBe(3); - const duplicated = result.data.blocks![0].elements[1]; - expect(duplicated.id).not.toBe("elem-1"); - expect(duplicated.isDraft).toBe(true); - expect(duplicated.headline.default).toBe("Question 1"); - } - }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].id).toBe("block-1"); + expect(result.data.blocks[1].id).toBe("block-2"); + } + }); - test("should return error for non-existent element", () => { - const survey = createMockSurvey(); - const result = duplicateElementInBlock(survey, "block-1", "non-existent"); + test("should return error when block not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const result = moveBlock(survey, "nonexistent", "up"); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("not found"); - } - }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Block with ID "nonexistent" not found'); + } + }); +}); + +describe("addElementToBlock", () => { + test("should add element to block", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const element = createMockElement("q1"); + const result = addElementToBlock(survey, "block-1", element); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements).toHaveLength(1); + expect(result.data.blocks[0].elements[0].id).toBe("q1"); + expect(result.data.blocks[0].elements[0].isDraft).toBe(true); + } + }); + + test("should append element to end by default", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [createMockElement("q1"), createMockElement("q2")]), + ]); + const element = createMockElement("q3"); + const result = addElementToBlock(survey, "block-1", element); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements).toHaveLength(3); + expect(result.data.blocks[0].elements[2].id).toBe("q3"); + } + }); + + test("should insert element at specific index", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [createMockElement("q1"), createMockElement("q2")]), + ]); + const element = createMockElement("q1.5"); + const result = addElementToBlock(survey, "block-1", element, 1); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements).toHaveLength(3); + expect(result.data.blocks[0].elements[1].id).toBe("q1.5"); + expect(result.data.blocks[0].elements[0].id).toBe("q1"); + expect(result.data.blocks[0].elements[2].id).toBe("q2"); + } + }); + + test("should return error for duplicate element ID", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [createMockElement("q1")]), + createMockBlock("block-2", "Block 2", [createMockElement("q2")]), + ]); + const element = createMockElement("q1"); + const result = addElementToBlock(survey, "block-2", element); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Element ID "q1" already exists'); + } + }); + + test("should return error when block not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const element = createMockElement("q1"); + const result = addElementToBlock(survey, "nonexistent", element); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Block with ID "nonexistent" not found'); + } + }); + + test("should return error for invalid index", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const element = createMockElement("q2"); + const result = addElementToBlock(survey, "block-1", element, 10); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain("Invalid index"); + } + }); +}); + +describe("updateElementInBlock", () => { + test("should update element headline", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = updateElementInBlock(survey, "block-1", "q1", { + headline: { default: "Updated Question" }, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements[0].headline).toEqual({ default: "Updated Question" }); + } + }); + + test("should update element ID if new ID is unique", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = updateElementInBlock(survey, "block-1", "q1", { id: "q2" }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements[0].id).toBe("q2"); + } + }); + + test("should return error when updating to duplicate element ID", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [createMockElement("q1"), createMockElement("q2")]), + ]); + const result = updateElementInBlock(survey, "block-1", "q1", { id: "q2" }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Element ID "q2" already exists'); + } + }); + + test("should return error when block not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = updateElementInBlock(survey, "nonexistent", "q1", { + headline: { default: "Updated" }, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Block with ID "nonexistent" not found'); + } + }); + + test("should return error when element not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = updateElementInBlock(survey, "block-1", "nonexistent", { + headline: { default: "Updated" }, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Element with ID "nonexistent" not found'); + } + }); +}); + +describe("deleteElementFromBlock", () => { + test("should delete element from block", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [createMockElement("q1"), createMockElement("q2")]), + ]); + const result = deleteElementFromBlock(survey, "block-1", "q1"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements).toHaveLength(1); + expect(result.data.blocks[0].elements[0].id).toBe("q2"); + } + }); + + test("should return error when block not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = deleteElementFromBlock(survey, "nonexistent", "q1"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Block with ID "nonexistent" not found'); + } + }); + + test("should return error when element not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = deleteElementFromBlock(survey, "block-1", "nonexistent"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Element with ID "nonexistent" not found'); + } + }); +}); + +describe("duplicateElementInBlock", () => { + test("should duplicate element with new ID", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = duplicateElementInBlock(survey, "block-1", "q1"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements).toHaveLength(2); + expect(result.data.blocks[0].elements[1].id).not.toBe("q1"); + expect(result.data.blocks[0].elements[1].isDraft).toBe(true); + expect(result.data.blocks[0].elements[1].headline).toEqual({ default: "Test Question" }); + } + }); + + test("should insert duplicate after original element", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [ + createMockElement("q1"), + createMockElement("q2"), + createMockElement("q3"), + ]), + ]); + const result = duplicateElementInBlock(survey, "block-1", "q2"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements).toHaveLength(4); + expect(result.data.blocks[0].elements[1].id).toBe("q2"); + expect(result.data.blocks[0].elements[2].id).not.toBe("q2"); + expect(result.data.blocks[0].elements[3].id).toBe("q3"); + } + }); + + test("should return error when block not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = duplicateElementInBlock(survey, "nonexistent", "q1"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Block with ID "nonexistent" not found'); + } + }); + + test("should return error when element not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = duplicateElementInBlock(survey, "block-1", "nonexistent"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Element with ID "nonexistent" not found'); + } + }); +}); + +describe("moveElementInBlock", () => { + test("should move element up", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [ + createMockElement("q1"), + createMockElement("q2"), + createMockElement("q3"), + ]), + ]); + const result = moveElementInBlock(survey, "block-1", "q2", "up"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements[0].id).toBe("q2"); + expect(result.data.blocks[0].elements[1].id).toBe("q1"); + expect(result.data.blocks[0].elements[2].id).toBe("q3"); + } + }); + + test("should move element down", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [ + createMockElement("q1"), + createMockElement("q2"), + createMockElement("q3"), + ]), + ]); + const result = moveElementInBlock(survey, "block-1", "q2", "down"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements[0].id).toBe("q1"); + expect(result.data.blocks[0].elements[1].id).toBe("q3"); + expect(result.data.blocks[0].elements[2].id).toBe("q2"); + } + }); + + test("should not move first element up", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [createMockElement("q1"), createMockElement("q2")]), + ]); + const result = moveElementInBlock(survey, "block-1", "q1", "up"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements[0].id).toBe("q1"); + expect(result.data.blocks[0].elements[1].id).toBe("q2"); + } + }); + + test("should not move last element down", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [createMockElement("q1"), createMockElement("q2")]), + ]); + const result = moveElementInBlock(survey, "block-1", "q2", "down"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements[0].id).toBe("q1"); + expect(result.data.blocks[0].elements[1].id).toBe("q2"); + } + }); + + test("should return error when block not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = moveElementInBlock(survey, "nonexistent", "q1", "up"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Block with ID "nonexistent" not found'); + } + }); + + test("should return error when element not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = moveElementInBlock(survey, "block-1", "nonexistent", "up"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Element with ID "nonexistent" not found'); + } }); }); diff --git a/apps/web/modules/survey/editor/lib/blocks.ts b/apps/web/modules/survey/editor/lib/blocks.ts index 48479baf53..53562924a4 100644 --- a/apps/web/modules/survey/editor/lib/blocks.ts +++ b/apps/web/modules/survey/editor/lib/blocks.ts @@ -130,6 +130,11 @@ export const updateBlock = ( * @returns Result with updated survey or Error */ export const deleteBlock = (survey: TSurvey, blockId: string): Result => { + // 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) { diff --git a/apps/web/modules/survey/editor/lib/logic-rule-engine.test.ts b/apps/web/modules/survey/editor/lib/logic-rule-engine.test.ts index 9cec67ee6f..19733f2102 100644 --- a/apps/web/modules/survey/editor/lib/logic-rule-engine.test.ts +++ b/apps/web/modules/survey/editor/lib/logic-rule-engine.test.ts @@ -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, }, ]); }); diff --git a/apps/web/modules/survey/editor/lib/shared-conditions-factory.test.ts b/apps/web/modules/survey/editor/lib/shared-conditions-factory.test.ts index c86d235578..e86cc090c1 100644 --- a/apps/web/modules/survey/editor/lib/shared-conditions-factory.test.ts +++ b/apps/web/modules/survey/editor/lib/shared-conditions-factory.test.ts @@ -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); }); }); diff --git a/apps/web/modules/survey/editor/lib/shared-conditions-factory.ts b/apps/web/modules/survey/editor/lib/shared-conditions-factory.ts index 07c331ba9c..254597d288 100644 --- a/apps/web/modules/survey/editor/lib/shared-conditions-factory.ts +++ b/apps/web/modules/survey/editor/lib/shared-conditions-factory.ts @@ -91,15 +91,9 @@ export function createSharedConditionsFactory( }; const config: TConditionsEditorConfig = { - 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), }; diff --git a/apps/web/modules/survey/editor/lib/utils.tsx b/apps/web/modules/survey/editor/lib/utils.tsx index 5ebb8c95b6..ad4042fe62 100644 --- a/apps/web/modules/survey/editor/lib/utils.tsx +++ b/apps/web/modules/survey/editor/lib/utils.tsx @@ -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", diff --git a/apps/web/modules/survey/editor/lib/validation.test.ts b/apps/web/modules/survey/editor/lib/validation.test.ts index ceb355895a..1930345764 100644 --- a/apps/web/modules/survey/editor/lib/validation.test.ts +++ b/apps/web/modules/survey/editor/lib/validation.test.ts @@ -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", () => { diff --git a/apps/web/modules/survey/editor/lib/validation.ts b/apps/web/modules/survey/editor/lib/validation.ts index 3eaf2b1331..79abe8533d 100644 --- a/apps/web/modules/survey/editor/lib/validation.ts +++ b/apps/web/modules/survey/editor/lib/validation.ts @@ -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[]) => { diff --git a/apps/web/modules/survey/lib/questions.tsx b/apps/web/modules/survey/lib/questions.tsx index 91fbe080c6..05d21a0dfe 100644 --- a/apps/web/modules/survey/lib/questions.tsx +++ b/apps/web/modules/survey/lib/questions.tsx @@ -172,6 +172,7 @@ export const getQuestionTypes = (t: TFunction): TQuestion[] => [ ctaButtonLabel: createI18nString(t("templates.book_interview"), []), buttonUrl: "", buttonExternal: true, + required: false, }, }, { diff --git a/apps/web/modules/survey/link/components/link-survey.tsx b/apps/web/modules/survey/link/components/link-survey.tsx index 1006785ce6..bbb3205d7b 100644 --- a/apps/web/modules/survey/link/components/link-survey.tsx +++ b/apps/web/modules/survey/link/components/link-survey.tsx @@ -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; diff --git a/packages/database/migration/20251118032116_migrate_questions_to_blocks/migration.ts b/packages/database/migration/20251118032116_migrate_questions_to_blocks/migration.ts index 5ca949f519..72285b1c98 100644 --- a/packages/database/migration/20251118032116_migrate_questions_to_blocks/migration.ts +++ b/packages/database/migration/20251118032116_migrate_questions_to_blocks/migration.ts @@ -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; - -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 }; - 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): 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(); - - 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, - endingIds: Set -): 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, - endingIds: Set -): 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((survey.endings ?? []).map((ending) => ending.id)); - - // Phase 1: Create blocks and ID mapping - const questionIdToBlockId = new Map(); - 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( diff --git a/packages/database/migration/20251118032116_migrate_questions_to_blocks/types.ts b/packages/database/migration/20251118032116_migrate_questions_to_blocks/types.ts new file mode 100644 index 0000000000..fdb3f66301 --- /dev/null +++ b/packages/database/migration/20251118032116_migrate_questions_to_blocks/types.ts @@ -0,0 +1,86 @@ +export type I18nString = Record; + +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 }; + 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; +}; diff --git a/packages/database/migration/20251118032116_migrate_questions_to_blocks/utils.ts b/packages/database/migration/20251118032116_migrate_questions_to_blocks/utils.ts new file mode 100644 index 0000000000..5e9a124f6a --- /dev/null +++ b/packages/database/migration/20251118032116_migrate_questions_to_blocks/utils.ts @@ -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 +): 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(); + + 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, + endingIds: Set +): 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, + endingIds: Set +): 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((survey.endings ?? []).map((ending) => ending.id)); + + // Phase 1: Create blocks and ID mapping + const questionIdToBlockId = new Map(); + 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, + }; +}; diff --git a/packages/js-core/src/types/survey.ts b/packages/js-core/src/types/survey.ts index 011cb0ed14..436f196e85 100644 --- a/packages/js-core/src/types/survey.ts +++ b/packages/js-core/src/types/survey.ts @@ -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; diff --git a/packages/surveys/src/components/general/block-conditional.tsx b/packages/surveys/src/components/general/block-conditional.tsx index 5eea9695f6..3885f29010 100644 --- a/packages/surveys/src/components/general/block-conditional.tsx +++ b/packages/surveys/src/components/general/block-conditional.tsx @@ -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; 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} diff --git a/packages/surveys/src/components/general/element-conditional.tsx b/packages/surveys/src/components/general/element-conditional.tsx index 5a8da19e86..a7e1c30d9e 100644 --- a/packages/surveys/src/components/general/element-conditional.tsx +++ b/packages/surveys/src/components/general/element-conditional.tsx @@ -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; - 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 ( - - {element.type === TSurveyElementTypeEnum.OpenText ? ( - - ) : element.type === TSurveyElementTypeEnum.MultipleChoiceSingle ? ( - - ) : element.type === TSurveyElementTypeEnum.MultipleChoiceMulti ? ( - - ) : element.type === TSurveyElementTypeEnum.NPS ? ( - - ) : element.type === TSurveyElementTypeEnum.CTA ? ( - - ) : element.type === TSurveyElementTypeEnum.Rating ? ( - - ) : element.type === TSurveyElementTypeEnum.Consent ? ( - - ) : element.type === TSurveyElementTypeEnum.Date ? ( - - ) : element.type === TSurveyElementTypeEnum.PictureSelection ? ( - - ) : element.type === TSurveyElementTypeEnum.FileUpload ? ( - - ) : element.type === TSurveyElementTypeEnum.Cal ? ( - - ) : element.type === TSurveyElementTypeEnum.Matrix ? ( - - ) : element.type === TSurveyElementTypeEnum.Address ? ( - - ) : element.type === TSurveyElementTypeEnum.Ranking ? ( - - ) : element.type === TSurveyElementTypeEnum.ContactInfo ? ( - - ) : null} - - ); + 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 ( + + ); + case TSurveyElementTypeEnum.MultipleChoiceSingle: + return ( + + ); + case TSurveyElementTypeEnum.MultipleChoiceMulti: + return ( + + ); + case TSurveyElementTypeEnum.NPS: + return ( + + ); + case TSurveyElementTypeEnum.CTA: + return ( + + ); + case TSurveyElementTypeEnum.Rating: + return ( + + ); + case TSurveyElementTypeEnum.Consent: + return ( + + ); + case TSurveyElementTypeEnum.Date: + return ( + + ); + case TSurveyElementTypeEnum.PictureSelection: + return ( + + ); + case TSurveyElementTypeEnum.FileUpload: + return ( + + ); + case TSurveyElementTypeEnum.Cal: + return ( + + ); + case TSurveyElementTypeEnum.Matrix: + return ( + + ); + case TSurveyElementTypeEnum.Address: + return ( + + ); + case TSurveyElementTypeEnum.Ranking: + return ( + + ); + case TSurveyElementTypeEnum.ContactInfo: + return ( + + ); + default: + return null; + } + }; + + return {renderElement()}; } diff --git a/packages/surveys/src/components/general/survey.tsx b/packages/surveys/src/components/general/survey.tsx index d4214a5227..a8911fadda 100644 --- a/packages/surveys/src/components/general/survey.tsx +++ b/packages/surveys/src/components/general/survey.tsx @@ -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>((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) && ( { - 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); diff --git a/packages/surveys/src/components/icons/link-icon.tsx b/packages/surveys/src/components/icons/link-icon.tsx index 8f1781a5dd..76e78f2887 100644 --- a/packages/surveys/src/components/icons/link-icon.tsx +++ b/packages/surveys/src/components/icons/link-icon.tsx @@ -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}> diff --git a/packages/surveys/src/components/questions/address-question.tsx b/packages/surveys/src/components/questions/address-question.tsx index 8e8c1b069d..31b046c1a3 100644 --- a/packages/surveys/src/components/questions/address-question.tsx +++ b/packages/surveys/src/components/questions/address-question.tsx @@ -36,44 +36,58 @@ export function AddressQuestion({ const [startTime, setStartTime] = useState(performance.now()); const isMediaAvailable = question.imageUrl || question.videoUrl; const formRef = useRef(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 ( @@ -118,32 +151,17 @@ export function AddressQuestion({ {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 && ( - - + + { handleChange(field.id, e.currentTarget.value); }} diff --git a/packages/surveys/src/components/questions/cal-question.tsx b/packages/surveys/src/components/questions/cal-question.tsx index f4c5409863..abf50ddc9e 100644 --- a/packages/surveys/src/components/questions/cal-question.tsx +++ b/packages/surveys/src/components/questions/cal-question.tsx @@ -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 ( - + {getLocalizedValue(question.label, languageCode)} diff --git a/packages/surveys/src/components/questions/cta-question.tsx b/packages/surveys/src/components/questions/cta-question.tsx index c880259b0d..088ba0e4dc 100644 --- a/packages/surveys/src/components/questions/cta-question.tsx +++ b/packages/surveys/src/components/questions/cta-question.tsx @@ -55,7 +55,7 @@ export function CTAQuestion({ { 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 diff --git a/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx b/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx index d9ac5cf924..4576010f7c 100644 --- a/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx +++ b/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx @@ -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({ { @@ -224,11 +222,11 @@ export function MultipleChoiceSingleQuestion({ {otherSelected ? ( { onChange({ [question.id]: e.currentTarget.value }); }} diff --git a/packages/surveys/src/components/questions/open-text-question.tsx b/packages/surveys/src/components/questions/open-text-question.tsx index a61c8fdadb..c92b96d401 100644 --- a/packages/surveys/src/components/questions/open-text-question.tsx +++ b/packages/surveys/src/components/questions/open-text-question.tsx @@ -169,7 +169,7 @@ export function OpenTextQuestion({ )} {question.inputType === "text" && question.charLimit?.max !== undefined && ( = 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} )} diff --git a/packages/surveys/src/components/questions/ranking-question.tsx b/packages/surveys/src/components/questions/ranking-question.tsx index 6b50932d4c..10d0eb2673 100644 --- a/packages/surveys/src/components/questions/ranking-question.tsx +++ b/packages/surveys/src/components/questions/ranking-question.tsx @@ -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 }); diff --git a/packages/surveys/src/components/wrappers/stacked-cards-container.tsx b/packages/surveys/src/components/wrappers/stacked-cards-container.tsx index d7eb85e8ac..edaa439fea 100644 --- a/packages/surveys/src/components/wrappers/stacked-cards-container.tsx +++ b/packages/surveys/src/components/wrappers/stacked-cards-container.tsx @@ -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]); diff --git a/packages/surveys/src/lib/logic.ts b/packages/surveys/src/lib/logic.ts index f993b2c13f..5e6abad6b8 100644 --- a/packages/surveys/src/lib/logic.ts +++ b/packages/surveys/src/lib/logic.ts @@ -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") { diff --git a/packages/surveys/src/lib/utils.test.ts b/packages/surveys/src/lib/utils.test.ts index f935fdb965..d2d7de769b 100644 --- a/packages/surveys/src/lib/utils.test.ts +++ b/packages/surveys/src/lib/utils.test.ts @@ -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(); }); }); diff --git a/packages/surveys/src/lib/utils.ts b/packages/surveys/src/lib/utils.ts index 31c893c631..05530d47db 100644 --- a/packages/surveys/src/lib/utils.ts +++ b/packages/surveys/src/lib/utils.ts @@ -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) ?? []; -}; diff --git a/packages/types/formbricks-surveys.ts b/packages/types/formbricks-surveys.ts index c9c429f615..e091c6aa66 100644 --- a/packages/types/formbricks-surveys.ts +++ b/packages/types/formbricks-surveys.ts @@ -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; diff --git a/packages/types/surveys/elements.ts b/packages/types/surveys/elements.ts index 4252aa2268..025c26ade7 100644 --- a/packages/types/surveys/elements.ts +++ b/packages/types/surveys/elements.ts @@ -126,6 +126,8 @@ export const ZSurveyElementChoice = z.object({ label: ZI18nString, }); +export type TSurveyElementChoice = z.infer; + export const ZShuffleOption = z.enum(["none", "all", "exceptLast"]); export type TShuffleOption = z.infer; diff --git a/packages/types/surveys/types.ts b/packages/types/surveys/types.ts index 119852a8f0..4ef7012795 100644 --- a/packages/types/surveys/types.ts +++ b/packages/types/surveys/types.ts @@ -286,10 +286,6 @@ export const ZActionObjective = z.enum(["calculate", "requireAnswer", "jumpToQue export type TDynamicLogicField = z.infer; export type TActionObjective = z.infer; -// Actions -export const ZActionVariableValueType = z.union([z.literal("static"), ZDynamicLogicField]); -export type TActionVariableValueType = z.infer; - 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) =>
{element?.required ? t("environments.surveys.edit.required") @@ -302,226 +576,16 @@ export const BlockCard = ({