survey mqp survey editor logic

This commit is contained in:
pandeymangg
2025-11-04 22:31:35 +05:30
parent 3d0f703ae1
commit c618e7d473
40 changed files with 1047 additions and 526 deletions

View File

@@ -1,6 +1,7 @@
import { createId } from "@paralleldrive/cuid2";
import { TFunction } from "i18next";
import { TSurvey, TSurveyOpenTextQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurveyElementTypeEnum, TSurveyOpenTextElement } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TTemplate } from "@formbricks/types/templates";
import {
buildCTAQuestion,
@@ -3598,19 +3599,26 @@ export const customSurveyTemplate = (t: TFunction): TTemplate => {
preset: {
...getDefaultSurveyPreset(t),
name: t("templates.custom_survey_name"),
questions: [
questions: [],
blocks: [
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: createI18nString(t("templates.custom_survey_question_1_headline"), []),
placeholder: createI18nString(t("templates.custom_survey_question_1_placeholder"), []),
buttonLabel: createI18nString(t("templates.next"), []),
required: true,
inputType: "text",
charLimit: {
enabled: false,
},
} as TSurveyOpenTextQuestion,
name: "Block 1",
elements: [
{
id: createId(),
type: TSurveyElementTypeEnum.OpenText,
headline: createI18nString(t("templates.custom_survey_question_1_headline"), []),
placeholder: createI18nString(t("templates.custom_survey_question_1_placeholder"), []),
buttonLabel: createI18nString(t("templates.next"), []),
required: true,
inputType: "text",
charLimit: {
enabled: false,
},
} as TSurveyOpenTextElement,
],
},
],
},
};

View File

@@ -1352,6 +1352,7 @@ checksums:
environments/surveys/edit/is_skipped: 9fb90b6578f603cca37d4e6c912bb401
environments/surveys/edit/is_submitted: 13e774a97ad5f5609555e6f99514e70f
environments/surveys/edit/italic: 555c60fb1d12ae305136202afa6deb3d
environments/surveys/edit/jump_to_block: 2fc00bd725c44f98861051c57bb2c392
environments/surveys/edit/jump_to_question: 742aabed8845190825418aa429f01b2d
environments/surveys/edit/keep_current_order: a7c944ad6b3515f2c4f83a2c81f8fc26
environments/surveys/edit/keep_showing_while_conditions_match: 2574802d87bd6da151c9145aacce7281
@@ -1532,6 +1533,7 @@ checksums:
environments/surveys/edit/unlock_targeting_title: 6098caf969cac64cd54e217471ae42d4
environments/surveys/edit/unsaved_changes_warning: a164f276c9f7344022aa4640b32abcf9
environments/surveys/edit/until_they_submit_a_response: c980c520f5b5883ed46f2e1c006082b5
environments/surveys/edit/untitled_block: fdaa045139deff5cc65fa027df0cc22e
environments/surveys/edit/upgrade_notice_description: 32b66a4f257ad8d38bc38dcc95fe23c4
environments/surveys/edit/upgrade_notice_title: 40866066ebc558ad0c92a4f19f12090c
environments/surveys/edit/upload: 4a6c84aa16db0f4e5697f49b45257bc7

View File

@@ -1,12 +1,14 @@
import { createId } from "@paralleldrive/cuid2";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
import {
TSurveyBlockLogic,
TSurveyBlockLogicAction,
TSurveyBlockLogicActionObjective,
} from "@formbricks/types/surveys/blocks";
import { TConditionGroup, TSingleCondition } from "@formbricks/types/surveys/logic";
import {
TActionCalculate,
TActionObjective,
TSurveyLogic,
TSurveyLogicAction,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveyVariable,
@@ -19,7 +21,7 @@ export const isConditionGroup = (condition: TCondition): condition is TCondition
return (condition as TConditionGroup).connector !== undefined;
};
export const duplicateLogicItem = (logicItem: TSurveyLogic): TSurveyLogic => {
export const duplicateLogicItem = (logicItem: TSurveyBlockLogic): TSurveyBlockLogic => {
const duplicateConditionGroup = (group: TConditionGroup): TConditionGroup => {
return {
...group,
@@ -41,7 +43,7 @@ export const duplicateLogicItem = (logicItem: TSurveyLogic): TSurveyLogic => {
};
};
const duplicateAction = (action: TSurveyLogicAction): TSurveyLogicAction => {
const duplicateAction = (action: TSurveyBlockLogicAction): TSurveyBlockLogicAction => {
return {
...action,
id: createId(),
@@ -197,9 +199,9 @@ export const updateCondition = (
};
export const getUpdatedActionBody = (
action: TSurveyLogicAction,
objective: TActionObjective
): TSurveyLogicAction => {
action: TSurveyBlockLogicAction,
objective: TSurveyBlockLogicActionObjective
): TSurveyBlockLogicAction => {
if (objective === action.objective) return action;
switch (objective) {
case "calculate":
@@ -216,12 +218,14 @@ export const getUpdatedActionBody = (
objective: "requireAnswer",
target: "",
};
case "jumpToQuestion":
case "jumpToBlock":
return {
id: action.id,
objective: "jumpToQuestion",
objective: "jumpToBlock",
target: "",
};
default:
return action;
}
};
@@ -622,7 +626,7 @@ const getRightOperandValue = (
export const performActions = (
survey: TJsEnvironmentStateSurvey,
actions: TSurveyLogicAction[],
actions: TSurveyBlockLogicAction[],
data: TResponseData,
calculationResults: TResponseVariables
): {
@@ -643,7 +647,7 @@ export const performActions = (
case "requireAnswer":
requiredQuestionIds.push(action.target);
break;
case "jumpToQuestion":
case "jumpToBlock":
if (!jumpTarget) {
jumpTarget = action.target;
}

View File

@@ -1,6 +1,7 @@
import { type TI18nString } from "@formbricks/types/i18n";
import { TResponseData, TResponseDataValue, TResponseVariables } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
@@ -60,7 +61,8 @@ const getRecallItemLabel = <T extends TSurvey>(
const isHiddenField = survey.hiddenFields.fieldIds?.includes(recallItemId);
if (isHiddenField) return recallItemId;
const surveyQuestion = survey.questions.find((question) => question.id === recallItemId);
const questions = survey.blocks.flatMap((b) => b.elements);
const surveyQuestion = questions.find((question) => question.id === recallItemId);
if (surveyQuestion) {
const headline = getLocalizedValue(surveyQuestion.headline, languageCode);
// Strip HTML tags to prevent raw HTML from showing in nested recalls
@@ -123,13 +125,14 @@ export const replaceRecallInfoWithUnderline = (label: string): string => {
};
// Checks for survey questions with a "recall" pattern but no fallback value.
export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): TSurveyQuestion | null => {
export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): TSurveyElement | null => {
const doesTextHaveRecall = (text: string) => {
const recalls = text.match(/#recall:[^ ]+/g);
return recalls?.some((recall) => !extractFallbackValue(recall));
};
for (const question of survey.questions) {
const questions = survey.blocks.flatMap((b) => b.elements);
for (const question of questions) {
if (
doesTextHaveRecall(getLocalizedValue(question.headline, language)) ||
(question.subheader && doesTextHaveRecall(getLocalizedValue(question.subheader, language)))
@@ -157,7 +160,8 @@ export const getRecallItems = (text: string, survey: TSurvey, languageCode: stri
let recallItems: TSurveyRecallItem[] = [];
ids.forEach((recallItemId) => {
const isHiddenField = survey.hiddenFields.fieldIds?.includes(recallItemId);
const isSurveyQuestion = survey.questions.find((question) => question.id === recallItemId);
const questions = survey.blocks.flatMap((b) => b.elements);
const isSurveyQuestion = questions.find((question) => question.id === recallItemId);
const isVariable = survey.variables.find((variable) => variable.id === recallItemId);
const recallItemLabel = getRecallItemLabel(recallItemId, survey, languageCode);

View File

@@ -62,6 +62,11 @@ export function LocalizedEditor({
autoFocus,
isExternalUrlsAllowed,
}: Readonly<LocalizedEditorProps>) {
// Derive questions from blocks for migrated surveys
const questions = useMemo(
() => localSurvey.blocks.flatMap((block) => block.elements),
[localSurvey.blocks]
);
const { t } = useTranslation();
const isInComplete = useMemo(
@@ -99,12 +104,12 @@ export function LocalizedEditor({
}
// Check if the question still exists before updating
const currentQuestion = localSurvey.questions[questionIdx];
const currentQuestion = questions[questionIdx];
// if this is a card, we wanna check if the card exists in the localSurvey
if (isCard) {
const isWelcomeCard = questionIdx === -1;
const isEndingCard = questionIdx >= localSurvey.questions.length;
const isEndingCard = questionIdx >= questions.length;
// For ending cards, check if the field exists before updating
if (isEndingCard) {

View File

@@ -1,8 +1,9 @@
"use client";
import { Language } from "@prisma/client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { LanguageToggle } from "./language-toggle";
@@ -10,7 +11,7 @@ interface SecondaryLanguageSelectProps {
projectLanguages: Language[];
defaultLanguage: Language;
setSelectedLanguageCode: (languageCode: string) => void;
setActiveQuestionId: (questionId: TSurveyQuestionId) => void;
setActiveQuestionId: (questionId: string) => void;
localSurvey: TSurvey;
updateSurveyLanguages: (language: Language) => void;
locale: TUserLocale;
@@ -32,6 +33,10 @@ export function SecondaryLanguageSelect({
);
};
const questions = useMemo(() => {
return localSurvey.blocks.flatMap((block) => block.elements);
}, [localSurvey.blocks]);
return (
<div className="space-y-2">
<p className="text-sm font-medium text-slate-800">
@@ -46,7 +51,7 @@ export function SecondaryLanguageSelect({
language={language}
onEdit={() => {
setSelectedLanguageCode(language.code);
setActiveQuestionId(localSurvey.questions[0]?.id);
setActiveQuestionId(questions[0]?.id);
}}
onToggle={() => {
updateSurveyLanguages(language);

View File

@@ -80,7 +80,14 @@ export const QuotaModal = ({
const { t } = useTranslation();
const [openConfirmationModal, setOpenConfirmationModal] = useState(false);
const [openConfirmChangesInInclusionCriteria, setOpenConfirmChangesInInclusionCriteria] = useState(false);
// Derive questions from blocks (with fallback to legacy questions)
const questions = useMemo(() => {
return survey.blocks.flatMap((block) => block.elements);
}, [survey.blocks]);
const defaultValues = useMemo(() => {
const firstQuestion = questions[0];
return {
name: quota?.name || "",
limit: quota?.limit || 1,
@@ -89,8 +96,8 @@ export const QuotaModal = ({
conditions: [
{
id: createId(),
leftOperand: { type: "question", value: survey.questions[0]?.id },
operator: getDefaultOperatorForQuestion(survey.questions[0], t),
leftOperand: { type: "question", value: firstQuestion?.id },
operator: firstQuestion ? getDefaultOperatorForQuestion(firstQuestion, t) : "equals",
},
],
},
@@ -99,7 +106,7 @@ export const QuotaModal = ({
countPartialSubmissions: quota?.countPartialSubmissions || false,
surveyId: survey.id,
};
}, [quota, survey]);
}, [quota, survey, questions, t]);
const form = useForm<TSurveyQuotaInput>({
defaultValues,

View File

@@ -13,7 +13,8 @@ import { render } from "@react-email/render";
import { TFunction } from "i18next";
import { CalendarDaysIcon, UploadIcon } from "lucide-react";
import React from "react";
import { type TSurvey, TSurveyQuestionTypeEnum, type TSurveyStyling } from "@formbricks/types/surveys/types";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { type TSurvey, type TSurveyStyling } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
import { WEBAPP_URL } from "@/lib/constants";
import { getLocalizedValue } from "@/lib/i18n/utils";
@@ -77,13 +78,26 @@ export async function PreviewEmailTemplate({
const url = `${surveyUrl}?preview=true`;
const urlWithPrefilling = `${surveyUrl}?preview=true&skipPrefilled=true&`;
const defaultLanguageCode = "default";
const firstQuestion = survey.questions[0];
// Derive questions from blocks
const questions = survey.blocks.flatMap((block) => block.elements);
const firstQuestion = questions[0] as TSurveyElement;
const headline = parseRecallInfo(getLocalizedValue(firstQuestion.headline, defaultLanguageCode));
const subheader = parseRecallInfo(getLocalizedValue(firstQuestion.subheader, defaultLanguageCode));
const brandColor = styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor;
const getButtonLabel = (survey: TSurvey, defaultLanguageCode: string) => {
const ctaQuestionBlock = survey.blocks.find((block) =>
block.elements.some((element) => element.type === TSurveyElementTypeEnum.CTA)
);
if (ctaQuestionBlock) {
return getLocalizedValue(ctaQuestionBlock.buttonLabel, defaultLanguageCode);
}
return t("common.next");
};
switch (firstQuestion.type) {
case TSurveyQuestionTypeEnum.OpenText:
case TSurveyElementTypeEnum.OpenText:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
@@ -91,7 +105,7 @@ export async function PreviewEmailTemplate({
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.Consent:
case TSurveyElementTypeEnum.Consent:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
@@ -120,7 +134,7 @@ export async function PreviewEmailTemplate({
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.NPS:
case TSurveyElementTypeEnum.NPS:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<Section className="w-full justify-center">
@@ -169,7 +183,7 @@ export async function PreviewEmailTemplate({
</Section>
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.CTA:
case TSurveyElementTypeEnum.CTA:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
@@ -187,13 +201,13 @@ export async function PreviewEmailTemplate({
isLight(brandColor) ? "text-black" : "text-white"
)}
href={`${urlWithPrefilling}${firstQuestion.id}=clicked`}>
{getLocalizedValue(firstQuestion.buttonLabel, defaultLanguageCode)}
{getButtonLabel(survey, defaultLanguageCode)}
</EmailButton>
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.Rating:
case TSurveyElementTypeEnum.Rating:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<Section className="w-full">
@@ -246,7 +260,7 @@ export async function PreviewEmailTemplate({
</Section>
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
case TSurveyElementTypeEnum.MultipleChoiceMulti:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
@@ -262,7 +276,7 @@ export async function PreviewEmailTemplate({
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.Ranking:
case TSurveyElementTypeEnum.Ranking:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
@@ -278,7 +292,7 @@ export async function PreviewEmailTemplate({
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
case TSurveyElementTypeEnum.MultipleChoiceSingle:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
@@ -295,7 +309,7 @@ export async function PreviewEmailTemplate({
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.PictureSelection:
case TSurveyElementTypeEnum.PictureSelection:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
@@ -321,7 +335,7 @@ export async function PreviewEmailTemplate({
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.Cal:
case TSurveyElementTypeEnum.Cal:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<Container>
@@ -337,7 +351,7 @@ export async function PreviewEmailTemplate({
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.Date:
case TSurveyElementTypeEnum.Date:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
@@ -350,7 +364,7 @@ export async function PreviewEmailTemplate({
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.Matrix:
case TSurveyElementTypeEnum.Matrix:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
@@ -391,8 +405,8 @@ export async function PreviewEmailTemplate({
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.Address:
case TSurveyQuestionTypeEnum.ContactInfo:
case TSurveyElementTypeEnum.Address:
case TSurveyElementTypeEnum.ContactInfo:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
@@ -407,7 +421,7 @@ export async function PreviewEmailTemplate({
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.FileUpload:
case TSurveyElementTypeEnum.FileUpload:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />

View File

@@ -15,13 +15,8 @@ import {
} from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
TSurvey,
TSurveyHiddenFields,
TSurveyQuestion,
TSurveyQuestionId,
TSurveyRecallItem,
} from "@formbricks/types/surveys/types";
import { TSurveyElement, TSurveyElementId, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyHiddenFields, TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { getTextContentWithRecallTruncated } from "@/lib/utils/recall";
import {
DropdownMenu,
@@ -46,7 +41,7 @@ const questionIconMapping = {
interface RecallItemSelectProps {
localSurvey: TSurvey;
questionId: TSurveyQuestionId;
questionId: TSurveyElementId;
addRecallItem: (question: TSurveyRecallItem) => void;
setShowRecallItemSelect: (show: boolean) => void;
recallItems: TSurveyRecallItem[];
@@ -64,14 +59,14 @@ export const RecallItemSelect = ({
}: RecallItemSelectProps) => {
const [searchValue, setSearchValue] = useState("");
const { t } = useTranslation();
const isNotAllowedQuestionType = (question: TSurveyQuestion): boolean => {
const isNotAllowedQuestionType = (question: TSurveyElement): boolean => {
return (
question.type === "fileUpload" ||
question.type === "cta" ||
question.type === "consent" ||
question.type === "pictureSelection" ||
question.type === "cal" ||
question.type === "matrix"
question.type === TSurveyElementTypeEnum.FileUpload ||
question.type === TSurveyElementTypeEnum.CTA ||
question.type === TSurveyElementTypeEnum.Consent ||
question.type === TSurveyElementTypeEnum.PictureSelection ||
question.type === TSurveyElementTypeEnum.Cal ||
question.type === TSurveyElementTypeEnum.Matrix
);
};
@@ -114,11 +109,14 @@ export const RecallItemSelect = ({
const isWelcomeCard = questionId === "start";
if (isWelcomeCard) return [];
const isEndingCard = !localSurvey.questions.map((question) => question.id).includes(questionId);
// Derive questions from blocks or fall back to legacy questions array
const questions = localSurvey.blocks.flatMap((block) => block.elements);
const isEndingCard = !questions.map((question) => question.id).includes(questionId);
const idx = isEndingCard
? localSurvey.questions.length
: localSurvey.questions.findIndex((recallQuestion) => recallQuestion.id === questionId);
const filteredQuestions = localSurvey.questions
? questions.length
: questions.findIndex((recallQuestion) => recallQuestion.id === questionId);
const filteredQuestions = questions
.filter((question, index) => {
const notAllowed = isNotAllowedQuestionType(question);
return (
@@ -130,7 +128,7 @@ export const RecallItemSelect = ({
});
return filteredQuestions;
}, [localSurvey.questions, questionId, recallItemIds, selectedLanguageCode]);
}, [localSurvey.blocks, questionId, recallItemIds, selectedLanguageCode]);
const filteredRecallItems: TSurveyRecallItem[] = useMemo(() => {
return [...surveyQuestionRecallItems, ...hiddenFieldRecallItems, ...variableRecallItems].filter(
@@ -146,7 +144,9 @@ export const RecallItemSelect = ({
const getRecallItemIcon = (recallItem: TSurveyRecallItem) => {
switch (recallItem.type) {
case "question":
const question = localSurvey.questions.find((question) => question.id === recallItem.id);
// Derive questions from blocks or fall back to legacy questions array
const questions = localSurvey.blocks.flatMap((block) => block.elements);
const question = questions.find((question) => question.id === recallItem.id);
if (question) {
return questionIconMapping[question?.type as keyof typeof questionIconMapping];
}

View File

@@ -189,7 +189,8 @@ export const RecallWrapper = ({
const info = extractRecallInfo(recallItem.label);
if (info) {
const recallItemId = extractId(info);
const recallQuestion = localSurvey.questions.find((q) => q.id === recallItemId);
const questions = localSurvey.blocks.flatMap((block) => block.elements);
const recallQuestion = questions.find((q) => q.id === recallItemId);
if (recallQuestion) {
// replace nested recall with "___"
return [recallItem.label.replace(info, "___")];

View File

@@ -6,6 +6,7 @@ import { ImagePlusIcon, TrashIcon } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import {
TSurvey,
TSurveyEndScreenCard,
@@ -92,7 +93,9 @@ export const QuestionFormInput = ({
const defaultLanguageCode =
localSurvey.languages.filter((lang) => lang.default)[0]?.language.code ?? "default";
const usedLanguageCode = selectedLanguageCode === defaultLanguageCode ? "default" : selectedLanguageCode;
const question: TSurveyQuestion = localSurvey.questions[questionIdx];
const questions = localSurvey.blocks?.flatMap((block) => block.elements) ?? localSurvey.questions;
const question: TSurveyElement = questions[questionIdx];
const isChoice = id.includes("choice");
const isMatrixLabelRow = id.includes("row");
const isMatrixLabelColumn = id.includes("column");
@@ -100,7 +103,7 @@ export const QuestionFormInput = ({
return isChoice || isMatrixLabelColumn || isMatrixLabelRow ? id.split("-")[0] : id;
}, [id, isChoice, isMatrixLabelColumn, isMatrixLabelRow]);
const isEndingCard = questionIdx >= localSurvey.questions.length;
const isEndingCard = questionIdx >= questions.length;
const isWelcomeCard = questionIdx === -1;
const index = getIndex(id, isChoice || isMatrixLabelColumn || isMatrixLabelRow);
@@ -108,7 +111,7 @@ export const QuestionFormInput = ({
return isWelcomeCard
? "start"
: isEndingCard
? localSurvey.endings[questionIdx - localSurvey.questions.length].id
? localSurvey.endings[questionIdx - questions.length].id
: question.id;
//eslint-disable-next-line
}, [isWelcomeCard, isEndingCard, question?.id]);
@@ -133,7 +136,7 @@ export const QuestionFormInput = ({
}
if (isEndingCard) {
return getEndingCardText(localSurvey, id, surveyLanguageCodes, questionIdx);
return getEndingCardText(localSurvey, questions, id, surveyLanguageCodes, questionIdx);
}
if ((isMatrixLabelColumn || isMatrixLabelRow) && typeof index === "number") {
@@ -160,6 +163,7 @@ export const QuestionFormInput = ({
localSurvey,
question,
questionIdx,
questions,
surveyLanguageCodes,
]);

View File

@@ -1,5 +1,6 @@
import { TFunction } from "i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import {
TSurvey,
TSurveyMatrixQuestion,
@@ -51,11 +52,12 @@ export const getWelcomeCardText = (
export const getEndingCardText = (
survey: TSurvey,
questions: TSurveyElement[],
id: string,
surveyLanguageCodes: string[],
questionIdx: number
): TI18nString => {
const endingCardIndex = questionIdx - survey.questions.length;
const endingCardIndex = questionIdx - questions.length;
const card = survey.endings[endingCardIndex];
if (card.type === "endScreen") {
return (card[id as keyof typeof card] as TI18nString) || createI18nString("", surveyLanguageCodes);
@@ -69,8 +71,9 @@ export const determineImageUploaderVisibility = (questionIdx: number, localSurve
case -1: // Welcome Card
return false;
default:
// Regular Survey Question
const question = localSurvey.questions[questionIdx];
// Regular Survey Question - derive questions from blocks
const questions = localSurvey.blocks.flatMap((block) => block.elements);
const question = questions[questionIdx];
return (!!question && !!question.imageUrl) || (!!question && !!question.videoUrl);
}
};

View File

@@ -8,7 +8,7 @@ import {
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
import { validateMediaAndPrepareBlocks } from "@/lib/survey/utils";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { selectSurvey } from "@/modules/survey/lib/survey";
@@ -63,7 +63,10 @@ export const createSurvey = async (
delete data.followUps;
}
if (data.questions) checkForInvalidImagesInQuestions(data.questions);
// Validate and prepare blocks
if (data.blocks && data.blocks.length > 0) {
data.blocks = validateMediaAndPrepareBlocks(data.blocks);
}
const survey = await prisma.survey.create({
data: {

View File

@@ -1,13 +1,17 @@
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { ConditionalLogic } from "@/modules/survey/editor/components/conditional-logic";
import { OptionIds } from "@/modules/survey/editor/components/option-ids";
import { UpdateQuestionId } from "@/modules/survey/editor/components/update-question-id";
interface AdvancedSettingsProps {
question: TSurveyQuestion;
question: TSurveyElement;
questionIdx: number;
localSurvey: TSurvey;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void;
selectedLanguageCode: string;
}
@@ -16,19 +20,23 @@ export const AdvancedSettings = ({
questionIdx,
localSurvey,
updateQuestion,
updateBlockLogic,
updateBlockLogicFallback,
selectedLanguageCode,
}: AdvancedSettingsProps) => {
const showOptionIds =
question.type === TSurveyQuestionTypeEnum.PictureSelection ||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
question.type === TSurveyQuestionTypeEnum.Ranking;
question.type === TSurveyElementTypeEnum.PictureSelection ||
question.type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
question.type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
question.type === TSurveyElementTypeEnum.Ranking;
return (
<div className="flex flex-col gap-4">
<ConditionalLogic
question={question}
updateQuestion={updateQuestion}
updateBlockLogic={updateBlockLogic}
updateBlockLogicFallback={updateBlockLogicFallback}
localSurvey={localSurvey}
questionIdx={questionIdx}
/>

View File

@@ -13,7 +13,9 @@ import {
} from "lucide-react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { duplicateLogicItem } from "@/lib/surveyLogic/utils";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { LogicEditor } from "@/modules/survey/editor/components/logic-editor";
@@ -33,8 +35,10 @@ import { Label } from "@/modules/ui/components/label";
interface ConditionalLogicProps {
localSurvey: TSurvey;
questionIdx: number;
question: TSurveyQuestion;
question: TSurveyElement;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void;
}
export function ConditionalLogic({
@@ -42,6 +46,8 @@ export function ConditionalLogic({
question,
questionIdx,
updateQuestion,
updateBlockLogic,
updateBlockLogicFallback,
}: ConditionalLogicProps) {
const { t } = useTranslation();
const transformedSurvey = useMemo(() => {
@@ -51,10 +57,18 @@ export function ConditionalLogic({
return modifiedSurvey;
}, [localSurvey]);
// Find the parent block for this question/element to get its logic
const parentBlock = localSurvey.blocks.find((block) =>
block.elements.some((element) => element.id === question.id)
);
const blockLogic = useMemo(() => parentBlock?.logic ?? [], [parentBlock?.logic]);
const blockLogicFallback = parentBlock?.logicFallback;
const addLogic = () => {
const operator = getDefaultOperatorForQuestion(question, t);
const initialCondition: TSurveyLogic = {
const initialCondition: TSurveyBlockLogic = {
id: createId(),
conditions: {
id: createId(),
@@ -73,55 +87,49 @@ export function ConditionalLogic({
actions: [
{
id: createId(),
objective: "jumpToQuestion",
objective: "jumpToBlock",
target: "",
},
],
};
updateQuestion(questionIdx, {
logic: [...(question?.logic ?? []), initialCondition],
});
updateBlockLogic(questionIdx, [...blockLogic, initialCondition]);
};
const handleRemoveLogic = (logicItemIdx: number) => {
const logicCopy = structuredClone(question.logic ?? []);
const logicCopy = structuredClone(blockLogic);
const isLast = logicCopy.length === 1;
logicCopy.splice(logicItemIdx, 1);
updateQuestion(questionIdx, {
logic: logicCopy,
logicFallback: isLast ? undefined : question.logicFallback,
});
updateBlockLogic(questionIdx, logicCopy);
if (isLast) {
updateBlockLogicFallback(questionIdx, undefined);
}
};
const moveLogic = (from: number, to: number) => {
const logicCopy = structuredClone(question.logic ?? []);
const logicCopy = structuredClone(blockLogic);
const [movedItem] = logicCopy.splice(from, 1);
logicCopy.splice(to, 0, movedItem);
updateQuestion(questionIdx, {
logic: logicCopy,
});
updateBlockLogic(questionIdx, logicCopy);
};
const duplicateLogic = (logicItemIdx: number) => {
const logicCopy = structuredClone(question.logic ?? []);
const logicCopy = structuredClone(blockLogic);
const logicItem = logicCopy[logicItemIdx];
const newLogicItem = duplicateLogicItem(logicItem);
logicCopy.splice(logicItemIdx + 1, 0, newLogicItem);
updateQuestion(questionIdx, {
logic: logicCopy,
});
updateBlockLogic(questionIdx, logicCopy);
};
const [parent] = useAutoAnimate();
useEffect(() => {
if (question.logic?.length === 0 && question.logicFallback) {
updateQuestion(questionIdx, { logicFallback: undefined });
if (blockLogic.length === 0 && blockLogicFallback) {
updateBlockLogicFallback(questionIdx, undefined);
}
}, [question.logic, questionIdx, question.logicFallback, updateQuestion]);
}, [blockLogic, questionIdx, blockLogicFallback, updateBlockLogicFallback]);
return (
<div className="mt-4" ref={parent}>
@@ -130,20 +138,22 @@ export function ConditionalLogic({
<SplitIcon className="h-4 w-4 rotate-90" />
</Label>
{question.logic && question.logic.length > 0 && (
{blockLogic.length > 0 && (
<div className="mt-2 flex flex-col gap-4" ref={parent}>
{question.logic.map((logicItem, logicItemIdx) => (
{blockLogic.map((logicItem, logicItemIdx) => (
<div
key={logicItem.id}
className="relative flex w-full grow items-start gap-2 rounded-lg border border-slate-200 bg-slate-50 p-3">
<LogicEditor
localSurvey={transformedSurvey}
logicItem={logicItem}
logicItem={logicItem as TSurveyBlockLogic}
updateQuestion={updateQuestion}
updateBlockLogic={updateBlockLogic}
updateBlockLogicFallback={updateBlockLogicFallback}
question={question}
questionIdx={questionIdx}
logicIdx={logicItemIdx}
isLast={logicItemIdx === (question.logic ?? []).length - 1}
isLast={logicItemIdx === blockLogic.length - 1}
/>
{logicItem.conditions.conditions.length > 1 && (
@@ -173,7 +183,7 @@ export function ConditionalLogic({
{t("common.move_up")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={logicItemIdx === (question.logic ?? []).length - 1}
disabled={logicItemIdx === blockLogic.length - 1}
onClick={() => {
moveLogic(logicItemIdx, logicItemIdx + 1);
}}

View File

@@ -70,10 +70,9 @@ export const EditorCardMenu = ({
return undefined;
});
const questions = survey.blocks.flatMap((block) => block.elements);
const isDeleteDisabled =
cardType === "question"
? survey.questions.length === 1
: survey.type === "link" && survey.endings.length === 1;
cardType === "question" ? questions.length === 1 : survey.type === "link" && survey.endings.length === 1;
const availableQuestionTypes = isCxMode ? getCXQuestionNameMap(t) : getQuestionNameMap(t);

View File

@@ -43,6 +43,8 @@ export const EndScreenForm = ({
const inputRef = useRef<HTMLInputElement>(null);
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
const questions = localSurvey.blocks.flatMap((block) => block.elements);
const [showEndingCardCTA, setshowEndingCardCTA] = useState<boolean>(
endingCard.type === "endScreen" &&
(!!getLocalizedValue(endingCard.buttonLabel, selectedLanguageCode) || !!endingCard.buttonLink)
@@ -55,7 +57,7 @@ export const EndScreenForm = ({
label={t("common.note") + "*"}
value={endingCard.headline}
localSurvey={localSurvey}
questionIdx={localSurvey.questions.length + endingCardIndex}
questionIdx={questions.length + endingCardIndex}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
@@ -73,7 +75,7 @@ export const EndScreenForm = ({
value={endingCard.subheader}
label={t("common.description")}
localSurvey={localSurvey}
questionIdx={localSurvey.questions.length + endingCardIndex}
questionIdx={questions.length + endingCardIndex}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
@@ -142,7 +144,7 @@ export const EndScreenForm = ({
className="rounded-md"
value={endingCard.buttonLabel}
localSurvey={localSurvey}
questionIdx={localSurvey.questions.length + endingCardIndex}
questionIdx={questions.length + endingCardIndex}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}

View File

@@ -45,27 +45,31 @@ export const HiddenFieldsCard = ({
};
const updateSurvey = (data: TSurveyHiddenFields, currentFieldId?: string) => {
const questions = [...localSurvey.questions];
let updatedSurvey = { ...localSurvey };
// Remove recall info from question headlines
// Remove recall info from question/element headlines
if (currentFieldId) {
questions.forEach((question) => {
for (const [languageCode, headline] of Object.entries(question.headline)) {
if (headline.includes(`recall:${currentFieldId}`)) {
const recallInfo = extractRecallInfo(headline);
if (recallInfo) {
question.headline[languageCode] = headline.replace(recallInfo, "");
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
...block,
elements: block.elements.map((element) => {
const updatedElement = { ...element };
for (const [languageCode, headline] of Object.entries(element.headline)) {
if (headline.includes(`recall:${currentFieldId}`)) {
const recallInfo = extractRecallInfo(headline);
if (recallInfo) {
updatedElement.headline[languageCode] = headline.replace(recallInfo, "");
}
}
}
}
});
return updatedElement;
}),
}));
}
setLocalSurvey({
...localSurvey,
questions,
...updatedSurvey,
hiddenFields: {
...localSurvey.hiddenFields,
...updatedSurvey.hiddenFields,
...data,
},
});
@@ -93,7 +97,8 @@ export const HiddenFieldsCard = ({
);
return;
}
if (recallQuestionIdx === localSurvey.questions.length) {
const totalQuestions = localSurvey.blocks.flatMap((b) => b.elements).length;
if (recallQuestionIdx === totalQuestions) {
toast.error(
t("environments.surveys.edit.hidden_field_used_in_recall_ending_card", { hiddenField: fieldId })
);
@@ -191,7 +196,8 @@ export const HiddenFieldsCard = ({
className="mt-5"
onSubmit={(e) => {
e.preventDefault();
const existingQuestionIds = localSurvey.questions.map((question) => question.id);
const existingElements = localSurvey.blocks.flatMap((b) => b.elements);
const existingQuestionIds = existingElements.map((question) => question.id);
const existingEndingCardIds = localSurvey.endings.map((ending) => ending.id);
const existingHiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];
const validateIdError = validateId(

View File

@@ -3,18 +3,17 @@
import { createId } from "@paralleldrive/cuid2";
import { CopyIcon, CornerDownRightIcon, EllipsisVerticalIcon, PlusIcon, TrashIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
TSurveyBlockLogic,
TSurveyBlockLogicAction,
TSurveyBlockLogicActionObjective,
} from "@formbricks/types/surveys/blocks";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import {
TActionNumberVariableCalculateOperator,
TActionTextVariableCalculateOperator,
} from "@formbricks/types/surveys/logic";
import {
TActionObjective,
TActionVariableValueType,
TSurvey,
TSurveyLogic,
TSurveyLogicAction,
TSurveyQuestion,
} from "@formbricks/types/surveys/types";
import { TActionVariableValueType, TSurvey } from "@formbricks/types/surveys/types";
import { getUpdatedActionBody } from "@/lib/surveyLogic/utils";
import {
getActionObjectiveOptions,
@@ -22,7 +21,7 @@ import {
getActionTargetOptions,
getActionValueOptions,
getActionVariableOptions,
hasJumpToQuestionAction,
hasJumpToBlockAction,
} from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
import {
@@ -36,10 +35,11 @@ import { cn } from "@/modules/ui/lib/utils";
interface LogicEditorActions {
localSurvey: TSurvey;
logicItem: TSurveyLogic;
logicItem: TSurveyBlockLogic;
logicIdx: number;
question: TSurveyQuestion;
question: TSurveyElement;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
questionIdx: number;
}
@@ -48,17 +48,24 @@ export function LogicEditorActions({
logicItem,
logicIdx,
question,
updateQuestion,
updateBlockLogic,
questionIdx,
}: LogicEditorActions) {
const actions = logicItem.actions;
const { t } = useTranslation();
// Find the parent block for this question/element to get its logic
const parentBlock = localSurvey.blocks?.find((block) =>
block.elements.some((element) => element.id === question.id)
);
const blockLogic = parentBlock?.logic ?? [];
const handleActionsChange = (
operation: "remove" | "addBelow" | "duplicate" | "update",
actionIdx: number,
action?: TSurveyLogicAction
action?: TSurveyBlockLogicAction
) => {
const logicCopy = structuredClone(question.logic) ?? [];
const logicCopy = structuredClone(blockLogic);
const currentLogicItem = logicCopy[logicIdx];
const actionsClone = currentLogicItem.actions;
@@ -69,7 +76,7 @@ export function LogicEditorActions({
case "addBelow":
actionsClone.splice(actionIdx + 1, 0, {
id: createId(),
objective: hasJumpToQuestionAction(logicItem.actions) ? "requireAnswer" : "jumpToQuestion",
objective: hasJumpToBlockAction(logicItem.actions) ? "requireAnswer" : "jumpToBlock",
target: "",
});
break;
@@ -82,28 +89,26 @@ export function LogicEditorActions({
break;
}
updateQuestion(questionIdx, {
logic: logicCopy,
});
updateBlockLogic(questionIdx, logicCopy);
};
const handleObjectiveChange = (actionIdx: number, objective: TActionObjective) => {
const handleObjectiveChange = (actionIdx: number, objective: TSurveyBlockLogicActionObjective) => {
const action = actions[actionIdx];
const actionBody = getUpdatedActionBody(action, objective);
handleActionsChange("update", actionIdx, actionBody);
};
const handleValuesChange = (actionIdx: number, values: Partial<TSurveyLogicAction>) => {
const handleValuesChange = (actionIdx: number, values: Partial<TSurveyBlockLogicAction>) => {
const action = actions[actionIdx];
const actionBody = { ...action, ...values } as TSurveyLogicAction;
const actionBody = { ...action, ...values } as TSurveyBlockLogicAction;
handleActionsChange("update", actionIdx, actionBody);
};
const filteredObjectiveOptions = getActionObjectiveOptions(t).filter(
(option) => option.value !== "jumpToQuestion"
(option) => option.value !== "jumpToBlock"
);
const jumpToQuestionActionIdx = actions.findIndex((action) => action.objective === "jumpToQuestion");
const jumpToBlockActionIdx = actions.findIndex((action) => action.objective === "jumpToBlock");
return (
<div className="flex grow flex-col gap-2">
@@ -129,12 +134,12 @@ export function LogicEditorActions({
key={`objective-${action.id}`}
showSearch={false}
options={
jumpToQuestionActionIdx === -1 || idx === jumpToQuestionActionIdx
jumpToBlockActionIdx === -1 || idx === jumpToBlockActionIdx
? getActionObjectiveOptions(t)
: filteredObjectiveOptions
}
value={action.objective}
onChangeValue={(val: TActionObjective) => {
onChangeValue={(val: TSurveyBlockLogicActionObjective) => {
handleObjectiveChange(idx, val);
}}
comboboxClasses="grow"

View File

@@ -1,6 +1,8 @@
"use client";
import { useTranslation } from "react-i18next";
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TConditionGroup } from "@formbricks/types/surveys/logic";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { createSharedConditionsFactory } from "@/modules/survey/editor/lib/shared-conditions-factory";
@@ -10,7 +12,8 @@ import { ConditionsEditor } from "@/modules/ui/components/conditions-editor";
interface LogicEditorConditionsProps {
conditions: TConditionGroup;
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyQuestion>) => void;
question: TSurveyQuestion;
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
question: TSurveyElement;
localSurvey: TSurvey;
questionIdx: number;
logicIdx: number;
@@ -23,11 +26,17 @@ export function LogicEditorConditions({
question,
localSurvey,
questionIdx,
updateQuestion,
updateBlockLogic,
depth = 0,
}: LogicEditorConditionsProps) {
const { t } = useTranslation();
// Find the parent block for this question/element to get its logic
const parentBlock = localSurvey.blocks?.find((block) =>
block.elements.some((element) => element.id === question.id)
);
const blockLogic = parentBlock?.logic ?? [];
const { config, callbacks } = createSharedConditionsFactory(
{
survey: localSurvey,
@@ -38,7 +47,7 @@ export function LogicEditorConditions({
},
{
onConditionsChange: (updater) => {
const logicCopy = structuredClone(question.logic) ?? [];
const logicCopy = structuredClone(blockLogic);
const logicItem = logicCopy[logicIdx];
if (!logicItem) return;
logicItem.conditions = updater(logicItem.conditions);
@@ -47,7 +56,7 @@ export function LogicEditorConditions({
logicCopy.splice(logicIdx, 1);
}
updateQuestion(questionIdx, { logic: logicCopy });
updateBlockLogic(questionIdx, logicCopy);
},
}
);

View File

@@ -3,8 +3,11 @@
import { ArrowRightIcon } from "lucide-react";
import { ReactElement, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
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";
@@ -18,9 +21,11 @@ import {
interface LogicEditorProps {
localSurvey: TSurvey;
logicItem: TSurveyLogic;
logicItem: TSurveyBlockLogic;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
question: TSurveyQuestion;
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void;
question: TSurveyElement;
questionIdx: number;
logicIdx: number;
isLast: boolean;
@@ -30,6 +35,8 @@ export function LogicEditor({
localSurvey,
logicItem,
updateQuestion,
updateBlockLogic,
updateBlockLogicFallback,
question,
questionIdx,
logicIdx,
@@ -37,6 +44,13 @@ export function LogicEditor({
}: LogicEditorProps) {
const { t } = useTranslation();
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
// Find the parent block for this question/element to get its logicFallback
const parentBlock = localSurvey.blocks?.find((block) =>
block.elements.some((element) => element.id === question.id)
);
const blockLogicFallback = parentBlock?.logicFallback;
const fallbackOptions = useMemo(() => {
let options: {
icon?: ReactElement;
@@ -44,12 +58,19 @@ export function LogicEditor({
value: string;
}[] = [];
for (let i = questionIdx + 1; i < localSurvey.questions.length; i++) {
const ques = localSurvey.questions[i];
// Derive questions from blocks
const allQuestions = localSurvey.blocks.flatMap((b) => b.elements);
const blocks = localSurvey.blocks;
for (let i = questionIdx + 1; i < allQuestions.length; i++) {
const ques = allQuestions[i];
// Find block ID for this question
const block = blocks.find((b) => b.elements.some((e) => e.id === ques.id));
options.push({
icon: QUESTIONS_ICON_MAP[ques.type],
label: getLocalizedValue(ques.headline, "default"),
value: ques.id,
label: getTextContent(recallToHeadline(ques.headline, localSurvey, false, "default").default ?? ""),
value: block?.id ?? ques.id, // Block ID if blocks exist, otherwise question ID
});
}
@@ -57,20 +78,24 @@ export function LogicEditor({
options.push({
label:
ending.type === "endScreen"
? getLocalizedValue(ending.headline, "default") || t("environments.surveys.edit.end_screen_card")
? getTextContent(
recallToHeadline(ending.headline ?? { default: "" }, localSurvey, false, "default").default ??
""
) || t("environments.surveys.edit.end_screen_card")
: ending.label || t("environments.surveys.edit.redirect_thank_you_card"),
value: ending.id,
});
});
return options;
}, [localSurvey.questions, localSurvey.endings, question.id, t]);
}, [localSurvey, questionIdx, QUESTIONS_ICON_MAP, t]);
return (
<div className="flex w-full min-w-full grow flex-col gap-4 overflow-x-auto pb-2 text-sm">
<LogicEditorConditions
conditions={logicItem.conditions}
updateQuestion={updateQuestion}
updateBlockLogic={updateBlockLogic}
question={question}
questionIdx={questionIdx}
localSurvey={localSurvey}
@@ -81,6 +106,7 @@ export function LogicEditor({
logicIdx={logicIdx}
question={question}
updateQuestion={updateQuestion}
updateBlockLogic={updateBlockLogic}
localSurvey={localSurvey}
questionIdx={questionIdx}
/>
@@ -95,11 +121,9 @@ export function LogicEditor({
</p>
<Select
autoComplete="true"
defaultValue={question.logicFallback || "defaultSelection"}
defaultValue={blockLogicFallback || "defaultSelection"}
onValueChange={(val) => {
updateQuestion(questionIdx, {
logicFallback: val === "defaultSelection" ? undefined : val,
});
updateBlockLogicFallback(questionIdx, val === "defaultSelection" ? undefined : val);
}}>
<SelectTrigger className="w-auto bg-white">
<SelectValue />

View File

@@ -1,12 +1,12 @@
import Image from "next/image";
import { useTranslation } from "react-i18next";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Label } from "@/modules/ui/components/label";
interface OptionIdsProps {
question: TSurveyQuestion;
question: TSurveyElement;
selectedLanguageCode: string;
}
@@ -15,9 +15,9 @@ export const OptionIds = ({ question, selectedLanguageCode }: OptionIdsProps) =>
const renderChoiceIds = () => {
switch (question.type) {
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
case TSurveyQuestionTypeEnum.Ranking:
case TSurveyElementTypeEnum.MultipleChoiceSingle:
case TSurveyElementTypeEnum.MultipleChoiceMulti:
case TSurveyElementTypeEnum.Ranking:
return (
<div className="flex flex-col gap-2">
{question.choices.map((choice) => (
@@ -28,7 +28,7 @@ export const OptionIds = ({ question, selectedLanguageCode }: OptionIdsProps) =>
</div>
);
case TSurveyQuestionTypeEnum.PictureSelection:
case TSurveyElementTypeEnum.PictureSelection:
return (
<div className="flex flex-col gap-3">
{question.choices.map((choice) => {

View File

@@ -9,6 +9,8 @@ import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import {
TSurvey,
TSurveyQuestion,
@@ -36,6 +38,7 @@ import { OpenQuestionForm } from "@/modules/survey/editor/components/open-questi
import { PictureSelectionForm } from "@/modules/survey/editor/components/picture-selection-form";
import { RankingQuestionForm } from "@/modules/survey/editor/components/ranking-question-form";
import { RatingQuestionForm } from "@/modules/survey/editor/components/rating-question-form";
import { findElementLocation } from "@/modules/survey/editor/lib/blocks";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { getQuestionIconMap, getTSurveyQuestionTypeEnumName } from "@/modules/survey/lib/questions";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
@@ -49,6 +52,13 @@ interface QuestionCardProps {
questionIdx: number;
moveQuestion: (questionIndex: number, up: boolean) => void;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void;
updateBlockButtonLabel: (
blockIndex: number,
labelKey: "buttonLabel" | "backButtonLabel",
labelValue: TI18nString | undefined
) => void;
deleteQuestion: (questionIdx: number) => void;
duplicateQuestion: (questionIdx: number) => void;
activeQuestionId: TSurveyQuestionId | null;
@@ -74,6 +84,9 @@ export const QuestionCard = ({
questionIdx,
moveQuestion,
updateQuestion,
updateBlockLogic,
updateBlockLogicFallback,
updateBlockButtonLabel,
duplicateQuestion,
deleteQuestion,
activeQuestionId,
@@ -97,19 +110,30 @@ export const QuestionCard = ({
const { t } = useTranslation();
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
const open = activeQuestionId === question.id;
const [openAdvanced, setOpenAdvanced] = useState(question.logic && question.logic.length > 0);
// Find the parent block for this question/element to get its logic
const { blockIndex: parentBlockIndex } = findElementLocation(localSurvey, question.id);
const parentBlock = parentBlockIndex !== -1 ? localSurvey.blocks[parentBlockIndex] : undefined;
const blockLogic = parentBlock?.logic ?? [];
const [openAdvanced, setOpenAdvanced] = useState(blockLogic.length > 0);
const [parent] = useAutoAnimate();
// Get button labels from the parent block (not from element)
const blockButtonLabel = parentBlock?.buttonLabel;
const blockBackButtonLabel = parentBlock?.backButtonLabel;
const updateEmptyButtonLabels = (
labelKey: "buttonLabel" | "backButtonLabel",
labelValue: TI18nString,
skipIndex: number
skipBlockIndex: number
) => {
localSurvey.questions.forEach((q, index) => {
if (index === skipIndex) return;
const currentLabel = q[labelKey];
// Update button labels for all blocks except the one at skipBlockIndex
localSurvey.blocks.forEach((block, index) => {
if (index === skipBlockIndex) return;
const currentLabel = block[labelKey];
if (!currentLabel || currentLabel[selectedLanguageCode]?.trim() === "") {
updateQuestion(index, { [labelKey]: labelValue });
updateBlockButtonLabel(index, labelKey, labelValue);
}
});
};
@@ -169,7 +193,11 @@ export const QuestionCard = ({
const handleRequiredToggle = () => {
// Fix for NPS and Rating questions having missing translations when buttonLabel is not removed
if (!question.required && (question.type === "nps" || question.type === "rating")) {
updateQuestion(questionIdx, { required: true, buttonLabel: undefined });
// Remove buttonLabel from the block when making NPS/Rating required
if (parentBlockIndex !== -1) {
updateBlockButtonLabel(parentBlockIndex, "buttonLabel", undefined);
}
updateQuestion(questionIdx, { required: true });
} else {
updateQuestion(questionIdx, { required: !question.required });
}
@@ -510,7 +538,7 @@ export const QuestionCard = ({
{questionIdx !== 0 && (
<QuestionFormInput
id="backButtonLabel"
value={question.backButtonLabel}
value={blockBackButtonLabel}
label={t("environments.surveys.edit.back_button_label")}
localSurvey={localSurvey}
questionIdx={questionIdx}
@@ -522,12 +550,22 @@ export const QuestionCard = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
onBlur={(e) => {
if (!question.backButtonLabel) return;
if (!blockBackButtonLabel) return;
let translatedBackButtonLabel = {
...question.backButtonLabel,
...blockBackButtonLabel,
[selectedLanguageCode]: e.target.value,
};
updateEmptyButtonLabels("backButtonLabel", translatedBackButtonLabel, 0);
if (parentBlockIndex === -1) return;
updateBlockButtonLabel(
parentBlockIndex,
"backButtonLabel",
translatedBackButtonLabel
);
updateEmptyButtonLabels(
"backButtonLabel",
translatedBackButtonLabel,
parentBlockIndex
);
}}
isStorageConfigured={isStorageConfigured}
/>
@@ -535,7 +573,7 @@ export const QuestionCard = ({
<div className="w-full">
<QuestionFormInput
id="buttonLabel"
value={question.buttonLabel}
value={blockButtonLabel}
label={t("environments.surveys.edit.next_button_label")}
localSurvey={localSurvey}
questionIdx={questionIdx}
@@ -546,18 +584,18 @@ export const QuestionCard = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
onBlur={(e) => {
if (!question.buttonLabel) return;
if (!blockButtonLabel) return;
let translatedNextButtonLabel = {
...question.buttonLabel,
...blockButtonLabel,
[selectedLanguageCode]: e.target.value,
};
if (questionIdx === localSurvey.questions.length - 1) return;
updateEmptyButtonLabels(
"buttonLabel",
translatedNextButtonLabel,
localSurvey.questions.length - 1
);
if (parentBlockIndex === -1) return;
updateBlockButtonLabel(parentBlockIndex, "buttonLabel", translatedNextButtonLabel);
// Don't propagate to last block
const lastBlockIndex = localSurvey.blocks.length - 1;
if (parentBlockIndex !== lastBlockIndex) {
updateEmptyButtonLabels("buttonLabel", translatedNextButtonLabel, lastBlockIndex);
}
}}
locale={locale}
isStorageConfigured={isStorageConfigured}
@@ -571,7 +609,7 @@ export const QuestionCard = ({
<div className="mt-4">
<QuestionFormInput
id="backButtonLabel"
value={question.backButtonLabel}
value={blockBackButtonLabel}
label={`"Back" Button Label`}
localSurvey={localSurvey}
questionIdx={questionIdx}
@@ -582,16 +620,37 @@ export const QuestionCard = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
onBlur={(e) => {
if (!blockBackButtonLabel) return;
const translatedBackButtonLabel = {
...blockBackButtonLabel,
[selectedLanguageCode]: e.target.value,
};
if (parentBlockIndex === -1) return;
updateBlockButtonLabel(
parentBlockIndex,
"backButtonLabel",
translatedBackButtonLabel
);
updateEmptyButtonLabels(
"backButtonLabel",
translatedBackButtonLabel,
parentBlockIndex
);
}}
isStorageConfigured={isStorageConfigured}
/>
</div>
)}
<AdvancedSettings
question={question}
// TODO -- We should remove this when we can confirm that everything works fine with the survey editor, not changing this right now in this file because it would require changing the question type to the respective element type in all the question forms.
question={question as unknown as TSurveyElement}
questionIdx={questionIdx}
localSurvey={localSurvey}
updateQuestion={updateQuestion}
updateBlockLogic={updateBlockLogic}
updateBlockLogicFallback={updateBlockLogicFallback}
selectedLanguageCode={selectedLanguageCode}
/>
</Collapsible.CollapsibleContent>

View File

@@ -1,7 +1,10 @@
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { Project } from "@prisma/client";
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { useMemo } from "react";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import { TSurvey, TSurveyQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { QuestionCard } from "@/modules/survey/editor/components/question-card";
@@ -10,6 +13,13 @@ interface QuestionsDraggableProps {
project: Project;
moveQuestion: (questionIndex: number, up: boolean) => void;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void;
updateBlockButtonLabel: (
blockIndex: number,
labelKey: "buttonLabel" | "backButtonLabel",
labelValue: TI18nString | undefined
) => void;
deleteQuestion: (questionIdx: number) => void;
duplicateQuestion: (questionIdx: number) => void;
activeQuestionId: TSurveyQuestionId | null;
@@ -39,6 +49,9 @@ export const QuestionsDroppable = ({
setActiveQuestionId,
setSelectedLanguageCode,
updateQuestion,
updateBlockLogic,
updateBlockLogicFallback,
updateBlockButtonLabel,
addQuestion,
isFormbricksCloud,
isCxMode,
@@ -50,25 +63,33 @@ export const QuestionsDroppable = ({
}: QuestionsDraggableProps) => {
const [parent] = useAutoAnimate();
// Derive questions from blocks for display
const questions = useMemo(() => {
return localSurvey.blocks.flatMap((block) => block.elements);
}, [localSurvey.blocks]);
return (
<div className="group mb-5 flex w-full flex-col gap-5" ref={parent}>
<SortableContext items={localSurvey.questions} strategy={verticalListSortingStrategy}>
{localSurvey.questions.map((question, questionIdx) => (
<SortableContext items={questions} strategy={verticalListSortingStrategy}>
{questions.map((question, questionIdx) => (
<QuestionCard
key={question.id}
localSurvey={localSurvey}
project={project}
question={question}
question={question as unknown as TSurveyQuestion}
questionIdx={questionIdx}
moveQuestion={moveQuestion}
updateQuestion={updateQuestion}
updateBlockLogic={updateBlockLogic}
updateBlockLogicFallback={updateBlockLogicFallback}
updateBlockButtonLabel={updateBlockButtonLabel}
duplicateQuestion={duplicateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
deleteQuestion={deleteQuestion}
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
lastQuestion={questionIdx === localSurvey.questions.length - 1}
lastQuestion={questionIdx === questions.length - 1}
isInvalid={invalidQuestions ? invalidQuestions.includes(question.id) : false}
addQuestion={addQuestion}
isFormbricksCloud={isFormbricksCloud}

View File

@@ -15,16 +15,12 @@ import { Language, Project } from "@prisma/client";
import React, { SetStateAction, useEffect, useMemo } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TSurveyBlock, TSurveyBlockLogic, TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks";
import { findBlocksWithCyclicLogic } from "@formbricks/types/surveys/blocks-validation";
import { type TConditionGroup, type TSingleCondition } from "@formbricks/types/surveys/logic";
import {
TSurvey,
TSurveyLogic,
TSurveyLogicAction,
TSurveyQuestion,
TSurveyQuestionId,
} from "@formbricks/types/surveys/types";
import { findQuestionsWithCyclicLogic } from "@formbricks/types/surveys/validation";
import { TSurvey, TSurveyQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getDefaultEndingCard } from "@/app/lib/survey-builder";
import { addMultiLanguageLabels, extractLanguageCodes } from "@/lib/i18n/utils";
@@ -39,6 +35,13 @@ import { EditWelcomeCard } from "@/modules/survey/editor/components/edit-welcome
import { HiddenFieldsCard } from "@/modules/survey/editor/components/hidden-fields-card";
import { QuestionsDroppable } from "@/modules/survey/editor/components/questions-droppable";
import { SurveyVariablesCard } from "@/modules/survey/editor/components/survey-variables-card";
import {
addBlock,
deleteBlock,
duplicateBlock,
moveBlock,
updateElementInBlock,
} from "@/modules/survey/editor/lib/blocks";
import { findQuestionUsedInLogic, isUsedInQuota, isUsedInRecall } from "@/modules/survey/editor/lib/utils";
import {
isEndingCardValid,
@@ -91,15 +94,41 @@ export const QuestionsView = ({
isExternalUrlsAllowed,
}: QuestionsViewProps) => {
const { t } = useTranslation();
// Derive questions from blocks for display
const questions = useMemo(() => {
return localSurvey.blocks.flatMap((block) => block.elements);
}, [localSurvey.blocks]);
const internalQuestionIdMap = useMemo(() => {
return localSurvey.questions.reduce((acc, question) => {
return questions.reduce((acc, question) => {
acc[question.id] = createId();
return acc;
}, {});
}, [localSurvey.questions]);
}, [questions]);
const surveyLanguages = localSurvey.languages;
const findElementLocation = (elementId: string) => {
const blocks = localSurvey.blocks;
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
const block = blocks[blockIndex];
const elementIndex = block.elements.findIndex((e) => e.id === elementId);
if (elementIndex !== -1) {
return { blockId: block.id, blockIndex, elementIndex };
}
}
return { blockId: null, blockIndex: -1, elementIndex: -1 };
};
const getQuestionIdFromBlockId = (block: TSurveyBlock): string => block.elements[0].id;
const getBlockName = (index: number): string => {
return `Block ${index + 1}`;
};
const handleQuestionLogicChange = (survey: TSurvey, compareId: string, updatedId: string): TSurvey => {
const updateConditions = (conditions: TConditionGroup): TConditionGroup => {
return {
@@ -128,11 +157,12 @@ export const QuestionsView = ({
return updatedCondition;
};
const updateActions = (actions: TSurveyLogicAction[]): TSurveyLogicAction[] => {
const updateActions = (actions: TSurveyBlockLogicAction[]): TSurveyBlockLogicAction[] => {
return actions.map((action) => {
let updatedAction = { ...action };
if (updatedAction.objective === "jumpToQuestion" && updatedAction.target === compareId) {
// Handle jumpToBlock actions (blocks model)
if (updatedAction.objective === "jumpToBlock" && updatedAction.target === compareId) {
updatedAction.target = updatedId;
}
@@ -144,29 +174,43 @@ export const QuestionsView = ({
});
};
const updatedBlocks = survey.blocks.map((block) => {
const updatedElements = block.elements.map((element) => {
let updatedElement = { ...element };
if (element.headline[selectedLanguageCode]?.includes(`recall:${compareId}`)) {
updatedElement.headline = {
...element.headline,
[selectedLanguageCode]: element.headline[selectedLanguageCode].replaceAll(
`recall:${compareId}`,
`recall:${updatedId}`
),
};
}
return updatedElement;
});
// Update block-level logic
let updatedLogic = block.logic;
if (block.logic) {
updatedLogic = block.logic.map((logicRule: TSurveyBlockLogic) => ({
...logicRule,
conditions: updateConditions(logicRule.conditions),
actions: updateActions(logicRule.actions),
}));
}
return {
...block,
elements: updatedElements,
logic: updatedLogic,
};
});
return {
...survey,
questions: survey.questions.map((question) => {
let updatedQuestion = { ...question };
if (question.headline[selectedLanguageCode].includes(`recall:${compareId}`)) {
question.headline[selectedLanguageCode] = question.headline[selectedLanguageCode].replaceAll(
`recall:${compareId}`,
`recall:${updatedId}`
);
}
// Update advanced logic
if (question.logic) {
updatedQuestion.logic = question.logic.map((logicRule: TSurveyLogic) => ({
...logicRule,
conditions: updateConditions(logicRule.conditions),
actions: updateActions(logicRule.actions),
}));
}
return updatedQuestion;
}),
blocks: updatedBlocks,
};
};
@@ -206,15 +250,21 @@ export const QuestionsView = ({
return;
}
const isFirstQuestion = question.id === localSurvey.questions[0].id;
const firstElement = localSurvey.blocks?.[0]?.elements[0];
const isFirstQuestion = firstElement ? question.id === firstElement.id : false;
if (validateQuestion(question, surveyLanguages, isFirstQuestion)) {
// If question is valid, we now check for cyclic logic
const questionsWithCyclicLogic = findQuestionsWithCyclicLogic(localSurvey.questions);
if (validateQuestion(question as unknown as TSurveyQuestion, surveyLanguages, isFirstQuestion)) {
const blocksWithCyclicLogic = findBlocksWithCyclicLogic(localSurvey.blocks);
if (questionsWithCyclicLogic.includes(question.id) && !invalidQuestions.includes(question.id)) {
setInvalidQuestions([...invalidQuestions, question.id]);
return;
for (const blockId of blocksWithCyclicLogic) {
const block = localSurvey.blocks.find((b) => b.id === blockId);
if (block) {
const questionId = getQuestionIdFromBlockId(block);
if (questionId === question.id) {
setInvalidQuestions([...invalidQuestions, question.id]);
return;
}
}
}
setInvalidQuestions(invalidQuestions.filter((id) => id !== question.id));
@@ -226,47 +276,147 @@ export const QuestionsView = ({
};
const updateQuestion = (questionIdx: number, updatedAttributes: any) => {
const question = questions[questionIdx];
if (!question) return;
const { blockId, blockIndex } = findElementLocation(question.id);
if (!blockId || blockIndex === -1) return;
let updatedSurvey = { ...localSurvey };
if ("id" in updatedAttributes) {
// Handle block-level attributes (logic, logicFallback, buttonLabel, backButtonLabel) separately
const blockLevelAttributes: any = {};
const elementLevelAttributes: any = {};
Object.keys(updatedAttributes).forEach((key) => {
if (key === "logic" || key === "logicFallback" || key === "buttonLabel" || key === "backButtonLabel") {
blockLevelAttributes[key] = updatedAttributes[key];
} else {
elementLevelAttributes[key] = updatedAttributes[key];
}
});
// Update block-level attributes if any
if (Object.keys(blockLevelAttributes).length > 0) {
const blocks = [...(updatedSurvey.blocks ?? [])];
blocks[blockIndex] = {
...blocks[blockIndex],
...blockLevelAttributes,
};
updatedSurvey = { ...updatedSurvey, blocks };
}
// Handle element ID changes
if ("id" in elementLevelAttributes) {
// if the survey question whose id is to be changed is linked to logic of any other survey then changing it
const initialQuestionId = updatedSurvey.questions[questionIdx].id;
updatedSurvey = handleQuestionLogicChange(updatedSurvey, initialQuestionId, updatedAttributes.id);
const initialQuestionId = question.id;
updatedSurvey = handleQuestionLogicChange(updatedSurvey, initialQuestionId, elementLevelAttributes.id);
if (invalidQuestions?.includes(initialQuestionId)) {
setInvalidQuestions(
invalidQuestions.map((id) => (id === initialQuestionId ? updatedAttributes.id : id))
invalidQuestions.map((id) => (id === initialQuestionId ? elementLevelAttributes.id : id))
);
}
// relink the question to internal Id
internalQuestionIdMap[updatedAttributes.id] =
internalQuestionIdMap[localSurvey.questions[questionIdx].id];
delete internalQuestionIdMap[localSurvey.questions[questionIdx].id];
setActiveQuestionId(updatedAttributes.id);
internalQuestionIdMap[elementLevelAttributes.id] = internalQuestionIdMap[question.id];
delete internalQuestionIdMap[question.id];
setActiveQuestionId(elementLevelAttributes.id);
}
updatedSurvey.questions[questionIdx] = {
...updatedSurvey.questions[questionIdx],
...updatedAttributes,
// Update element-level attributes if any
if (Object.keys(elementLevelAttributes).length > 0) {
const attributesToCheck = ["upperLabel", "lowerLabel"];
// If the value of upperLabel or lowerLabel is equal to {default:""}, then delete the key
const cleanedAttributes = { ...elementLevelAttributes };
attributesToCheck.forEach((attribute) => {
if (Object.keys(cleanedAttributes).includes(attribute)) {
const currentLabel = cleanedAttributes[attribute];
if (
currentLabel &&
Object.keys(currentLabel).length === 1 &&
currentLabel["default"].trim() === ""
) {
delete cleanedAttributes[attribute];
}
}
});
const result = updateElementInBlock(updatedSurvey, blockId, question.id, cleanedAttributes);
if (!result.ok) {
toast.error(result.error.message);
return;
}
updatedSurvey = result.data;
// Validate the updated question
const updatedQuestion = updatedSurvey.blocks
?.flatMap((b) => b.elements)
.find((q) => q.id === (cleanedAttributes.id ?? question.id));
if (updatedQuestion) {
validateSurveyQuestion(updatedQuestion as unknown as TSurveyQuestion);
}
}
setLocalSurvey(updatedSurvey);
};
// Update block logic (block-level property)
const updateBlockLogic = (questionIdx: number, logic: TSurveyBlockLogic[]) => {
const question = questions[questionIdx];
if (!question) return;
const { blockIndex } = findElementLocation(question.id);
if (blockIndex === -1) return;
const blocks = [...(localSurvey.blocks ?? [])];
blocks[blockIndex] = {
...blocks[blockIndex],
logic,
};
const attributesToCheck = ["buttonLabel", "upperLabel", "lowerLabel"];
setLocalSurvey({ ...localSurvey, blocks });
};
// If the value of buttonLabel, lowerLabel or upperLabel is equal to {default:""}, then delete buttonLabel key
attributesToCheck.forEach((attribute) => {
if (Object.keys(updatedAttributes).includes(attribute)) {
const currentLabel = updatedSurvey.questions[questionIdx][attribute];
if (currentLabel && Object.keys(currentLabel).length === 1 && currentLabel["default"].trim() === "") {
delete updatedSurvey.questions[questionIdx][attribute];
}
}
});
setLocalSurvey(updatedSurvey);
validateSurveyQuestion(updatedSurvey.questions[questionIdx]);
// Update block logic fallback (block-level property)
const updateBlockLogicFallback = (questionIdx: number, logicFallback: string | undefined) => {
const question = questions[questionIdx];
if (!question) return;
const { blockIndex } = findElementLocation(question.id);
if (blockIndex === -1) return;
const blocks = [...(localSurvey.blocks ?? [])];
blocks[blockIndex] = {
...blocks[blockIndex],
logicFallback,
};
setLocalSurvey({ ...localSurvey, blocks });
};
// Update block button label (block-level property)
const updateBlockButtonLabel = (
blockIndex: number,
labelKey: "buttonLabel" | "backButtonLabel",
labelValue: TI18nString | undefined
) => {
const blocks = [...(localSurvey.blocks ?? [])];
blocks[blockIndex] = {
...blocks[blockIndex],
[labelKey]: labelValue,
};
setLocalSurvey({ ...localSurvey, blocks });
};
const deleteQuestion = (questionIdx: number) => {
const questionId = localSurvey.questions[questionIdx].id;
const activeQuestionIdTemp = activeQuestionId ?? localSurvey.questions[0].id;
const question = questions[questionIdx];
if (!question) return;
const questionId = question.id;
const activeQuestionIdTemp = activeQuestionId ?? questions[0]?.id;
let updatedSurvey: TSurvey = { ...localSurvey };
// checking if this question is used in logic of any other question
@@ -277,7 +427,7 @@ export const QuestionsView = ({
}
const recallQuestionIdx = isUsedInRecall(localSurvey, questionId);
if (recallQuestionIdx === localSurvey.questions.length) {
if (recallQuestionIdx === questions.length) {
toast.error(t("environments.surveys.edit.question_used_in_recall_ending_card"));
return;
}
@@ -300,26 +450,43 @@ export const QuestionsView = ({
}
// check if we are recalling from this question for every language
updatedSurvey.questions.forEach((question) => {
for (const [languageCode, headline] of Object.entries(question.headline)) {
if (headline.includes(`recall:${questionId}`)) {
const recallInfo = extractRecallInfo(headline);
if (recallInfo) {
question.headline[languageCode] = headline.replace(recallInfo, "");
updatedSurvey.blocks = (updatedSurvey.blocks ?? []).map((block) => ({
...block,
elements: block.elements.map((element) => {
const updatedElement = { ...element };
for (const [languageCode, headline] of Object.entries(element.headline)) {
if (headline.includes(`recall:${questionId}`)) {
const recallInfo = extractRecallInfo(headline);
if (recallInfo) {
updatedElement.headline = {
...updatedElement.headline,
[languageCode]: headline.replace(recallInfo, ""),
};
}
}
}
}
});
return updatedElement;
}),
}));
updatedSurvey.questions.splice(questionIdx, 1);
// Find and delete the block containing this question
const { blockId } = findElementLocation(questionId);
if (!blockId) return;
const result = deleteBlock(updatedSurvey, blockId);
if (!result.ok) {
toast.error(result.error.message);
return;
}
const firstEndingCard = localSurvey.endings[0];
setLocalSurvey(updatedSurvey);
setLocalSurvey(result.data);
delete internalQuestionIdMap[questionId];
if (questionId === activeQuestionIdTemp) {
if (questionIdx <= localSurvey.questions.length && localSurvey.questions.length > 0) {
setActiveQuestionId(localSurvey.questions[questionIdx % localSurvey.questions.length].id);
const newQuestions = result.data.blocks?.flatMap((b) => b.elements) ?? [];
if (questionIdx <= newQuestions.length && newQuestions.length > 0) {
setActiveQuestionId(newQuestions[questionIdx % newQuestions.length].id);
} else if (firstEndingCard) {
setActiveQuestionId(firstEndingCard.id);
}
@@ -329,42 +496,52 @@ export const QuestionsView = ({
};
const duplicateQuestion = (questionIdx: number) => {
const questionToDuplicate = structuredClone(localSurvey.questions[questionIdx]);
const question = questions[questionIdx];
if (!question) return;
const newQuestionId = createId();
const { blockId } = findElementLocation(question.id);
if (!blockId) return;
// create a copy of the question with a new id
const duplicatedQuestion = {
...questionToDuplicate,
id: newQuestionId,
};
const result = duplicateBlock(localSurvey, blockId);
// insert the new question right after the original one
const updatedSurvey = { ...localSurvey };
updatedSurvey.questions.splice(questionIdx + 1, 0, duplicatedQuestion);
if (!result.ok) {
toast.error(result.error.message);
return;
}
setLocalSurvey(updatedSurvey);
setActiveQuestionId(newQuestionId);
internalQuestionIdMap[newQuestionId] = createId();
// The duplicated block has new element IDs, find the first one
const allBlocks = result.data.blocks ?? [];
const { blockIndex } = findElementLocation(question.id);
const duplicatedBlock = allBlocks[blockIndex + 1];
const newElementId = duplicatedBlock?.elements[0]?.id;
if (newElementId) {
setActiveQuestionId(newElementId);
internalQuestionIdMap[newElementId] = createId();
}
setLocalSurvey(result.data);
toast.success(t("environments.surveys.edit.question_duplicated"));
};
const addQuestion = (question: TSurveyQuestion, index?: number) => {
const updatedSurvey = { ...localSurvey };
const newQuestions = [...localSurvey.questions];
const languageSymbols = extractLanguageCodes(localSurvey.languages);
const updatedQuestion = addMultiLanguageLabels(question, languageSymbols);
if (index !== undefined) {
newQuestions.splice(index, 0, { ...updatedQuestion, isDraft: true });
} else {
newQuestions.push({ ...updatedQuestion, isDraft: true });
}
updatedSurvey.questions = newQuestions;
const blockName = getBlockName(index ?? questions.length);
const newBlock = {
name: blockName,
elements: [{ ...updatedQuestion, isDraft: true }],
};
setLocalSurvey(updatedSurvey);
const result = addBlock(t, localSurvey, newBlock, index);
if (!result.ok) {
toast.error(result.error.message);
return;
}
setLocalSurvey(result.data);
setActiveQuestionId(question.id);
internalQuestionIdMap[question.id] = createId();
};
@@ -380,12 +557,21 @@ export const QuestionsView = ({
};
const moveQuestion = (questionIndex: number, up: boolean) => {
const newQuestions = Array.from(localSurvey.questions);
const [reorderedQuestion] = newQuestions.splice(questionIndex, 1);
const destinationIndex = up ? questionIndex - 1 : questionIndex + 1;
newQuestions.splice(destinationIndex, 0, reorderedQuestion);
const updatedSurvey = { ...localSurvey, questions: newQuestions };
setLocalSurvey(updatedSurvey);
const question = questions[questionIndex];
if (!question) return;
const { blockId } = findElementLocation(question.id);
if (!blockId) return;
const direction = up ? "up" : "down";
const result = moveBlock(localSurvey, blockId, direction);
if (!result.ok) {
toast.error(result.error.message);
return;
}
setLocalSurvey(result.data);
};
//useEffect to validate survey when changes are made to languages
@@ -393,9 +579,9 @@ export const QuestionsView = ({
if (!invalidQuestions) return;
let updatedInvalidQuestions: string[] = invalidQuestions;
// Validate each question
localSurvey.questions.forEach((question, index) => {
questions.forEach((question, index) => {
updatedInvalidQuestions = validateSurveyQuestionsInBatch(
question,
question as unknown as TSurveyQuestion,
updatedInvalidQuestions,
surveyLanguages,
index === 0
@@ -405,7 +591,7 @@ export const QuestionsView = ({
if (JSON.stringify(updatedInvalidQuestions) !== JSON.stringify(invalidQuestions)) {
setInvalidQuestions(updatedInvalidQuestions);
}
}, [localSurvey.questions, surveyLanguages, invalidQuestions, setInvalidQuestions]);
}, [questions, surveyLanguages, invalidQuestions, setInvalidQuestions]);
useEffect(() => {
const questionWithEmptyFallback = checkForEmptyFallBackValue(localSurvey, selectedLanguageCode);
@@ -429,13 +615,24 @@ export const QuestionsView = ({
const onQuestionCardDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
const newQuestions = Array.from(localSurvey.questions);
const sourceIndex = newQuestions.findIndex((question) => question.id === active.id);
const destinationIndex = newQuestions.findIndex((question) => question.id === over?.id);
const [reorderedQuestion] = newQuestions.splice(sourceIndex, 1);
newQuestions.splice(destinationIndex, 0, reorderedQuestion);
const updatedSurvey = { ...localSurvey, questions: newQuestions };
setLocalSurvey(updatedSurvey);
// Find source and destination block indices
const sourceQuestion = questions.find((q) => q.id === active.id);
const destQuestion = questions.find((q) => q.id === over?.id);
if (!sourceQuestion || !destQuestion) return;
const { blockIndex: sourceBlockIndex } = findElementLocation(sourceQuestion.id);
const { blockIndex: destBlockIndex } = findElementLocation(destQuestion.id);
if (sourceBlockIndex === -1 || destBlockIndex === -1) return;
if (sourceBlockIndex === destBlockIndex) return; // No move needed
// Reorder blocks
const blocks = [...(localSurvey.blocks ?? [])];
const [movedBlock] = blocks.splice(sourceBlockIndex, 1);
blocks.splice(destBlockIndex, 0, movedBlock);
setLocalSurvey({ ...localSurvey, blocks });
};
const onEndingCardDragEnd = (event: DragEndEvent) => {
@@ -480,6 +677,9 @@ export const QuestionsView = ({
project={project}
moveQuestion={moveQuestion}
updateQuestion={updateQuestion}
updateBlockLogic={updateBlockLogic}
updateBlockLogicFallback={updateBlockLogicFallback}
updateBlockButtonLabel={updateBlockButtonLabel}
duplicateQuestion={duplicateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}

View File

@@ -109,8 +109,10 @@ export const SurveyEditor = ({
const surveyClone = structuredClone(survey);
setLocalSurvey(surveyClone);
if (survey.questions.length > 0) {
setActiveQuestionId(survey.questions[0].id);
// Set first element from first block, or first question for legacy surveys
const firstBlock = survey.blocks[0];
if (firstBlock) {
setActiveQuestionId(firstBlock.elements?.[0]?.id);
}
}
@@ -137,11 +139,12 @@ export const SurveyEditor = ({
// when the survey type changes, we need to reset the active question id to the first question
useEffect(() => {
if (localSurvey?.questions?.length && localSurvey.questions.length > 0) {
setActiveQuestionId(localSurvey.questions[0].id);
const firstBlock = localSurvey?.blocks[0];
if (firstBlock) {
setActiveQuestionId(firstBlock.elements[0]?.id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSurvey?.type, survey?.questions]);
}, [localSurvey?.type]);
useEffect(() => {
if (!localSurvey?.languages) return;

View File

@@ -12,7 +12,6 @@ import { TSegment } from "@formbricks/types/segment";
import {
TSurvey,
TSurveyEditorTabs,
TSurveyQuestion,
ZSurvey,
ZSurveyEndScreenCard,
ZSurveyRedirectUrlCard,
@@ -171,12 +170,14 @@ export const SurveyMenuBar = ({
if (!localSurveyValidation.success) {
const currentError = localSurveyValidation.error.errors[0];
if (currentError.path[0] === "questions") {
const questionIdx = currentError.path[1];
const question: TSurveyQuestion = localSurvey.questions[questionIdx];
if (question) {
if (currentError.path[0] === "blocks") {
const blockIdx = currentError.path[1];
const elementIdx = currentError.path[3]; // path is ["blocks", blockIdx, "elements", elementIdx, ...]
const block = localSurvey.blocks?.[blockIdx];
const element = block?.elements[elementIdx];
if (element) {
setInvalidQuestions((prevInvalidQuestions) =>
prevInvalidQuestions ? [...prevInvalidQuestions, question.id] : [question.id]
prevInvalidQuestions ? [...prevInvalidQuestions, element.id] : [element.id]
);
}
} else if (currentError.path[0] === "welcomeCard") {
@@ -235,10 +236,19 @@ export const SurveyMenuBar = ({
return false;
}
localSurvey.questions = localSurvey.questions.map((question) => {
const { isDraft, ...rest } = question;
return rest;
});
// Clean up blocks by removing isDraft from elements
if (localSurvey.blocks) {
localSurvey.blocks = localSurvey.blocks.map((block) => ({
...block,
elements: block.elements.map((element) => {
const { isDraft, ...rest } = element;
return rest;
}),
}));
}
// Set questions to empty array for blocks-based surveys
localSurvey.questions = [];
localSurvey.endings = localSurvey.endings.map((ending) => {
if (ending.type === "redirectToUrl") {

View File

@@ -78,7 +78,7 @@ export const SurveyVariablesCardItem = ({
// Removed auto-submit effect
const onVariableDelete = (variableToDelete: TSurveyVariable) => {
const questions = [...localSurvey.questions];
const questions = localSurvey.blocks.flatMap((block) => block.elements);
const quesIdx = findVariableUsedInLogic(localSurvey, variableToDelete.id);
if (quesIdx !== -1) {
@@ -101,7 +101,7 @@ export const SurveyVariablesCardItem = ({
return;
}
if (recallQuestionIdx === localSurvey.questions.length) {
if (recallQuestionIdx === questions.length) {
toast.error(
t("environments.surveys.edit.variable_used_in_recall_ending_card", {
variable: variableToDelete.name,
@@ -131,21 +131,27 @@ export const SurveyVariablesCardItem = ({
);
return;
}
// remove recall references
questions.forEach((question) => {
for (const [languageCode, headline] of Object.entries(question.headline)) {
if (headline.includes(`recall:${variableToDelete.id}`)) {
const recallInfo = extractRecallInfo(headline);
if (recallInfo) {
question.headline[languageCode] = headline.replace(recallInfo, "");
}
}
}
});
// remove recall references from blocks
setLocalSurvey((prevSurvey) => {
const updatedBlocks = prevSurvey.blocks.map((block) => ({
...block,
elements: block.elements.map((element) => {
const updatedHeadline = { ...element.headline };
for (const [languageCode, headline] of Object.entries(element.headline)) {
if (headline.includes(`recall:${variableToDelete.id}`)) {
const recallInfo = extractRecallInfo(headline);
if (recallInfo) {
updatedHeadline[languageCode] = headline.replace(recallInfo, "");
}
}
}
return { ...element, headline: updatedHeadline };
}),
}));
const updatedVariables = prevSurvey.variables.filter((v) => v.id !== variableToDelete.id);
return { ...prevSurvey, variables: updatedVariables, questions };
return { ...prevSurvey, variables: updatedVariables, blocks: updatedBlocks };
});
};

View File

@@ -3,7 +3,8 @@
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { validateId } from "@formbricks/types/surveys/validation";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
@@ -11,7 +12,7 @@ import { Label } from "@/modules/ui/components/label";
interface UpdateQuestionIdProps {
localSurvey: TSurvey;
question: TSurveyQuestion;
question: TSurveyElement;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
}
@@ -35,7 +36,8 @@ export const UpdateQuestionId = ({
return;
}
const questionIds = localSurvey.questions.map((q) => q.id);
const questions = localSurvey.blocks.flatMap((block) => block.elements);
const questionIds = questions.map((q) => q.id);
const endingCardIds = localSurvey.endings.map((e) => e.id);
const hiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];

View File

@@ -388,3 +388,26 @@ export const duplicateElementInBlock = (
blocks,
});
};
/**
* Find the location of an element within the survey blocks
* @param survey - The survey object
* @param elementId - The ID of the element to find
* @returns Object containing blockId, blockIndex, and elementIndex
*/
export const findElementLocation = (
survey: TSurvey,
elementId: string
): { blockId: string | null; blockIndex: number; elementIndex: number } => {
const blocks = survey.blocks;
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
const block = blocks[blockIndex];
const elementIndex = block.elements.findIndex((e) => e.id === elementId);
if (elementIndex !== -1) {
return { blockId: block.id, blockIndex, elementIndex };
}
}
return { blockId: null, blockIndex: -1, elementIndex: -1 };
};

View File

@@ -1,12 +1,13 @@
import { createId } from "@paralleldrive/cuid2";
import { TFunction } from "i18next";
import { TSurveyQuotaLogic } from "@formbricks/types/quota";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
TConditionGroup,
TSingleCondition,
TSurveyLogicConditionsOperator,
} from "@formbricks/types/surveys/logic";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys/types";
import {
addConditionBelow,
createGroupFromResource,
@@ -54,13 +55,16 @@ export function createSharedConditionsFactory(
const { survey, t, questionIdx, getDefaultOperator, includeCreateGroup = false } = params;
const { onConditionsChange } = updateCallbacks;
// Derive questions from blocks
const questions = survey.blocks.flatMap((block) => block.elements.map((element) => element));
// Handles special update logic for matrix questions, setting appropriate operators and metadata
const handleMatrixQuestionUpdate = (resourceId: string, updates: Partial<TSingleCondition>): boolean => {
if (updates.leftOperand && updates.leftOperand.type === "question") {
const [questionId, rowId] = updates.leftOperand.value.split(".");
const questionEntity = survey.questions.find((q) => q.id === questionId);
const questionEntity = questions.find((q) => q.id === questionId);
if (questionEntity && questionEntity.type === TSurveyQuestionTypeEnum.Matrix) {
if (questionEntity && questionEntity.type === TSurveyElementTypeEnum.Matrix) {
if (updates.leftOperand.value.includes(".")) {
// Matrix question with rowId is selected
onConditionsChange((conditions) => {
@@ -103,12 +107,11 @@ export function createSharedConditionsFactory(
// Creates and adds a new empty condition below the specified condition
onAddConditionBelow: (resourceId: string) => {
// When adding a condition in the context of a specific question, default to that question
const defaultLeftOperandValue =
questionIdx !== undefined ? survey.questions[questionIdx].id : survey.questions[0].id;
const defaultLeftOperandValue = questionIdx !== undefined ? questions[questionIdx].id : questions[0].id;
const defaultOperator =
questionIdx !== undefined
? getDefaultOperatorForQuestion(survey.questions[questionIdx], t)
: getDefaultOperatorForQuestion(survey.questions[0], t);
? getDefaultOperatorForQuestion(questions[questionIdx], t)
: getDefaultOperatorForQuestion(questions[0], t);
const newCondition: TSingleCondition = {
id: createId(),
leftOperand: { value: defaultLeftOperandValue, type: "question" },
@@ -150,7 +153,7 @@ export function createSharedConditionsFactory(
// Check if the operator is correct for the question
if (updates.leftOperand?.type === "question" && updates.operator) {
const questionId = updates.leftOperand.value.split(".")[0];
const question = survey.questions.find((q) => q.id === questionId);
const question = questions.find((q) => q.id === questionId);
if (question) {
const operatorOptions = getQuestionOperatorOptions(question, t);

View File

@@ -4,7 +4,7 @@ import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { checkForInvalidImagesInQuestions, validateMediaAndPrepareBlocks } from "@/lib/survey/utils";
import { validateMediaAndPrepareBlocks } from "@/lib/survey/utils";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
@@ -25,8 +25,6 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
const { triggers, environmentId, segment, questions, languages, type, followUps, ...surveyData } =
updatedSurvey;
checkForInvalidImagesInQuestions(questions);
// Validate and prepare blocks for persistence
if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
data.blocks = validateMediaAndPrepareBlocks(updatedSurvey.blocks);
@@ -234,11 +232,6 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
};
}
data.questions = questions.map((question) => {
const { isDraft, ...rest } = question;
return rest;
});
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const organization = await getOrganizationAIKeys(organizationId);
if (!organization) {

View File

@@ -3,6 +3,8 @@ import { EyeOffIcon, FileDigitIcon, FileType2Icon } from "lucide-react";
import { HTMLInputTypeAttribute, JSX } from "react";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TSurveyBlockLogic, TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
TConditionGroup,
TLeftOperand,
@@ -13,12 +15,8 @@ import {
import {
TSurvey,
TSurveyEndings,
TSurveyLogic,
TSurveyLogicAction,
TSurveyLogicActions,
TSurveyQuestion,
TSurveyQuestionId,
TSurveyQuestionTypeEnum,
TSurveyVariable,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
@@ -113,7 +111,8 @@ export const getConditionValueOptions = (
): TComboboxGroupedOption[] => {
const hiddenFields = localSurvey.hiddenFields?.fieldIds ?? [];
const variables = localSurvey.variables ?? [];
const questions = localSurvey.questions;
// Derive questions from blocks
const questions = localSurvey.blocks.flatMap((block) => block.elements.map((element) => element));
const groupedOptions: TComboboxGroupedOption[] = [];
const questionOptions: TComboboxOption[] = [];
@@ -121,18 +120,22 @@ export const getConditionValueOptions = (
questions
.filter((_, idx) => (typeof currQuestionIdx === "undefined" ? true : idx <= currQuestionIdx))
.forEach((question) => {
if (question.type === TSurveyQuestionTypeEnum.Matrix) {
if (question.type === TSurveyElementTypeEnum.Matrix) {
// Rows submenu
const questionHeadline = getTextContent(getLocalizedValue(question.headline, "default"));
const rows = question.rows.map((row, rowIdx) => ({
icon: getQuestionIconMapping(t)[question.type],
label: `${getLocalizedValue(row.label, "default")} (${questionHeadline})`,
value: `${question.id}.${rowIdx}`,
meta: {
type: "question",
rowIdx: rowIdx.toString(),
},
}));
const processedHeadline = recallToHeadline(question.headline, localSurvey, false, "default");
const questionHeadline = getTextContent(processedHeadline.default ?? "");
const rows = question.rows.map((row, rowIdx) => {
const processedLabel = recallToHeadline(row.label, localSurvey, false, "default");
return {
icon: getQuestionIconMapping(t)[question.type],
label: `${getTextContent(processedLabel.default ?? "")} (${questionHeadline})`,
value: `${question.id}.${rowIdx}`,
meta: {
type: "question",
rowIdx: rowIdx.toString(),
},
};
});
questionOptions.push({
icon: getQuestionIconMapping(t)[question.type],
@@ -159,7 +162,9 @@ export const getConditionValueOptions = (
} else {
questionOptions.push({
icon: getQuestionIconMapping(t)[question.type],
label: getTextContent(getLocalizedValue(question.headline, "default")),
label: getTextContent(
recallToHeadline(question.headline, localSurvey, false, "default").default ?? ""
),
value: question.id,
meta: {
type: "question",
@@ -230,15 +235,15 @@ export const replaceEndingCardHeadlineRecall = (survey: TSurvey, language: strin
export const getActionObjectiveOptions = (t: TFunction): TComboboxOption[] => [
{ label: t("environments.surveys.edit.calculate"), value: "calculate" },
{ label: t("environments.surveys.edit.require_answer"), value: "requireAnswer" },
{ label: t("environments.surveys.edit.jump_to_question"), value: "jumpToQuestion" },
{ label: t("environments.surveys.edit.jump_to_question"), value: "jumpToBlock" },
];
export const hasJumpToQuestionAction = (actions: TSurveyLogicActions): boolean => {
return actions.some((action) => action.objective === "jumpToQuestion");
export const hasJumpToBlockAction = (actions: TSurveyBlockLogicAction[]): boolean => {
return actions.some((action) => action.objective === "jumpToBlock");
};
export const getQuestionOperatorOptions = (
question: TSurveyQuestion,
question: TSurveyElement,
t: TFunction,
condition?: TSingleCondition
): TComboboxOption[] => {
@@ -247,7 +252,7 @@ export const getQuestionOperatorOptions = (
if (question.type === "openText") {
const inputType = question.inputType === "number" ? "number" : "text";
options = getLogicRules(t).question[`openText.${inputType}`].options;
} else if (question.type === TSurveyQuestionTypeEnum.Matrix && condition) {
} else if (question.type === TSurveyElementTypeEnum.Matrix && condition) {
const isMatrixRow =
condition.leftOperand.type === "question" && condition.leftOperand?.meta?.row !== undefined;
options = getLogicRules(t).question[`matrix${isMatrixRow ? ".row" : ""}`].options;
@@ -263,7 +268,7 @@ export const getQuestionOperatorOptions = (
};
export const getDefaultOperatorForQuestion = (
question: TSurveyQuestion,
question: TSurveyElement,
t: TFunction
): TSurveyLogicConditionsOperator => {
const options = getQuestionOperatorOptions(question, t);
@@ -273,8 +278,9 @@ export const getDefaultOperatorForQuestion = (
export const getFormatLeftOperandValue = (condition: TSingleCondition, localSurvey: TSurvey): string => {
if (condition.leftOperand.type === "question") {
const questionEntity = localSurvey.questions.find((q) => q.id === condition.leftOperand.value);
if (questionEntity && questionEntity.type === TSurveyQuestionTypeEnum.Matrix) {
const questions = localSurvey.blocks.flatMap((block) => block.elements.map((element) => element));
const question = questions.find((q) => q.id === condition.leftOperand.value);
if (question && question.type === TSurveyElementTypeEnum.Matrix) {
if (condition.leftOperand?.meta?.row !== undefined) {
return `${condition.leftOperand.value}.${condition.leftOperand.meta.row}`;
}
@@ -296,10 +302,11 @@ export const getConditionOperatorOptions = (
} else if (condition.leftOperand.type === "hiddenField") {
return getLogicRules(t).hiddenField.options;
} else if (condition.leftOperand.type === "question") {
const questions = localSurvey.questions ?? [];
// Derive questions from blocks
const questions = localSurvey.blocks.flatMap((block) => block.elements.map((element) => element));
const question = questions.find((question) => {
let leftOperandQuestionId = condition.leftOperand.value;
if (question.type === TSurveyQuestionTypeEnum.Matrix) {
if (question.type === TSurveyElementTypeEnum.Matrix) {
leftOperandQuestionId = condition.leftOperand.value.split(".")[0];
}
return question.id === leftOperandQuestionId;
@@ -341,7 +348,9 @@ export const getMatchValueProps = (
return { show: false, options: [] };
}
let questions = localSurvey.questions.filter((_, idx) =>
// Derive questions from blocks
const allQuestions = localSurvey.blocks.flatMap((block) => block.elements.map((element) => element));
let questions = allQuestions.filter((_, idx) =>
typeof questionIdx === "undefined" ? true : idx <= questionIdx
);
let variables = localSurvey.variables ?? [];
@@ -359,19 +368,19 @@ export const getMatchValueProps = (
}
if (condition.leftOperand.type === "question") {
if (selectedQuestion?.type === TSurveyQuestionTypeEnum.OpenText) {
const allowedQuestionTypes = [TSurveyQuestionTypeEnum.OpenText];
if (selectedQuestion?.type === TSurveyElementTypeEnum.OpenText) {
const allowedQuestionTypes = [TSurveyElementTypeEnum.OpenText];
if (selectedQuestion.inputType === "number") {
allowedQuestionTypes.push(TSurveyQuestionTypeEnum.Rating, TSurveyQuestionTypeEnum.NPS);
allowedQuestionTypes.push(TSurveyElementTypeEnum.Rating, TSurveyElementTypeEnum.NPS);
}
if (["equals", "doesNotEqual"].includes(condition.operator)) {
if (selectedQuestion.inputType !== "number") {
allowedQuestionTypes.push(
TSurveyQuestionTypeEnum.Date,
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
TSurveyQuestionTypeEnum.MultipleChoiceMulti
TSurveyElementTypeEnum.Date,
TSurveyElementTypeEnum.MultipleChoiceSingle,
TSurveyElementTypeEnum.MultipleChoiceMulti
);
}
}
@@ -381,7 +390,9 @@ export const getMatchValueProps = (
const questionOptions = allowedQuestions.map((question) => {
return {
icon: getQuestionIconMapping(t)[question.type],
label: getTextContent(getLocalizedValue(question.headline, "default")),
label: getTextContent(
recallToHeadline(question.headline, localSurvey, false, "default").default ?? ""
),
value: question.id,
meta: {
type: "question",
@@ -447,8 +458,8 @@ export const getMatchValueProps = (
options: groupedOptions,
};
} else if (
selectedQuestion?.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
selectedQuestion?.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
selectedQuestion?.type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
selectedQuestion?.type === TSurveyElementTypeEnum.MultipleChoiceMulti
) {
const operatorsToFilterNone = [
"includesOneOf",
@@ -457,7 +468,7 @@ export const getMatchValueProps = (
"doesNotIncludeAllOf",
];
const shouldFilterNone =
selectedQuestion.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
selectedQuestion.type === TSurveyElementTypeEnum.MultipleChoiceMulti &&
operatorsToFilterNone.includes(condition.operator);
const choices = selectedQuestion.choices
@@ -477,7 +488,7 @@ export const getMatchValueProps = (
showInput: false,
options: [{ label: t("common.choices"), value: "choices", options: choices }],
};
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.PictureSelection) {
} else if (selectedQuestion?.type === TSurveyElementTypeEnum.PictureSelection) {
const choices = selectedQuestion.choices.map((choice, idx) => {
return {
imgSrc: choice.imageUrl,
@@ -494,7 +505,7 @@ export const getMatchValueProps = (
showInput: false,
options: [{ label: t("common.choices"), value: "choices", options: choices }],
};
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.Rating) {
} else if (selectedQuestion?.type === TSurveyElementTypeEnum.Rating) {
const choices = Array.from({ length: selectedQuestion.range }, (_, idx) => {
return {
label: `${idx + 1}`,
@@ -541,7 +552,7 @@ export const getMatchValueProps = (
showInput: false,
options: groupedOptions,
};
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.NPS) {
} else if (selectedQuestion?.type === TSurveyElementTypeEnum.NPS) {
const choices = Array.from({ length: 11 }, (_, idx) => {
return {
label: `${idx}`,
@@ -588,15 +599,17 @@ export const getMatchValueProps = (
showInput: false,
options: groupedOptions,
};
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.Date) {
} else if (selectedQuestion?.type === TSurveyElementTypeEnum.Date) {
const openTextQuestions = questions.filter((question) =>
[TSurveyQuestionTypeEnum.OpenText, TSurveyQuestionTypeEnum.Date].includes(question.type)
[TSurveyElementTypeEnum.OpenText, TSurveyElementTypeEnum.Date].includes(question.type)
);
const questionOptions = openTextQuestions.map((question) => {
return {
icon: getQuestionIconMapping(t)[question.type],
label: getLocalizedValue(question.headline, "default"),
label: getTextContent(
recallToHeadline(question.headline, localSurvey, false, "default").default ?? ""
),
value: question.id,
meta: {
type: "question",
@@ -660,7 +673,7 @@ export const getMatchValueProps = (
inputType: "date",
options: groupedOptions,
};
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.Matrix) {
} else if (selectedQuestion?.type === TSurveyElementTypeEnum.Matrix) {
const choices = selectedQuestion.columns.map((column, colIdx) => {
return {
label: getLocalizedValue(column.label, "default"),
@@ -680,12 +693,12 @@ export const getMatchValueProps = (
} else if (condition.leftOperand.type === "variable") {
if (selectedVariable?.type === "text") {
const allowedQuestionTypes = [
TSurveyQuestionTypeEnum.OpenText,
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
TSurveyElementTypeEnum.OpenText,
TSurveyElementTypeEnum.MultipleChoiceSingle,
];
if (["equals", "doesNotEqual"].includes(condition.operator)) {
allowedQuestionTypes.push(TSurveyQuestionTypeEnum.MultipleChoiceMulti, TSurveyQuestionTypeEnum.Date);
allowedQuestionTypes.push(TSurveyElementTypeEnum.MultipleChoiceMulti, TSurveyElementTypeEnum.Date);
}
const allowedQuestions = questions.filter((question) => allowedQuestionTypes.includes(question.type));
@@ -760,8 +773,8 @@ export const getMatchValueProps = (
} else if (selectedVariable?.type === "number") {
const allowedQuestions = questions.filter(
(question) =>
[TSurveyQuestionTypeEnum.Rating, TSurveyQuestionTypeEnum.NPS].includes(question.type) ||
(question.type === TSurveyQuestionTypeEnum.OpenText && question.inputType === "number")
[TSurveyElementTypeEnum.Rating, TSurveyElementTypeEnum.NPS].includes(question.type) ||
(question.type === TSurveyElementTypeEnum.OpenText && question.inputType === "number")
);
const questionOptions = allowedQuestions.map((question) => {
@@ -834,12 +847,12 @@ export const getMatchValueProps = (
}
} else if (condition.leftOperand.type === "hiddenField") {
const allowedQuestionTypes = [
TSurveyQuestionTypeEnum.OpenText,
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
TSurveyElementTypeEnum.OpenText,
TSurveyElementTypeEnum.MultipleChoiceSingle,
];
if (["equals", "doesNotEqual"].includes(condition.operator)) {
allowedQuestionTypes.push(TSurveyQuestionTypeEnum.MultipleChoiceMulti, TSurveyQuestionTypeEnum.Date);
allowedQuestionTypes.push(TSurveyElementTypeEnum.MultipleChoiceMulti, TSurveyElementTypeEnum.Date);
}
const allowedQuestions = questions.filter((question) => allowedQuestionTypes.includes(question.type));
@@ -917,36 +930,61 @@ export const getMatchValueProps = (
};
export const getActionTargetOptions = (
action: TSurveyLogicAction,
action: TSurveyBlockLogicAction,
localSurvey: TSurvey,
currQuestionIdx: number,
t: TFunction
): TComboboxOption[] => {
let questions = localSurvey.questions.filter((_, idx) => idx > currQuestionIdx);
// Derive questions from blocks
const allQuestions = localSurvey.blocks?.flatMap((b) => b.elements) ?? [];
let questions = allQuestions.filter((_, idx) => idx > currQuestionIdx);
if (action.objective === "requireAnswer") {
questions = questions.filter((question) => !question.required);
// Return question IDs (elements) for requireAnswer
return questions.map((question) => {
const processedHeadline = recallToHeadline(question.headline, localSurvey, false, "default");
return {
icon: getQuestionIconMapping(t)[question.type],
label: getTextContent(processedHeadline.default ?? ""),
value: question.id, // Element ID
};
});
}
// For jumpToBlock, we need block IDs
const blocks = localSurvey.blocks ?? [];
const questionOptions = questions.map((question) => {
// Find which block this question belongs to
const block = blocks.find((b) => b.elements.some((e) => e.id === question.id));
const processedHeadline = recallToHeadline(question.headline, localSurvey, false, "default");
return {
icon: getQuestionIconMapping(t)[question.type],
label: getTextContent(getLocalizedValue(question.headline, "default")),
value: question.id,
label: getTextContent(processedHeadline.default ?? ""),
value: block?.id ?? question.id, // Block ID for jumpToBlock
};
});
if (action.objective === "requireAnswer") return questionOptions;
// Ending cards
const endingCardOptions = localSurvey.endings.map((ending) => {
return {
label:
ending.type === "endScreen"
? getTextContent(getLocalizedValue(ending.headline, "default")) ||
t("environments.surveys.edit.end_screen_card")
: ending.label || t("environments.surveys.edit.redirect_thank_you_card"),
value: ending.id,
};
if (ending.type === "endScreen") {
const processedHeadline = recallToHeadline(
ending.headline ?? { default: "" },
localSurvey,
false,
"default"
);
return {
label:
getTextContent(processedHeadline.default ?? "") || t("environments.surveys.edit.end_screen_card"),
value: ending.id,
};
} else {
return {
label: ending.label || t("environments.surveys.edit.redirect_thank_you_card"),
value: ending.id,
};
}
});
return [...questionOptions, ...endingCardOptions];
@@ -1015,9 +1053,10 @@ export const getActionValueOptions = (
questionIdx: number,
t: TFunction
): TComboboxGroupedOption[] => {
const questions = localSurvey.blocks.flatMap((block) => block.elements.map((element) => element));
const hiddenFields = localSurvey.hiddenFields?.fieldIds ?? [];
let variables = localSurvey.variables ?? [];
const questions = localSurvey.questions.filter((_, idx) => idx <= questionIdx);
const filteredQuestions = questions.filter((_, idx) => idx <= questionIdx);
const hiddenFieldsOptions = hiddenFields.map((field) => {
return {
@@ -1037,13 +1076,13 @@ export const getActionValueOptions = (
if (!selectedVariable) return [];
if (selectedVariable.type === "text") {
const allowedQuestions = questions.filter((question) =>
const allowedQuestions = filteredQuestions.filter((question) =>
[
TSurveyQuestionTypeEnum.OpenText,
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
TSurveyQuestionTypeEnum.Rating,
TSurveyQuestionTypeEnum.NPS,
TSurveyQuestionTypeEnum.Date,
TSurveyElementTypeEnum.OpenText,
TSurveyElementTypeEnum.MultipleChoiceSingle,
TSurveyElementTypeEnum.Rating,
TSurveyElementTypeEnum.NPS,
TSurveyElementTypeEnum.Date,
].includes(question.type)
);
@@ -1099,10 +1138,10 @@ export const getActionValueOptions = (
return groupedOptions;
} else if (selectedVariable.type === "number") {
const allowedQuestions = questions.filter(
const allowedQuestions = filteredQuestions.filter(
(question) =>
[TSurveyQuestionTypeEnum.Rating, TSurveyQuestionTypeEnum.NPS].includes(question.type) ||
(question.type === TSurveyQuestionTypeEnum.OpenText && question.inputType === "number")
[TSurveyElementTypeEnum.Rating, TSurveyElementTypeEnum.NPS].includes(question.type) ||
(question.type === TSurveyElementTypeEnum.OpenText && question.inputType === "number")
);
const questionOptions = allowedQuestions.map((question) => {
@@ -1209,21 +1248,25 @@ export const findQuestionUsedInLogic = (survey: TSurvey, questionId: TSurveyQues
}
};
const isUsedInAction = (action: TSurveyLogicAction): boolean => {
const isUsedInAction = (action: TSurveyBlockLogicAction): boolean => {
return (
(action.objective === "jumpToQuestion" && action.target === questionId) ||
(action.objective === "requireAnswer" && action.target === questionId)
// Note: jumpToBlock targets block IDs, not question IDs, so we only check requireAnswer
action.objective === "requireAnswer" && action.target === questionId
);
};
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
const isUsedInLogicRule = (logicRule: TSurveyBlockLogic): boolean => {
return isUsedInCondition(logicRule.conditions) || logicRule.actions.some(isUsedInAction);
};
return survey.questions.findIndex(
// Derive questions from blocks (cast as questions to access logic properties)
const questions = (survey.blocks?.flatMap((b) => b.elements) ?? []) as unknown as TSurveyQuestion[];
return questions.findIndex(
(question) =>
question.logicFallback === questionId ||
(question.id !== questionId && question.logic?.some(isUsedInLogicRule))
(question.id !== questionId &&
(question.logic as unknown as TSurveyBlockLogic[])?.some(isUsedInLogicRule))
);
};
@@ -1322,17 +1365,18 @@ export const isUsedInRecall = (survey: TSurvey, id: string): number => {
return -2; // Special index for welcome card
}
// Derive questions from blocks (cast as questions to access logic properties)
const questions = (survey.blocks?.flatMap((b) => b.elements) ?? []) as unknown as TSurveyQuestion[];
// Check questions
const questionIndex = survey.questions.findIndex((question) =>
checkQuestionForRecall(question, recallPattern)
);
const questionIndex = questions.findIndex((question) => checkQuestionForRecall(question, recallPattern));
if (questionIndex !== -1) {
return questionIndex;
}
// Check ending cards
if (checkEndingCardsForRecall(survey.endings, recallPattern)) {
return survey.questions.length; // Special index for ending cards
return questions.length; // Special index for ending cards
}
return -1; // Not found
@@ -1375,11 +1419,16 @@ export const findOptionUsedInLogic = (
return false;
};
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
const isUsedInLogicRule = (logicRule: TSurveyBlockLogic): boolean => {
return isUsedInCondition(logicRule.conditions);
};
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
// Derive questions from blocks (cast as questions to access logic properties)
const questions = (survey.blocks?.flatMap((b) => b.elements) ?? []) as unknown as TSurveyQuestion[];
return questions.findIndex((question) =>
(question.logic as unknown as TSurveyBlockLogic[])?.some(isUsedInLogicRule)
);
};
export const findVariableUsedInLogic = (survey: TSurvey, variableId: string): number => {
@@ -1396,15 +1445,20 @@ export const findVariableUsedInLogic = (survey: TSurvey, variableId: string): nu
}
};
const isUsedInAction = (action: TSurveyLogicAction): boolean => {
const isUsedInAction = (action: TSurveyBlockLogicAction): boolean => {
return action.objective === "calculate" && action.variableId === variableId;
};
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
const isUsedInLogicRule = (logicRule: TSurveyBlockLogic): boolean => {
return isUsedInCondition(logicRule.conditions) || logicRule.actions.some(isUsedInAction);
};
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
// Derive questions from blocks (cast as questions to access logic properties)
const questions = (survey.blocks?.flatMap((b) => b.elements) ?? []) as unknown as TSurveyQuestion[];
return questions.findIndex((question) =>
(question.logic as unknown as TSurveyBlockLogic[])?.some(isUsedInLogicRule)
);
};
export const findHiddenFieldUsedInLogic = (survey: TSurvey, hiddenFieldId: string): number => {
@@ -1422,11 +1476,16 @@ export const findHiddenFieldUsedInLogic = (survey: TSurvey, hiddenFieldId: strin
}
};
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
const isUsedInLogicRule = (logicRule: TSurveyBlockLogic): boolean => {
return isUsedInCondition(logicRule.conditions);
};
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
// Derive questions from blocks (cast as questions to access logic properties)
const questions = (survey.blocks?.flatMap((b) => b.elements) ?? []) as unknown as TSurveyQuestion[];
return questions.findIndex((question) =>
(question.logic as unknown as TSurveyBlockLogic[])?.some(isUsedInLogicRule)
);
};
export const getSurveyFollowUpActionDefaultBody = (t: TFunction): string => {
@@ -1436,15 +1495,21 @@ export const getSurveyFollowUpActionDefaultBody = (t: TFunction): string => {
};
export const findEndingCardUsedInLogic = (survey: TSurvey, endingCardId: string): number => {
const isUsedInAction = (action: TSurveyLogicAction): boolean => {
return action.objective === "jumpToQuestion" && action.target === endingCardId;
const isUsedInAction = (action: TSurveyBlockLogicAction): boolean => {
// jumpToBlock can target ending card IDs as well as block IDs
return action.objective === "jumpToBlock" && action.target === endingCardId;
};
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
const isUsedInLogicRule = (logicRule: TSurveyBlockLogic): boolean => {
return logicRule.actions.some(isUsedInAction);
};
return survey.questions.findIndex(
(question) => question.logicFallback === endingCardId || question.logic?.some(isUsedInLogicRule)
// Derive questions from blocks (cast as questions to access logic properties)
const questions = (survey.blocks?.flatMap((b) => b.elements) ?? []) as unknown as TSurveyQuestion[];
return questions.findIndex(
(question) =>
question.logicFallback === endingCardId ||
(question.logic as unknown as TSurveyBlockLogic[])?.some(isUsedInLogicRule)
);
};

View File

@@ -17,7 +17,8 @@ import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSurveyFollowUpAction, TSurveyFollowUpTrigger } from "@formbricks/database/types/survey-follow-up";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { recallToHeadline } from "@/lib/utils/recall";
@@ -100,14 +101,15 @@ export const FollowUpModal = ({
const [firstRender, setFirstRender] = useState(true);
const emailSendToOptions: EmailSendToOption[] = useMemo(() => {
const { questions } = localSurvey;
// Derive questions from blocks
const questions = localSurvey.blocks.flatMap((block) => block.elements);
const openTextAndContactQuestions = questions.filter((question) => {
if (question.type === TSurveyQuestionTypeEnum.ContactInfo) {
if (question.type === TSurveyElementTypeEnum.ContactInfo) {
return question.email.show;
}
if (question.type === TSurveyQuestionTypeEnum.OpenText) {
if (question.type === TSurveyElementTypeEnum.OpenText) {
if (question.inputType === "email") {
return true;
}
@@ -145,7 +147,7 @@ export const FollowUpModal = ({
],
id: question.id,
type:
question.type === TSurveyQuestionTypeEnum.OpenText
question.type === TSurveyElementTypeEnum.OpenText
? "openTextQuestion"
: ("contactInfoQuestion" as EmailSendToOption["type"]),
})),

View File

@@ -27,7 +27,9 @@ export const TemplateContainerWithPreview = ({
const { t } = useTranslation();
const initialTemplate = customSurveyTemplate(t);
const [activeTemplate, setActiveTemplate] = useState<TTemplate>(initialTemplate);
const [activeQuestionId, setActiveQuestionId] = useState<string>(initialTemplate.preset.questions[0].id);
const [activeQuestionId, setActiveQuestionId] = useState<string>(
initialTemplate.preset.questions[0]?.id || initialTemplate.preset.blocks[0]?.elements[0]?.id || ""
);
const [templateSearch, setTemplateSearch] = useState<string | null>(null);
return (
@@ -56,7 +58,9 @@ export const TemplateContainerWithPreview = ({
userId={userId}
templateSearch={templateSearch ?? ""}
onTemplateClick={(template) => {
setActiveQuestionId(template.preset.questions[0].id);
setActiveQuestionId(
template.preset.questions[0]?.id || template.preset.blocks[0]?.elements[0]?.id || ""
);
setActiveTemplate(template);
}}
/>

View File

@@ -1,13 +1,8 @@
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
import { TActionCalculate, TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks";
import { TConditionGroup, TSingleCondition } from "@formbricks/types/surveys/logic";
import {
TActionCalculate,
TSurveyLogicAction,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveyVariable,
} from "@formbricks/types/surveys/types";
import { TSurveyQuestion, TSurveyQuestionTypeEnum, TSurveyVariable } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n";
const getVariableValue = (
@@ -51,7 +46,7 @@ export const evaluateLogic = (
export const performActions = (
survey: TJsEnvironmentStateSurvey,
actions: TSurveyLogicAction[],
actions: TSurveyBlockLogicAction[],
data: TResponseData,
calculationResults: TResponseVariables
): {
@@ -72,7 +67,7 @@ export const performActions = (
case "requireAnswer":
requiredQuestionIds.push(action.target);
break;
case "jumpToQuestion":
case "jumpToBlock":
if (!jumpTarget) {
jumpTarget = action.target;
}

View File

@@ -16,6 +16,12 @@ export const findBlocksWithCyclicLogic = (blocks: TSurveyBlock[]): string[] => {
const jumpActions = findJumpToBlockActions(logic.actions);
for (const jumpAction of jumpActions) {
const destination = jumpAction.target;
// Skip if destination is not a valid block ID (it's an ending card)
if (!blocks.find((b) => b.id === destination)) {
continue;
}
if (!visited[destination] && checkForCyclicLogic(destination)) {
cyclicBlocks.add(blockId);
recStack[blockId] = false;
@@ -32,14 +38,18 @@ export const findBlocksWithCyclicLogic = (blocks: TSurveyBlock[]): string[] => {
// Check fallback logic
if (block?.logicFallback) {
const fallbackBlockId = block.logicFallback;
if (!visited[fallbackBlockId] && checkForCyclicLogic(fallbackBlockId)) {
cyclicBlocks.add(blockId);
recStack[blockId] = false;
return true;
} else if (recStack[fallbackBlockId]) {
cyclicBlocks.add(blockId);
recStack[blockId] = false;
return true;
// Skip if fallback is not a valid block (it's an ending card)
if (blocks.find((b) => b.id === fallbackBlockId)) {
if (!visited[fallbackBlockId] && checkForCyclicLogic(fallbackBlockId)) {
cyclicBlocks.add(blockId);
recStack[blockId] = false;
return true;
} else if (recStack[fallbackBlockId]) {
cyclicBlocks.add(blockId);
recStack[blockId] = false;
return true;
}
}
}

View File

@@ -54,6 +54,8 @@ export const ZActionCalculate = z.union([ZActionCalculateText, ZActionCalculateN
export type TActionCalculate = z.infer<typeof ZActionCalculate>;
export type TSurveyBlockLogicActionObjective = "calculate" | "requireAnswer" | "jumpToBlock";
// RequireAnswer action - targets element IDs
export const ZActionRequireAnswer = z.object({
@@ -80,15 +82,12 @@ export const ZSurveyBlockLogicAction = z.union([ZActionCalculate, ZActionRequire
export type TSurveyBlockLogicAction = z.infer<typeof ZSurveyBlockLogicAction>;
const ZSurveyBlockLogicActions = z.array(ZSurveyBlockLogicAction);
export type TSurveyBlockLogicActions = z.infer<typeof ZSurveyBlockLogicActions>;
// Block Logic
export const ZSurveyBlockLogic = z.object({
id: ZId,
conditions: ZConditionGroup,
actions: ZSurveyBlockLogicActions,
actions: z.array(ZSurveyBlockLogicAction),
});
export type TSurveyBlockLogic = z.infer<typeof ZSurveyBlockLogic>;

View File

@@ -658,9 +658,8 @@ export const ZSurvey = z
recontactDays: z.number().nullable(),
displayLimit: z.number().nullable(),
welcomeCard: ZSurveyWelcomeCard,
questions: ZSurveyQuestions.min(1, {
message: "Survey must have at least one question",
}).superRefine((questions, ctx) => {
// TODO: Remove this once blocks are the single source of truth
questions: ZSurveyQuestions.default([]).superRefine((questions, ctx) => {
const questionIds = questions.map((q) => q.id);
const uniqueQuestionIds = new Set(questionIds);
if (uniqueQuestionIds.size !== questionIds.length) {
@@ -742,14 +741,14 @@ export const ZSurvey = z
.superRefine((survey, ctx) => {
const { questions, blocks, languages, welcomeCard, endings, isBackButtonHidden } = survey;
// Validate: must have questions OR blocks, not both
// Validate: must have questions OR blocks with elements, not both
const hasQuestions = questions.length > 0;
const hasBlocks = blocks.length > 0;
const hasBlocks = blocks.length > 0 && blocks.some((b) => b.elements.length > 0);
if (!hasQuestions && !hasBlocks) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Survey must have either questions or blocks",
message: "Survey must have either questions or blocks with elements",
path: ["questions"],
});
}
@@ -1599,11 +1598,13 @@ export const ZSurvey = z
if (blocksWithCyclicLogic.length > 0) {
blocksWithCyclicLogic.forEach((blockId) => {
const blockIndex = blocks.findIndex((b) => b.id === blockId);
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Conditional Logic: Cyclic logic detected in block ${String(blockIndex + 1)} (${blocks[blockIndex].name}).`,
path: ["blocks", blockIndex, "logic"],
});
if (blockIndex !== -1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Conditional Logic: Cyclic logic detected in block ${String(blockIndex + 1)} (${blocks[blockIndex].name}).`,
path: ["blocks", blockIndex, "logic"],
});
}
});
}
}
@@ -3496,8 +3497,8 @@ export const ZSurveyCreateInput = makeSchemaOptional(ZSurvey.innerType())
})
.extend({
name: z.string(), // Keep name required
questions: ZSurvey.innerType().shape.questions, // Keep questions required and with its original validation
blocks: ZSurveyBlocks.default([]),
questions: ZSurvey.innerType().shape.questions,
blocks: ZSurvey.innerType().shape.blocks,
languages: z.array(ZSurveyLanguage).default([]),
welcomeCard: ZSurveyWelcomeCard.default({
enabled: false,
@@ -3522,8 +3523,8 @@ export const ZSurveyCreateInputWithEnvironmentId = makeSchemaOptional(ZSurvey.in
.extend({
name: z.string(), // Keep name required
environmentId: z.string(),
questions: ZSurvey.innerType().shape.questions, // Keep questions required and with its original validation
blocks: ZSurveyBlocks.default([]),
questions: ZSurvey.innerType().shape.questions,
blocks: ZSurvey.innerType().shape.blocks,
languages: z.array(ZSurveyLanguage).default([]),
welcomeCard: ZSurveyWelcomeCard.default({
enabled: false,

View File

@@ -1,5 +1,6 @@
import { z } from "zod";
import { ZProjectConfigChannel, ZProjectConfigIndustry } from "./project";
import { ZSurveyBlocks } from "./surveys/blocks";
import {
ZSurveyEndings,
ZSurveyHiddenFields,
@@ -27,7 +28,8 @@ export const ZTemplate = z.object({
preset: z.object({
name: z.string(),
welcomeCard: ZSurveyWelcomeCard,
questions: ZSurveyQuestions,
blocks: ZSurveyBlocks.default([]),
questions: ZSurveyQuestions.default([]),
endings: ZSurveyEndings,
hiddenFields: ZSurveyHiddenFields,
}),