feat: refactor survey editor logic to use blocks model (#6778)

This commit is contained in:
Anshuman Pandey
2025-11-06 15:45:15 +05:30
committed by GitHub
56 changed files with 1329 additions and 696 deletions
+20 -12
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: t("templates.custom_survey_block_1_name"),
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,
],
},
],
},
};
+2
View File
@@ -1532,6 +1532,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
@@ -2100,6 +2101,7 @@ checksums:
templates/csat_survey_question_3_headline: 25974b7f1692cad41908fe305830b6c0
templates/csat_survey_question_3_placeholder: 3ec4d0b6bb1f26bb2c32e9e8bd377ae3
templates/cta_description: bc94a2ddc965b286a8677b0642696c7e
templates/custom_survey_block_1_name: 5e1b4dce0cb70662441b663507a69454
templates/custom_survey_description: 0492afdea2ef1bd683eaf48a2bad2caa
templates/custom_survey_name: 6fc756927ca9ea22c26368cccd64a67e
templates/custom_survey_question_1_headline: 0abf9d41e0b5c5567c3833fd63048398
+1
View File
@@ -360,6 +360,7 @@ describe("Response Utils", () => {
});
});
// TODO: Fix this test after the survey editor poc is merged
describe("extractSurveyDetails", () => {
const mockSurvey: Partial<TSurvey> = {
id: "survey1",
+6 -5
View File
@@ -1,6 +1,7 @@
import { describe, expect, test, vi } from "vitest";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
import { TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks";
import { TConditionGroup, TSingleCondition } from "@formbricks/types/surveys/logic";
import { TSurveyLogic, TSurveyLogicAction, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
@@ -206,13 +207,13 @@ describe("surveyLogic", () => {
});
test("getUpdatedActionBody returns new action bodies correctly", () => {
const base: TSurveyLogicAction = { id: "A", objective: "requireAnswer", target: "q" };
const base: TSurveyBlockLogicAction = { id: "A", objective: "requireAnswer", target: "q" };
const calc = getUpdatedActionBody(base, "calculate");
expect(calc.objective).toBe("calculate");
const req = getUpdatedActionBody(calc, "requireAnswer");
expect(req.objective).toBe("requireAnswer");
const jump = getUpdatedActionBody(req, "jumpToQuestion");
expect(jump.objective).toBe("jumpToQuestion");
const jump = getUpdatedActionBody(req, "jumpToBlock");
expect(jump.objective).toBe("jumpToBlock");
});
test("evaluateLogic handles AND/OR groups and single conditions", () => {
@@ -244,7 +245,7 @@ describe("surveyLogic", () => {
test("performActions calculates, requires, and jumps correctly", () => {
const data: TResponseData = { q: "5" };
const initialVars: TResponseVariables = {};
const actions: TSurveyLogicAction[] = [
const actions: TSurveyBlockLogicAction[] = [
{
id: "a1",
objective: "calculate",
@@ -253,7 +254,7 @@ describe("surveyLogic", () => {
value: { type: "static", value: 3 },
},
{ id: "a2", objective: "requireAnswer", target: "q2" },
{ id: "a3", objective: "jumpToQuestion", target: "q3" },
{ id: "a3", objective: "jumpToBlock", target: "q3" },
];
const result = performActions(mockSurvey, actions, data, initialVars);
expect(result.calculations.v).toBe(3);
+16 -12
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;
}
+58 -33
View File
@@ -145,7 +145,7 @@ describe("recall utility functions", () => {
const headline = { en: "How do you like #recall:product/fallback:ournbspproduct#?" };
const survey = {
id: "test-survey",
questions: [{ id: "product", headline: { en: "Product Question" } }],
blocks: [{ id: "b1", elements: [{ id: "product", headline: { en: "Product Question" } }] }],
hiddenFields: { fieldIds: [] },
variables: [],
} as any;
@@ -158,7 +158,7 @@ describe("recall utility functions", () => {
const headline = { en: "Rate #recall:product/fallback:ournbspproduct#" };
const survey = {
id: "test-survey",
questions: [{ id: "product", headline: { en: "Product Question" } }],
blocks: [{ id: "b1", elements: [{ id: "product", headline: { en: "Product Question" } }] }],
hiddenFields: { fieldIds: [] },
variables: [],
} as any;
@@ -171,7 +171,7 @@ describe("recall utility functions", () => {
const headline = { en: "Your email is #recall:email/fallback:notnbspprovided#" };
const survey: TSurvey = {
id: "test-survey",
questions: [],
blocks: [],
hiddenFields: { fieldIds: ["email"] },
variables: [],
} as unknown as TSurvey;
@@ -184,7 +184,7 @@ describe("recall utility functions", () => {
const headline = { en: "Your plan is #recall:plan/fallback:unknown#" };
const survey: TSurvey = {
id: "test-survey",
questions: [],
blocks: [],
hiddenFields: { fieldIds: [] },
variables: [{ id: "plan", name: "Subscription Plan" }],
} as unknown as TSurvey;
@@ -207,7 +207,7 @@ describe("recall utility functions", () => {
};
const survey = {
id: "test-survey",
questions: [{ id: "inner", headline: { en: "Inner with @outer" } }],
blocks: [{ id: "b1", elements: [{ id: "inner", headline: { en: "Inner with @outer" } }] }],
hiddenFields: { fieldIds: [] },
variables: [],
} as any;
@@ -241,41 +241,56 @@ describe("recall utility functions", () => {
test("identifies question with empty fallback value", () => {
const questionHeadline = { en: "Question with #recall:id1/fallback:# empty fallback" };
const survey = {
questions: [
blocks: [
{
id: "q1",
headline: questionHeadline,
id: "b1",
elements: [
{
id: "q1",
headline: questionHeadline,
},
],
},
],
} as any;
const result = checkForEmptyFallBackValue(survey, "en");
expect(result).toBe(survey.questions[0]);
expect(result).toBe(survey.blocks[0].elements[0]);
});
test("identifies question with empty fallback in subheader", () => {
const questionSubheader = { en: "Subheader with #recall:id1/fallback:# empty fallback" };
const survey = {
questions: [
blocks: [
{
id: "q1",
headline: { en: "Normal question" },
subheader: questionSubheader,
id: "b1",
elements: [
{
id: "q1",
headline: { en: "Normal question" },
subheader: questionSubheader,
},
],
},
],
} as any;
const result = checkForEmptyFallBackValue(survey, "en");
expect(result).toBe(survey.questions[0]);
expect(result).toBe(survey.blocks[0].elements[0]);
});
test("returns null when no empty fallback values are found", () => {
const questionHeadline = { en: "Question with #recall:id1/fallback:default# valid fallback" };
const survey = {
questions: [
blocks: [
{
id: "q1",
headline: questionHeadline,
id: "b1",
elements: [
{
id: "q1",
headline: questionHeadline,
},
],
},
],
} as any;
@@ -288,16 +303,21 @@ describe("recall utility functions", () => {
describe("replaceHeadlineRecall", () => {
test("processes all questions in a survey", () => {
const survey: TSurvey = {
questions: [
blocks: [
{
id: "q1",
headline: { en: "Question with #recall:id1/fallback:default#" },
id: "b1",
elements: [
{
id: "q1",
headline: { en: "Question with #recall:id1/fallback:default#" },
},
{
id: "q2",
headline: { en: "Another with #recall:id2/fallback:other#" },
},
],
},
{
id: "q2",
headline: { en: "Another with #recall:id2/fallback:other#" },
},
] as unknown as TSurveyQuestion[],
],
hiddenFields: { fieldIds: [] },
variables: [],
} as unknown as TSurvey;
@@ -308,8 +328,8 @@ describe("recall utility functions", () => {
// Verify recallToHeadline was called for each question
expect(result).not.toBe(survey); // Should be a clone
expect(result.questions[0].headline).not.toEqual(survey.questions[0].headline);
expect(result.questions[1].headline).not.toEqual(survey.questions[1].headline);
expect(result.blocks[0].elements[0].headline).not.toEqual(survey.blocks[0].elements[0].headline);
expect(result.blocks[0].elements[1].headline).not.toEqual(survey.blocks[0].elements[1].headline);
});
});
@@ -317,10 +337,15 @@ describe("recall utility functions", () => {
test("extracts recall items from text", () => {
const text = "Text with #recall:id1/fallback:val1# and #recall:id2/fallback:val2#";
const survey: TSurvey = {
questions: [
{ id: "id1", headline: { en: "Question One" } },
{ id: "id2", headline: { en: "Question Two" } },
] as unknown as TSurveyQuestion[],
blocks: [
{
id: "b1",
elements: [
{ id: "id1", headline: { en: "Question One" } },
{ id: "id2", headline: { en: "Question Two" } },
],
},
],
hiddenFields: { fieldIds: [] },
variables: [],
} as unknown as TSurvey;
@@ -339,7 +364,7 @@ describe("recall utility functions", () => {
test("handles hidden fields in recall items", () => {
const text = "Text with #recall:hidden1/fallback:val1#";
const survey: TSurvey = {
questions: [],
blocks: [],
hiddenFields: { fieldIds: ["hidden1"] },
variables: [],
} as unknown as TSurvey;
@@ -354,7 +379,7 @@ describe("recall utility functions", () => {
test("handles variables in recall items", () => {
const text = "Text with #recall:var1/fallback:val1#";
const survey: TSurvey = {
questions: [],
blocks: [],
hiddenFields: { fieldIds: [] },
variables: [{ id: "var1", name: "Variable One" }],
} as unknown as TSurvey;
+12 -6
View File
@@ -1,9 +1,11 @@
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";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
export interface fallbacks {
@@ -60,7 +62,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 = getQuestionsFromBlocks(survey.blocks);
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 +126,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 = getQuestionsFromBlocks(survey.blocks);
for (const question of questions) {
if (
doesTextHaveRecall(getLocalizedValue(question.headline, language)) ||
(question.subheader && doesTextHaveRecall(getLocalizedValue(question.subheader, language)))
@@ -143,7 +147,8 @@ export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): T
// Processes each question in a survey to ensure headlines are formatted correctly for recall and return the modified survey.
export const replaceHeadlineRecall = <T extends TSurvey>(survey: T, language: string): T => {
const modifiedSurvey = structuredClone(survey);
modifiedSurvey.questions.forEach((question) => {
const questions = getQuestionsFromBlocks(modifiedSurvey.blocks);
questions.forEach((question) => {
question.headline = recallToHeadline(question.headline, modifiedSurvey, false, language);
});
return modifiedSurvey;
@@ -157,7 +162,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 = getQuestionsFromBlocks(survey.blocks);
const isSurveyQuestion = questions.find((question) => question.id === recallItemId);
const isVariable = survey.variables.find((variable) => variable.id === recallItemId);
const recallItemLabel = getRecallItemLabel(recallItemId, survey, languageCode);
+1
View File
@@ -2248,6 +2248,7 @@
"csat_survey_question_3_headline": "Ugh, sorry! Können wir irgendwas tun, um deine Erfahrung zu verbessern?",
"csat_survey_question_3_placeholder": "Tippe deine Antwort hier...",
"cta_description": "Information anzeigen und Benutzer auffordern, eine bestimmte Aktion auszuführen",
"custom_survey_block_1_name": "Block 1",
"custom_survey_description": "Erstelle eine Umfrage ohne Vorlage.",
"custom_survey_name": "Eigene Umfrage erstellen",
"custom_survey_question_1_headline": "Was möchtest Du wissen?",
+1
View File
@@ -2248,6 +2248,7 @@
"csat_survey_question_3_headline": "Ugh, sorry! Is there anything we can do to improve your experience?",
"csat_survey_question_3_placeholder": "Type your answer here...",
"cta_description": "Display information and prompt users to take a specific action",
"custom_survey_block_1_name": "Block 1",
"custom_survey_description": "Create a survey without template.",
"custom_survey_name": "Start from scratch",
"custom_survey_question_1_headline": "What would you like to know?",
+1
View File
@@ -2248,6 +2248,7 @@
"csat_survey_question_3_headline": "Ah, désolé ! Y a-t-il quelque chose que nous puissions faire pour améliorer votre expérience ?",
"csat_survey_question_3_placeholder": "Entrez votre réponse ici...",
"cta_description": "Afficher des informations et inciter les utilisateurs à effectuer une action spécifique",
"custom_survey_block_1_name": "Bloc 1",
"custom_survey_description": "Créez une enquête sans utiliser de modèle.",
"custom_survey_name": "Tout créer moi-même",
"custom_survey_question_1_headline": "Que voudriez-vous savoir ?",
+1
View File
@@ -2248,6 +2248,7 @@
"csat_survey_question_3_headline": "申し訳ありません!体験を改善するために何かできることはありますか?",
"csat_survey_question_3_placeholder": "ここに回答を入力してください...",
"cta_description": "情報を表示し、特定の行動を促す",
"custom_survey_block_1_name": "ブロック1",
"custom_survey_description": "テンプレートを使わずにアンケートを作成する。",
"custom_survey_name": "最初から始める",
"custom_survey_question_1_headline": "何を知りたいですか?",
+1
View File
@@ -2248,6 +2248,7 @@
"csat_survey_question_3_headline": "Ah, foi mal! Tem algo que a gente possa fazer pra melhorar sua experiência?",
"csat_survey_question_3_placeholder": "Digite sua resposta aqui...",
"cta_description": "Mostrar informações e pedir para os usuários tomarem uma ação específica",
"custom_survey_block_1_name": "Bloco 1",
"custom_survey_description": "Crie uma pesquisa sem modelo.",
"custom_survey_name": "Começar do zero",
"custom_survey_question_1_headline": "O que você gostaria de saber?",
+1
View File
@@ -2248,6 +2248,7 @@
"csat_survey_question_3_headline": "Oh, desculpe! Há algo que possamos fazer para melhorar a sua experiência?",
"csat_survey_question_3_placeholder": "Escreva a sua resposta aqui...",
"cta_description": "Exibir informações e solicitar aos utilizadores que tomem uma ação específica",
"custom_survey_block_1_name": "Bloco 1",
"custom_survey_description": "Crie um inquérito sem modelo.",
"custom_survey_name": "Começar do zero",
"custom_survey_question_1_headline": "O que gostaria de saber?",
+1
View File
@@ -2248,6 +2248,7 @@
"csat_survey_question_3_headline": "Of, îmi pare rău! Există ceva ce putem face pentru a-ți îmbunătăți experiența?",
"csat_survey_question_3_placeholder": "Tastează răspunsul aici...",
"cta_description": "Afișează informații și solicită utilizatorilor să ia o acțiune specifică",
"custom_survey_block_1_name": "Bloc 1",
"custom_survey_description": "Creează un sondaj fără șablon.",
"custom_survey_name": "Începe de la zero",
"custom_survey_question_1_headline": "Ce ați dori să știți?",
+1
View File
@@ -2248,6 +2248,7 @@
"csat_survey_question_3_headline": "糟糕, 对不起!我们可以做些什么来改善您的体验?",
"csat_survey_question_3_placeholder": "在此输入您的答案...",
"cta_description": "显示 信息 并 提示用户采取 特定行动",
"custom_survey_block_1_name": "模块 1",
"custom_survey_description": "创建 一个 没有 模板 的 调查。",
"custom_survey_name": "从零开始",
"custom_survey_question_1_headline": "你 想 知道 什么?",
+1
View File
@@ -2248,6 +2248,7 @@
"csat_survey_question_3_headline": "唉,抱歉!我們是否有任何可以改善您體驗的地方?",
"csat_survey_question_3_placeholder": "在此輸入您的答案...",
"cta_description": "顯示資訊並提示使用者採取特定操作",
"custom_survey_block_1_name": "區塊 1",
"custom_survey_description": "建立沒有範本的問卷。",
"custom_survey_name": "從頭開始",
"custom_survey_question_1_headline": "您想瞭解什麼?",
@@ -9,6 +9,7 @@ import { getTextContent, isValidHTML } from "@formbricks/types/surveys/validatio
import { TUserLocale } from "@formbricks/types/user";
import { md } from "@/lib/markdownIt";
import { recallToHeadline } from "@/lib/utils/recall";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { isLabelValidForAllLanguages } from "@/modules/survey/editor/lib/validation";
import { Editor } from "@/modules/ui/components/editor";
import { LanguageIndicator } from "./language-indicator";
@@ -62,6 +63,8 @@ export function LocalizedEditor({
autoFocus,
isExternalUrlsAllowed,
}: Readonly<LocalizedEditorProps>) {
// Derive questions from blocks for migrated surveys
const questions = useMemo(() => getQuestionsFromBlocks(localSurvey.blocks), [localSurvey.blocks]);
const { t } = useTranslation();
const isInComplete = useMemo(
@@ -99,12 +102,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) {
@@ -2,15 +2,16 @@
import { Language } from "@prisma/client";
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 { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { LanguageToggle } from "./language-toggle";
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,8 @@ export function SecondaryLanguageSelect({
);
};
const questions = getQuestionsFromBlocks(localSurvey.blocks);
return (
<div className="space-y-2">
<p className="text-sm font-medium text-slate-800">
@@ -46,7 +49,7 @@ export function SecondaryLanguageSelect({
language={language}
onEdit={() => {
setSelectedLanguageCode(language.code);
setActiveQuestionId(localSurvey.questions[0]?.id);
setActiveQuestionId(questions[0]?.id);
}}
onToggle={() => {
updateSurveyLanguages(language);
@@ -20,6 +20,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createQuotaAction, updateQuotaAction } from "@/modules/ee/quotas/actions";
import { EndingCardSelector } from "@/modules/ee/quotas/components/ending-card-selector";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { getDefaultOperatorForQuestion } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
@@ -80,7 +81,11 @@ export const QuotaModal = ({
const { t } = useTranslation();
const [openConfirmationModal, setOpenConfirmationModal] = useState(false);
const [openConfirmChangesInInclusionCriteria, setOpenConfirmChangesInInclusionCriteria] = useState(false);
const questions = useMemo(() => getQuestionsFromBlocks(survey.blocks), [survey.blocks]);
const defaultValues = useMemo(() => {
const firstQuestion = questions[0];
return {
name: quota?.name || "",
limit: quota?.limit || 1,
@@ -89,8 +94,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 +104,7 @@ export const QuotaModal = ({
countPartialSubmissions: quota?.countPartialSubmissions || false,
surveyId: survey.id,
};
}, [quota, survey]);
}, [quota, survey, questions, t]);
const form = useForm<TSurveyQuotaInput>({
defaultValues,
@@ -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 { 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";
@@ -21,6 +22,7 @@ import { COLOR_DEFAULTS } from "@/lib/styling/constants";
import { isLight, mixColor } from "@/lib/utils/colors";
import { parseRecallInfo } from "@/lib/utils/recall";
import { RatingSmiley } from "@/modules/analysis/components/RatingSmiley";
import { findElementLocation, getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { getNPSOptionColor, getRatingNumberOptionColor } from "../lib/utils";
import { QuestionHeader } from "./email-question-header";
@@ -77,13 +79,19 @@ 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 = getQuestionsFromBlocks(survey.blocks);
const firstQuestion = questions[0];
const { block } = findElementLocation(survey, firstQuestion.id);
const headline = parseRecallInfo(getLocalizedValue(firstQuestion.headline, defaultLanguageCode));
const subheader = parseRecallInfo(getLocalizedValue(firstQuestion.subheader, defaultLanguageCode));
const brandColor = styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor;
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 +99,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 +128,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 +177,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 +195,13 @@ export async function PreviewEmailTemplate({
isLight(brandColor) ? "text-black" : "text-white"
)}
href={`${urlWithPrefilling}${firstQuestion.id}=clicked`}>
{getLocalizedValue(firstQuestion.buttonLabel, defaultLanguageCode)}
{getLocalizedValue(block?.buttonLabel, defaultLanguageCode)}
</EmailButton>
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.Rating:
case TSurveyElementTypeEnum.Rating:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<Section className="w-full">
@@ -246,7 +254,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 +270,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 +286,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 +303,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 +329,7 @@ export async function PreviewEmailTemplate({
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.Cal:
case TSurveyElementTypeEnum.Cal:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<Container>
@@ -337,7 +345,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 +358,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 +399,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 +415,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" />
@@ -15,14 +15,10 @@ 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 { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import {
DropdownMenu,
DropdownMenuContent,
@@ -46,7 +42,7 @@ const questionIconMapping = {
interface RecallItemSelectProps {
localSurvey: TSurvey;
questionId: TSurveyQuestionId;
questionId: TSurveyElementId;
addRecallItem: (question: TSurveyRecallItem) => void;
setShowRecallItemSelect: (show: boolean) => void;
recallItems: TSurveyRecallItem[];
@@ -64,17 +60,19 @@ 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
);
};
const questions = useMemo(() => getQuestionsFromBlocks(localSurvey.blocks), [localSurvey.blocks]);
const recallItemIds = useMemo(() => {
return recallItems.map((recallItem) => recallItem.id);
}, [recallItems]);
@@ -114,11 +112,11 @@ export const RecallItemSelect = ({
const isWelcomeCard = questionId === "start";
if (isWelcomeCard) return [];
const isEndingCard = !localSurvey.questions.map((question) => question.id).includes(questionId);
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]);
}, [questionId, questions, recallItemIds, selectedLanguageCode]);
const filteredRecallItems: TSurveyRecallItem[] = useMemo(() => {
return [...surveyQuestionRecallItems, ...hiddenFieldRecallItems, ...variableRecallItems].filter(
@@ -146,7 +144,7 @@ export const RecallItemSelect = ({
const getRecallItemIcon = (recallItem: TSurveyRecallItem) => {
switch (recallItem.type) {
case "question":
const question = localSurvey.questions.find((question) => question.id === recallItem.id);
const question = questions.find((question) => question.id === recallItem.id);
if (question) {
return questionIconMapping[question?.type as keyof typeof questionIconMapping];
}
@@ -18,6 +18,7 @@ import {
} from "@/lib/utils/recall";
import { FallbackInput } from "@/modules/survey/components/question-form-input/components/fallback-input";
import { RecallItemSelect } from "@/modules/survey/components/question-form-input/components/recall-item-select";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { Button } from "@/modules/ui/components/button";
interface RecallWrapperRenderProps {
@@ -189,7 +190,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 = getQuestionsFromBlocks(localSurvey.blocks);
const recallQuestion = questions.find((q) => q.id === recallItemId);
if (recallQuestion) {
// replace nested recall with "___"
return [recallItem.label.replace(info, "___")];
@@ -6,12 +6,12 @@ import { ImagePlusIcon, TrashIcon } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
TSurvey,
TSurveyEndScreenCard,
TSurveyQuestion,
TSurveyQuestionChoice,
TSurveyQuestionTypeEnum,
TSurveyRedirectUrlCard,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
@@ -21,6 +21,7 @@ import { recallToHeadline } from "@/lib/utils/recall";
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
import { MultiLangWrapper } from "@/modules/survey/components/question-form-input/components/multi-lang-wrapper";
import { RecallWrapper } from "@/modules/survey/components/question-form-input/components/recall-wrapper";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { Button } from "@/modules/ui/components/button";
import { FileInput } from "@/modules/ui/components/file-input";
import { Input } from "@/modules/ui/components/input";
@@ -92,7 +93,10 @@ 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 = useMemo(() => getQuestionsFromBlocks(localSurvey.blocks), [localSurvey.blocks]);
const question: TSurveyElement = questions[questionIdx];
const isChoice = id.includes("choice");
const isMatrixLabelRow = id.includes("row");
const isMatrixLabelColumn = id.includes("column");
@@ -100,7 +104,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 +112,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 +137,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") {
@@ -144,9 +148,9 @@ export const QuestionFormInput = ({
(question &&
(id.includes(".")
? // Handle nested properties
(question[id.split(".")[0] as keyof TSurveyQuestion] as any)?.[id.split(".")[1]]
(question[id.split(".")[0] as keyof TSurveyElement] as any)?.[id.split(".")[1]]
: // Original behavior
(question[id as keyof TSurveyQuestion] as TI18nString))) ||
(question[id as keyof TSurveyElement] as TI18nString))) ||
createI18nString("", surveyLanguageCodes)
);
}, [
@@ -160,12 +164,13 @@ export const QuestionFormInput = ({
localSurvey,
question,
questionIdx,
questions,
surveyLanguageCodes,
]);
const [text, setText] = useState(elementText);
const [showImageUploader, setShowImageUploader] = useState<boolean>(
determineImageUploaderVisibility(questionIdx, localSurvey)
determineImageUploaderVisibility(questionIdx, questions)
);
const highlightContainerRef = useRef<HTMLInputElement>(null);
@@ -293,7 +298,7 @@ export const QuestionFormInput = ({
const renderRemoveDescriptionButton = () => {
if (
question &&
(question.type === TSurveyQuestionTypeEnum.CTA || question.type === TSurveyQuestionTypeEnum.Consent)
(question.type === TSurveyElementTypeEnum.CTA || question.type === TSurveyElementTypeEnum.Consent)
) {
return false;
}
@@ -2,12 +2,8 @@ import "@testing-library/jest-dom/vitest";
import { TFunction } from "i18next";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { type TI18nString } from "@formbricks/types/i18n";
import {
TSurvey,
TSurveyMultipleChoiceQuestion,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { createI18nString } from "@/lib/i18n/utils";
import * as i18nUtils from "@/lib/i18n/utils";
import {
@@ -48,7 +44,7 @@ describe("utils", () => {
describe("getChoiceLabel", () => {
test("returns the choice label from a question", () => {
const surveyLanguageCodes = ["en"];
const choiceQuestion: TSurveyMultipleChoiceQuestion = {
const choiceQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: createI18nString("Question?", surveyLanguageCodes),
@@ -57,7 +53,7 @@ describe("utils", () => {
{ id: "c1", label: createI18nString("Choice 1", surveyLanguageCodes) },
{ id: "c2", label: createI18nString("Choice 2", surveyLanguageCodes) },
],
};
} as unknown as TSurveyElement;
const result = getChoiceLabel(choiceQuestion, 1, surveyLanguageCodes);
expect(result).toEqual(createI18nString("Choice 2", surveyLanguageCodes));
@@ -65,13 +61,13 @@ describe("utils", () => {
test("returns empty i18n string when choice doesn't exist", () => {
const surveyLanguageCodes = ["en"];
const choiceQuestion: TSurveyMultipleChoiceQuestion = {
const choiceQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: createI18nString("Question?", surveyLanguageCodes),
required: true,
choices: [],
};
} as unknown as TSurveyElement;
const result = getChoiceLabel(choiceQuestion, 0, surveyLanguageCodes);
expect(result).toEqual(createI18nString("", surveyLanguageCodes));
@@ -94,7 +90,7 @@ describe("utils", () => {
{ id: "col-1", label: createI18nString("Column 1", surveyLanguageCodes) },
{ id: "col-2", label: createI18nString("Column 2", surveyLanguageCodes) },
],
} as unknown as TSurveyQuestion;
} as unknown as TSurveyElement;
const result = getMatrixLabel(matrixQuestion, 1, surveyLanguageCodes, "row");
expect(result).toEqual(createI18nString("Row 2", surveyLanguageCodes));
@@ -115,7 +111,7 @@ describe("utils", () => {
{ id: "col-1", label: createI18nString("Column 1", surveyLanguageCodes) },
{ id: "col-2", label: createI18nString("Column 2", surveyLanguageCodes) },
],
} as unknown as TSurveyQuestion;
} as unknown as TSurveyElement;
const result = getMatrixLabel(matrixQuestion, 0, surveyLanguageCodes, "column");
expect(result).toEqual(createI18nString("Column 1", surveyLanguageCodes));
@@ -130,7 +126,7 @@ describe("utils", () => {
required: true,
rows: [],
columns: [],
} as unknown as TSurveyQuestion;
} as unknown as TSurveyElement;
const result = getMatrixLabel(matrixQuestion, 0, surveyLanguageCodes, "row");
expect(result).toEqual(createI18nString("", surveyLanguageCodes));
@@ -225,7 +221,7 @@ describe("utils", () => {
pin: null,
} as unknown as TSurvey;
const result = getEndingCardText(survey, "headline", surveyLanguageCodes, 0);
const result = getEndingCardText(survey, [], "headline", surveyLanguageCodes, 0);
expect(result).toEqual(createI18nString("End Screen", surveyLanguageCodes));
});
@@ -257,32 +253,14 @@ describe("utils", () => {
pin: null,
} as unknown as TSurvey;
const result = getEndingCardText(survey, "headline", surveyLanguageCodes, 0);
const result = getEndingCardText(survey, [], "headline", surveyLanguageCodes, 0);
expect(result).toEqual(createI18nString("", surveyLanguageCodes));
});
});
describe("determineImageUploaderVisibility", () => {
test("returns false for welcome card", () => {
const survey = {
id: "survey1",
name: "Test Survey",
createdAt: new Date(),
updatedAt: new Date(),
status: "draft",
questions: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
styling: {},
environmentId: "env1",
type: "app",
triggers: [],
recontactDays: null,
endings: [],
delay: 0,
pin: null,
} as unknown as TSurvey;
const result = determineImageUploaderVisibility(-1, survey);
const result = determineImageUploaderVisibility(-1, []);
expect(result).toBe(false);
});
@@ -294,14 +272,19 @@ describe("utils", () => {
createdAt: new Date(),
updatedAt: new Date(),
status: "draft",
questions: [
blocks: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: createI18nString("Question?", surveyLanguageCodes),
required: true,
imageUrl: "https://example.com/image.jpg",
} as unknown as TSurveyQuestion,
id: "b1",
elements: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: createI18nString("Question?", surveyLanguageCodes),
required: true,
imageUrl: "https://example.com/image.jpg",
},
],
},
],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
styling: {},
@@ -314,7 +297,7 @@ describe("utils", () => {
pin: null,
} as unknown as TSurvey;
const result = determineImageUploaderVisibility(0, survey);
const result = determineImageUploaderVisibility(0, survey.blocks[0].elements);
expect(result).toBe(true);
});
@@ -326,14 +309,19 @@ describe("utils", () => {
createdAt: new Date(),
updatedAt: new Date(),
status: "draft",
questions: [
blocks: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: createI18nString("Question?", surveyLanguageCodes),
required: true,
videoUrl: "https://example.com/video.mp4",
} as unknown as TSurveyQuestion,
id: "b1",
elements: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: createI18nString("Question?", surveyLanguageCodes),
required: true,
videoUrl: "https://example.com/video.mp4",
},
],
},
],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
styling: {},
@@ -346,7 +334,7 @@ describe("utils", () => {
pin: null,
} as unknown as TSurvey;
const result = determineImageUploaderVisibility(0, survey);
const result = determineImageUploaderVisibility(0, survey.blocks[0].elements);
expect(result).toBe(true);
});
@@ -358,13 +346,18 @@ describe("utils", () => {
createdAt: new Date(),
updatedAt: new Date(),
status: "draft",
questions: [
blocks: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: createI18nString("Question?", surveyLanguageCodes),
required: true,
} as unknown as TSurveyQuestion,
id: "b1",
elements: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: createI18nString("Question?", surveyLanguageCodes),
required: true,
},
],
},
],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
styling: {},
@@ -377,7 +370,7 @@ describe("utils", () => {
pin: null,
} as unknown as TSurvey;
const result = determineImageUploaderVisibility(0, survey);
const result = determineImageUploaderVisibility(0, survey.blocks[0].elements);
expect(result).toBe(false);
});
});
@@ -1,11 +1,11 @@
import { TFunction } from "i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import {
TSurvey,
TSurveyMatrixQuestion,
TSurveyMultipleChoiceQuestion,
TSurveyQuestion,
} from "@formbricks/types/surveys/types";
TSurveyElement,
TSurveyMatrixElement,
TSurveyMultipleChoiceElement,
} from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { createI18nString } from "@/lib/i18n/utils";
import { isLabelValidForAllLanguages } from "@/lib/i18n/utils";
@@ -21,21 +21,21 @@ export const getIndex = (id: string, isChoice: boolean) => {
};
export const getChoiceLabel = (
question: TSurveyQuestion,
question: TSurveyElement,
choiceIdx: number,
surveyLanguageCodes: string[]
): TI18nString => {
const choiceQuestion = question as TSurveyMultipleChoiceQuestion;
const choiceQuestion = question as TSurveyMultipleChoiceElement;
return choiceQuestion.choices[choiceIdx]?.label || createI18nString("", surveyLanguageCodes);
};
export const getMatrixLabel = (
question: TSurveyQuestion,
question: TSurveyElement,
idx: number,
surveyLanguageCodes: string[],
type: "row" | "column"
): TI18nString => {
const matrixQuestion = question as TSurveyMatrixQuestion;
const matrixQuestion = question as TSurveyMatrixElement;
const matrixFields = type === "row" ? matrixQuestion.rows : matrixQuestion.columns;
return matrixFields[idx]?.label || createI18nString("", surveyLanguageCodes);
};
@@ -51,27 +51,30 @@ 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") {
if (card?.type === "endScreen") {
return (card[id as keyof typeof card] as TI18nString) || createI18nString("", surveyLanguageCodes);
} else {
return createI18nString("", surveyLanguageCodes);
}
};
export const determineImageUploaderVisibility = (questionIdx: number, localSurvey: TSurvey) => {
export const determineImageUploaderVisibility = (questionIdx: number, questions: TSurveyElement[]) => {
switch (questionIdx) {
case -1: // Welcome Card
return false;
default:
// Regular Survey Question
const question = localSurvey.questions[questionIdx];
default: {
// Regular Survey Question - derive questions from blocks
const question = questions[questionIdx];
return (!!question && !!question.imageUrl) || (!!question && !!question.videoUrl);
}
}
};
@@ -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: {
@@ -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}
/>
@@ -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,19 @@ export function ConditionalLogic({
return modifiedSurvey;
}, [localSurvey]);
// Find the parent block for this question/element to get its logic
const parentBlock = useMemo(
() => localSurvey.blocks.find((block) => block.elements.some((element) => element.id === question.id)),
[localSurvey.blocks, 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 +88,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,9 +139,9 @@ 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">
@@ -140,10 +149,12 @@ export function ConditionalLogic({
localSurvey={transformedSurvey}
logicItem={logicItem}
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 +184,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);
}}
@@ -12,6 +12,7 @@ import {
TSurveyQuestionTypeEnum,
TSurveyRedirectUrlCard,
} from "@formbricks/types/surveys/types";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import {
getCXQuestionNameMap,
getQuestionDefaults,
@@ -70,10 +71,10 @@ export const EditorCardMenu = ({
return undefined;
});
const questions = getQuestionsFromBlocks(survey.blocks);
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);
@@ -9,6 +9,7 @@ import { createI18nString, extractLanguageCodes, getLocalizedValue } from "@/lib
import { headlineToRecall, recallToHeadline } from "@/lib/utils/recall";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { RecallWrapper } from "@/modules/survey/components/question-form-input/components/recall-wrapper";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
@@ -43,6 +44,8 @@ export const EndScreenForm = ({
const inputRef = useRef<HTMLInputElement>(null);
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
const questions = getQuestionsFromBlocks(localSurvey.blocks);
const [showEndingCardCTA, setshowEndingCardCTA] = useState<boolean>(
endingCard.type === "endScreen" &&
(!!getLocalizedValue(endingCard.buttonLabel, selectedLanguageCode) || !!endingCard.buttonLink)
@@ -55,7 +58,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 +76,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 +145,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}
@@ -3,7 +3,7 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as Collapsible from "@radix-ui/react-collapsible";
import { EyeOff } from "lucide-react";
import { useState } from "react";
import { useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSurveyQuota } from "@formbricks/types/quota";
@@ -11,6 +11,7 @@ import { TSurvey, TSurveyHiddenFields, TSurveyQuestionId } from "@formbricks/typ
import { validateId } from "@formbricks/types/surveys/validation";
import { cn } from "@/lib/cn";
import { extractRecallInfo } from "@/lib/utils/recall";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { findHiddenFieldUsedInLogic, isUsedInQuota, isUsedInRecall } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
@@ -44,28 +45,34 @@ export const HiddenFieldsCard = ({
}
};
const updateSurvey = (data: TSurveyHiddenFields, currentFieldId?: string) => {
const questions = [...localSurvey.questions];
const questions = useMemo(() => getQuestionsFromBlocks(localSurvey.blocks), [localSurvey.blocks]);
// Remove recall info from question headlines
const updateSurvey = (data: TSurveyHiddenFields, currentFieldId?: string) => {
let updatedSurvey = { ...localSurvey };
// 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 +100,9 @@ export const HiddenFieldsCard = ({
);
return;
}
if (recallQuestionIdx === localSurvey.questions.length) {
const totalQuestions = questions.length;
if (recallQuestionIdx === totalQuestions) {
toast.error(
t("environments.surveys.edit.hidden_field_used_in_recall_ending_card", { hiddenField: fieldId })
);
@@ -191,7 +200,7 @@ export const HiddenFieldsCard = ({
className="mt-5"
onSubmit={(e) => {
e.preventDefault();
const existingQuestionIds = localSurvey.questions.map((question) => question.id);
const existingQuestionIds = questions.map((question) => question.id);
const existingEndingCardIds = localSurvey.endings.map((ending) => ending.id);
const existingHiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];
const validateIdError = validateId(
@@ -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"
@@ -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);
},
}
);
@@ -3,10 +3,14 @@
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 { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { getQuestionIconMap } from "@/modules/survey/lib/questions";
import {
Select,
@@ -18,9 +22,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 +36,8 @@ export function LogicEditor({
localSurvey,
logicItem,
updateQuestion,
updateBlockLogic,
updateBlockLogicFallback,
question,
questionIdx,
logicIdx,
@@ -37,6 +45,14 @@ 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 +60,33 @@ 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 = getQuestionsFromBlocks(localSurvey.blocks);
const blocks = localSurvey.blocks;
// Track which blocks we've already added to avoid duplicates when a block has multiple elements
const addedBlockIds = new Set<string>();
// Iterate over the questions AFTER the current question
for (let i = questionIdx + 1; i < allQuestions.length; i++) {
const ques = allQuestions[i];
const block = blocks.find((b) => b.elements.some((e) => e.id === ques.id));
if (!block) continue;
// Skip if we've already added this block
if (addedBlockIds.has(block.id)) continue;
addedBlockIds.add(block.id);
// Use the first element's headline as the block label
const firstElement = block.elements[0];
options.push({
icon: QUESTIONS_ICON_MAP[ques.type],
label: getLocalizedValue(ques.headline, "default"),
value: ques.id,
icon: QUESTIONS_ICON_MAP[firstElement.type],
label: getTextContent(
recallToHeadline(firstElement.headline, localSurvey, false, "default").default ?? ""
),
value: block.id,
});
}
@@ -57,20 +94,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 +122,7 @@ export function LogicEditor({
logicIdx={logicIdx}
question={question}
updateQuestion={updateQuestion}
updateBlockLogic={updateBlockLogic}
localSurvey={localSurvey}
questionIdx={questionIdx}
/>
@@ -95,11 +137,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 />
@@ -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) => {
@@ -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>
@@ -1,15 +1,26 @@
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";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
interface QuestionsDraggableProps {
localSurvey: TSurvey;
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 +50,9 @@ export const QuestionsDroppable = ({
setActiveQuestionId,
setSelectedLanguageCode,
updateQuestion,
updateBlockLogic,
updateBlockLogicFallback,
updateBlockButtonLabel,
addQuestion,
isFormbricksCloud,
isCxMode,
@@ -50,25 +64,33 @@ export const QuestionsDroppable = ({
}: QuestionsDraggableProps) => {
const [parent] = useAutoAnimate();
// Derive questions from blocks for display
const questions = useMemo(() => getQuestionsFromBlocks(localSurvey.blocks), [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}
// TODO: Refactor question forms to use TSurveyElement instead of TSurveyQuestion
// The forms no longer need TSurveyQuestion since logic/buttonLabel are now block-level
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}
@@ -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,15 @@ 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,
findElementLocation,
getQuestionsFromBlocks,
moveBlock,
updateElementInBlock,
} from "@/modules/survey/editor/lib/blocks";
import { findQuestionUsedInLogic, isUsedInQuota, isUsedInRecall } from "@/modules/survey/editor/lib/utils";
import {
isEndingCardValid,
@@ -91,15 +96,25 @@ export const QuestionsView = ({
isExternalUrlsAllowed,
}: QuestionsViewProps) => {
const { t } = useTranslation();
// Derive questions from blocks for display
const questions = useMemo(() => getQuestionsFromBlocks(localSurvey.blocks), [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 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 +143,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 +160,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 +236,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 +262,158 @@ export const QuestionsView = ({
};
const updateQuestion = (questionIdx: number, updatedAttributes: any) => {
const question = questions[questionIdx];
if (!question) return;
const { blockId, blockIndex } = findElementLocation(localSurvey, 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"];
const attributesToCheck = ["buttonLabel", "upperLabel", "lowerLabel"];
// 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];
// 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);
validateSurveyQuestion(updatedSurvey.questions[questionIdx]);
};
// Update block logic (block-level property)
const updateBlockLogic = (questionIdx: number, logic: TSurveyBlockLogic[]) => {
const question = questions[questionIdx];
if (!question) return;
const { blockIndex } = findElementLocation(localSurvey, question.id);
if (blockIndex === -1) return;
setLocalSurvey((prevSurvey) => {
const blocks = [...(prevSurvey.blocks ?? [])];
blocks[blockIndex] = {
...blocks[blockIndex],
logic,
};
return { ...prevSurvey, blocks };
});
};
// Update block logic fallback (block-level property)
const updateBlockLogicFallback = (questionIdx: number, logicFallback: string | undefined) => {
const question = questions[questionIdx];
if (!question) return;
const { blockIndex } = findElementLocation(localSurvey, question.id);
if (blockIndex === -1) return;
setLocalSurvey((prevSurvey) => {
const blocks = [...(prevSurvey.blocks ?? [])];
blocks[blockIndex] = {
...blocks[blockIndex],
logicFallback,
};
return { ...prevSurvey, blocks };
});
};
// Update block button label (block-level property)
const updateBlockButtonLabel = (
blockIndex: number,
labelKey: "buttonLabel" | "backButtonLabel",
labelValue: TI18nString | undefined
) => {
setLocalSurvey((prevSurvey) => {
const blocks = [...(prevSurvey.blocks ?? [])];
// Bounds check
if (blockIndex < 0 || blockIndex >= blocks.length) {
return prevSurvey;
}
blocks[blockIndex] = {
...blocks[blockIndex],
[labelKey]: labelValue,
};
return { ...prevSurvey, 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 +424,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 +447,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(localSurvey, 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 +493,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(localSurvey, 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(localSurvey, 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 +554,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(localSurvey, 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 +576,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 +588,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 +612,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(localSurvey, sourceQuestion.id);
const { blockIndex: destBlockIndex } = findElementLocation(localSurvey, 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 +674,9 @@ export const QuestionsView = ({
project={project}
moveQuestion={moveQuestion}
updateQuestion={updateQuestion}
updateBlockLogic={updateBlockLogic}
updateBlockLogicFallback={updateBlockLogicFallback}
updateBlockButtonLabel={updateBlockButtonLabel}
duplicateQuestion={duplicateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
@@ -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;
@@ -9,10 +9,10 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TSegment } from "@formbricks/types/segment";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import {
TSurvey,
TSurveyEditorTabs,
TSurveyQuestion,
ZSurvey,
ZSurveyEndScreenCard,
ZSurveyRedirectUrlCard,
@@ -171,14 +171,25 @@ 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) {
setInvalidQuestions((prevInvalidQuestions) =>
prevInvalidQuestions ? [...prevInvalidQuestions, question.id] : [question.id]
);
if (currentError.path[0] === "blocks") {
const blockIdx = currentError.path[1];
// Check if this is an element-level error (path includes "elements")
// Element errors: ["blocks", blockIdx, "elements", elementIdx, ...]
// Block errors: ["blocks", blockIdx, "buttonLabel"] or ["blocks", blockIdx, "logic"]
if (currentError.path[2] === "elements" && typeof currentError.path[3] === "number") {
const elementIdx = currentError.path[3];
const block: TSurveyBlock = localSurvey.blocks?.[blockIdx];
const element = block?.elements[elementIdx];
if (element) {
setInvalidQuestions((prevInvalidQuestions) =>
prevInvalidQuestions ? [...prevInvalidQuestions, element.id] : [element.id]
);
}
}
// For block-level errors (buttonLabel, logic, etc.), we don't mark specific questions as invalid
// The error will still be shown in the toast/UI via the error message
} else if (currentError.path[0] === "welcomeCard") {
setInvalidQuestions((prevInvalidQuestions) =>
prevInvalidQuestions ? [...prevInvalidQuestions, "start"] : ["start"]
@@ -235,10 +246,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") {
@@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TSurvey, TSurveyVariable } from "@formbricks/types/surveys/types";
import { extractRecallInfo } from "@/lib/utils/recall";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { findVariableUsedInLogic, isUsedInQuota, isUsedInRecall } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { FormControl, FormField, FormItem, FormProvider } from "@/modules/ui/components/form";
@@ -78,7 +79,7 @@ export const SurveyVariablesCardItem = ({
// Removed auto-submit effect
const onVariableDelete = (variableToDelete: TSurveyVariable) => {
const questions = [...localSurvey.questions];
const questions = getQuestionsFromBlocks(localSurvey.blocks);
const quesIdx = findVariableUsedInLogic(localSurvey, variableToDelete.id);
if (quesIdx !== -1) {
@@ -101,7 +102,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 +132,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 };
});
};
@@ -3,15 +3,17 @@
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 { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
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 +37,8 @@ export const UpdateQuestionId = ({
return;
}
const questionIds = localSurvey.questions.map((q) => q.id);
const questions = getQuestionsFromBlocks(localSurvey.blocks);
const questionIds = questions.map((q) => q.id);
const endingCardIds = localSurvey.endings.map((e) => e.id);
const hiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];
@@ -24,6 +24,32 @@ export const isElementIdUnique = (elementId: string, blocks: TSurveyBlock[]): bo
return true;
};
/**
* 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, elementIndex and the block
*/
export const findElementLocation = (
survey: TSurvey,
elementId: string
): { blockId: string | null; blockIndex: number; elementIndex: number; block: TSurveyBlock | null } => {
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, block };
}
}
return { blockId: null, blockIndex: -1, elementIndex: -1, block: null };
};
export const getQuestionsFromBlocks = (blocks: TSurveyBlock[]): TSurveyElement[] =>
blocks.flatMap((block) => block.elements);
// ============================================
// BLOCK OPERATIONS
// ============================================
@@ -100,28 +100,33 @@ describe("shared-conditions-factory", () => {
id: "survey1",
name: "Test Survey",
type: "app",
questions: [
blocks: [
{
id: "question1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
{
id: "matrix-question",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix Question" },
required: false,
shuffleOption: "none",
rows: [
{ id: "row1", label: { default: "Row 1" } },
{ id: "row2", label: { default: "Row 2" } },
],
columns: [
{ id: "col1", label: { default: "Column 1" } },
{ id: "col2", label: { default: "Column 2" } },
id: "block1",
elements: [
{
id: "question1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
{
id: "matrix-question",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix Question" },
required: false,
shuffleOption: "none",
rows: [
{ id: "row1", label: { default: "Row 1" } },
{ id: "row2", label: { default: "Row 2" } },
],
columns: [
{ id: "col1", label: { default: "Column 1" } },
{ id: "col2", label: { default: "Column 2" } },
],
},
],
},
],
@@ -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,
@@ -15,6 +16,7 @@ import {
toggleGroupConnector,
updateCondition,
} from "@/lib/surveyLogic/utils";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import {
getConditionOperatorOptions,
getConditionValueOptions,
@@ -54,13 +56,16 @@ export function createSharedConditionsFactory(
const { survey, t, questionIdx, getDefaultOperator, includeCreateGroup = false } = params;
const { onConditionsChange } = updateCallbacks;
// Derive questions from blocks
const questions = getQuestionsFromBlocks(survey.blocks);
// 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 +108,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 +154,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);
+1 -8
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) {
+226 -109
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";
@@ -26,6 +24,7 @@ import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { isConditionGroup } from "@/lib/surveyLogic/utils";
import { recallToHeadline } from "@/lib/utils/recall";
import { findElementLocation, getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { TComboboxGroupedOption, TComboboxOption } from "@/modules/ui/components/input-combo-box";
import { TLogicRuleOption, getLogicRules } from "./logic-rule-engine";
@@ -113,7 +112,8 @@ export const getConditionValueOptions = (
): TComboboxGroupedOption[] => {
const hiddenFields = localSurvey.hiddenFields?.fieldIds ?? [];
const variables = localSurvey.variables ?? [];
const questions = localSurvey.questions;
// Derive questions from blocks
const questions = getQuestionsFromBlocks(localSurvey.blocks);
const groupedOptions: TComboboxGroupedOption[] = [];
const questionOptions: TComboboxOption[] = [];
@@ -121,18 +121,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 +163,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 +236,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 +253,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 +269,7 @@ export const getQuestionOperatorOptions = (
};
export const getDefaultOperatorForQuestion = (
question: TSurveyQuestion,
question: TSurveyElement,
t: TFunction
): TSurveyLogicConditionsOperator => {
const options = getQuestionOperatorOptions(question, t);
@@ -273,8 +279,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 = getQuestionsFromBlocks(localSurvey.blocks);
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 +303,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 = getQuestionsFromBlocks(localSurvey.blocks);
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 +349,9 @@ export const getMatchValueProps = (
return { show: false, options: [] };
}
let questions = localSurvey.questions.filter((_, idx) =>
// Derive questions from blocks
const allQuestions = getQuestionsFromBlocks(localSurvey.blocks);
let questions = allQuestions.filter((_, idx) =>
typeof questionIdx === "undefined" ? true : idx <= questionIdx
);
let variables = localSurvey.variables ?? [];
@@ -359,19 +369,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 +391,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 +459,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 +469,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 +489,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 +506,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 +553,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 +600,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 +674,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 +694,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 +774,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 +848,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 +931,76 @@ 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
};
});
}
const questionOptions = questions.map((question) => {
return {
icon: getQuestionIconMapping(t)[question.type],
label: getTextContent(getLocalizedValue(question.headline, "default")),
value: question.id,
};
});
// For jumpToBlock, we need block IDs
// Track which blocks we've already added to avoid duplicates when a block has multiple elements
const blocks = localSurvey.blocks ?? [];
const addedBlockIds = new Set<string>();
const questionOptions: TComboboxOption[] = [];
if (action.objective === "requireAnswer") return questionOptions;
for (const question of questions) {
// Find which block this question belongs to
const block = blocks.find((b) => b.elements.some((e) => e.id === question.id));
if (!block) continue;
// Skip if we've already added this block
if (addedBlockIds.has(block.id)) continue;
// Mark this block as added
addedBlockIds.add(block.id);
// Use the first element's headline as the block label
const firstElement = block.elements[0];
const processedHeadline = recallToHeadline(firstElement.headline, localSurvey, false, "default");
questionOptions.push({
icon: getQuestionIconMapping(t)[firstElement.type],
label: getTextContent(processedHeadline.default ?? ""),
value: block.id,
});
}
// 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 +1069,10 @@ export const getActionValueOptions = (
questionIdx: number,
t: TFunction
): TComboboxGroupedOption[] => {
const questions = getQuestionsFromBlocks(localSurvey.blocks);
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 +1092,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 +1154,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) => {
@@ -1196,6 +1251,13 @@ const isUsedInRightOperand = (
};
export const findQuestionUsedInLogic = (survey: TSurvey, questionId: TSurveyQuestionId): number => {
const { block } = findElementLocation(survey, questionId);
// The parent block for this questionId was not found in the survey, while this shouldn't happen but we still have a safety check and return -1
if (!block) {
return -1;
}
const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => {
if (isConditionGroup(condition)) {
// It's a TConditionGroup
@@ -1209,22 +1271,33 @@ export const findQuestionUsedInLogic = (survey: TSurvey, questionId: TSurveyQues
}
};
const isUsedInAction = (action: TSurveyLogicAction): boolean => {
return (
(action.objective === "jumpToQuestion" && action.target === questionId) ||
(action.objective === "requireAnswer" && action.target === questionId)
);
const isUsedInAction = (action: TSurveyBlockLogicAction): boolean => {
if (action.objective === "requireAnswer" && action.target === questionId) {
return true;
}
return action.objective === "jumpToBlock" && action.target === block.id;
};
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
const isUsedInLogicRule = (logicRule: TSurveyBlockLogic): boolean => {
return isUsedInCondition(logicRule.conditions) || logicRule.actions.some(isUsedInAction);
};
return survey.questions.findIndex(
(question) =>
question.logicFallback === questionId ||
(question.id !== questionId && question.logic?.some(isUsedInLogicRule))
);
// Derive questions from blocks (cast as questions to access logic properties)
const questions = getQuestionsFromBlocks(survey.blocks);
return questions.findIndex((question) => {
const { block } = findElementLocation(survey, question.id);
if (!block) {
return false;
}
return (
block.logicFallback === questionId ||
(question.id !== questionId && block.logic?.some(isUsedInLogicRule))
);
});
};
export const isUsedInQuota = (
@@ -1322,17 +1395,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 +1449,22 @@ 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 = getQuestionsFromBlocks(survey.blocks);
return questions.findIndex((question) => {
const { block } = findElementLocation(survey, question.id);
if (!block) {
return false;
}
return block.logic?.some(isUsedInLogicRule);
});
};
export const findVariableUsedInLogic = (survey: TSurvey, variableId: string): number => {
@@ -1396,15 +1481,26 @@ 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) => {
const { block } = findElementLocation(survey, question.id);
if (!block) {
return false;
}
return block.logic?.some(isUsedInLogicRule);
});
};
export const findHiddenFieldUsedInLogic = (survey: TSurvey, hiddenFieldId: string): number => {
@@ -1422,11 +1518,22 @@ 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 = getQuestionsFromBlocks(survey.blocks);
return questions.findIndex((question) => {
const { block } = findElementLocation(survey, question.id);
if (!block) {
return false;
}
return block.logic?.some(isUsedInLogicRule);
});
};
export const getSurveyFollowUpActionDefaultBody = (t: TFunction): string => {
@@ -1436,15 +1543,25 @@ 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 = getQuestionsFromBlocks(survey.blocks);
return questions.findIndex((question) => {
const { block } = findElementLocation(survey, question.id);
if (!block) {
return false;
}
return block.logicFallback === endingCardId || block.logic?.some(isUsedInLogicRule);
});
};
@@ -17,10 +17,12 @@ 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";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { getSurveyFollowUpActionDefaultBody } from "@/modules/survey/editor/lib/utils";
import {
TCreateSurveyFollowUpForm,
@@ -100,14 +102,15 @@ export const FollowUpModal = ({
const [firstRender, setFirstRender] = useState(true);
const emailSendToOptions: EmailSendToOption[] = useMemo(() => {
const { questions } = localSurvey;
// Derive questions from blocks
const questions = getQuestionsFromBlocks(localSurvey.blocks);
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 +148,7 @@ export const FollowUpModal = ({
],
id: question.id,
type:
question.type === TSurveyQuestionTypeEnum.OpenText
question.type === TSurveyElementTypeEnum.OpenText
? "openTextQuestion"
: ("contactInfoQuestion" as EmailSendToOption["type"]),
})),
@@ -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);
}}
/>
@@ -10,6 +10,7 @@ import type {
TResponseVariables,
} from "@formbricks/types/responses";
import { TUploadFileConfig } from "@formbricks/types/storage";
import { TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks";
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { EndingCard } from "@/components/general/ending-card";
import { ErrorComponent } from "@/components/general/error-component";
@@ -426,7 +427,7 @@ export function Survey({
) {
const { jumpTarget, requiredQuestionIds, calculations } = performActions(
localSurvey,
logic.actions,
logic.actions as TSurveyBlockLogicAction[], // TODO: Temporary type assertion until the survey editor poc is completed, fix properly later
localResponseData,
calculationResults
);
+19 -22
View File
@@ -1,12 +1,9 @@
import { describe, expect, test, vi } from "vitest";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
import { TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks";
import { TConditionGroup, TSingleCondition } from "@formbricks/types/surveys/logic";
import {
TSurveyLogicAction,
TSurveyQuestionTypeEnum,
TSurveyVariable,
} from "@formbricks/types/surveys/types";
import { TSurveyQuestionTypeEnum, TSurveyVariable } from "@formbricks/types/surveys/types";
import { evaluateLogic, isConditionGroup, performActions } from "./logic";
// Mock the imported function
@@ -362,10 +359,10 @@ describe("Survey Logic", () => {
};
test("performs jump action", () => {
const actions: TSurveyLogicAction[] = [
const actions: TSurveyBlockLogicAction[] = [
{
id: "var1",
objective: "jumpToQuestion",
objective: "jumpToBlock",
target: "q5",
},
];
@@ -377,7 +374,7 @@ describe("Survey Logic", () => {
});
test("performs require answer action", () => {
const actions: TSurveyLogicAction[] = [
const actions: TSurveyBlockLogicAction[] = [
{
id: "var1",
objective: "requireAnswer",
@@ -392,7 +389,7 @@ describe("Survey Logic", () => {
});
test("performs calculate action - add", () => {
const actions: TSurveyLogicAction[] = [
const actions: TSurveyBlockLogicAction[] = [
{
id: "var2",
objective: "calculate",
@@ -407,7 +404,7 @@ describe("Survey Logic", () => {
});
test("performs calculate action - subtract", () => {
const actions: TSurveyLogicAction[] = [
const actions: TSurveyBlockLogicAction[] = [
{
id: "var2",
objective: "calculate",
@@ -422,7 +419,7 @@ describe("Survey Logic", () => {
});
test("performs calculate action - multiply", () => {
const actions: TSurveyLogicAction[] = [
const actions: TSurveyBlockLogicAction[] = [
{
id: "var2",
objective: "calculate",
@@ -437,7 +434,7 @@ describe("Survey Logic", () => {
});
test("performs calculate action - divide", () => {
const actions: TSurveyLogicAction[] = [
const actions: TSurveyBlockLogicAction[] = [
{
id: "var2",
objective: "calculate",
@@ -452,7 +449,7 @@ describe("Survey Logic", () => {
});
test("handles divide by zero", () => {
const actions: TSurveyLogicAction[] = [
const actions: TSurveyBlockLogicAction[] = [
{
id: "var2",
objective: "calculate",
@@ -467,7 +464,7 @@ describe("Survey Logic", () => {
});
test("performs calculate action - assign", () => {
const actions: TSurveyLogicAction[] = [
const actions: TSurveyBlockLogicAction[] = [
{
id: "var2",
objective: "calculate",
@@ -482,7 +479,7 @@ describe("Survey Logic", () => {
});
test("performs calculate action - concat", () => {
const actions: TSurveyLogicAction[] = [
const actions: TSurveyBlockLogicAction[] = [
{
id: "var1",
objective: "calculate",
@@ -497,7 +494,7 @@ describe("Survey Logic", () => {
});
test("performs calculate action with question value", () => {
const actions: TSurveyLogicAction[] = [
const actions: TSurveyBlockLogicAction[] = [
{
id: "var2",
objective: "calculate",
@@ -512,7 +509,7 @@ describe("Survey Logic", () => {
});
test("performs calculate action with variable value", () => {
const actions: TSurveyLogicAction[] = [
const actions: TSurveyBlockLogicAction[] = [
{
id: "var2",
objective: "calculate",
@@ -527,7 +524,7 @@ describe("Survey Logic", () => {
});
test("performs multiple actions in order", () => {
const actions: TSurveyLogicAction[] = [
const actions: TSurveyBlockLogicAction[] = [
{
id: "var2",
objective: "calculate",
@@ -542,7 +539,7 @@ describe("Survey Logic", () => {
},
{
id: "var2",
objective: "jumpToQuestion",
objective: "jumpToBlock",
target: "q5",
},
];
@@ -554,15 +551,15 @@ describe("Survey Logic", () => {
});
test("takes first jump target when multiple jump actions exist", () => {
const actions: TSurveyLogicAction[] = [
const actions: TSurveyBlockLogicAction[] = [
{
id: "var2",
objective: "jumpToQuestion",
objective: "jumpToBlock",
target: "q2",
},
{
id: "var2",
objective: "jumpToQuestion",
objective: "jumpToBlock",
target: "q3",
},
];
+4 -9
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;
}
+18 -8
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;
}
}
}
+3 -4
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>;
+16 -15
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,
+3 -1
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,
}),