mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-18 19:41:17 -05:00
Merge branch 'epic/survey-mqp' into feat/survey-editor-blocks-ui
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("xm-templates", () => {
|
||||
expect(result).toEqual({
|
||||
name: "",
|
||||
endings: expect.any(Array),
|
||||
questions: [],
|
||||
blocks: [],
|
||||
styling: {
|
||||
overwriteThemeStyling: true,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
|
||||
308
apps/web/app/lib/survey-block-builder.ts
Normal file
308
apps/web/app/lib/survey-block-builder.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
): {
|
||||
|
||||
@@ -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?");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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") },
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user