Merge branch 'fix/blocks-migration' into fix/cta-logic-templates

This commit is contained in:
pandeymangg
2025-11-21 16:36:05 +05:30
38 changed files with 2141 additions and 1533 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "言語が見つかりません。始めるには、最初のものを追加してください。",

View File

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

View File

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

View File

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

View File

@@ -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": "没有找到语言。添加第一个以开始。",

View File

@@ -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": "找不到語言。新增第一個語言以開始使用。",

View File

@@ -36,6 +36,7 @@ import { PictureSelectionForm } from "@/modules/survey/editor/components/picture
import { RankingQuestionForm } from "@/modules/survey/editor/components/ranking-question-form";
import { RatingQuestionForm } from "@/modules/survey/editor/components/rating-question-form";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { isLabelValidForAllLanguages } from "@/modules/survey/editor/lib/validation";
import { getQuestionIconMap, getTSurveyQuestionTypeEnumName } from "@/modules/survey/lib/questions";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
@@ -46,8 +47,8 @@ interface BlockCardProps {
blockIdx: number;
moveQuestion: (questionIndex: number, up: boolean) => void;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
updateBlockLogic: (blockIdx: number, logic: TSurveyBlockLogic[]) => void;
updateBlockLogicFallback: (blockIdx: number, logicFallback: string | undefined) => void;
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void;
updateBlockButtonLabel: (
blockIndex: number,
labelKey: "buttonLabel" | "backButtonLabel",
@@ -128,11 +129,293 @@ export const BlockCard = ({
const hasInvalidElement = block.elements.some((element) => invalidQuestions?.includes(element.id));
const [openAdvanced, setOpenAdvanced] = useState(blockLogic.length > 0);
// Check if button labels have incomplete translations for any enabled language
// A button label is invalid if it exists but doesn't have valid text for all enabled languages
const hasInvalidButtonLabel =
block.buttonLabel !== undefined &&
!isLabelValidForAllLanguages(block.buttonLabel, localSurvey.languages ?? []);
// Check if back button label is invalid
// Back button label should exist for all blocks except the first one
const hasInvalidBackButtonLabel =
blockIdx > 0 &&
block.backButtonLabel !== undefined &&
!isLabelValidForAllLanguages(block.backButtonLabel, localSurvey.languages ?? []);
// Block should be highlighted if it has invalid elements OR invalid button labels
const isBlockInvalid = hasInvalidElement || hasInvalidButtonLabel || hasInvalidBackButtonLabel;
const [isBlockCollapsed, setIsBlockCollapsed] = useState(false);
const [openAdvanced, setOpenAdvanced] = useState(blockLogic.length > 0);
const [parent] = useAutoAnimate();
const [elementsParent] = useAutoAnimate();
const getElementHeadline = (
element: TSurveyElement,
languageCode: string
): (string | React.ReactElement)[] | string | undefined => {
const headlineData = recallToHeadline(element.headline, localSurvey, true, languageCode);
const headlineText = headlineData[languageCode];
if (headlineText) {
return formatTextWithSlashes(getTextContent(headlineText ?? ""));
}
return getTSurveyQuestionTypeEnumName(element.type, t);
};
const shouldShowCautionAlert = (elementType: TSurveyElementTypeEnum): boolean => {
return (
responseCount > 0 &&
[
TSurveyElementTypeEnum.MultipleChoiceSingle,
TSurveyElementTypeEnum.MultipleChoiceMulti,
TSurveyElementTypeEnum.PictureSelection,
TSurveyElementTypeEnum.Rating,
TSurveyElementTypeEnum.NPS,
TSurveyElementTypeEnum.Ranking,
TSurveyElementTypeEnum.Matrix,
].includes(elementType)
);
};
const renderElementForm = (element: TSurveyElement, questionIdx: number) => {
switch (element.type) {
case TSurveyElementTypeEnum.OpenText:
return (
<OpenQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.MultipleChoiceSingle:
return (
<MultipleChoiceQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.MultipleChoiceMulti:
return (
<MultipleChoiceQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.NPS:
return (
<NPSQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.CTA:
return (
<CTAQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.Rating:
return (
<RatingQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.Consent:
return (
<ConsentQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.Date:
return (
<DateQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.PictureSelection:
return (
<PictureSelectionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
);
case TSurveyElementTypeEnum.FileUpload:
return (
<FileUploadQuestionForm
localSurvey={localSurvey}
project={project}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
isFormbricksCloud={isFormbricksCloud}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.Cal:
return (
<CalQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.Matrix:
return (
<MatrixQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.Address:
return (
<AddressQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.Ranking:
return (
<RankingQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.ContactInfo:
return (
<ContactInfoQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
default:
return null;
}
};
const style = {
transition: transition ?? "transform 100ms ease",
transform: CSS.Translate.toString(transform),
@@ -142,6 +425,13 @@ export const BlockCard = ({
const blockQuestionCount = block.elements.length;
const blockQuestionCountText = blockQuestionCount === 1 ? "question" : "questions";
let blockSidebarColorClass = "";
if (isBlockInvalid) {
blockSidebarColorClass = "bg-red-400";
} else {
blockSidebarColorClass = isBlockOpen ? "bg-slate-700" : "bg-slate-400";
}
return (
<div
className={cn(
@@ -155,7 +445,8 @@ export const BlockCard = ({
{...listeners}
{...attributes}
className={cn(
hasInvalidElement ? "bg-red-400" : isBlockOpen ? "bg-slate-700" : "bg-slate-400",
// isBlockInvalid ? "bg-red-400" : isBlockOpen ? "bg-slate-700" : "bg-slate-400",
blockSidebarColorClass,
"top-0 w-10 rounded-l-lg p-2 text-center text-sm text-white hover:cursor-grab hover:bg-slate-600",
"flex flex-col items-center justify-between gap-2"
)}>
@@ -186,11 +477,11 @@ export const BlockCard = ({
</p>
</div>
</div>
<div onClick={(e) => e.stopPropagation()}>
<div>
<BlockMenu
blockIndex={blockIdx}
isFirstBlock={blockIdx === 0}
isLastBlock={blockIdx === totalBlocks - 1}
isOnlyBlock={totalBlocks === 1}
onDuplicate={() => duplicateBlock(block.id)}
onDelete={() => deleteBlock(block.id)}
onMoveUp={() => moveBlock(block.id, "up")}
@@ -211,13 +502,12 @@ export const BlockCard = ({
}
questionIdx += elementIndex;
const isInvalid = invalidQuestions ? invalidQuestions.includes(element.id) : false;
const open = activeQuestionId === element.id;
const isOpen = activeQuestionId === element.id;
return (
<div key={element.id} className={cn(elementIndex > 0 && "border-t border-slate-200")}>
<Collapsible.Root
open={open}
open={isOpen}
onOpenChange={() => {
if (activeQuestionId !== element.id) {
setActiveQuestionId(element.id);
@@ -229,7 +519,7 @@ export const BlockCard = ({
<Collapsible.CollapsibleTrigger
asChild
className={cn(
open ? "bg-slate-50" : "",
isOpen ? "bg-slate-50" : "",
"flex w-full cursor-pointer justify-between gap-4 p-4 hover:bg-slate-50"
)}
aria-label="Toggle question details">
@@ -246,25 +536,9 @@ export const BlockCard = ({
</p>
)}
<h3 className="text-sm font-semibold">
{recallToHeadline(
element.headline,
localSurvey,
true,
selectedLanguageCode
)[selectedLanguageCode]
? formatTextWithSlashes(
getTextContent(
recallToHeadline(
element.headline,
localSurvey,
true,
selectedLanguageCode
)[selectedLanguageCode] ?? ""
)
)
: getTSurveyQuestionTypeEnumName(element.type, t)}
{getElementHeadline(element, selectedLanguageCode)}
</h3>
{!open && (
{!isOpen && (
<p className="mt-1 truncate text-xs text-slate-500">
{element?.required
? t("environments.surveys.edit.required")
@@ -302,226 +576,16 @@ export const BlockCard = ({
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "pb-4"}`}>
{responseCount > 0 &&
[
TSurveyElementTypeEnum.MultipleChoiceSingle,
TSurveyElementTypeEnum.MultipleChoiceMulti,
TSurveyElementTypeEnum.PictureSelection,
TSurveyElementTypeEnum.Rating,
TSurveyElementTypeEnum.NPS,
TSurveyElementTypeEnum.Ranking,
TSurveyElementTypeEnum.Matrix,
].includes(element.type) ? (
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${isOpen && "pb-4"}`}>
{shouldShowCautionAlert(element.type) && (
<Alert variant="warning" size="small" className="w-fill" role="alert">
<AlertTitle>{t("environments.surveys.edit.caution_text")}</AlertTitle>
<AlertButton onClick={() => onAlertTrigger()}>
{t("common.learn_more")}
</AlertButton>
</Alert>
) : null}
{element.type === TSurveyElementTypeEnum.OpenText ? (
<OpenQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : element.type === TSurveyElementTypeEnum.MultipleChoiceSingle ? (
<MultipleChoiceQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : element.type === TSurveyElementTypeEnum.MultipleChoiceMulti ? (
<MultipleChoiceQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : element.type === TSurveyElementTypeEnum.NPS ? (
<NPSQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : element.type === TSurveyElementTypeEnum.CTA ? (
<CTAQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : element.type === TSurveyElementTypeEnum.Rating ? (
<RatingQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : element.type === TSurveyElementTypeEnum.Consent ? (
<ConsentQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : element.type === TSurveyElementTypeEnum.Date ? (
<DateQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : element.type === TSurveyElementTypeEnum.PictureSelection ? (
<PictureSelectionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
) : element.type === TSurveyElementTypeEnum.FileUpload ? (
<FileUploadQuestionForm
localSurvey={localSurvey}
project={project}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
isFormbricksCloud={isFormbricksCloud}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : element.type === TSurveyElementTypeEnum.Cal ? (
<CalQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : element.type === TSurveyElementTypeEnum.Matrix ? (
<MatrixQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : element.type === TSurveyElementTypeEnum.Address ? (
<AddressQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : element.type === TSurveyElementTypeEnum.Ranking ? (
<RankingQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : element.type === TSurveyElementTypeEnum.ContactInfo ? (
<ContactInfoQuestionForm
localSurvey={localSurvey}
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : null}
)}
{renderElementForm(element, questionIdx)}
<div className="mt-4">
<Collapsible.Root
open={openAdvanced}

View File

@@ -6,9 +6,9 @@ import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
interface BlockMenuProps {
blockIndex: number;
isFirstBlock: boolean;
isLastBlock: boolean;
isOnlyBlock: boolean;
onDuplicate: () => void;
onDelete: () => void;
onMoveUp: () => void;
@@ -16,9 +16,9 @@ interface BlockMenuProps {
}
export const BlockMenu = ({
blockIndex,
isFirstBlock,
isLastBlock,
isOnlyBlock,
onDuplicate,
onDelete,
onMoveUp,
@@ -77,9 +77,12 @@ export const BlockMenu = ({
<Button
variant="ghost"
size="icon"
disabled={isOnlyBlock}
onClick={(e) => {
e.stopPropagation();
onDelete();
if (!isOnlyBlock) {
e.stopPropagation();
onDelete();
}
}}
className="h-8 w-8">
<TrashIcon className="h-4 w-4" />

View File

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

View File

@@ -1,16 +1,14 @@
"use client";
import { ArrowRightIcon } from "lucide-react";
import { ReactElement, useMemo } from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { recallToHeadline } from "@/lib/utils/recall";
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
import { getQuestionIconMap } from "@/modules/survey/lib/questions";
import {
Select,
SelectContent,
@@ -41,39 +39,23 @@ export function LogicEditor({
isLast,
}: LogicEditorProps) {
const { t } = useTranslation();
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
const blockLogicFallback = block.logicFallback;
const fallbackOptions = useMemo(() => {
let options: {
icon?: ReactElement;
label: string;
value: string;
}[] = [];
const blocks = localSurvey.blocks;
// Track which blocks we've already added to avoid duplicates when a block has multiple elements
const addedBlockIds = new Set<string>();
// Iterate over the elements AFTER the current block
// Add blocks AFTER the current block
for (let i = blockIdx + 1; i < blocks.length; i++) {
const currentBlock = blocks[i];
if (addedBlockIds.has(currentBlock.id)) continue;
addedBlockIds.add(currentBlock.id);
// Use the first element's headline as the block label
const firstElement = currentBlock.elements[0];
if (!firstElement) continue;
options.push({
icon: QUESTIONS_ICON_MAP[firstElement.type],
label: getTextContent(
recallToHeadline(firstElement.headline, localSurvey, false, "default").default ?? ""
),
label: currentBlock.name,
value: currentBlock.id,
});
}
@@ -92,7 +74,7 @@ export function LogicEditor({
});
return options;
}, [localSurvey, blockIdx, QUESTIONS_ICON_MAP, t]);
}, [localSurvey, blockIdx, t]);
return (
<div className="flex w-full min-w-full grow flex-col gap-4 overflow-x-auto pb-2 text-sm">
@@ -133,15 +115,12 @@ export function LogicEditor({
</SelectTrigger>
<SelectContent>
<SelectItem key="fallback_default_selection" value={"defaultSelection"}>
{t("environments.surveys.edit.next_question")}
{t("environments.surveys.edit.next_block")}
</SelectItem>
{fallbackOptions.map((option) => (
<SelectItem key={`fallback_${option.value}`} value={option.value}>
<div className="flex items-center gap-2">
{option.icon}
{option.label}
</div>
{option.label}
</SelectItem>
))}
</SelectContent>

View File

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

View File

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

View File

@@ -3,7 +3,6 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { HashIcon, PlusIcon, SmileIcon, StarIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyRatingElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
@@ -26,7 +25,6 @@ interface RatingQuestionFormProps {
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
buttonLabel?: TI18nString;
}
export const RatingQuestionForm = ({
@@ -40,7 +38,6 @@ export const RatingQuestionForm = ({
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
buttonLabel,
}: RatingQuestionFormProps) => {
const { t } = useTranslation();
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
@@ -181,27 +178,6 @@ export const RatingQuestionForm = ({
</div>
</div>
<div className="mt-3">
{!question.required && (
<div className="flex-1">
<QuestionFormInput
id="buttonLabel"
value={buttonLabel}
label={t("environments.surveys.edit.next_button_label")}
localSurvey={localSurvey}
questionIdx={questionIdx}
placeholder={"skip"}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
</div>
)}
</div>
{question.scale !== "star" && (
<AdvancedOptionToggle
isChecked={question.isColorCodingEnabled}

File diff suppressed because it is too large Load Diff

View File

@@ -130,6 +130,11 @@ export const updateBlock = (
* @returns Result with updated survey or Error
*/
export const deleteBlock = (survey: TSurvey, blockId: string): Result<TSurvey, Error> => {
// Prevent deleting the last block
if (survey.blocks?.length === 1) {
return err(new Error("Cannot delete the last block in the survey"));
}
const filteredBlocks = survey.blocks?.filter((b) => b.id !== blockId) || [];
if (filteredBlocks.length === survey.blocks?.length) {

View File

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

View File

@@ -91,15 +91,9 @@ export function createSharedConditionsFactory(
};
const config: TConditionsEditorConfig<TSingleCondition> = {
getLeftOperandOptions: () =>
blockIdx !== undefined
? getConditionValueOptions(survey, t, blockIdx)
: getConditionValueOptions(survey, t),
getLeftOperandOptions: () => getConditionValueOptions(survey, t, blockIdx),
getOperatorOptions: (condition) => getConditionOperatorOptions(condition, survey, t),
getValueProps: (condition) =>
blockIdx !== undefined
? getMatchValueProps(condition, survey, t, blockIdx)
: getMatchValueProps(condition, survey, t),
getValueProps: (condition) => getMatchValueProps(condition, survey, t, blockIdx),
getDefaultOperator,
formatLeftOperandValue: (condition) => getFormatLeftOperandValue(condition, survey),
};

View File

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

View File

@@ -1,495 +1,8 @@
import { createId } from "@paralleldrive/cuid2";
import { logger } from "@formbricks/logger";
import type { MigrationScript } from "../../src/scripts/migration-runner";
// Type definitions for migration
type I18nString = Record<string, string>;
interface SurveyQuestion {
id: string;
type: string;
headline?: I18nString;
logic?: SurveyLogic[];
logicFallback?: string;
buttonLabel?: I18nString;
backButtonLabel?: I18nString;
buttonUrl?: string;
buttonExternal?: boolean;
dismissButtonLabel?: I18nString;
ctaButtonLabel?: I18nString;
[key: string]: unknown;
}
// Single condition type (leaf node)
interface SingleCondition {
id: string;
leftOperand: { value: string; type: string; meta?: Record<string, unknown> };
operator: string;
rightOperand?: { type: string; value: string | number | string[] };
connector?: undefined; // Single conditions don't have connectors
}
// Condition group type (has nested conditions)
interface ConditionGroup {
id: string;
connector: "and" | "or";
conditions: Condition[];
}
// Union type for both
type Condition = SingleCondition | ConditionGroup;
// Type guards
const isSingleCondition = (condition: Condition): condition is SingleCondition => {
return "leftOperand" in condition && "operator" in condition;
};
const isConditionGroup = (condition: Condition): condition is ConditionGroup => {
return "conditions" in condition && "connector" in condition;
};
interface SurveyLogic {
id: string;
conditions: ConditionGroup; // Logic always starts with a condition group
actions: LogicAction[];
}
interface LogicAction {
id: string;
objective: string;
target?: string;
[key: string]: unknown;
}
interface Block {
id: string;
name: string;
elements: SurveyQuestion[];
logic?: SurveyLogic[];
logicFallback?: string;
buttonLabel?: I18nString;
backButtonLabel?: I18nString;
}
interface SurveyRecord {
id: string;
questions: SurveyQuestion[];
blocks?: Block[];
endings?: { id: string; [key: string]: unknown }[];
}
interface MigratedSurvey {
id: string;
blocks: Block[];
questions: SurveyQuestion[];
}
// Statistics tracking for CTA migration
interface CTAMigrationStats {
totalCTAElements: number;
ctaWithExternalLink: number;
ctaWithoutExternalLink: number;
}
/**
* Check if a condition references a CTA element with a specific operator
* Can handle both SingleCondition and ConditionGroup
*/
const conditionReferencesCTA = (
condition: Condition | null | undefined,
ctaElementId: string,
operator?: string
): boolean => {
if (!condition) return false;
// Check if it's a single condition
if (isSingleCondition(condition)) {
if (condition.leftOperand.value === ctaElementId) {
if (operator) {
return condition.operator === operator;
}
return true;
}
return false;
}
// It's a condition group - check nested conditions
if (isConditionGroup(condition)) {
return condition.conditions.some((c) => conditionReferencesCTA(c, ctaElementId, operator));
}
return false;
};
/**
* Remove conditions that reference a CTA element with specific operators
*/
const removeCtaConditions = (
conditionGroup: ConditionGroup,
ctaElementId: string,
operatorsToRemove: string[]
): ConditionGroup | null => {
const filteredConditions = conditionGroup.conditions.filter((condition) => {
// Check if it's a single condition referencing the CTA
if (isSingleCondition(condition)) {
if (condition.leftOperand.value === ctaElementId) {
return !operatorsToRemove.includes(condition.operator);
}
return true;
}
// It's a condition group - recurse
if (isConditionGroup(condition)) {
const cleaned = removeCtaConditions(condition, ctaElementId, operatorsToRemove);
if (!cleaned || cleaned.conditions.length === 0) {
return false;
}
// Replace the condition with the cleaned version
Object.assign(condition, cleaned);
return true;
}
return true;
});
if (filteredConditions.length === 0) {
return null;
}
return {
...conditionGroup,
conditions: filteredConditions,
};
};
/**
* Migrate a single CTA question: update fields and clean logic
*/
const migrateCTAQuestion = (question: SurveyQuestion, stats: CTAMigrationStats): void => {
if (question.type !== "cta") return;
stats.totalCTAElements++;
// Check if CTA has external link
const hasExternalButton = question.buttonExternal === true && Boolean(question.buttonUrl);
if (hasExternalButton) {
stats.ctaWithExternalLink++;
// Copy buttonLabel to ctaButtonLabel
if (question.buttonLabel) {
question.ctaButtonLabel = question.buttonLabel;
}
// Ensure buttonUrl and buttonExternal are set
question.buttonExternal = true;
} else {
stats.ctaWithoutExternalLink++;
// CTA without external link: remove buttonExternal and buttonUrl
delete question.buttonExternal;
delete question.buttonUrl;
}
// Remove old fields that are no longer used
delete question.buttonLabel;
delete question.dismissButtonLabel;
};
/**
* Clean CTA logic from a question's logic array
*/
const cleanCTALogicFromQuestion = (question: SurveyQuestion, ctaQuestions: Map<string, boolean>): void => {
if (!question.logic || question.logic.length === 0) return;
const cleanedLogic: SurveyLogic[] = [];
question.logic.forEach((logicRule) => {
let shouldKeepRule = true;
let modifiedConditions = logicRule.conditions;
// Check each CTA question
ctaQuestions.forEach((hasExternalButton, ctaId) => {
if (!hasExternalButton) {
// CTA without external button - remove ALL conditions referencing this CTA
if (conditionReferencesCTA(modifiedConditions, ctaId)) {
const cleanedConditions = removeCtaConditions(modifiedConditions, ctaId, [
"isClicked",
"isSkipped",
]);
if (!cleanedConditions?.conditions || cleanedConditions.conditions.length === 0) {
shouldKeepRule = false;
} else {
modifiedConditions = cleanedConditions;
}
}
} else if (conditionReferencesCTA(modifiedConditions, ctaId, "isSkipped")) {
// CTA with external button - remove isSkipped, keep isClicked
const cleanedConditions = removeCtaConditions(modifiedConditions, ctaId, ["isSkipped"]);
if (!cleanedConditions?.conditions || cleanedConditions.conditions.length === 0) {
shouldKeepRule = false;
} else {
modifiedConditions = cleanedConditions;
}
}
});
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- shouldKeepRule can be modified in loop
if (shouldKeepRule) {
cleanedLogic.push({
...logicRule,
conditions: modifiedConditions,
});
}
});
if (cleanedLogic.length === 0) {
delete question.logic;
} else {
question.logic = cleanedLogic;
}
};
/**
* Process all CTA questions in a survey: migrate fields and clean logic
*/
const processCTAQuestions = (questions: SurveyQuestion[], stats: CTAMigrationStats): void => {
// Build map of CTA question IDs to their external button status
const ctaQuestions = new Map<string, boolean>();
questions.forEach((question) => {
if (question.type === "cta") {
const hasExternalButton = question.buttonExternal === true && Boolean(question.buttonUrl);
ctaQuestions.set(question.id, hasExternalButton);
}
});
if (ctaQuestions.size === 0) return;
// First pass: migrate CTA question fields
questions.forEach((question) => {
migrateCTAQuestion(question, stats);
});
// Second pass: clean CTA logic from ALL questions
questions.forEach((question) => {
cleanCTALogicFromQuestion(question, ctaQuestions);
});
};
/**
* Generate block name from question headline or use index-based fallback
* @param questionIdx - The 0-based index of the question in the survey
* @returns Block name (e.g., "Block 1", "Block 2")
*/
const getBlockName = (questionIdx: number): string => {
return `Block ${String(questionIdx + 1)}`;
};
/**
* Update logic actions: convert jumpToQuestion to jumpToBlock with new block IDs
* @param actions - Array of logic actions
* @param questionIdToBlockId - Map of question IDs to new block IDs
* @param endingIds - Set of valid ending card IDs
* @returns Updated actions array with jumpToBlock objectives
*/
const updateLogicActions = (
actions: LogicAction[],
questionIdToBlockId: Map<string, string>,
endingIds: Set<string>
): LogicAction[] => {
return actions.map((action) => {
if (action.objective === "jumpToQuestion") {
const target = action.target ?? "";
const blockId = questionIdToBlockId.get(target);
if (blockId) {
// Target is a question ID - convert to block ID
return {
...action,
objective: "jumpToBlock",
target: blockId,
};
}
// Check if target is a valid ending card ID
if (endingIds.has(target)) {
// Target is an ending card - keep it as is but change objective
return {
...action,
objective: "jumpToBlock",
target,
};
}
// Target is neither a question nor an ending card - keep as is
return {
...action,
objective: "jumpToBlock",
target,
};
}
// calculate and requireAnswer stay unchanged
return action;
});
};
/**
* Update logic fallback: convert question ID to block ID
* @param fallback - The fallback question ID or ending card ID
* @param questionIdToBlockId - Map of question IDs to new block IDs
* @param endingIds - Set of valid ending card IDs
* @returns Updated fallback with block ID, unchanged ending card ID, or undefined if invalid
*/
const updateLogicFallback = (
fallback: string,
questionIdToBlockId: Map<string, string>,
endingIds: Set<string>
): string | undefined => {
const blockId = questionIdToBlockId.get(fallback);
if (blockId) {
// Fallback is a question ID - convert to block ID
return blockId;
}
// Check if fallback is a valid ending card ID
if (endingIds.has(fallback)) {
// Fallback is an ending card - keep it as is
return fallback;
}
// Fallback is neither a question nor an ending card - remove it
return undefined;
};
/**
* Convert logic operand types from "question" to "element" recursively (immutable)
* @param condition - Condition or condition group to convert
* @returns New condition object with "element" type instead of "question"
*/
const convertQuestionToElementType = (condition: Condition | null | undefined): Condition | null => {
if (!condition) return null;
// Handle single condition
if (isSingleCondition(condition)) {
const newCondition: SingleCondition = { ...condition };
// Update leftOperand if it's of type "question"
if (condition.leftOperand.type === "question") {
newCondition.leftOperand = {
...condition.leftOperand,
type: "element",
};
}
// Update rightOperand if it exists and is of type "question"
if (condition.rightOperand && condition.rightOperand.type === "question") {
newCondition.rightOperand = {
...condition.rightOperand,
type: "element",
};
}
return newCondition;
}
// Handle condition group
if (isConditionGroup(condition)) {
const newConditionGroup: ConditionGroup = {
...condition,
conditions: condition.conditions.map((nestedCondition) => {
const converted = convertQuestionToElementType(nestedCondition);
return converted ?? nestedCondition;
}),
};
return newConditionGroup;
}
return null;
};
/**
* Migrate a survey from questions to blocks structure
* Each question becomes a block with a single element
* @param survey - Survey record with questions
* @param createIdFn - Function to generate CUIDs for blocks
* @param ctaStats - Statistics tracker for CTA migration
* @returns Migrated survey with blocks and empty questions array
*/
const migrateQuestionsSurveyToBlocks = (
survey: SurveyRecord,
createIdFn: () => string,
ctaStats: CTAMigrationStats
): MigratedSurvey => {
// Skip if no questions
if (survey.questions.length === 0) {
return { ...survey, blocks: survey.blocks ?? [], questions: [] };
}
// STEP 1: Process CTA questions FIRST (before converting to blocks)
processCTAQuestions(survey.questions, ctaStats);
// Create set of valid ending card IDs for validation
const endingIds = new Set<string>((survey.endings ?? []).map((ending) => ending.id));
// Phase 1: Create blocks and ID mapping
const questionIdToBlockId = new Map<string, string>();
const blocks: Block[] = [];
for (let i = 0; i < survey.questions.length; i++) {
const question = survey.questions[i];
const blockId = createIdFn();
questionIdToBlockId.set(question.id, blockId);
// Extract logic from question level
const { logic, logicFallback, buttonLabel, backButtonLabel, ...baseElement } = question;
blocks.push({
id: blockId,
name: getBlockName(i),
elements: [baseElement],
buttonLabel,
backButtonLabel,
logic, // Will update in Phase 2
logicFallback, // Will update in Phase 2
});
}
// Phase 2: Update all logic references
for (const block of blocks) {
if (block.logic && block.logic.length > 0) {
block.logic = block.logic.map((item) => {
// Convert "question" type to "element" type in conditions (immutably)
const updatedConditions = convertQuestionToElementType(item.conditions);
// Since item.conditions is always a ConditionGroup, the result should be too
if (!updatedConditions || !isConditionGroup(updatedConditions)) {
// This should never happen, but if it does, keep the original
return item;
}
return {
...item,
conditions: updatedConditions,
actions: updateLogicActions(item.actions, questionIdToBlockId, endingIds),
};
});
}
if (block.logicFallback) {
block.logicFallback = updateLogicFallback(block.logicFallback, questionIdToBlockId, endingIds);
}
}
return {
...survey,
blocks,
questions: [],
};
};
import type { Block, CTAMigrationStats, SurveyQuestion, SurveyRecord } from "./types";
import { migrateQuestionsSurveyToBlocks } from "./utils";
export const migrateQuestionsToBlocks: MigrationScript = {
type: "data",

View File

@@ -0,0 +1,87 @@
export type I18nString = Record<string, string>;
export interface SurveyQuestion {
id: string;
type: string;
headline?: I18nString;
logic?: SurveyLogic[];
logicFallback?: string;
buttonLabel?: I18nString;
backButtonLabel?: I18nString;
buttonUrl?: string;
buttonExternal?: boolean;
dismissButtonLabel?: I18nString;
ctaButtonLabel?: I18nString;
[key: string]: unknown;
}
// Single condition type (leaf node)
export interface SingleCondition {
id: string;
leftOperand: { value: string; type: string; meta?: Record<string, unknown> };
operator: string;
rightOperand?: { type: string; value: string | number | string[] };
connector?: undefined; // Single conditions don't have connectors
}
// Condition group type (has nested conditions)
export interface ConditionGroup {
id: string;
connector: "and" | "or";
conditions: Condition[];
}
// Union type for both
export type Condition = SingleCondition | ConditionGroup;
export interface SurveyLogic {
id: string;
conditions: ConditionGroup; // Logic always starts with a condition group
actions: LogicAction[];
}
export interface LogicAction {
id: string;
objective: string;
target?: string;
[key: string]: unknown;
}
export interface Block {
id: string;
name: string;
elements: SurveyQuestion[];
logic?: SurveyLogic[];
logicFallback?: string;
buttonLabel?: I18nString;
backButtonLabel?: I18nString;
}
export interface SurveyRecord {
id: string;
questions: SurveyQuestion[];
blocks?: Block[];
endings?: { id: string; [key: string]: unknown }[];
}
export interface MigratedSurvey {
id: string;
blocks: Block[];
questions: SurveyQuestion[];
}
// 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;
};

View File

@@ -0,0 +1,417 @@
import {
type Block,
type CTAMigrationStats,
type Condition,
type ConditionGroup,
type LogicAction,
type MigratedSurvey,
type SingleCondition,
type SurveyLogic,
type SurveyQuestion,
type SurveyRecord,
isConditionGroup as checkIsConditionGroup,
isSingleCondition as checkIsSingleCondition,
} from "./types";
/**
* Check if a condition references a CTA element with a specific operator
* Can handle both SingleCondition and ConditionGroup
*/
export const conditionReferencesCTA = (
condition: Condition | null | undefined,
ctaElementId: string,
operator?: string
): boolean => {
if (!condition) return false;
// Check if it's a single condition
if (checkIsSingleCondition(condition)) {
if (condition.leftOperand.value === ctaElementId) {
if (operator) {
return condition.operator === operator;
}
return true;
}
return false;
}
// It's a condition group - check nested conditions
if (checkIsConditionGroup(condition)) {
return condition.conditions.some((c) => conditionReferencesCTA(c, ctaElementId, operator));
}
return false;
};
/**
* Remove conditions that reference a CTA element with specific operators
*/
export const removeCtaConditions = (
conditionGroup: ConditionGroup,
ctaElementId: string,
operatorsToRemove: string[]
): ConditionGroup | null => {
const filteredConditions = conditionGroup.conditions.filter((condition) => {
// Check if it's a single condition referencing the CTA
if (checkIsSingleCondition(condition)) {
if (condition.leftOperand.value === ctaElementId) {
return !operatorsToRemove.includes(condition.operator);
}
return true;
}
// It's a condition group - recurse
if (checkIsConditionGroup(condition)) {
const cleaned = removeCtaConditions(condition, ctaElementId, operatorsToRemove);
if (!cleaned || cleaned.conditions.length === 0) {
return false;
}
// Replace the condition with the cleaned version
Object.assign(condition, cleaned);
return true;
}
return true;
});
if (filteredConditions.length === 0) {
return null;
}
return {
...conditionGroup,
conditions: filteredConditions,
};
};
/**
* Migrate a single CTA question: update fields and clean logic
*/
export const migrateCTAQuestion = (question: SurveyQuestion, stats: CTAMigrationStats): void => {
if (question.type !== "cta") return;
stats.totalCTAElements++;
// Check if CTA has external link
const hasExternalButton = question.buttonExternal === true && Boolean(question.buttonUrl);
if (hasExternalButton) {
stats.ctaWithExternalLink++;
// Copy buttonLabel to ctaButtonLabel
if (question.buttonLabel) {
question.ctaButtonLabel = question.buttonLabel;
}
// Ensure buttonUrl and buttonExternal are set
question.buttonExternal = true;
} else {
stats.ctaWithoutExternalLink++;
// CTA without external link: remove buttonExternal and buttonUrl
delete question.buttonExternal;
delete question.buttonUrl;
}
// Remove old fields that are no longer used
delete question.buttonLabel;
delete question.dismissButtonLabel;
};
/**
* Clean CTA logic from a question's logic array
*/
export const cleanCTALogicFromQuestion = (
question: SurveyQuestion,
ctaQuestions: Map<string, boolean>
): void => {
if (!question.logic || question.logic.length === 0) return;
const cleanedLogic: SurveyLogic[] = [];
question.logic.forEach((logicRule) => {
let shouldKeepRule = true;
let modifiedConditions = logicRule.conditions;
// Check each CTA question
ctaQuestions.forEach((hasExternalButton, ctaId) => {
if (!hasExternalButton) {
// CTA without external button - remove ALL conditions referencing this CTA
if (conditionReferencesCTA(modifiedConditions, ctaId)) {
const cleanedConditions = removeCtaConditions(modifiedConditions, ctaId, [
"isClicked",
"isSkipped",
]);
if (!cleanedConditions?.conditions || cleanedConditions.conditions.length === 0) {
shouldKeepRule = false;
} else {
modifiedConditions = cleanedConditions;
}
}
} else if (conditionReferencesCTA(modifiedConditions, ctaId, "isSkipped")) {
// CTA with external button - remove isSkipped, keep isClicked
const cleanedConditions = removeCtaConditions(modifiedConditions, ctaId, ["isSkipped"]);
if (!cleanedConditions?.conditions || cleanedConditions.conditions.length === 0) {
shouldKeepRule = false;
} else {
modifiedConditions = cleanedConditions;
}
}
});
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- shouldKeepRule can be modified in loop
if (shouldKeepRule) {
cleanedLogic.push({
...logicRule,
conditions: modifiedConditions,
});
}
});
if (cleanedLogic.length === 0) {
delete question.logic;
} else {
question.logic = cleanedLogic;
}
};
/**
* Process all CTA questions in a survey: migrate fields and clean logic
*/
export const processCTAQuestions = (questions: SurveyQuestion[], stats: CTAMigrationStats): void => {
// Build map of CTA question IDs to their external button status
const ctaQuestions = new Map<string, boolean>();
questions.forEach((question) => {
if (question.type === "cta") {
const hasExternalButton = question.buttonExternal === true && Boolean(question.buttonUrl);
ctaQuestions.set(question.id, hasExternalButton);
}
});
if (ctaQuestions.size === 0) return;
// First pass: migrate CTA question fields
questions.forEach((question) => {
migrateCTAQuestion(question, stats);
});
// Second pass: clean CTA logic from ALL questions
questions.forEach((question) => {
cleanCTALogicFromQuestion(question, ctaQuestions);
});
};
/**
* Generate block name from question headline or use index-based fallback
* @param questionIdx - The 0-based index of the question in the survey
* @returns Block name (e.g., "Block 1", "Block 2")
*/
export const getBlockName = (questionIdx: number): string => {
return `Block ${String(questionIdx + 1)}`;
};
/**
* Update logic actions: convert jumpToQuestion to jumpToBlock with new block IDs
* @param actions - Array of logic actions
* @param questionIdToBlockId - Map of question IDs to new block IDs
* @param endingIds - Set of valid ending card IDs
* @returns Updated actions array with jumpToBlock objectives
*/
export const updateLogicActions = (
actions: LogicAction[],
questionIdToBlockId: Map<string, string>,
endingIds: Set<string>
): LogicAction[] => {
return actions.map((action) => {
if (action.objective === "jumpToQuestion") {
const target = action.target ?? "";
const blockId = questionIdToBlockId.get(target);
if (blockId) {
// Target is a question ID - convert to block ID
return {
...action,
objective: "jumpToBlock",
target: blockId,
};
}
// Check if target is a valid ending card ID
if (endingIds.has(target)) {
// Target is an ending card - keep it as is but change objective
return {
...action,
objective: "jumpToBlock",
target,
};
}
// Target is neither a question nor an ending card - keep as is
return {
...action,
objective: "jumpToBlock",
target,
};
}
// calculate and requireAnswer stay unchanged
return action;
});
};
/**
* Update logic fallback: convert question ID to block ID
* @param fallback - The fallback question ID or ending card ID
* @param questionIdToBlockId - Map of question IDs to new block IDs
* @param endingIds - Set of valid ending card IDs
* @returns Updated fallback with block ID, unchanged ending card ID, or undefined if invalid
*/
export const updateLogicFallback = (
fallback: string,
questionIdToBlockId: Map<string, string>,
endingIds: Set<string>
): string | undefined => {
const blockId = questionIdToBlockId.get(fallback);
if (blockId) {
// Fallback is a question ID - convert to block ID
return blockId;
}
// Check if fallback is a valid ending card ID
if (endingIds.has(fallback)) {
// Fallback is an ending card - keep it as is
return fallback;
}
// Fallback is neither a question nor an ending card - remove it
return undefined;
};
/**
* Convert logic operand types from "question" to "element" recursively (immutable)
* @param condition - Condition or condition group to convert
* @returns New condition object with "element" type instead of "question"
*/
export const convertQuestionToElementType = (condition: Condition | null | undefined): Condition | null => {
if (!condition) return null;
// Handle single condition
if (checkIsSingleCondition(condition)) {
const newCondition: SingleCondition = { ...condition };
// Update leftOperand if it's of type "question"
if (condition.leftOperand.type === "question") {
newCondition.leftOperand = {
...condition.leftOperand,
type: "element",
};
}
// Update rightOperand if it exists and is of type "question"
if (condition.rightOperand && condition.rightOperand.type === "question") {
newCondition.rightOperand = {
...condition.rightOperand,
type: "element",
};
}
return newCondition;
}
// Handle condition group
if (checkIsConditionGroup(condition)) {
const newConditionGroup: ConditionGroup = {
...condition,
conditions: condition.conditions.map((nestedCondition) => {
const converted = convertQuestionToElementType(nestedCondition);
return converted ?? nestedCondition;
}),
};
return newConditionGroup;
}
return null;
};
/**
* Migrate a survey from questions to blocks structure
* Each question becomes a block with a single element
* @param survey - Survey record with questions
* @param createIdFn - Function to generate CUIDs for blocks
* @param ctaStats - Statistics tracker for CTA migration
* @returns Migrated survey with blocks and empty questions array
*/
export const migrateQuestionsSurveyToBlocks = (
survey: SurveyRecord,
createIdFn: () => string,
ctaStats: CTAMigrationStats
): MigratedSurvey => {
// Skip if no questions
if (survey.questions.length === 0) {
return { ...survey, blocks: survey.blocks ?? [], questions: [] };
}
// STEP 1: Process CTA questions FIRST (before converting to blocks)
processCTAQuestions(survey.questions, ctaStats);
// Create set of valid ending card IDs for validation
const endingIds = new Set<string>((survey.endings ?? []).map((ending) => ending.id));
// Phase 1: Create blocks and ID mapping
const questionIdToBlockId = new Map<string, string>();
const blocks: Block[] = [];
for (let i = 0; i < survey.questions.length; i++) {
const question = survey.questions[i];
const blockId = createIdFn();
questionIdToBlockId.set(question.id, blockId);
// Extract logic from question level
const { logic, logicFallback, buttonLabel, backButtonLabel, ...baseElement } = question;
blocks.push({
id: blockId,
name: getBlockName(i),
elements: [baseElement],
buttonLabel,
backButtonLabel,
logic, // Will update in Phase 2
logicFallback, // Will update in Phase 2
});
}
// Phase 2: Update all logic references
for (const block of blocks) {
if (block.logic && block.logic.length > 0) {
block.logic = block.logic.map((item) => {
// Convert "question" type to "element" type in conditions (immutably)
const updatedConditions = convertQuestionToElementType(item.conditions);
// Since item.conditions is always a ConditionGroup, the result should be too
if (!updatedConditions || !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 {
...survey,
blocks,
questions: [],
};
};

View File

@@ -27,7 +27,6 @@ interface BlockConditionalProps {
setTtc: (ttc: TResponseTtc) => void;
surveyId: string;
autoFocusEnabled: boolean;
currentBlockId: string;
isBackButtonHidden: boolean;
onOpenExternalURL?: (url: string) => void | Promise<void>;
dir?: "ltr" | "rtl" | "auto";
@@ -208,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}

View File

@@ -30,8 +30,6 @@ interface ElementConditionalProps {
onChange: (responseData: TResponseData) => void;
onBack: () => void;
onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
isFirstElement: boolean;
isLastElement: boolean;
languageCode: string;
prefilledElementValue?: TResponseDataValue;
skipPrefilled?: boolean;
@@ -113,195 +111,228 @@ export function ElementConditional({
return null;
}
return (
<div ref={containerRef}>
{element.type === TSurveyElementTypeEnum.OpenText ? (
<OpenTextQuestion
key={element.id}
question={element}
value={typeof value === "string" ? value : ""}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
dir={dir}
/>
) : element.type === TSurveyElementTypeEnum.MultipleChoiceSingle ? (
<MultipleChoiceSingleQuestion
key={element.id}
question={element}
value={typeof value === "string" ? value : undefined}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
dir={dir}
/>
) : element.type === TSurveyElementTypeEnum.MultipleChoiceMulti ? (
<MultipleChoiceMultiQuestion
key={element.id}
question={element}
value={Array.isArray(value) ? value : []}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
dir={dir}
/>
) : element.type === TSurveyElementTypeEnum.NPS ? (
<NPSQuestion
key={element.id}
question={element}
value={typeof value === "number" ? value : undefined}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
dir={dir}
/>
) : element.type === TSurveyElementTypeEnum.CTA ? (
<CTAQuestion
key={element.id}
question={element}
value={typeof value === "string" ? value : ""}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
onOpenExternalURL={onOpenExternalURL}
/>
) : element.type === TSurveyElementTypeEnum.Rating ? (
<RatingQuestion
key={element.id}
question={element}
value={typeof value === "number" ? value : undefined}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
dir={dir}
/>
) : element.type === TSurveyElementTypeEnum.Consent ? (
<ConsentQuestion
key={element.id}
question={element}
value={typeof value === "string" ? value : ""}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
dir={dir}
/>
) : element.type === TSurveyElementTypeEnum.Date ? (
<DateQuestion
key={element.id}
question={element}
value={typeof value === "string" ? value : ""}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
/>
) : element.type === TSurveyElementTypeEnum.PictureSelection ? (
<PictureSelectionQuestion
key={element.id}
question={element}
value={Array.isArray(value) ? value : []}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
dir={dir}
/>
) : element.type === TSurveyElementTypeEnum.FileUpload ? (
<FileUploadQuestion
key={element.id}
surveyId={surveyId}
question={element}
value={Array.isArray(value) ? value : []}
onChange={onChange}
onFileUpload={onFileUpload}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
/>
) : element.type === TSurveyElementTypeEnum.Cal ? (
<CalQuestion
key={element.id}
question={element}
value={typeof value === "string" ? value : ""}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
currentQuestionId={currentElementId}
/>
) : element.type === TSurveyElementTypeEnum.Matrix ? (
<MatrixQuestion
question={element}
value={typeof value === "object" && !Array.isArray(value) ? value : {}}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
currentQuestionId={currentElementId}
/>
) : element.type === TSurveyElementTypeEnum.Address ? (
<AddressQuestion
question={element}
value={Array.isArray(value) ? value : undefined}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
currentQuestionId={currentElementId}
autoFocusEnabled={autoFocusEnabled}
dir={dir}
/>
) : element.type === TSurveyElementTypeEnum.Ranking ? (
<RankingQuestion
question={element}
value={Array.isArray(value) ? getResponseValueForRankingQuestion(value, element.choices) : []}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
/>
) : element.type === TSurveyElementTypeEnum.ContactInfo ? (
<ContactInfoQuestion
question={element}
value={Array.isArray(value) ? value : undefined}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
currentQuestionId={currentElementId}
autoFocusEnabled={autoFocusEnabled}
dir={dir}
/>
) : null}
</div>
);
const renderElement = () => {
switch (element.type) {
case TSurveyElementTypeEnum.OpenText:
return (
<OpenTextQuestion
key={element.id}
question={element}
value={typeof value === "string" ? value : ""}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
dir={dir}
/>
);
case TSurveyElementTypeEnum.MultipleChoiceSingle:
return (
<MultipleChoiceSingleQuestion
key={element.id}
question={element}
value={typeof value === "string" ? value : undefined}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
dir={dir}
/>
);
case TSurveyElementTypeEnum.MultipleChoiceMulti:
return (
<MultipleChoiceMultiQuestion
key={element.id}
question={element}
value={Array.isArray(value) ? value : []}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
dir={dir}
/>
);
case TSurveyElementTypeEnum.NPS:
return (
<NPSQuestion
key={element.id}
question={element}
value={typeof value === "number" ? value : undefined}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
dir={dir}
/>
);
case TSurveyElementTypeEnum.CTA:
return (
<CTAQuestion
key={element.id}
question={element}
value={typeof value === "string" ? value : ""}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
onOpenExternalURL={onOpenExternalURL}
/>
);
case TSurveyElementTypeEnum.Rating:
return (
<RatingQuestion
key={element.id}
question={element}
value={typeof value === "number" ? value : undefined}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
dir={dir}
/>
);
case TSurveyElementTypeEnum.Consent:
return (
<ConsentQuestion
key={element.id}
question={element}
value={typeof value === "string" ? value : ""}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
dir={dir}
/>
);
case TSurveyElementTypeEnum.Date:
return (
<DateQuestion
key={element.id}
question={element}
value={typeof value === "string" ? value : ""}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
/>
);
case TSurveyElementTypeEnum.PictureSelection:
return (
<PictureSelectionQuestion
key={element.id}
question={element}
value={Array.isArray(value) ? value : []}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
dir={dir}
/>
);
case TSurveyElementTypeEnum.FileUpload:
return (
<FileUploadQuestion
key={element.id}
surveyId={surveyId}
question={element}
value={Array.isArray(value) ? value : []}
onChange={onChange}
onFileUpload={onFileUpload}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
/>
);
case TSurveyElementTypeEnum.Cal:
return (
<CalQuestion
key={element.id}
question={element}
value={typeof value === "string" ? value : ""}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
currentQuestionId={currentElementId}
/>
);
case TSurveyElementTypeEnum.Matrix:
return (
<MatrixQuestion
question={element}
value={typeof value === "object" && !Array.isArray(value) ? value : {}}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
currentQuestionId={currentElementId}
/>
);
case TSurveyElementTypeEnum.Address:
return (
<AddressQuestion
question={element}
value={Array.isArray(value) ? value : undefined}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
currentQuestionId={currentElementId}
autoFocusEnabled={autoFocusEnabled}
dir={dir}
/>
);
case TSurveyElementTypeEnum.Ranking:
return (
<RankingQuestion
question={element}
value={Array.isArray(value) ? getResponseValueForRankingQuestion(value, element.choices) : []}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentElementId}
/>
);
case TSurveyElementTypeEnum.ContactInfo:
return (
<ContactInfoQuestion
question={element}
value={Array.isArray(value) ? value : undefined}
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
currentQuestionId={currentElementId}
autoFocusEnabled={autoFocusEnabled}
dir={dir}
/>
);
default:
return null;
}
};
return <div ref={containerRef}>{renderElement()}</div>;
}

View File

@@ -777,7 +777,6 @@ export function Survey({
isLastBlock={block.id === localSurvey.blocks[localSurvey.blocks.length - 1].id}
languageCode={selectedLanguage}
autoFocusEnabled={autoFocusEnabled}
currentBlockId={blockId}
isBackButtonHidden={localSurvey.isBackButtonHidden}
onOpenExternalURL={onOpenExternalURL}
dir={dir}

View File

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

View File

@@ -36,44 +36,58 @@ export function AddressQuestion({
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const formRef = useRef<HTMLFormElement>(null);
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
const safeValue = useMemo(() => {
return Array.isArray(value) ? value : ["", "", "", "", "", ""];
}, [value]);
const isCurrent = question.id === currentQuestionId;
const fields = [
{
id: "addressLine1",
...question.addressLine1,
label: question.addressLine1.placeholder[languageCode],
},
{
id: "addressLine2",
...question.addressLine2,
label: question.addressLine2.placeholder[languageCode],
},
{
id: "city",
...question.city,
label: question.city.placeholder[languageCode],
},
{
id: "state",
...question.state,
label: question.state.placeholder[languageCode],
},
{
id: "zip",
...question.zip,
label: question.zip.placeholder[languageCode],
},
{
id: "country",
...question.country,
label: question.country.placeholder[languageCode],
},
];
const fields = useMemo(
() => [
{
id: "addressLine1",
...question.addressLine1,
label: question.addressLine1.placeholder[languageCode],
},
{
id: "addressLine2",
...question.addressLine2,
label: question.addressLine2.placeholder[languageCode],
},
{
id: "city",
...question.city,
label: question.city.placeholder[languageCode],
},
{
id: "state",
...question.state,
label: question.state.placeholder[languageCode],
},
{
id: "zip",
...question.zip,
label: question.zip.placeholder[languageCode],
},
{
id: "country",
...question.country,
label: question.country.placeholder[languageCode],
},
],
[
question.addressLine1,
question.addressLine2,
question.city,
question.state,
question.zip,
question.country,
languageCode,
]
);
const handleChange = (fieldId: string, fieldValue: string) => {
const newValue = fields.map((field) => {
@@ -102,6 +116,25 @@ export function AddressQuestion({
[question.id, autoFocusEnabled, currentQuestionId]
);
const isFieldRequired = useCallback(
(field: (typeof fields)[number]) => {
if (field.required) {
return true;
}
// if all fields are optional and the question is required, then the fields should be required
if (
fields.filter((currField) => currField.show).every((currField) => !currField.required) &&
question.required
) {
return true;
}
return false;
},
[fields, question.required]
);
return (
<form key={question.id} onSubmit={handleSubmit} className="fb-w-full" ref={formRef}>
<div>
@@ -118,32 +151,17 @@ export function AddressQuestion({
<div className="fb-flex fb-flex-col fb-space-y-2 fb-mt-4 fb-w-full">
{fields.map((field, index) => {
const isFieldRequired = () => {
if (field.required) {
return true;
}
// if all fields are optional and the question is required, then the fields should be required
if (
fields.filter((currField) => currField.show).every((currField) => !currField.required) &&
question.required
) {
return true;
}
return false;
};
const isRequired = isFieldRequired(field);
return (
field.show && (
<div className="fb-space-y-1">
<Label htmlForId={field.id} text={isFieldRequired() ? `${field.label}*` : field.label} />
<div className="fb-space-y-1" key={field.id}>
<Label htmlForId={field.id} text={isRequired ? `${field.label}*` : field.label} />
<Input
id={field.id}
key={field.id}
required={isFieldRequired()}
required={isRequired}
value={safeValue[index] || ""}
type={field.id === "email" ? "email" : "text"}
type="text"
onChange={(e) => {
handleChange(field.id, e.currentTarget.value);
}}

View File

@@ -35,10 +35,11 @@ export function CalQuestion({
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
const onSuccessfulBooking = useCallback(() => {
setErrorMessage("");
onChange({ [question.id]: "booked" });
const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedttc);
}, [onChange, question.id, setTtc, startTime, ttc]);
}, [onChange, question.id, setTtc, startTime, ttc, setErrorMessage]);
return (
<form

View File

@@ -96,7 +96,7 @@ export function ConsentQuestion({
aria-labelledby={`${question.id}-label`}
required={question.required}
/>
<span id={`${question.id}-label`} className="fb-ml-3 fb-mr-3 fb-font-medium fb-flex-1" dir="auto">
<span className="fb-ml-3 fb-mr-3 fb-font-medium fb-flex-1" dir="auto">
{getLocalizedValue(question.label, languageCode)}
</span>
</label>

View File

@@ -127,10 +127,14 @@ export function DateQuestion({
key={question.id}
onSubmit={(e) => {
e.preventDefault();
// Validate required field
if (question.required && !value) {
setErrorMessage(t("errors.please_select_a_date"));
return;
}
setErrorMessage("");
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
}}
@@ -189,6 +193,15 @@ export function DateQuestion({
isOpen={datePickerOpen}
onChange={(value) => {
const date = value as Date;
if (!date) {
if (question.required) {
setErrorMessage(t("errors.please_select_a_date"));
}
return;
}
setErrorMessage("");
setSelectedDate(date);
// Get the timezone offset in minutes and convert it to milliseconds

View File

@@ -237,9 +237,8 @@ export function MultipleChoiceMultiQuestion({
baseLabelClassName
)}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
// Accessibility: if spacebar was pressed pass this down to the checkbox
if (e.key === " ") {
if (otherSelected) return;
e.preventDefault();
document.getElementById(otherOption.id)?.click();
}
@@ -248,7 +247,7 @@ export function MultipleChoiceMultiQuestion({
<input
type="checkbox"
dir={dir}
tabIndex={-1}
tabIndex={isCurrent ? 0 : -1}
id={otherOption.id}
name={question.id}
value={getLocalizedValue(otherOption.label, languageCode)}
@@ -279,7 +278,7 @@ export function MultipleChoiceMultiQuestion({
<input
ref={otherSpecify}
dir={otherOptionInputDir}
id={`${otherOption.id}-label`}
id={`${otherOption.id}-specify`}
maxLength={250}
name={question.id}
tabIndex={isCurrent ? 0 : -1}

View File

@@ -180,9 +180,7 @@ export function MultipleChoiceSingleQuestion({
<label
tabIndex={isCurrent ? 0 : -1}
className={cn(
value === getLocalizedValue(otherOption.label, languageCode)
? "fb-border-brand fb-bg-input-bg-selected fb-z-10"
: "fb-border-border",
otherSelected ? "fb-border-brand fb-bg-input-bg-selected fb-z-10" : "fb-border-border",
"fb-text-heading focus-within:fb-border-brand fb-bg-input-bg focus-within:fb-bg-input-bg-selected hover:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none"
)}
onKeyDown={(e) => {
@@ -224,11 +222,11 @@ export function MultipleChoiceSingleQuestion({
{otherSelected ? (
<input
ref={otherSpecify}
id={`${otherOption.id}-label`}
id={`${otherOption.id}-input`}
dir={otherOptionInputDir}
name={question.id}
pattern=".*\S+.*"
value={value}
value={value ?? ""}
onChange={(e) => {
onChange({ [question.id]: e.currentTarget.value });
}}

View File

@@ -169,7 +169,7 @@ export function OpenTextQuestion({
)}
{question.inputType === "text" && question.charLimit?.max !== undefined && (
<span
className={`fb-text-xs ${currentLength >= question.charLimit?.max ? "fb-text-red-500 font-semibold" : "text-neutral-400"}`}>
className={`fb-text-xs ${currentLength >= question.charLimit?.max ? "fb-text-red-500 fb-font-semibold" : "fb-text-neutral-400"}`}>
{currentLength}/{question.charLimit?.max}
</span>
)}

View File

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

View File

@@ -1199,7 +1199,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) =>
@@ -1224,7 +1227,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) =>