Merge branch 'epic/survey-mqp' into feat/survey-editor-blocks-ui

This commit is contained in:
pandeymangg
2025-11-12 22:37:12 +05:30
29 changed files with 3998 additions and 3391 deletions

View File

@@ -32,14 +32,22 @@ const mockProject: TProject = {
};
const mockTemplate: TXMTemplate = {
name: "$[projectName] Survey",
questions: [
blocks: [
{
id: "q1",
inputType: "text",
type: "email" as any,
headline: { default: "$[projectName] Question" },
required: false,
charLimit: { enabled: true, min: 400, max: 1000 },
id: "block1",
name: "Block 1",
elements: [
{
id: "q1",
type: "openText" as const,
inputType: "text" as const,
headline: { default: "$[projectName] Question" },
subheader: { default: "" },
required: false,
placeholder: { default: "" },
charLimit: 1000,
},
],
},
],
endings: [
@@ -66,9 +74,9 @@ describe("replacePresetPlaceholders", () => {
expect(result.name).toBe("Test Project Survey");
});
test("replaces projectName placeholder in question headline", () => {
test("replaces projectName placeholder in element headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.questions[0].headline.default).toBe("Test Project Question");
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
});
test("returns a new object without mutating the original template", () => {

View File

@@ -1,13 +1,16 @@
import { TProject } from "@formbricks/types/project";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TXMTemplate } from "@formbricks/types/templates";
import { replaceQuestionPresetPlaceholders } from "@/lib/utils/templates";
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
// replace all occurences of projectName with the actual project name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject) => {
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => {
const survey = structuredClone(template);
survey.name = survey.name.replace("$[projectName]", project.name);
survey.questions = survey.questions.map((question) => {
return replaceQuestionPresetPlaceholders(question, project);
});
return { ...template, ...survey };
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
...block,
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
}));
return { ...survey, name: survey.name.replace("$[projectName]", project.name), blocks: modifiedBlocks };
};

View File

@@ -20,7 +20,7 @@ describe("xm-templates", () => {
expect(result).toEqual({
name: "",
endings: expect.any(Array),
questions: [],
blocks: [],
styling: {
overwriteThemeStyling: true,
},

View File

@@ -3,19 +3,21 @@ import { TFunction } from "i18next";
import { logger } from "@formbricks/logger";
import { TXMTemplate } from "@formbricks/types/templates";
import {
buildCTAQuestion,
buildNPSQuestion,
buildOpenTextQuestion,
buildRatingQuestion,
getDefaultEndingCard,
} from "@/app/lib/survey-builder";
buildBlock,
buildCTAElement,
buildNPSElement,
buildOpenTextElement,
buildRatingElement,
createBlockJumpLogic,
} from "@/app/lib/survey-block-builder";
import { getDefaultEndingCard } from "@/app/lib/survey-builder";
export const getXMSurveyDefault = (t: TFunction): TXMTemplate => {
try {
return {
name: "",
endings: [getDefaultEndingCard([], t)],
questions: [],
blocks: [],
styling: {
overwriteThemeStyling: true,
},
@@ -30,25 +32,40 @@ const npsSurvey = (t: TFunction): TXMTemplate => {
return {
...getXMSurveyDefault(t),
name: t("templates.nps_survey_name"),
questions: [
buildNPSQuestion({
headline: t("templates.nps_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
upperLabel: t("templates.nps_survey_question_1_upper_label"),
isColorCodingEnabled: true,
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildNPSElement({
headline: t("templates.nps_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
upperLabel: t("templates.nps_survey_question_1_upper_label"),
isColorCodingEnabled: true,
}),
],
t,
}),
buildOpenTextQuestion({
headline: t("templates.nps_survey_question_2_headline"),
required: false,
inputType: "text",
buildBlock({
name: "Block 2",
elements: [
buildOpenTextElement({
headline: t("templates.nps_survey_question_2_headline"),
required: false,
inputType: "text",
}),
],
t,
}),
buildOpenTextQuestion({
headline: t("templates.nps_survey_question_3_headline"),
required: false,
inputType: "text",
buildBlock({
name: "Block 3",
elements: [
buildOpenTextElement({
headline: t("templates.nps_survey_question_3_headline"),
required: false,
inputType: "text",
}),
],
t,
}),
],
@@ -56,15 +73,27 @@ const npsSurvey = (t: TFunction): TXMTemplate => {
};
const starRatingSurvey = (t: TFunction): TXMTemplate => {
const reusableQuestionIds = [createId(), createId(), createId()];
const reusableElementIds = [createId(), createId(), createId()];
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
const defaultSurvey = getXMSurveyDefault(t);
return {
...defaultSurvey,
name: t("templates.star_rating_survey_name"),
questions: [
buildRatingQuestion({
id: reusableQuestionIds[0],
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildRatingElement({
id: reusableElementIds[0],
range: 5,
scale: "number",
headline: t("templates.star_rating_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
}),
],
logic: [
{
id: createId(),
@@ -75,7 +104,7 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[0],
value: reusableElementIds[0],
type: "question",
},
operator: "isLessThanOrEqual",
@@ -89,64 +118,44 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: reusableQuestionIds[2],
objective: "jumpToBlock",
target: block3Id,
},
],
},
],
range: 5,
scale: "number",
headline: t("templates.star_rating_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
t,
}),
buildCTAQuestion({
id: reusableQuestionIds[1],
subheader: t("templates.star_rating_survey_question_2_html"),
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[1],
type: "question",
},
operator: "isClicked",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: defaultSurvey.endings[0].id,
},
],
},
buildBlock({
name: "Block 2",
elements: [
buildCTAElement({
id: reusableElementIds[1],
subheader: t("templates.star_rating_survey_question_2_html"),
headline: t("templates.star_rating_survey_question_2_headline"),
required: true,
buttonUrl: "https://formbricks.com/github",
buttonExternal: true,
}),
],
headline: t("templates.star_rating_survey_question_2_headline"),
required: true,
buttonUrl: "https://formbricks.com/github",
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
buttonLabel: t("templates.star_rating_survey_question_2_button_label"),
buttonExternal: true,
t,
}),
buildOpenTextQuestion({
id: reusableQuestionIds[2],
headline: t("templates.star_rating_survey_question_3_headline"),
required: true,
subheader: t("templates.star_rating_survey_question_3_subheader"),
buildBlock({
id: block3Id,
name: "Block 3",
elements: [
buildOpenTextElement({
id: reusableElementIds[2],
headline: t("templates.star_rating_survey_question_3_headline"),
required: true,
subheader: t("templates.star_rating_survey_question_3_subheader"),
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
inputType: "text",
}),
],
buttonLabel: t("templates.star_rating_survey_question_3_button_label"),
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
inputType: "text",
t,
}),
],
@@ -154,15 +163,27 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
};
const csatSurvey = (t: TFunction): TXMTemplate => {
const reusableQuestionIds = [createId(), createId(), createId()];
const reusableElementIds = [createId(), createId(), createId()];
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
const defaultSurvey = getXMSurveyDefault(t);
return {
...defaultSurvey,
name: t("templates.csat_survey_name"),
questions: [
buildRatingQuestion({
id: reusableQuestionIds[0],
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildRatingElement({
id: reusableElementIds[0],
range: 5,
scale: "smiley",
headline: t("templates.csat_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
upperLabel: t("templates.csat_survey_question_1_upper_label"),
}),
],
logic: [
{
id: createId(),
@@ -173,7 +194,7 @@ const csatSurvey = (t: TFunction): TXMTemplate => {
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[0],
value: reusableElementIds[0],
type: "question",
},
operator: "isLessThanOrEqual",
@@ -187,60 +208,40 @@ const csatSurvey = (t: TFunction): TXMTemplate => {
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: reusableQuestionIds[2],
objective: "jumpToBlock",
target: block3Id,
},
],
},
],
range: 5,
scale: "smiley",
headline: t("templates.csat_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
upperLabel: t("templates.csat_survey_question_1_upper_label"),
t,
}),
buildOpenTextQuestion({
id: reusableQuestionIds[1],
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[1],
type: "question",
},
operator: "isSubmitted",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: defaultSurvey.endings[0].id,
},
],
},
buildBlock({
name: "Block 2",
elements: [
buildOpenTextElement({
id: reusableElementIds[1],
headline: t("templates.csat_survey_question_2_headline"),
required: false,
placeholder: t("templates.csat_survey_question_2_placeholder"),
inputType: "text",
}),
],
headline: t("templates.csat_survey_question_2_headline"),
required: false,
placeholder: t("templates.csat_survey_question_2_placeholder"),
inputType: "text",
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isSubmitted")],
t,
}),
buildOpenTextQuestion({
id: reusableQuestionIds[2],
headline: t("templates.csat_survey_question_3_headline"),
required: false,
placeholder: t("templates.csat_survey_question_3_placeholder"),
inputType: "text",
buildBlock({
id: block3Id,
name: "Block 3",
elements: [
buildOpenTextElement({
id: reusableElementIds[2],
headline: t("templates.csat_survey_question_3_headline"),
required: false,
placeholder: t("templates.csat_survey_question_3_placeholder"),
inputType: "text",
}),
],
t,
}),
],
@@ -251,21 +252,31 @@ const cessSurvey = (t: TFunction): TXMTemplate => {
return {
...getXMSurveyDefault(t),
name: t("templates.cess_survey_name"),
questions: [
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.cess_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
upperLabel: t("templates.cess_survey_question_1_upper_label"),
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildRatingElement({
range: 5,
scale: "number",
headline: t("templates.cess_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
upperLabel: t("templates.cess_survey_question_1_upper_label"),
}),
],
t,
}),
buildOpenTextQuestion({
headline: t("templates.cess_survey_question_2_headline"),
required: true,
placeholder: t("templates.cess_survey_question_2_placeholder"),
inputType: "text",
buildBlock({
name: "Block 2",
elements: [
buildOpenTextElement({
headline: t("templates.cess_survey_question_2_headline"),
required: true,
placeholder: t("templates.cess_survey_question_2_placeholder"),
inputType: "text",
}),
],
t,
}),
],
@@ -273,15 +284,27 @@ const cessSurvey = (t: TFunction): TXMTemplate => {
};
const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
const reusableQuestionIds = [createId(), createId(), createId()];
const reusableElementIds = [createId(), createId(), createId()];
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
const defaultSurvey = getXMSurveyDefault(t);
return {
...defaultSurvey,
name: t("templates.smileys_survey_name"),
questions: [
buildRatingQuestion({
id: reusableQuestionIds[0],
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildRatingElement({
id: reusableElementIds[0],
range: 5,
scale: "smiley",
headline: t("templates.smileys_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
}),
],
logic: [
{
id: createId(),
@@ -292,7 +315,7 @@ const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[0],
value: reusableElementIds[0],
type: "question",
},
operator: "isLessThanOrEqual",
@@ -306,64 +329,44 @@ const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: reusableQuestionIds[2],
objective: "jumpToBlock",
target: block3Id,
},
],
},
],
range: 5,
scale: "smiley",
headline: t("templates.smileys_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
t,
}),
buildCTAQuestion({
id: reusableQuestionIds[1],
subheader: t("templates.smileys_survey_question_2_html"),
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[1],
type: "question",
},
operator: "isClicked",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: defaultSurvey.endings[0].id,
},
],
},
buildBlock({
name: "Block 2",
elements: [
buildCTAElement({
id: reusableElementIds[1],
subheader: t("templates.smileys_survey_question_2_html"),
headline: t("templates.smileys_survey_question_2_headline"),
required: true,
buttonUrl: "https://formbricks.com/github",
buttonExternal: true,
}),
],
headline: t("templates.smileys_survey_question_2_headline"),
required: true,
buttonUrl: "https://formbricks.com/github",
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
buttonLabel: t("templates.smileys_survey_question_2_button_label"),
buttonExternal: true,
t,
}),
buildOpenTextQuestion({
id: reusableQuestionIds[2],
headline: t("templates.smileys_survey_question_3_headline"),
required: true,
subheader: t("templates.smileys_survey_question_3_subheader"),
buildBlock({
id: block3Id,
name: "Block 3",
elements: [
buildOpenTextElement({
id: reusableElementIds[2],
headline: t("templates.smileys_survey_question_3_headline"),
required: true,
subheader: t("templates.smileys_survey_question_3_subheader"),
placeholder: t("templates.smileys_survey_question_3_placeholder"),
inputType: "text",
}),
],
buttonLabel: t("templates.smileys_survey_question_3_button_label"),
placeholder: t("templates.smileys_survey_question_3_placeholder"),
inputType: "text",
t,
}),
],
@@ -374,25 +377,40 @@ const enpsSurvey = (t: TFunction): TXMTemplate => {
return {
...getXMSurveyDefault(t),
name: t("templates.enps_survey_name"),
questions: [
buildNPSQuestion({
headline: t("templates.enps_survey_question_1_headline"),
required: false,
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
upperLabel: t("templates.enps_survey_question_1_upper_label"),
isColorCodingEnabled: true,
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildNPSElement({
headline: t("templates.enps_survey_question_1_headline"),
required: false,
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
upperLabel: t("templates.enps_survey_question_1_upper_label"),
isColorCodingEnabled: true,
}),
],
t,
}),
buildOpenTextQuestion({
headline: t("templates.enps_survey_question_2_headline"),
required: false,
inputType: "text",
buildBlock({
name: "Block 2",
elements: [
buildOpenTextElement({
headline: t("templates.enps_survey_question_2_headline"),
required: false,
inputType: "text",
}),
],
t,
}),
buildOpenTextQuestion({
headline: t("templates.enps_survey_question_3_headline"),
required: false,
inputType: "text",
buildBlock({
name: "Block 3",
elements: [
buildOpenTextElement({
headline: t("templates.enps_survey_question_3_headline"),
required: false,
inputType: "text",
}),
],
t,
}),
],

View File

@@ -0,0 +1,308 @@
import { createId } from "@paralleldrive/cuid2";
import type { TFunction } from "i18next";
import type { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import type {
TSurveyCTAElement,
TSurveyConsentElement,
TSurveyElement,
TSurveyMultipleChoiceElement,
TSurveyNPSElement,
TSurveyOpenTextElement,
TSurveyOpenTextElementInputType,
TSurveyRatingElement,
} from "@formbricks/types/surveys/elements";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import type { TShuffleOption } from "@formbricks/types/surveys/types";
import { createI18nString } from "@/lib/i18n/utils";
const getDefaultButtonLabel = (label: string | undefined, t: TFunction) =>
createI18nString(label || t("common.next"), []);
const getDefaultBackButtonLabel = (label: string | undefined, t: TFunction) =>
createI18nString(label || t("common.back"), []);
export const buildMultipleChoiceElement = ({
id,
headline,
type,
subheader,
choices,
choiceIds,
shuffleOption,
required,
containsOther = false,
}: {
id?: string;
headline: string;
type: TSurveyElementTypeEnum.MultipleChoiceMulti | TSurveyElementTypeEnum.MultipleChoiceSingle;
subheader?: string;
choices: string[];
choiceIds?: string[];
shuffleOption?: TShuffleOption;
required?: boolean;
containsOther?: boolean;
}): TSurveyMultipleChoiceElement => {
return {
id: id ?? createId(),
type,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
choices: choices.map((choice, index) => {
const isLastIndex = index === choices.length - 1;
let choiceId: string;
if (containsOther && isLastIndex) {
choiceId = "other";
} else if (choiceIds) {
choiceId = choiceIds[index];
} else {
choiceId = createId();
}
return { id: choiceId, label: createI18nString(choice, []) };
}),
shuffleOption: shuffleOption || "none",
required: required ?? false,
};
};
export const buildOpenTextElement = ({
id,
headline,
subheader,
placeholder,
inputType,
required,
longAnswer,
}: {
id?: string;
headline: string;
subheader?: string;
placeholder?: string;
required?: boolean;
inputType: TSurveyOpenTextElementInputType;
longAnswer?: boolean;
}): TSurveyOpenTextElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.OpenText,
inputType,
subheader: subheader ? createI18nString(subheader, []) : undefined,
placeholder: placeholder ? createI18nString(placeholder, []) : undefined,
headline: createI18nString(headline, []),
required: required ?? false,
longAnswer,
charLimit: {
enabled: false,
},
};
};
export const buildRatingElement = ({
id,
headline,
subheader,
scale,
range,
lowerLabel,
upperLabel,
required,
isColorCodingEnabled = false,
}: {
id?: string;
headline: string;
scale: TSurveyRatingElement["scale"];
range: TSurveyRatingElement["range"];
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): TSurveyRatingElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.Rating,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
scale,
range,
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
};
};
export const buildConsentElement = ({
id,
headline,
subheader,
label,
required,
}: {
id?: string;
headline: string;
subheader: string;
required?: boolean;
label: string;
}): TSurveyConsentElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.Consent,
subheader: createI18nString(subheader, []),
headline: createI18nString(headline, []),
required: required ?? false,
label: createI18nString(label, []),
};
};
export const buildCTAElement = ({
id,
headline,
subheader,
buttonExternal,
required,
dismissButtonLabel,
buttonUrl,
}: {
id?: string;
headline: string;
buttonExternal: boolean;
subheader: string;
required?: boolean;
dismissButtonLabel?: string;
buttonUrl?: string;
}): TSurveyCTAElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.CTA,
subheader: createI18nString(subheader, []),
headline: createI18nString(headline, []),
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
required: required ?? false,
buttonExternal,
buttonUrl,
};
};
export const buildNPSElement = ({
id,
headline,
subheader,
lowerLabel,
upperLabel,
required,
isColorCodingEnabled = false,
}: {
id?: string;
headline: string;
subheader?: string;
lowerLabel?: string;
upperLabel?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): TSurveyNPSElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.NPS,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
};
};
// Helper function to create block-level jump logic based on operator
export const createBlockJumpLogic = (
sourceElementId: string,
targetBlockId: string,
operator: "isSkipped" | "isSubmitted" | "isClicked"
): TSurveyBlockLogic => ({
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: sourceElementId,
type: "question",
},
operator: operator,
},
],
},
actions: [
{
id: createId(),
objective: "jumpToBlock",
target: targetBlockId,
},
],
});
// Helper function to create block-level jump logic based on choice selection
export const createBlockChoiceJumpLogic = (
sourceElementId: string,
choiceId: string | number,
targetBlockId: string
): TSurveyBlockLogic => ({
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: sourceElementId,
type: "question",
},
operator: "equals",
rightOperand: {
type: "static",
value: choiceId,
},
},
],
},
actions: [
{
id: createId(),
objective: "jumpToBlock",
target: targetBlockId,
},
],
});
// Block builder function
export const buildBlock = ({
id,
name,
elements,
logic,
logicFallback,
buttonLabel,
backButtonLabel,
t,
}: {
id?: string;
name: string;
elements: TSurveyElement[];
logic?: TSurveyBlockLogic[];
logicFallback?: string;
buttonLabel?: string;
backButtonLabel?: string;
t: TFunction;
}): TSurveyBlock => {
return {
id: id ?? createId(),
name,
elements,
logic,
logicFallback,
buttonLabel: buttonLabel ? getDefaultButtonLabel(buttonLabel, t) : undefined,
backButtonLabel: backButtonLabel ? getDefaultBackButtonLabel(backButtonLabel, t) : undefined,
};
};

View File

@@ -1,15 +1,6 @@
import { describe, expect, test } from "vitest";
import { TShuffleOption, TSurveyLogic, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
buildCTAQuestion,
buildConsentQuestion,
buildMultipleChoiceQuestion,
buildNPSQuestion,
buildOpenTextQuestion,
buildRatingQuestion,
buildSurvey,
createChoiceJumpLogic,
createJumpLogic,
getDefaultEndingCard,
getDefaultSurveyPreset,
getDefaultWelcomeCard,
@@ -19,595 +10,81 @@ import {
const mockT = (props: any): string => (typeof props === "string" ? props : props.key);
describe("Survey Builder", () => {
describe("buildMultipleChoiceQuestion", () => {
test("creates a single choice question with required fields", () => {
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: ["Option 1", "Option 2", "Option 3"],
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Test Question" },
choices: expect.arrayContaining([
expect.objectContaining({ label: { default: "Option 1" } }),
expect.objectContaining({ label: { default: "Option 2" } }),
expect.objectContaining({ label: { default: "Option 3" } }),
]),
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
shuffleOption: "none",
required: false,
});
expect(question.choices.length).toBe(3);
expect(question.id).toBeDefined();
describe("Helper Functions", () => {
test("getDefaultSurveyPreset returns expected default survey preset", () => {
const preset = getDefaultSurveyPreset(mockT);
expect(preset.name).toBe("New Survey");
// test welcomeCard and endings
expect(preset.welcomeCard).toHaveProperty("headline");
expect(preset.endings).toHaveLength(1);
expect(preset.endings[0]).toHaveProperty("headline");
expect(preset.hiddenFields).toEqual(hiddenFieldsDefault);
expect(preset.blocks).toEqual([]);
});
test("creates a multiple choice question with provided ID", () => {
const customId = "custom-id-123";
const question = buildMultipleChoiceQuestion({
id: customId,
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
choices: ["Option 1", "Option 2"],
t: mockT,
test("getDefaultWelcomeCard returns expected welcome card", () => {
const welcomeCard = getDefaultWelcomeCard(mockT);
expect(welcomeCard).toMatchObject({
enabled: false,
headline: { default: "templates.default_welcome_card_headline" },
timeToFinish: false,
showResponseCount: false,
});
expect(question.id).toBe(customId);
expect(question.type).toBe(TSurveyQuestionTypeEnum.MultipleChoiceMulti);
// Check that the welcome card is properly structured
expect(welcomeCard).toHaveProperty("enabled");
expect(welcomeCard).toHaveProperty("headline");
expect(welcomeCard).toHaveProperty("showResponseCount");
expect(welcomeCard).toHaveProperty("timeToFinish");
});
test("handles 'other' option correctly", () => {
const choices = ["Option 1", "Option 2", "Other"];
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices,
containsOther: true,
t: mockT,
test("getDefaultEndingCard returns expected ending card", () => {
const languages: string[] = [];
const endingCard = getDefaultEndingCard(languages, mockT);
expect(endingCard).toMatchObject({
type: "endScreen",
headline: { default: "templates.default_ending_card_headline" },
subheader: { default: "templates.default_ending_card_subheader" },
});
expect(question.choices.length).toBe(3);
expect(question.choices[2].id).toBe("other");
expect(endingCard.id).toBeDefined();
expect(endingCard).toHaveProperty("buttonLabel");
expect(endingCard).toHaveProperty("buttonLink");
});
test("uses provided choice IDs when available", () => {
const choiceIds = ["id1", "id2", "id3"];
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: ["Option 1", "Option 2", "Option 3"],
choiceIds,
t: mockT,
test("hiddenFieldsDefault has expected structure", () => {
expect(hiddenFieldsDefault).toMatchObject({
enabled: true,
fieldIds: [],
});
expect(question.choices[0].id).toBe(choiceIds[0]);
expect(question.choices[1].id).toBe(choiceIds[1]);
expect(question.choices[2].id).toBe(choiceIds[2]);
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
test("buildSurvey returns built survey with overridden preset properties", () => {
const config = {
name: "Custom Survey",
role: "productManager" as const,
industries: ["saas" as const],
channels: ["link" as const],
description: "A custom survey description",
blocks: [],
endings: [getDefaultEndingCard([], mockT)],
hiddenFields: hiddenFieldsDefault,
};
const shuffleOption: TShuffleOption = "all";
const survey = buildSurvey(config, mockT);
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
subheader: "This is a subheader",
choices: ["Option 1", "Option 2"],
buttonLabel: "Custom Next",
backButtonLabel: "Custom Back",
shuffleOption,
required: false,
logic,
t: mockT,
});
// role, industries, channels, description
expect(survey.role).toBe(config.role);
expect(survey.industries).toEqual(config.industries);
expect(survey.channels).toEqual(config.channels);
expect(survey.description).toBe(config.description);
expect(question.subheader).toEqual({ default: "This is a subheader" });
expect(question.buttonLabel).toEqual({ default: "Custom Next" });
expect(question.backButtonLabel).toEqual({ default: "Custom Back" });
expect(question.shuffleOption).toBe("all");
expect(question.required).toBe(false);
expect(question.logic).toBe(logic);
});
});
// preset overrides
expect(survey.preset.name).toBe(config.name);
expect(survey.preset.endings).toEqual(config.endings);
expect(survey.preset.hiddenFields).toEqual(config.hiddenFields);
expect(survey.preset.blocks).toEqual(config.blocks);
describe("buildOpenTextQuestion", () => {
test("creates an open text question with required fields", () => {
const question = buildOpenTextQuestion({
headline: "Open Question",
inputType: "text",
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Open Question" },
inputType: "text",
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
charLimit: {
enabled: false,
},
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildOpenTextQuestion({
id: "custom-id",
headline: "Open Question",
subheader: "Answer this question",
placeholder: "Type here",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
longAnswer: true,
inputType: "email",
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "Answer this question" });
expect(question.placeholder).toEqual({ default: "Type here" });
expect(question.buttonLabel).toEqual({ default: "Submit" });
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.longAnswer).toBe(true);
expect(question.inputType).toBe("email");
expect(question.logic).toBe(logic);
});
});
describe("buildRatingQuestion", () => {
test("creates a rating question with required fields", () => {
const question = buildRatingQuestion({
headline: "Rating Question",
scale: "number",
range: 5,
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rating Question" },
scale: "number",
range: 5,
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
isColorCodingEnabled: false,
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildRatingQuestion({
id: "custom-id",
headline: "Rating Question",
subheader: "Rate us",
scale: "star",
range: 10,
lowerLabel: "Poor",
upperLabel: "Excellent",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
isColorCodingEnabled: true,
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "Rate us" });
expect(question.scale).toBe("star");
expect(question.range).toBe(10);
expect(question.lowerLabel).toEqual({ default: "Poor" });
expect(question.upperLabel).toEqual({ default: "Excellent" });
expect(question.buttonLabel).toEqual({ default: "Submit" });
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.isColorCodingEnabled).toBe(true);
expect(question.logic).toBe(logic);
});
});
describe("buildNPSQuestion", () => {
test("creates an NPS question with required fields", () => {
const question = buildNPSQuestion({
headline: "NPS Question",
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "NPS Question" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
isColorCodingEnabled: false,
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildNPSQuestion({
id: "custom-id",
headline: "NPS Question",
subheader: "How likely are you to recommend us?",
lowerLabel: "Not likely",
upperLabel: "Very likely",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
isColorCodingEnabled: true,
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "How likely are you to recommend us?" });
expect(question.lowerLabel).toEqual({ default: "Not likely" });
expect(question.upperLabel).toEqual({ default: "Very likely" });
expect(question.buttonLabel).toEqual({ default: "Submit" });
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.isColorCodingEnabled).toBe(true);
expect(question.logic).toBe(logic);
});
});
describe("buildConsentQuestion", () => {
test("creates a consent question with required fields", () => {
const question = buildConsentQuestion({
headline: "Consent Question",
subheader: "",
label: "I agree to terms",
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.Consent,
headline: { default: "Consent Question" },
subheader: { default: "" },
label: { default: "I agree to terms" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildConsentQuestion({
id: "custom-id",
headline: "Consent Question",
subheader: "Please read the terms",
label: "I agree to terms",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "Please read the terms" });
expect(question.label).toEqual({ default: "I agree to terms" });
expect(question.buttonLabel).toEqual({ default: "Submit" });
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.logic).toBe(logic);
});
});
describe("buildCTAQuestion", () => {
test("creates a CTA question with required fields", () => {
const question = buildCTAQuestion({
headline: "CTA Question",
subheader: "",
buttonExternal: false,
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA Question" },
subheader: { default: "" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
buttonExternal: false,
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildCTAQuestion({
id: "custom-id",
headline: "CTA Question",
subheader: "<p>Click the button</p>",
buttonLabel: "Click me",
buttonExternal: true,
buttonUrl: "https://example.com",
backButtonLabel: "Previous",
required: false,
dismissButtonLabel: "No thanks",
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "<p>Click the button</p>" });
expect(question.buttonLabel).toEqual({ default: "Click me" });
expect(question.buttonExternal).toBe(true);
expect(question.buttonUrl).toBe("https://example.com");
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.dismissButtonLabel).toEqual({ default: "No thanks" });
expect(question.logic).toBe(logic);
});
test("handles external button with URL", () => {
const question = buildCTAQuestion({
headline: "CTA Question",
subheader: "",
buttonExternal: true,
buttonUrl: "https://formbricks.com",
t: mockT,
});
expect(question.buttonExternal).toBe(true);
expect(question.buttonUrl).toBe("https://formbricks.com");
});
});
// Test combinations of parameters for edge cases
describe("Edge cases", () => {
test("multiple choice question with empty choices array", () => {
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [],
t: mockT,
});
expect(question.choices).toEqual([]);
});
test("open text question with all parameters", () => {
const question = buildOpenTextQuestion({
id: "custom-id",
headline: "Open Question",
subheader: "Answer this question",
placeholder: "Type here",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
longAnswer: true,
inputType: "email",
logic: [],
t: mockT,
});
expect(question).toMatchObject({
id: "custom-id",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Open Question" },
subheader: { default: "Answer this question" },
placeholder: { default: "Type here" },
buttonLabel: { default: "Submit" },
backButtonLabel: { default: "Previous" },
required: false,
longAnswer: true,
inputType: "email",
logic: [],
});
// default values from getDefaultSurveyPreset
expect(survey.preset.welcomeCard).toHaveProperty("headline");
});
});
});
describe("Helper Functions", () => {
test("createJumpLogic returns valid jump logic", () => {
const sourceId = "q1";
const targetId = "q2";
const operator: "isClicked" = "isClicked";
const logic = createJumpLogic(sourceId, targetId, operator);
// Check structure
expect(logic).toHaveProperty("id");
expect(logic).toHaveProperty("conditions");
expect(logic.conditions).toHaveProperty("conditions");
expect(Array.isArray(logic.conditions.conditions)).toBe(true);
// Check one of the inner conditions
const condition = logic.conditions.conditions[0];
// Need to use type checking to ensure condition is a TSingleCondition not a TConditionGroup
if (!("connector" in condition)) {
expect(condition.leftOperand.value).toBe(sourceId);
expect(condition.operator).toBe(operator);
}
// Check actions
expect(Array.isArray(logic.actions)).toBe(true);
const action = logic.actions[0];
if (action.objective === "jumpToQuestion") {
expect(action.target).toBe(targetId);
}
});
test("createChoiceJumpLogic returns valid jump logic based on choice selection", () => {
const sourceId = "q1";
const choiceId = "choice1";
const targetId = "q2";
const logic = createChoiceJumpLogic(sourceId, choiceId, targetId);
expect(logic).toHaveProperty("id");
expect(logic.conditions).toHaveProperty("conditions");
const condition = logic.conditions.conditions[0];
if (!("connector" in condition)) {
expect(condition.leftOperand.value).toBe(sourceId);
expect(condition.operator).toBe("equals");
expect(condition.rightOperand?.value).toBe(choiceId);
}
const action = logic.actions[0];
if (action.objective === "jumpToQuestion") {
expect(action.target).toBe(targetId);
}
});
test("getDefaultWelcomeCard returns expected welcome card", () => {
const card = getDefaultWelcomeCard(mockT);
expect(card.enabled).toBe(false);
expect(card.headline).toEqual({ default: "templates.default_welcome_card_headline" });
expect(card.subheader).toEqual({ default: "templates.default_welcome_card_html" });
expect(card.buttonLabel).toEqual({ default: "templates.default_welcome_card_button_label" });
// boolean flags
expect(card.timeToFinish).toBe(false);
expect(card.showResponseCount).toBe(false);
});
test("getDefaultEndingCard returns expected end screen card", () => {
// Pass empty languages array to simulate no languages
const card = getDefaultEndingCard([], mockT);
expect(card).toHaveProperty("id");
expect(card.type).toBe("endScreen");
expect(card.headline).toEqual({ default: "templates.default_ending_card_headline" });
expect(card.subheader).toEqual({ default: "templates.default_ending_card_subheader" });
expect(card.buttonLabel).toEqual({ default: "templates.default_ending_card_button_label" });
expect(card.buttonLink).toBe("https://formbricks.com");
});
test("getDefaultSurveyPreset returns expected default survey preset", () => {
const preset = getDefaultSurveyPreset(mockT);
expect(preset.name).toBe("New Survey");
expect(preset.questions).toEqual([]);
// test welcomeCard and endings
expect(preset.welcomeCard).toHaveProperty("headline");
expect(Array.isArray(preset.endings)).toBe(true);
expect(preset.hiddenFields).toEqual(hiddenFieldsDefault);
});
test("buildSurvey returns built survey with overridden preset properties", () => {
const config = {
name: "Custom Survey",
industries: ["eCommerce"] as string[],
channels: ["link"],
description: "Test survey",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText, // changed from "OpenText"
headline: { default: "Question 1" },
inputType: "text",
buttonLabel: { default: "Next" },
backButtonLabel: { default: "Back" },
required: true,
},
],
endings: [
{
id: "end1",
type: "endScreen",
headline: { default: "End Screen" },
subheader: { default: "Thanks" },
buttonLabel: { default: "Finish" },
buttonLink: "https://formbricks.com",
},
],
hiddenFields: { enabled: false, fieldIds: ["f1"] },
};
const survey = buildSurvey(config as any, mockT);
expect(survey.name).toBe(config.name);
expect(survey.industries).toEqual(config.industries);
expect(survey.channels).toEqual(config.channels);
expect(survey.description).toBe(config.description);
// preset overrides
expect(survey.preset.name).toBe(config.name);
expect(survey.preset.questions).toEqual(config.questions);
expect(survey.preset.endings).toEqual(config.endings);
expect(survey.preset.hiddenFields).toEqual(config.hiddenFields);
});
test("hiddenFieldsDefault has expected default configuration", () => {
expect(hiddenFieldsDefault).toEqual({ enabled: true, fieldIds: [] });
});
});

View File

@@ -1,284 +1,17 @@
import { createId } from "@paralleldrive/cuid2";
import { TFunction } from "i18next";
import {
TShuffleOption,
TSurveyCTAQuestion,
TSurveyConsentQuestion,
import type { TFunction } from "i18next";
import type { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import type {
TSurveyEndScreenCard,
TSurveyEnding,
TSurveyHiddenFields,
TSurveyLanguage,
TSurveyLogic,
TSurveyMultipleChoiceQuestion,
TSurveyNPSQuestion,
TSurveyOpenTextQuestion,
TSurveyOpenTextQuestionInputType,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveyRatingQuestion,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { TTemplate, TTemplateRole } from "@formbricks/types/templates";
import type { TTemplate, TTemplateRole } from "@formbricks/types/templates";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
const getDefaultButtonLabel = (label: string | undefined, t: TFunction) =>
createI18nString(label || t("common.next"), []);
const getDefaultBackButtonLabel = (label: string | undefined, t: TFunction) =>
createI18nString(label || t("common.back"), []);
export const buildMultipleChoiceQuestion = ({
id,
headline,
type,
subheader,
choices,
choiceIds,
buttonLabel,
backButtonLabel,
shuffleOption,
required,
logic,
containsOther = false,
t,
}: {
id?: string;
headline: string;
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti | TSurveyQuestionTypeEnum.MultipleChoiceSingle;
subheader?: string;
choices: string[];
choiceIds?: string[];
buttonLabel?: string;
backButtonLabel?: string;
shuffleOption?: TShuffleOption;
required?: boolean;
logic?: TSurveyLogic[];
containsOther?: boolean;
t: TFunction;
}): TSurveyMultipleChoiceQuestion => {
return {
id: id ?? createId(),
type,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
choices: choices.map((choice, index) => {
const isLastIndex = index === choices.length - 1;
const id = containsOther && isLastIndex ? "other" : choiceIds ? choiceIds[index] : createId();
return { id, label: createI18nString(choice, []) };
}),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
shuffleOption: shuffleOption || "none",
required: required ?? false,
logic,
};
};
export const buildOpenTextQuestion = ({
id,
headline,
subheader,
placeholder,
inputType,
buttonLabel,
backButtonLabel,
required,
logic,
longAnswer,
t,
}: {
id?: string;
headline: string;
subheader?: string;
placeholder?: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
logic?: TSurveyLogic[];
inputType: TSurveyOpenTextQuestionInputType;
longAnswer?: boolean;
t: TFunction;
}): TSurveyOpenTextQuestion => {
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.OpenText,
inputType,
subheader: subheader ? createI18nString(subheader, []) : undefined,
placeholder: placeholder ? createI18nString(placeholder, []) : undefined,
headline: createI18nString(headline, []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
required: required ?? false,
longAnswer,
logic,
charLimit: {
enabled: false,
},
};
};
export const buildRatingQuestion = ({
id,
headline,
subheader,
scale,
range,
lowerLabel,
upperLabel,
buttonLabel,
backButtonLabel,
required,
logic,
isColorCodingEnabled = false,
t,
}: {
id?: string;
headline: string;
scale: TSurveyRatingQuestion["scale"];
range: TSurveyRatingQuestion["range"];
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
placeholder?: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
logic?: TSurveyLogic[];
isColorCodingEnabled?: boolean;
t: TFunction;
}): TSurveyRatingQuestion => {
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.Rating,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
scale,
range,
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
logic,
};
};
export const buildNPSQuestion = ({
id,
headline,
subheader,
lowerLabel,
upperLabel,
buttonLabel,
backButtonLabel,
required,
logic,
isColorCodingEnabled = false,
t,
}: {
id?: string;
headline: string;
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
placeholder?: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
logic?: TSurveyLogic[];
isColorCodingEnabled?: boolean;
t: TFunction;
}): TSurveyNPSQuestion => {
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.NPS,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
logic,
};
};
export const buildConsentQuestion = ({
id,
headline,
subheader,
label,
buttonLabel,
backButtonLabel,
required,
logic,
t,
}: {
id?: string;
headline: string;
subheader: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
logic?: TSurveyLogic[];
label: string;
t: TFunction;
}): TSurveyConsentQuestion => {
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.Consent,
subheader: createI18nString(subheader, []),
headline: createI18nString(headline, []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
required: required ?? false,
label: createI18nString(label, []),
logic,
};
};
export const buildCTAQuestion = ({
id,
headline,
subheader,
buttonLabel,
buttonExternal,
backButtonLabel,
required,
logic,
dismissButtonLabel,
buttonUrl,
t,
}: {
id?: string;
headline: string;
buttonExternal: boolean;
subheader: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
logic?: TSurveyLogic[];
dismissButtonLabel?: string;
buttonUrl?: string;
t: TFunction;
}): TSurveyCTAQuestion => {
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.CTA,
subheader: createI18nString(subheader, []),
headline: createI18nString(headline, []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
required: required ?? false,
buttonExternal,
buttonUrl,
logic,
};
};
// Helper function to create standard jump logic based on operator
export const createJumpLogic = (
sourceQuestionId: string,
@@ -377,13 +110,13 @@ export const getDefaultSurveyPreset = (t: TFunction): TTemplate["preset"] => {
welcomeCard: getDefaultWelcomeCard(t),
endings: [getDefaultEndingCard([], t)],
hiddenFields: hiddenFieldsDefault,
questions: [],
blocks: [],
};
};
/**
* Generic builder for survey.
* @param config - The configuration for survey settings and questions.
* @param config - The configuration for survey settings and blocks.
* @param t - The translation function.
*/
export const buildSurvey = (
@@ -393,9 +126,9 @@ export const buildSurvey = (
channels: ("link" | "app" | "website")[];
role: TTemplateRole;
description: string;
questions: TSurveyQuestion[];
endings?: TSurveyEnding[];
hiddenFields?: TSurveyHiddenFields;
blocks: TSurveyBlock[];
endings: TSurveyEnding[];
hiddenFields: TSurveyHiddenFields;
},
t: TFunction
): TTemplate => {
@@ -409,7 +142,7 @@ export const buildSurvey = (
preset: {
...localSurvey,
name: config.name,
questions: config.questions,
blocks: config.blocks ?? [],
endings: config.endings ?? localSurvey.endings,
hiddenFields: config.hiddenFields ?? hiddenFieldsDefault,
},

File diff suppressed because it is too large Load Diff

View File

@@ -2511,7 +2511,6 @@ checksums:
templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e
templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72
templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00
templates/preview_survey_welcome_card_html: 5fc24f7cfeba1af9a3fc3ddb6fb67de4
templates/prioritize_features_description: 1eae41fad0e3947f803d8539081e59ec
templates/prioritize_features_name: 4ca59ff1f9c319aaa68c3106d820fd6a
templates/prioritize_features_question_1_choice_1: 7c0b2da44eacc271073d4f15caaa86c8

View File

@@ -8,7 +8,7 @@ import {
} from "@formbricks/types/surveys/blocks";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TConditionGroup, TSingleCondition } from "@formbricks/types/surveys/logic";
import { TActionCalculate, TSurveyVariable } from "@formbricks/types/surveys/types";
import { TActionCalculate, TSurveyLogicAction, TSurveyVariable } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -625,7 +625,7 @@ const getRightOperandValue = (
export const performActions = (
survey: TJsEnvironmentStateSurvey,
actions: TSurveyBlockLogicAction[],
actions: TSurveyBlockLogicAction[] | TSurveyLogicAction[],
data: TResponseData,
calculationResults: TResponseVariables
): {

View File

@@ -1,164 +1,119 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TTemplate } from "@formbricks/types/templates";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { type TProject } from "@formbricks/types/project";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TTemplate } from "@formbricks/types/templates";
import * as i18nUtils from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { replacePresetPlaceholders, replaceQuestionPresetPlaceholders } from "./templates";
import { replaceElementPresetPlaceholders, replacePresetPlaceholders } from "./templates";
// Mock the imported functions
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: vi.fn(),
}));
vi.mock("@/lib/i18n/utils");
vi.mock("@/lib/pollyfills/structuredClone");
vi.mock("@/lib/pollyfills/structuredClone", () => ({
structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
}));
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(structuredClone).mockImplementation((obj) => JSON.parse(JSON.stringify(obj)));
// Mock getLocalizedValue to return the value from the object
vi.mocked(i18nUtils.getLocalizedValue).mockImplementation((obj: any, lang: string) => obj?.[lang] || "");
});
describe("Template Utilities", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("replaceElementPresetPlaceholders", () => {
test("returns original element when project is not provided", () => {
const element = {
type: "openText",
headline: { default: "Question about $[projectName]?" },
} as unknown as TSurveyElement;
describe("replaceQuestionPresetPlaceholders", () => {
test("returns original question when project is not provided", () => {
const question: TSurveyQuestion = {
id: "test-id",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "Test Question $[projectName]",
},
} as unknown as TSurveyQuestion;
const result = replaceElementPresetPlaceholders(element, undefined as any);
const result = replaceQuestionPresetPlaceholders(question, undefined as unknown as TProject);
expect(result).toEqual(element);
});
expect(result).toEqual(question);
expect(structuredClone).not.toHaveBeenCalled();
test("replaces projectName placeholder in headline", () => {
const element = {
type: "openText",
headline: { default: "How do you like $[projectName]?" },
} as unknown as TSurveyElement;
const project = {
name: "TestProject",
} as unknown as TProject;
const result = replaceElementPresetPlaceholders(element, project);
// The function directly replaces without calling getLocalizedValue in the test scenario
expect(result.headline?.default).toBe("How do you like TestProject?");
});
test("replaces projectName placeholder in subheader", () => {
const question: TSurveyQuestion = {
id: "test-id",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "Test Question",
},
subheader: {
default: "Subheader for $[projectName]",
},
} as unknown as TSurveyQuestion;
const element = {
type: "openText",
headline: { default: "Question" },
subheader: { default: "Subheader for $[projectName]" },
} as unknown as TSurveyElement;
const project: TProject = {
id: "project-id",
name: "Test Project",
organizationId: "org-id",
const project = {
name: "TestProject",
} as unknown as TProject;
// Mock for headline and subheader with correct return values
vi.mocked(getLocalizedValue).mockReturnValueOnce("Test Question");
vi.mocked(getLocalizedValue).mockReturnValueOnce("Subheader for $[projectName]");
const result = replaceElementPresetPlaceholders(element, project);
const result = replaceQuestionPresetPlaceholders(question, project);
expect(vi.mocked(getLocalizedValue)).toHaveBeenCalledTimes(2);
expect(result.subheader?.default).toBe("Subheader for Test Project");
expect(result.headline?.default).toBe("Question");
expect(result.subheader?.default).toBe("Subheader for TestProject");
});
test("handles missing headline and subheader", () => {
const question: TSurveyQuestion = {
id: "test-id",
type: TSurveyQuestionTypeEnum.OpenText,
} as unknown as TSurveyQuestion;
const element = {
type: "openText",
} as unknown as TSurveyElement;
const project: TProject = {
id: "project-id",
name: "Test Project",
organizationId: "org-id",
const project = {
name: "TestProject",
} as unknown as TProject;
const result = replaceQuestionPresetPlaceholders(question, project);
const result = replaceElementPresetPlaceholders(element, project);
expect(structuredClone).toHaveBeenCalledWith(question);
expect(result).toEqual(question);
expect(getLocalizedValue).not.toHaveBeenCalled();
expect(structuredClone).toHaveBeenCalledWith(element);
expect(result).toEqual(element);
});
});
describe("replacePresetPlaceholders", () => {
test("replaces projectName placeholder in template name and questions", () => {
const template: TTemplate = {
id: "template-1",
name: "Test Template",
description: "Template Description",
test("replaces projectName placeholder in template name and blocks", () => {
const mockTemplate = {
name: "Template 1",
preset: {
name: "$[projectName] Feedback",
questions: [
welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false },
blocks: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "How do you like $[projectName]?",
},
},
{
id: "q2",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "Another question",
},
subheader: {
default: "About $[projectName]",
},
id: "block1",
name: "Block 1",
elements: [
{
id: "elem1",
type: "openText",
headline: { default: "How would you rate $[projectName]?" },
required: true,
inputType: "text",
},
],
},
],
endings: [],
hiddenFields: { enabled: true, fieldIds: [] },
},
category: "product",
} as unknown as TTemplate;
const project = {
name: "Awesome App",
};
name: "TestProject",
} as TProject;
// Mock getLocalizedValue to return the original strings with placeholders
vi.mocked(getLocalizedValue)
.mockReturnValueOnce("How do you like $[projectName]?")
.mockReturnValueOnce("Another question")
.mockReturnValueOnce("About $[projectName]");
const result = replacePresetPlaceholders(mockTemplate, project);
const result = replacePresetPlaceholders(template, project);
expect(result.preset.name).toBe("Awesome App Feedback");
expect(structuredClone).toHaveBeenCalledWith(template.preset);
// Verify that replaceQuestionPresetPlaceholders was applied to both questions
expect(vi.mocked(getLocalizedValue)).toHaveBeenCalledTimes(3);
expect(result.preset.questions[0].headline?.default).toBe("How do you like Awesome App?");
expect(result.preset.questions[1].subheader?.default).toBe("About Awesome App");
});
test("maintains other template properties", () => {
const template: TTemplate = {
id: "template-1",
name: "Test Template",
description: "Template Description",
preset: {
name: "$[projectName] Feedback",
questions: [],
},
category: "product",
} as unknown as TTemplate;
const project = {
name: "Awesome App",
};
const result = replacePresetPlaceholders(template, project) as unknown as {
name: string;
description: string;
};
expect(result.name).toBe(template.name);
expect(result.description).toBe(template.description);
expect(structuredClone).toHaveBeenCalledWith(mockTemplate.preset);
expect(result.preset.name).toBe("TestProject Feedback");
expect(result.preset.blocks[0].elements[0].headline?.default).toBe("How would you rate TestProject?");
});
});
});

View File

@@ -1,37 +1,46 @@
import { TProject } from "@formbricks/types/project";
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
import { TTemplate } from "@formbricks/types/templates";
import type { TProject } from "@formbricks/types/project";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TTemplate } from "@formbricks/types/templates";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
export const replaceQuestionPresetPlaceholders = (
question: TSurveyQuestion,
export const replaceElementPresetPlaceholders = (
element: TSurveyElement,
project: TProject
): TSurveyQuestion => {
if (!project) return question;
const newQuestion = structuredClone(question);
): TSurveyElement => {
if (!project) return element;
const newElement = structuredClone(element);
const defaultLanguageCode = "default";
if (newQuestion.headline) {
newQuestion.headline[defaultLanguageCode] = getLocalizedValue(
newQuestion.headline,
if (newElement.headline) {
newElement.headline[defaultLanguageCode] = getLocalizedValue(
newElement.headline,
defaultLanguageCode
).replace("$[projectName]", project.name);
}
if (newQuestion.subheader) {
newQuestion.subheader[defaultLanguageCode] = getLocalizedValue(
newQuestion.subheader,
if (newElement.subheader) {
newElement.subheader[defaultLanguageCode] = getLocalizedValue(
newElement.subheader,
defaultLanguageCode
)?.replace("$[projectName]", project.name);
}
return newQuestion;
return newElement;
};
// replace all occurences of projectName with the actual project name in the current template
export const replacePresetPlaceholders = (template: TTemplate, project: any) => {
const preset = structuredClone(template.preset);
preset.name = preset.name.replace("$[projectName]", project.name);
preset.questions = preset.questions.map((question) => {
return replaceQuestionPresetPlaceholders(question, project);
});
// Handle blocks if present
if (preset.blocks && preset.blocks.length > 0) {
preset.blocks = preset.blocks.map((block) => ({
...block,
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
}));
}
return { ...template, preset };
};

View File

@@ -2662,7 +2662,6 @@
"preview_survey_question_2_choice_2_label": "Nein, danke!",
"preview_survey_question_2_headline": "Möchtest Du auf dem Laufenden bleiben?",
"preview_survey_welcome_card_headline": "Willkommen!",
"preview_survey_welcome_card_html": "Danke für dein Feedback - los geht's!",
"prioritize_features_description": "Identifiziere die Funktionen, die deine Nutzer am meisten und am wenigsten brauchen.",
"prioritize_features_name": "Funktionen priorisieren",
"prioritize_features_question_1_choice_1": "Funktion 1",

View File

@@ -2662,7 +2662,6 @@
"preview_survey_question_2_choice_2_label": "No, thank you!",
"preview_survey_question_2_headline": "Want to stay in the loop?",
"preview_survey_welcome_card_headline": "Welcome!",
"preview_survey_welcome_card_html": "Thanks for providing your feedback - let's go!",
"prioritize_features_description": "Identify features your users need most and least.",
"prioritize_features_name": "Prioritize Features",
"prioritize_features_question_1_choice_1": "Feature 1",

View File

@@ -2662,7 +2662,6 @@
"preview_survey_question_2_choice_2_label": "Non, merci !",
"preview_survey_question_2_headline": "Souhaitez-vous être informé ?",
"preview_survey_welcome_card_headline": "Bienvenue !",
"preview_survey_welcome_card_html": "Merci pour vos retours - allons-y !",
"prioritize_features_description": "Identifiez les fonctionnalités dont vos utilisateurs ont le plus et le moins besoin.",
"prioritize_features_name": "Prioriser les fonctionnalités",
"prioritize_features_question_1_choice_1": "Fonctionnalité 1",

View File

@@ -2662,7 +2662,6 @@
"preview_survey_question_2_choice_2_label": "いいえ、結構です!",
"preview_survey_question_2_headline": "最新情報を知りたいですか?",
"preview_survey_welcome_card_headline": "ようこそ!",
"preview_survey_welcome_card_html": "フィードバックありがとうございます - さあ、始めましょう!",
"prioritize_features_description": "ユーザーが最も必要とする機能と最も必要としない機能を特定する。",
"prioritize_features_name": "機能の優先順位付け",
"prioritize_features_question_1_choice_1": "機能1",

View File

@@ -2662,7 +2662,6 @@
"preview_survey_question_2_choice_2_label": "Não, obrigado!",
"preview_survey_question_2_headline": "Quer ficar por dentro?",
"preview_survey_welcome_card_headline": "Bem-vindo!",
"preview_survey_welcome_card_html": "Valeu pelo feedback - bora lá!",
"prioritize_features_description": "Identifique os recursos que seus usuários mais e menos precisam.",
"prioritize_features_name": "Priorizar Funcionalidades",
"prioritize_features_question_1_choice_1": "Recurso 1",

View File

@@ -2662,7 +2662,6 @@
"preview_survey_question_2_choice_2_label": "Não, obrigado!",
"preview_survey_question_2_headline": "Quer manter-se atualizado?",
"preview_survey_welcome_card_headline": "Bem-vindo!",
"preview_survey_welcome_card_html": "Obrigado por fornecer o seu feedback - vamos a isso!",
"prioritize_features_description": "Identifique as funcionalidades que os seus utilizadores precisam mais e menos.",
"prioritize_features_name": "Priorizar Funcionalidades",
"prioritize_features_question_1_choice_1": "Funcionalidade 1",

View File

@@ -2662,7 +2662,6 @@
"preview_survey_question_2_choice_2_label": "Nu, mulţumesc!",
"preview_survey_question_2_headline": "Vrei să fii în temă?",
"preview_survey_welcome_card_headline": "Bun venit!",
"preview_survey_welcome_card_html": "Mulțumesc pentru feedback-ul dvs - să începem!",
"prioritize_features_description": "Identificați caracteristicile de care utilizatorii dumneavoastră au cel mai mult și cel mai puțin nevoie.",
"prioritize_features_name": "Prioritizați caracteristicile",
"prioritize_features_question_1_choice_1": "Caracteristica 1",

View File

@@ -2662,7 +2662,6 @@
"preview_survey_question_2_choice_2_label": "不,谢谢!",
"preview_survey_question_2_headline": "想 了解 最新信息吗?",
"preview_survey_welcome_card_headline": "欢迎!",
"preview_survey_welcome_card_html": "感谢 提供 您 的 反馈 - 一起 出发!",
"prioritize_features_description": "确定 用户 最 需要 和 最 不 需要 的 功能。",
"prioritize_features_name": "优先 功能",
"prioritize_features_question_1_choice_1": "功能 1",

View File

@@ -2662,7 +2662,6 @@
"preview_survey_question_2_choice_2_label": "不用了,謝謝!",
"preview_survey_question_2_headline": "想要緊跟最新動態嗎?",
"preview_survey_welcome_card_headline": "歡迎!",
"preview_survey_welcome_card_html": "感謝您提供回饋 - 開始吧!",
"prioritize_features_description": "找出您的使用者最需要和最不需要的功能。",
"prioritize_features_name": "優先排序功能",
"prioritize_features_question_1_choice_1": "功能 1",

View File

@@ -6,8 +6,8 @@ import { useTranslation } from "react-i18next";
import { TTemplate } from "@formbricks/types/templates";
import { customSurveyTemplate } from "@/app/lib/templates";
import { cn } from "@/lib/cn";
import { replacePresetPlaceholders } from "@/lib/utils/templates";
import { Button } from "@/modules/ui/components/button";
import { replacePresetPlaceholders } from "../lib/utils";
interface StartFromScratchTemplateProps {
activeTemplate: TTemplate | null;

View File

@@ -109,7 +109,7 @@ export const TemplateTags = ({ template, selectedFilter }: TemplateTagsProps) =>
{channelTag}
</div>
)}
{template.preset.questions.some((question) => question.logic && question.logic.length > 0) && (
{template.preset.blocks.some((block) => block.logic && block.logic.length > 0) && (
<TooltipRenderer
tooltipContent={t("environments.surveys.templates.uses_branching_logic")}
shouldRender={true}>

View File

@@ -4,8 +4,8 @@ import { Project } from "@prisma/client";
import { useTranslation } from "react-i18next";
import { TTemplate, TTemplateFilter } from "@formbricks/types/templates";
import { cn } from "@/lib/cn";
import { replacePresetPlaceholders } from "@/lib/utils/templates";
import { Button } from "@/modules/ui/components/button";
import { replacePresetPlaceholders } from "../lib/utils";
import { TemplateTags } from "./template-tags";
interface TemplateProps {

View File

@@ -1,115 +1,43 @@
import "@testing-library/jest-dom/vitest";
import { describe, expect, test, vi } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TTemplate } from "@formbricks/types/templates";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import {
getChannelMapping,
getIndustryMapping,
getRoleMapping,
replacePresetPlaceholders,
replaceQuestionPresetPlaceholders,
} from "./utils";
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: vi.fn(),
}));
import { replacePresetPlaceholders } from "@/lib/utils/templates";
import { getChannelMapping, getIndustryMapping, getRoleMapping } from "./utils";
vi.mock("@/lib/pollyfills/structuredClone", () => ({
structuredClone: vi.fn((val) => JSON.parse(JSON.stringify(val))),
}));
describe("Template utils", () => {
test("replaceQuestionPresetPlaceholders replaces project name in headline and subheader", () => {
const mockQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "How would you rate $[projectName]?",
},
subheader: {
default: "Tell us about $[projectName]",
},
required: false,
} as unknown as TSurveyQuestion;
const mockProject = {
id: "project-1",
name: "TestProject",
createdAt: new Date(),
updatedAt: new Date(),
} as unknown as TProject;
// Reset and setup mocks with simple return values
vi.mocked(getLocalizedValue).mockReset();
vi.mocked(getLocalizedValue)
.mockReturnValueOnce("How would you rate $[projectName]?")
.mockReturnValueOnce("Tell us about $[projectName]");
const result = replaceQuestionPresetPlaceholders(mockQuestion, mockProject);
expect(structuredClone).toHaveBeenCalledWith(mockQuestion);
expect(getLocalizedValue).toHaveBeenCalledTimes(2);
expect(result.headline?.default).toBe("How would you rate TestProject?");
expect(result.subheader?.default).toBe("Tell us about TestProject");
});
test("replaceQuestionPresetPlaceholders returns original question if project is null", () => {
const mockQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "How would you rate $[projectName]?",
},
required: false,
} as unknown as TSurveyQuestion;
const result = replaceQuestionPresetPlaceholders(mockQuestion, null as unknown as TProject);
expect(result).toBe(mockQuestion);
});
test("replaceQuestionPresetPlaceholders handles missing subheader", () => {
const mockQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "How would you rate $[projectName]?",
},
required: false,
} as unknown as TSurveyQuestion;
const mockProject = {
id: "project-1",
name: "TestProject",
createdAt: new Date(),
updatedAt: new Date(),
} as unknown as TProject;
vi.mocked(getLocalizedValue).mockReturnValueOnce("How would you rate $[projectName]?");
const result = replaceQuestionPresetPlaceholders(mockQuestion, mockProject);
expect(result.headline?.default).toBe("How would you rate TestProject?");
expect(result.subheader).toBeUndefined();
});
test("replacePresetPlaceholders replaces project name in template", () => {
test("replacePresetPlaceholders replaces project name in template with blocks", () => {
const mockTemplate: TTemplate = {
name: "Test Template",
description: "Template description",
preset: {
name: "$[projectName] Feedback",
questions: [
welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false },
blocks: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "How would you rate $[projectName]?",
},
required: false,
} as unknown as TSurveyQuestion,
id: "block1",
name: "Block 1",
elements: [
{
id: "elem1",
type: "openText",
headline: {
default: "How would you rate $[projectName]?",
},
required: false,
inputType: "text",
} as unknown as TSurveyElement,
],
},
],
endings: [],
hiddenFields: { enabled: true, fieldIds: [] },
} as any,
};
@@ -117,13 +45,11 @@ describe("Template utils", () => {
name: "TestProject",
};
vi.mocked(getLocalizedValue).mockReturnValueOnce("How would you rate $[projectName]?");
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(structuredClone).toHaveBeenCalledWith(mockTemplate.preset);
expect(result.preset.name).toBe("TestProject Feedback");
expect(result.preset.questions[0].headline?.default).toBe("How would you rate TestProject?");
expect(result.preset.blocks[0].elements[0].headline?.default).toBe("How would you rate TestProject?");
});
test("getChannelMapping returns correct channel mappings", () => {

View File

@@ -1,41 +1,6 @@
import { TFunction } from "i18next";
import { TProject, TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project";
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
import { TTemplate, TTemplateRole } from "@formbricks/types/templates";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
export const replaceQuestionPresetPlaceholders = (
question: TSurveyQuestion,
project: TProject
): TSurveyQuestion => {
if (!project) return question;
const newQuestion = structuredClone(question);
const defaultLanguageCode = "default";
if (newQuestion.headline) {
newQuestion.headline[defaultLanguageCode] = getLocalizedValue(
newQuestion.headline,
defaultLanguageCode
).replace("$[projectName]", project.name);
}
if (newQuestion.subheader) {
newQuestion.subheader[defaultLanguageCode] = getLocalizedValue(
newQuestion.subheader,
defaultLanguageCode
)?.replace("$[projectName]", project.name);
}
return newQuestion;
};
// replace all occurences of projectName with the actual project name in the current template
export const replacePresetPlaceholders = (template: TTemplate, project: any) => {
const preset = structuredClone(template.preset);
preset.name = preset.name.replace("$[projectName]", project.name);
preset.questions = preset.questions.map((question) => {
return replaceQuestionPresetPlaceholders(question, project);
});
return { ...template, preset };
};
import { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project";
import { TTemplateRole } from "@formbricks/types/templates";
export const getChannelMapping = (t: TFunction): { value: TProjectConfigChannel; label: string }[] => [
{ value: "website", label: t("common.website_survey") },

View File

@@ -38,7 +38,7 @@ import {
TSurveyRatingElement,
} from "@formbricks/types/surveys/elements";
import { createI18nString } from "@/lib/i18n/utils";
import { replaceQuestionPresetPlaceholders } from "@/lib/utils/templates";
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
export type TQuestion = {
id: string;
@@ -346,7 +346,7 @@ export const universalQuestionPresets = {
export const getQuestionDefaults = (id: string, project: any, t: TFunction) => {
const questionType = getQuestionTypes(t).find((questionType) => questionType.id === id);
return replaceQuestionPresetPlaceholders(questionType?.preset, project);
return replaceElementPresetPlaceholders(questionType?.preset, project);
};
export const getTSurveyQuestionTypeEnumName = (id: string, t: TFunction) => {

View File

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

View File

@@ -1,13 +1,7 @@
import { z } from "zod";
import { ZProjectConfigChannel, ZProjectConfigIndustry } from "./project";
import { ZSurveyBlocks } from "./surveys/blocks";
import {
ZSurveyEndings,
ZSurveyHiddenFields,
ZSurveyQuestions,
ZSurveyStyling,
ZSurveyWelcomeCard,
} from "./surveys/types";
import { ZSurveyEndings, ZSurveyHiddenFields, ZSurveyStyling, ZSurveyWelcomeCard } from "./surveys/types";
export const ZTemplateRole = z.enum([
"productManager",
@@ -29,7 +23,6 @@ export const ZTemplate = z.object({
name: z.string(),
welcomeCard: ZSurveyWelcomeCard,
blocks: ZSurveyBlocks.default([]),
questions: ZSurveyQuestions.default([]),
endings: ZSurveyEndings,
hiddenFields: ZSurveyHiddenFields,
}),
@@ -39,7 +32,7 @@ export type TTemplate = z.infer<typeof ZTemplate>;
export const ZXMTemplate = z.object({
name: z.string(),
questions: ZSurveyQuestions,
blocks: ZSurveyBlocks,
endings: ZSurveyEndings,
styling: ZSurveyStyling,
});