mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-03 12:21:05 -05:00
feat: refactor survey editor logic to use blocks model (#6778)
This commit is contained in:
@@ -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,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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 ?",
|
||||
|
||||
@@ -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": "何を知りたいですか?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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": "你 想 知道 什么?",
|
||||
|
||||
@@ -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" />
|
||||
|
||||
+19
-21
@@ -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];
|
||||
}
|
||||
|
||||
+3
-1
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user