mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-22 11:29:22 -05:00
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3d679d087 | |||
| c79a600efc | |||
| 7a8da3b84b | |||
| 4b2d48397d | |||
| 3ea81dc7c1 | |||
| d9b6b550a9 | |||
| 56a6ba08ba | |||
| 1ba55ff66c | |||
| 0cf621d76c | |||
| 3dc615fdc0 | |||
| 7157b17901 | |||
| 82c26941e4 | |||
| 591d5fa3d4 | |||
| 211bca1bd8 | |||
| 5a20839c5b | |||
| 85743bd3d0 | |||
| 335ec02361 | |||
| 7918523957 | |||
| 3b5fe4cb94 | |||
| 6bbd5ec7ef | |||
| c9542dcf79 | |||
| 4277a9dc34 | |||
| b1da63e47d | |||
| 8c05154a86 | |||
| 45122de652 | |||
| 2180bf98ba | |||
| 2d4a94721b | |||
| b2b97c8bed | |||
| f349f7199d | |||
| e7d8803a13 | |||
| 53a9b218bc | |||
| c618e7d473 | |||
| 3d0f703ae1 | |||
| 33eadaaa7b | |||
| 452617529c | |||
| 5951eea618 | |||
| e314feb416 | |||
| 0910b0f1a7 | |||
| 10ba42eb31 | |||
| 04f1e17e23 | |||
| 4642cc60c9 | |||
| 49fa5c587c | |||
| 4f9b48b5e5 | |||
| 80789327d0 | |||
| 38108a32d1 | |||
| ce4b64da0e | |||
| 9790b071d7 | |||
| 1f5ba0e60e | |||
| b502bbc91e | |||
| 6772ac7c20 |
+17
-9
@@ -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", () => {
|
||||
|
||||
+10
-7
@@ -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 };
|
||||
};
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ describe("xm-templates", () => {
|
||||
expect(result).toEqual({
|
||||
name: "",
|
||||
endings: expect.any(Array),
|
||||
questions: [],
|
||||
blocks: [],
|
||||
styling: {
|
||||
overwriteThemeStyling: true,
|
||||
},
|
||||
|
||||
+222
-204
@@ -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,
|
||||
}),
|
||||
],
|
||||
|
||||
+17
-6
@@ -3,7 +3,7 @@
|
||||
import { TFunction } from "i18next";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Control, Controller, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -14,14 +14,15 @@ import {
|
||||
TIntegrationAirtableInput,
|
||||
TIntegrationAirtableTables,
|
||||
} from "@formbricks/types/integration/airtable";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown";
|
||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
|
||||
import AirtableLogo from "@/images/airtableLogo.svg";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -71,6 +72,7 @@ const NoBaseFoundError = () => {
|
||||
const renderQuestionSelection = ({
|
||||
t,
|
||||
selectedSurvey,
|
||||
questions,
|
||||
control,
|
||||
includeVariables,
|
||||
setIncludeVariables,
|
||||
@@ -83,6 +85,7 @@ const renderQuestionSelection = ({
|
||||
}: {
|
||||
t: TFunction;
|
||||
selectedSurvey: TSurvey;
|
||||
questions: TSurveyElement[];
|
||||
control: Control<IntegrationModalInputs>;
|
||||
includeVariables: boolean;
|
||||
setIncludeVariables: (value: boolean) => void;
|
||||
@@ -99,7 +102,7 @@ const renderQuestionSelection = ({
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
|
||||
{questions.map((question) => (
|
||||
<Controller
|
||||
key={question.id}
|
||||
control={control}
|
||||
@@ -120,7 +123,9 @@ const renderQuestionSelection = ({
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">
|
||||
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
||||
{getTextContent(
|
||||
recallToHeadline(question.headline, selectedSurvey, false, "default")["default"]
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -194,6 +199,11 @@ export const AddIntegrationModal = ({
|
||||
};
|
||||
|
||||
const selectedSurvey = surveys.find((item) => item.id === survey);
|
||||
const questions = useMemo(
|
||||
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
||||
[selectedSurvey]
|
||||
);
|
||||
|
||||
const submitHandler = async (data: IntegrationModalInputs) => {
|
||||
try {
|
||||
if (!data.base || data.base === "") {
|
||||
@@ -218,7 +228,7 @@ export const AddIntegrationModal = ({
|
||||
surveyName: selectedSurvey.name,
|
||||
questionIds: data.questions,
|
||||
questions:
|
||||
data.questions.length === selectedSurvey.questions.length
|
||||
data.questions.length === questions.length
|
||||
? t("common.all_questions")
|
||||
: t("common.selected_questions"),
|
||||
createdAt: new Date(),
|
||||
@@ -395,6 +405,7 @@ export const AddIntegrationModal = ({
|
||||
renderQuestionSelection({
|
||||
t,
|
||||
selectedSurvey,
|
||||
questions,
|
||||
control,
|
||||
includeVariables,
|
||||
setIncludeVariables,
|
||||
|
||||
+17
-8
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -20,9 +20,9 @@ import {
|
||||
isValidGoogleSheetsUrl,
|
||||
} from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/util";
|
||||
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
@@ -86,12 +86,17 @@ export const AddIntegrationModal = ({
|
||||
},
|
||||
};
|
||||
|
||||
const questions = useMemo(
|
||||
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
||||
[selectedSurvey]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSurvey && !selectedIntegration) {
|
||||
const questionIds = selectedSurvey.questions.map((question) => question.id);
|
||||
const questionIds = questions.map((question) => question.id);
|
||||
setSelectedQuestions(questionIds);
|
||||
}
|
||||
}, [selectedIntegration, selectedSurvey]);
|
||||
}, [questions, selectedIntegration, selectedSurvey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIntegration) {
|
||||
@@ -145,7 +150,7 @@ export const AddIntegrationModal = ({
|
||||
integrationData.surveyName = selectedSurvey.name;
|
||||
integrationData.questionIds = selectedQuestions;
|
||||
integrationData.questions =
|
||||
selectedQuestions.length === selectedSurvey?.questions.length
|
||||
selectedQuestions.length === questions.length
|
||||
? t("common.all_questions")
|
||||
: t("common.selected_questions");
|
||||
integrationData.createdAt = new Date();
|
||||
@@ -263,7 +268,7 @@ export const AddIntegrationModal = ({
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
|
||||
{questions.map((question) => (
|
||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||
<label htmlFor={question.id} className="flex cursor-pointer items-center">
|
||||
<Checkbox
|
||||
@@ -277,7 +282,11 @@ export const AddIntegrationModal = ({
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2 w-[30rem] truncate">
|
||||
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
||||
{getTextContent(
|
||||
recallToHeadline(question.headline, selectedSurvey, false, "default")[
|
||||
"default"
|
||||
]
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
+12
-6
@@ -13,6 +13,7 @@ import {
|
||||
TIntegrationNotionDatabase,
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import {
|
||||
ERRORS,
|
||||
@@ -20,9 +21,9 @@ import {
|
||||
UNSUPPORTED_TYPES_BY_NOTION,
|
||||
} from "@/app/(app)/environments/[environmentId]/project/integrations/notion/constants";
|
||||
import NotionLogo from "@/images/notion.png";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -91,6 +92,11 @@ export const AddIntegrationModal = ({
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const questions = useMemo(
|
||||
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
||||
[selectedSurvey]
|
||||
);
|
||||
|
||||
const notionIntegrationData: TIntegrationInput = {
|
||||
type: "notion",
|
||||
config: {
|
||||
@@ -119,10 +125,10 @@ export const AddIntegrationModal = ({
|
||||
}, [selectedDatabase?.id]);
|
||||
|
||||
const questionItems = useMemo(() => {
|
||||
const questions = selectedSurvey
|
||||
? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
|
||||
const mappedQuestions = selectedSurvey
|
||||
? questions.map((q) => ({
|
||||
id: q.id,
|
||||
name: getLocalizedValue(q.headline, "default"),
|
||||
name: getTextContent(recallToHeadline(q.headline, selectedSurvey, false, "default")["default"]),
|
||||
type: q.type,
|
||||
}))
|
||||
: [];
|
||||
@@ -155,7 +161,7 @@ export const AddIntegrationModal = ({
|
||||
},
|
||||
];
|
||||
|
||||
return [...questions, ...variables, ...hiddenFields, ...Metadata, ...createdAt];
|
||||
return [...mappedQuestions, ...variables, ...hiddenFields, ...Metadata, ...createdAt];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedSurvey?.id]);
|
||||
|
||||
|
||||
+15
-6
@@ -17,8 +17,8 @@ import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import SlackLogo from "@/images/slacklogo.png";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
@@ -73,14 +73,19 @@ export const AddChannelMappingModal = ({
|
||||
},
|
||||
};
|
||||
|
||||
const questions = useMemo(
|
||||
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
||||
[selectedSurvey]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSurvey) {
|
||||
const questionIds = selectedSurvey.questions.map((question) => question.id);
|
||||
const questionIds = questions.map((question) => question.id);
|
||||
if (!selectedIntegration) {
|
||||
setSelectedQuestions(questionIds);
|
||||
}
|
||||
}
|
||||
}, [selectedIntegration, selectedSurvey]);
|
||||
}, [questions, selectedIntegration, selectedSurvey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIntegration) {
|
||||
@@ -269,7 +274,7 @@ export const AddChannelMappingModal = ({
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default")?.questions?.map((question) => (
|
||||
{questions.map((question) => (
|
||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||
<label htmlFor={question.id} className="flex cursor-pointer items-center">
|
||||
<Checkbox
|
||||
@@ -283,7 +288,11 @@ export const AddChannelMappingModal = ({
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">
|
||||
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
||||
{getTextContent(
|
||||
recallToHeadline(question.headline, selectedSurvey, false, "default")[
|
||||
"default"
|
||||
]
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
+5
-1
@@ -10,6 +10,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
|
||||
interface ResponseDataViewProps {
|
||||
survey: TSurvey;
|
||||
@@ -55,7 +56,10 @@ export const formatContactInfoData = (responseValue: TResponseDataValue): Record
|
||||
export const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => {
|
||||
const responseData: Record<string, any> = {};
|
||||
|
||||
for (const question of survey.questions) {
|
||||
// Derive questions from blocks
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
for (const question of questions) {
|
||||
const responseValue = response.data[question.id];
|
||||
switch (question.type) {
|
||||
case "matrix":
|
||||
|
||||
+7
-4
@@ -5,7 +5,8 @@ import { TFunction } from "i18next";
|
||||
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
||||
@@ -13,6 +14,7 @@ import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@/modules/survey/lib/questions";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
@@ -29,7 +31,7 @@ import {
|
||||
} from "../lib/utils";
|
||||
|
||||
const getQuestionColumnsData = (
|
||||
question: TSurveyQuestion,
|
||||
question: TSurveyElement,
|
||||
survey: TSurvey,
|
||||
isExpanded: boolean,
|
||||
t: TFunction
|
||||
@@ -54,7 +56,7 @@ const getQuestionColumnsData = (
|
||||
};
|
||||
|
||||
// Helper function to get localized question headline
|
||||
const getQuestionHeadline = (question: TSurveyQuestion, survey: TSurvey) => {
|
||||
const getQuestionHeadline = (question: TSurveyElement, survey: TSurvey) => {
|
||||
return getTextContent(
|
||||
getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default")
|
||||
);
|
||||
@@ -265,7 +267,8 @@ export const generateResponseTableColumns = (
|
||||
t: TFunction,
|
||||
showQuotasColumn: boolean
|
||||
): ColumnDef<TResponseTableData>[] => {
|
||||
const questionColumns = survey.questions.flatMap((question) =>
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
const questionColumns = questions.flatMap((question) =>
|
||||
getQuestionColumnsData(question, survey, isExpanded, t)
|
||||
);
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionSummaryAddress } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyElementSummaryAddress } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
@@ -11,7 +11,7 @@ import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface AddressSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryAddress;
|
||||
questionSummary: TSurveyElementSummaryAddress;
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
locale: TUserLocale;
|
||||
|
||||
+2
-2
@@ -2,13 +2,13 @@
|
||||
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionSummaryCta } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyElementSummaryCta } from "@formbricks/types/surveys/types";
|
||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { convertFloatToNDecimal } from "../lib/utils";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface CTASummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryCta;
|
||||
questionSummary: TSurveyElementSummaryCta;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionSummaryCal } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyElementSummaryCal } from "@formbricks/types/surveys/types";
|
||||
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface CalSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryCal;
|
||||
questionSummary: TSurveyElementSummaryCal;
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
+5
-9
@@ -1,24 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryConsent,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyElementSummaryConsent, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { convertFloatToNDecimal } from "../lib/utils";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface ConsentSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryConsent;
|
||||
questionSummary: TSurveyElementSummaryConsent;
|
||||
survey: TSurvey;
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
questionType: TSurveyElementTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionSummaryContactInfo } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyElementSummaryContactInfo } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
@@ -11,7 +11,7 @@ import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface ContactInfoSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryContactInfo;
|
||||
questionSummary: TSurveyElementSummaryContactInfo;
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
locale: TUserLocale;
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionSummaryDate } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
@@ -13,7 +13,7 @@ import { Button } from "@/modules/ui/components/button";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface DateQuestionSummary {
|
||||
questionSummary: TSurveyQuestionSummaryDate;
|
||||
questionSummary: TSurveyElementSummaryDate;
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
locale: TUserLocale;
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import { DownloadIcon, FileIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionSummaryFileUpload } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyElementSummaryFileUpload } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
@@ -14,7 +14,7 @@ import { Button } from "@/modules/ui/components/button";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface FileUploadSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryFileUpload;
|
||||
questionSummary: TSurveyElementSummaryFileUpload;
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
locale: TUserLocale;
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import { InboxIcon, Link, MessageSquareTextIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurveyQuestionSummaryHiddenFields } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementSummaryHiddenFields } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
@@ -13,7 +13,7 @@ import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface HiddenFieldsSummaryProps {
|
||||
environment: TEnvironment;
|
||||
questionSummary: TSurveyQuestionSummaryHiddenFields;
|
||||
questionSummary: TSurveyElementSummaryHiddenFields;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
|
||||
+5
-9
@@ -1,23 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryMatrix,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyElementSummaryMatrix, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface MatrixQuestionSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryMatrix;
|
||||
questionSummary: TSurveyElementSummaryMatrix;
|
||||
survey: TSurvey;
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
questionType: TSurveyElementTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
|
||||
+5
-5
@@ -4,12 +4,12 @@ import { InboxIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Fragment, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyElementSummaryMultipleChoice,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryMultipleChoice,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyType,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getChoiceIdByValue } from "@/lib/response/utils";
|
||||
@@ -22,14 +22,14 @@ import { convertFloatToNDecimal } from "../lib/utils";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface MultipleChoiceSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryMultipleChoice;
|
||||
questionSummary: TSurveyElementSummaryMultipleChoice;
|
||||
environmentId: string;
|
||||
surveyType: TSurveyType;
|
||||
survey: TSurvey;
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
questionType: TSurveyElementTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
|
||||
+5
-9
@@ -1,24 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryNps,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyElementSummaryNps, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { HalfCircle, ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { convertFloatToNDecimal } from "../lib/utils";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface NPSSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryNps;
|
||||
questionSummary: TSurveyElementSummaryNps;
|
||||
survey: TSurvey;
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
questionType: TSurveyElementTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyElementSummaryOpenText } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
@@ -14,7 +14,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface OpenTextSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryOpenText;
|
||||
questionSummary: TSurveyElementSummaryOpenText;
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
locale: TUserLocale;
|
||||
|
||||
+5
-5
@@ -3,12 +3,12 @@
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyElementSummaryPictureSelection,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryPictureSelection,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getChoiceIdByValue } from "@/lib/response/utils";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
@@ -17,12 +17,12 @@ import { convertFloatToNDecimal } from "../lib/utils";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface PictureChoiceSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryPictureSelection;
|
||||
questionSummary: TSurveyElementSummaryPictureSelection;
|
||||
survey: TSurvey;
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
questionType: TSurveyElementTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import type { JSX } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionSummary } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyElementSummary } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||
@@ -11,7 +11,7 @@ import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
|
||||
interface HeadProps {
|
||||
questionSummary: TSurveyQuestionSummary;
|
||||
questionSummary: TSurveyElementSummary;
|
||||
showResponses?: boolean;
|
||||
additionalInfo?: JSX.Element;
|
||||
survey: TSurvey;
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionSummaryRanking } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyElementSummaryRanking } from "@formbricks/types/surveys/types";
|
||||
import { getChoiceIdByValue } from "@/lib/response/utils";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { convertFloatToNDecimal } from "../lib/utils";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface RankingSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryRanking;
|
||||
questionSummary: TSurveyElementSummaryRanking;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
|
||||
+5
-9
@@ -3,25 +3,21 @@
|
||||
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryRating,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyElementSummaryRating, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { RatingResponse } from "@/modules/ui/components/rating-response";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface RatingSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryRating;
|
||||
questionSummary: TSurveyElementSummaryRating;
|
||||
survey: TSurvey;
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
questionType: TSurveyElementTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
|
||||
+3
-2
@@ -2,7 +2,8 @@
|
||||
|
||||
import { TimerIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionType, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||
import { getQuestionIcon } from "@/modules/survey/lib/questions";
|
||||
@@ -15,7 +16,7 @@ interface SummaryDropOffsProps {
|
||||
|
||||
export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const getIcon = (questionType: TSurveyQuestionType) => {
|
||||
const getIcon = (questionType: TSurveyElementTypeEnum) => {
|
||||
const Icon = getQuestionIcon(questionType, t);
|
||||
return <Icon className="mt-[3px] h-5 w-5 shrink-0 text-slate-600" />;
|
||||
};
|
||||
|
||||
+20
-19
@@ -3,8 +3,9 @@
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TI18nString, TSurveyQuestionId, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import {
|
||||
@@ -45,9 +46,9 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
const { setSelectedFilter, selectedFilter } = useResponseFilter();
|
||||
const { t } = useTranslation();
|
||||
const setFilter = (
|
||||
questionId: TSurveyQuestionId,
|
||||
questionId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
questionType: TSurveyElementTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => {
|
||||
@@ -111,7 +112,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
) : (
|
||||
summary.map((questionSummary) => {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.OpenText) {
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.OpenText) {
|
||||
return (
|
||||
<OpenTextSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -123,8 +124,8 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
);
|
||||
}
|
||||
if (
|
||||
questionSummary.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||
questionSummary.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
|
||||
questionSummary.type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
|
||||
questionSummary.type === TSurveyElementTypeEnum.MultipleChoiceMulti
|
||||
) {
|
||||
return (
|
||||
<MultipleChoiceSummary
|
||||
@@ -137,7 +138,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.NPS) {
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.NPS) {
|
||||
return (
|
||||
<NPSSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -147,7 +148,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.CTA) {
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.CTA) {
|
||||
return (
|
||||
<CTASummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -156,7 +157,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Rating) {
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.Rating) {
|
||||
return (
|
||||
<RatingSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -166,7 +167,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Consent) {
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.Consent) {
|
||||
return (
|
||||
<ConsentSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -176,7 +177,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.PictureSelection) {
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.PictureSelection) {
|
||||
return (
|
||||
<PictureChoiceSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -186,7 +187,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Date) {
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.Date) {
|
||||
return (
|
||||
<DateQuestionSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -197,7 +198,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.FileUpload) {
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.FileUpload) {
|
||||
return (
|
||||
<FileUploadSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -208,7 +209,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Cal) {
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.Cal) {
|
||||
return (
|
||||
<CalSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -218,7 +219,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.Matrix) {
|
||||
return (
|
||||
<MatrixQuestionSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -228,7 +229,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Address) {
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.Address) {
|
||||
return (
|
||||
<AddressSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -239,7 +240,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Ranking) {
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.Ranking) {
|
||||
return (
|
||||
<RankingSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -258,7 +259,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.ContactInfo) {
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.ContactInfo) {
|
||||
return (
|
||||
<ContactInfoSummary
|
||||
key={questionSummary.question.id}
|
||||
|
||||
+2
-1
@@ -5,7 +5,8 @@ import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||
import { TI18nString, TSurvey, TSurveyMetadata } from "@formbricks/types/surveys/types";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurvey, TSurveyMetadata } from "@formbricks/types/surveys/types";
|
||||
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
||||
import { createI18nString, extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
|
||||
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
||||
|
||||
+867
-231
File diff suppressed because it is too large
Load Diff
+131
-78
@@ -14,23 +14,26 @@ import {
|
||||
TResponseVariables,
|
||||
ZResponseFilterCriteria,
|
||||
} from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurveyAddressElement,
|
||||
TSurveyContactInfoElement,
|
||||
TSurveyElement,
|
||||
TSurveyElementTypeEnum,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyContactInfoQuestion,
|
||||
TSurveyElementSummaryAddress,
|
||||
TSurveyElementSummaryContactInfo,
|
||||
TSurveyElementSummaryDate,
|
||||
TSurveyElementSummaryFileUpload,
|
||||
TSurveyElementSummaryHiddenFields,
|
||||
TSurveyElementSummaryMultipleChoice,
|
||||
TSurveyElementSummaryOpenText,
|
||||
TSurveyElementSummaryPictureSelection,
|
||||
TSurveyElementSummaryRanking,
|
||||
TSurveyElementSummaryRating,
|
||||
TSurveyLanguage,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryAddress,
|
||||
TSurveyQuestionSummaryDate,
|
||||
TSurveyQuestionSummaryFileUpload,
|
||||
TSurveyQuestionSummaryHiddenFields,
|
||||
TSurveyQuestionSummaryMultipleChoice,
|
||||
TSurveyQuestionSummaryOpenText,
|
||||
TSurveyQuestionSummaryPictureSelection,
|
||||
TSurveyQuestionSummaryRanking,
|
||||
TSurveyQuestionSummaryRating,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyQuestionChoice,
|
||||
TSurveySummary,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
@@ -40,6 +43,7 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { buildWhereClause } from "@/lib/response/utils";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { findElementLocation, getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { convertFloatTo2Decimal } from "./utils";
|
||||
@@ -97,25 +101,26 @@ export const getSurveySummaryMeta = (
|
||||
|
||||
const evaluateLogicAndGetNextQuestionId = (
|
||||
localSurvey: TSurvey,
|
||||
questions: TSurveyElement[],
|
||||
data: TResponseData,
|
||||
localVariables: TResponseVariables,
|
||||
currentQuestionIndex: number,
|
||||
currQuesTemp: TSurveyQuestion,
|
||||
currQuesTemp: TSurveyElement,
|
||||
selectedLanguage: string | null
|
||||
): {
|
||||
nextQuestionId: TSurveyQuestionId | undefined;
|
||||
nextQuestionId: string | undefined;
|
||||
updatedSurvey: TSurvey;
|
||||
updatedVariables: TResponseVariables;
|
||||
} => {
|
||||
const questions = localSurvey.questions;
|
||||
|
||||
let updatedSurvey = { ...localSurvey };
|
||||
let updatedVariables = { ...localVariables };
|
||||
|
||||
let firstJumpTarget: string | undefined;
|
||||
|
||||
if (currQuesTemp.logic && currQuesTemp.logic.length > 0) {
|
||||
for (const logic of currQuesTemp.logic) {
|
||||
const { block: currentBlock } = findElementLocation(localSurvey, currQuesTemp.id);
|
||||
|
||||
if (currentBlock?.logic && currentBlock.logic.length > 0) {
|
||||
for (const logic of currentBlock.logic) {
|
||||
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
|
||||
const { jumpTarget, requiredQuestionIds, calculations } = performActions(
|
||||
updatedSurvey,
|
||||
@@ -125,9 +130,13 @@ const evaluateLogicAndGetNextQuestionId = (
|
||||
);
|
||||
|
||||
if (requiredQuestionIds.length > 0) {
|
||||
updatedSurvey.questions = updatedSurvey.questions.map((q) =>
|
||||
requiredQuestionIds.includes(q.id) ? { ...q, required: true } : q
|
||||
);
|
||||
// Update blocks to mark elements as required
|
||||
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
|
||||
...block,
|
||||
elements: block.elements.map((e) =>
|
||||
requiredQuestionIds.includes(e.id) ? { ...e, required: true } : e
|
||||
),
|
||||
}));
|
||||
}
|
||||
updatedVariables = { ...updatedVariables, ...calculations };
|
||||
|
||||
@@ -139,8 +148,8 @@ const evaluateLogicAndGetNextQuestionId = (
|
||||
}
|
||||
|
||||
// If no jump target was set, check for a fallback logic
|
||||
if (!firstJumpTarget && currQuesTemp.logicFallback) {
|
||||
firstJumpTarget = currQuesTemp.logicFallback;
|
||||
if (!firstJumpTarget && currentBlock?.logicFallback) {
|
||||
firstJumpTarget = currentBlock.logicFallback;
|
||||
}
|
||||
|
||||
// Return the first jump target if found, otherwise go to the next question
|
||||
@@ -151,10 +160,11 @@ const evaluateLogicAndGetNextQuestionId = (
|
||||
|
||||
export const getSurveySummaryDropOff = (
|
||||
survey: TSurvey,
|
||||
questions: TSurveyElement[],
|
||||
responses: TSurveySummaryResponse[],
|
||||
displayCount: number
|
||||
): TSurveySummary["dropOff"] => {
|
||||
const initialTtc = survey.questions.reduce((acc: Record<string, number>, question) => {
|
||||
const initialTtc = questions.reduce((acc: Record<string, number>, question) => {
|
||||
acc[question.id] = 0;
|
||||
return acc;
|
||||
}, {});
|
||||
@@ -162,9 +172,9 @@ export const getSurveySummaryDropOff = (
|
||||
let totalTtc = { ...initialTtc };
|
||||
let responseCounts = { ...initialTtc };
|
||||
|
||||
let dropOffArr = new Array(survey.questions.length).fill(0) as number[];
|
||||
let impressionsArr = new Array(survey.questions.length).fill(0) as number[];
|
||||
let dropOffPercentageArr = new Array(survey.questions.length).fill(0) as number[];
|
||||
let dropOffArr = new Array(questions.length).fill(0) as number[];
|
||||
let impressionsArr = new Array(questions.length).fill(0) as number[];
|
||||
let dropOffPercentageArr = new Array(questions.length).fill(0) as number[];
|
||||
|
||||
const surveyVariablesData = survey.variables?.reduce(
|
||||
(acc, variable) => {
|
||||
@@ -191,8 +201,8 @@ export const getSurveySummaryDropOff = (
|
||||
|
||||
let currQuesIdx = 0;
|
||||
|
||||
while (currQuesIdx < localSurvey.questions.length) {
|
||||
const currQues = localSurvey.questions[currQuesIdx];
|
||||
while (currQuesIdx < questions.length) {
|
||||
const currQues = questions[currQuesIdx];
|
||||
if (!currQues) break;
|
||||
|
||||
// question is not answered and required
|
||||
@@ -206,6 +216,7 @@ export const getSurveySummaryDropOff = (
|
||||
|
||||
const { nextQuestionId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextQuestionId(
|
||||
localSurvey,
|
||||
questions,
|
||||
localResponseData,
|
||||
localVariables,
|
||||
currQuesIdx,
|
||||
@@ -217,7 +228,7 @@ export const getSurveySummaryDropOff = (
|
||||
localVariables = updatedVariables;
|
||||
|
||||
if (nextQuestionId) {
|
||||
const nextQuesIdx = survey.questions.findIndex((q) => q.id === nextQuestionId);
|
||||
const nextQuesIdx = questions.findIndex((q) => q.id === nextQuestionId);
|
||||
if (!response.data[nextQuestionId] && !response.finished) {
|
||||
dropOffArr[nextQuesIdx]++;
|
||||
impressionsArr[nextQuesIdx]++;
|
||||
@@ -250,13 +261,13 @@ export const getSurveySummaryDropOff = (
|
||||
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
|
||||
}
|
||||
|
||||
for (let i = 1; i < survey.questions.length; i++) {
|
||||
for (let i = 1; i < questions.length; i++) {
|
||||
if (impressionsArr[i] !== 0) {
|
||||
dropOffPercentageArr[i] = (dropOffArr[i] / impressionsArr[i]) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
const dropOff = survey.questions.map((question, index) => {
|
||||
const dropOff = questions.map((question, index) => {
|
||||
return {
|
||||
questionId: question.id,
|
||||
questionType: question.type,
|
||||
@@ -277,13 +288,22 @@ const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: strin
|
||||
return language?.default ? "default" : language?.language.code || "default";
|
||||
};
|
||||
|
||||
const checkForI18n = (responseData: TResponseData, id: string, survey: TSurvey, languageCode: string) => {
|
||||
const question = survey.questions.find((question) => question.id === id);
|
||||
const checkForI18n = (
|
||||
responseData: TResponseData,
|
||||
id: string,
|
||||
questions: TSurveyElement[],
|
||||
languageCode: string
|
||||
) => {
|
||||
const question = questions.find((question) => question.id === id);
|
||||
|
||||
if (question?.type === "multipleChoiceMulti" || question?.type === "ranking") {
|
||||
// Initialize an array to hold the choice values
|
||||
let choiceValues = [] as string[];
|
||||
|
||||
// Type guard: both question types have choices property
|
||||
const hasChoices = "choices" in question;
|
||||
if (!hasChoices) return [];
|
||||
|
||||
(typeof responseData[id] === "string"
|
||||
? ([responseData[id]] as string[])
|
||||
: (responseData[id] as string[])
|
||||
@@ -301,25 +321,31 @@ const checkForI18n = (responseData: TResponseData, id: string, survey: TSurvey,
|
||||
}
|
||||
|
||||
// Return the localized value of the choice fo multiSelect single question
|
||||
const choice = (question as TSurveyMultipleChoiceQuestion)?.choices.find(
|
||||
(choice) => choice.label[languageCode] === responseData[id]
|
||||
);
|
||||
if (question && "choices" in question) {
|
||||
const choice = question.choices?.find(
|
||||
(choice: TSurveyQuestionChoice) => choice.label?.[languageCode] === responseData[id]
|
||||
);
|
||||
return choice && "label" in choice
|
||||
? getLocalizedValue(choice.label, "default") || responseData[id]
|
||||
: responseData[id];
|
||||
}
|
||||
|
||||
return getLocalizedValue(choice?.label, "default") || responseData[id];
|
||||
return responseData[id];
|
||||
};
|
||||
|
||||
export const getQuestionSummary = async (
|
||||
survey: TSurvey,
|
||||
questions: TSurveyElement[],
|
||||
responses: TSurveySummaryResponse[],
|
||||
dropOff: TSurveySummary["dropOff"]
|
||||
): Promise<TSurveySummary["summary"]> => {
|
||||
const VALUES_LIMIT = 50;
|
||||
let summary: TSurveySummary["summary"] = [];
|
||||
|
||||
for (const question of survey.questions) {
|
||||
for (const question of questions) {
|
||||
switch (question.type) {
|
||||
case TSurveyQuestionTypeEnum.OpenText: {
|
||||
let values: TSurveyQuestionSummaryOpenText["samples"] = [];
|
||||
case TSurveyElementTypeEnum.OpenText: {
|
||||
let values: TSurveyElementSummaryOpenText["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (answer && typeof answer === "string") {
|
||||
@@ -335,7 +361,7 @@ export const getQuestionSummary = async (
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
question: question,
|
||||
responseCount: values.length,
|
||||
samples: values.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
@@ -343,9 +369,9 @@ export const getQuestionSummary = async (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti: {
|
||||
let values: TSurveyQuestionSummaryMultipleChoice["choices"] = [];
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti: {
|
||||
let values: TSurveyElementSummaryMultipleChoice["choices"] = [];
|
||||
|
||||
const otherOption = question.choices.find((choice) => choice.id === "other");
|
||||
const noneOption = question.choices.find((choice) => choice.id === "none");
|
||||
@@ -363,7 +389,7 @@ export const getQuestionSummary = async (
|
||||
const noneLabel = noneOption ? getLocalizedValue(noneOption.label, "default") : null;
|
||||
let noneCount = 0;
|
||||
|
||||
const otherValues: TSurveyQuestionSummaryMultipleChoice["choices"][number]["others"] = [];
|
||||
const otherValues: TSurveyElementSummaryMultipleChoice["choices"][number]["others"] = [];
|
||||
let totalSelectionCount = 0;
|
||||
let totalResponseCount = 0;
|
||||
responses.forEach((response) => {
|
||||
@@ -372,11 +398,11 @@ export const getQuestionSummary = async (
|
||||
const answer =
|
||||
responseLanguageCode === "default"
|
||||
? response.data[question.id]
|
||||
: checkForI18n(response.data, question.id, survey, responseLanguageCode);
|
||||
: checkForI18n(response.data, question.id, questions, responseLanguageCode);
|
||||
|
||||
let hasValidAnswer = false;
|
||||
|
||||
if (Array.isArray(answer) && question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
|
||||
if (Array.isArray(answer) && question.type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
|
||||
answer.forEach((value) => {
|
||||
if (value) {
|
||||
totalSelectionCount++;
|
||||
@@ -396,7 +422,7 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
} else if (
|
||||
typeof answer === "string" &&
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle
|
||||
question.type === TSurveyElementTypeEnum.MultipleChoiceSingle
|
||||
) {
|
||||
if (answer) {
|
||||
totalSelectionCount++;
|
||||
@@ -462,8 +488,8 @@ export const getQuestionSummary = async (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.PictureSelection: {
|
||||
let values: TSurveyQuestionSummaryPictureSelection["choices"] = [];
|
||||
case TSurveyElementTypeEnum.PictureSelection: {
|
||||
let values: TSurveyElementSummaryPictureSelection["choices"] = [];
|
||||
const choiceCountMap: Record<string, number> = {};
|
||||
|
||||
question.choices.forEach((choice) => {
|
||||
@@ -506,8 +532,8 @@ export const getQuestionSummary = async (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Rating: {
|
||||
let values: TSurveyQuestionSummaryRating["choices"] = [];
|
||||
case TSurveyElementTypeEnum.Rating: {
|
||||
let values: TSurveyElementSummaryRating["choices"] = [];
|
||||
const choiceCountMap: Record<number, number> = {};
|
||||
const range = question.range;
|
||||
|
||||
@@ -553,7 +579,7 @@ export const getQuestionSummary = async (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.NPS: {
|
||||
case TSurveyElementTypeEnum.NPS: {
|
||||
const data = {
|
||||
promoters: 0,
|
||||
passives: 0,
|
||||
@@ -610,7 +636,7 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.CTA: {
|
||||
case TSurveyElementTypeEnum.CTA: {
|
||||
const data = {
|
||||
clicked: 0,
|
||||
dismissed: 0,
|
||||
@@ -626,7 +652,7 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
|
||||
const totalResponses = data.clicked + data.dismissed;
|
||||
const idx = survey.questions.findIndex((q) => q.id === question.id);
|
||||
const idx = questions.findIndex((q) => q.id === question.id);
|
||||
const impressions = dropOff[idx].impressions;
|
||||
|
||||
summary.push({
|
||||
@@ -643,7 +669,7 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Consent: {
|
||||
case TSurveyElementTypeEnum.Consent: {
|
||||
const data = {
|
||||
accepted: 0,
|
||||
dismissed: 0,
|
||||
@@ -678,8 +704,8 @@ export const getQuestionSummary = async (
|
||||
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Date: {
|
||||
let values: TSurveyQuestionSummaryDate["samples"] = [];
|
||||
case TSurveyElementTypeEnum.Date: {
|
||||
let values: TSurveyElementSummaryDate["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (answer && typeof answer === "string") {
|
||||
@@ -703,8 +729,8 @@ export const getQuestionSummary = async (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.FileUpload: {
|
||||
let values: TSurveyQuestionSummaryFileUpload["files"] = [];
|
||||
case TSurveyElementTypeEnum.FileUpload: {
|
||||
let values: TSurveyElementSummaryFileUpload["files"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (Array.isArray(answer)) {
|
||||
@@ -728,7 +754,7 @@ export const getQuestionSummary = async (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Cal: {
|
||||
case TSurveyElementTypeEnum.Cal: {
|
||||
const data = {
|
||||
booked: 0,
|
||||
skipped: 0,
|
||||
@@ -761,7 +787,7 @@ export const getQuestionSummary = async (
|
||||
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Matrix: {
|
||||
case TSurveyElementTypeEnum.Matrix: {
|
||||
const rows = question.rows.map((row) => getLocalizedValue(row.label, "default"));
|
||||
const columns = question.columns.map((column) => getLocalizedValue(column.label, "default"));
|
||||
let totalResponseCount = 0;
|
||||
@@ -822,9 +848,8 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Address:
|
||||
case TSurveyQuestionTypeEnum.ContactInfo: {
|
||||
let values: TSurveyQuestionSummaryAddress["samples"] = [];
|
||||
case TSurveyElementTypeEnum.Address: {
|
||||
let values: TSurveyElementSummaryAddress["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (Array.isArray(answer) && answer.length > 0) {
|
||||
@@ -839,8 +864,8 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: question.type as TSurveyQuestionTypeEnum.ContactInfo,
|
||||
question: question as TSurveyContactInfoQuestion,
|
||||
type: TSurveyElementTypeEnum.Address,
|
||||
question: question as TSurveyAddressElement,
|
||||
responseCount: values.length,
|
||||
samples: values.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
@@ -848,13 +873,38 @@ export const getQuestionSummary = async (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Ranking: {
|
||||
let values: TSurveyQuestionSummaryRanking["choices"] = [];
|
||||
case TSurveyElementTypeEnum.ContactInfo: {
|
||||
let values: TSurveyElementSummaryContactInfo["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (Array.isArray(answer) && answer.length > 0) {
|
||||
values.push({
|
||||
id: response.id,
|
||||
updatedAt: response.updatedAt,
|
||||
value: answer,
|
||||
contact: response.contact,
|
||||
contactAttributes: response.contactAttributes,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: TSurveyElementTypeEnum.ContactInfo,
|
||||
question: question as TSurveyContactInfoElement,
|
||||
responseCount: values.length,
|
||||
samples: values.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.Ranking: {
|
||||
let values: TSurveyElementSummaryRanking["choices"] = [];
|
||||
const questionChoices = question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
|
||||
let totalResponseCount = 0;
|
||||
const choiceRankSums: Record<string, number> = {};
|
||||
const choiceCountMap: Record<string, number> = {};
|
||||
questionChoices.forEach((choice) => {
|
||||
questionChoices.forEach((choice: string) => {
|
||||
choiceRankSums[choice] = 0;
|
||||
choiceCountMap[choice] = 0;
|
||||
});
|
||||
@@ -865,7 +915,7 @@ export const getQuestionSummary = async (
|
||||
const answer =
|
||||
responseLanguageCode === "default"
|
||||
? response.data[question.id]
|
||||
: checkForI18n(response.data, question.id, survey, responseLanguageCode);
|
||||
: checkForI18n(response.data, question.id, questions, responseLanguageCode);
|
||||
|
||||
if (Array.isArray(answer)) {
|
||||
totalResponseCount++;
|
||||
@@ -879,7 +929,7 @@ export const getQuestionSummary = async (
|
||||
}
|
||||
});
|
||||
|
||||
questionChoices.forEach((choice) => {
|
||||
questionChoices.forEach((choice: string) => {
|
||||
const count = choiceCountMap[choice];
|
||||
const avgRanking = count > 0 ? choiceRankSums[choice] / count : 0;
|
||||
values.push({
|
||||
@@ -902,7 +952,7 @@ export const getQuestionSummary = async (
|
||||
}
|
||||
|
||||
survey.hiddenFields?.fieldIds?.forEach((hiddenFieldId) => {
|
||||
let values: TSurveyQuestionSummaryHiddenFields["samples"] = [];
|
||||
let values: TSurveyElementSummaryHiddenFields["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[hiddenFieldId];
|
||||
if (answer && typeof answer === "string") {
|
||||
@@ -938,6 +988,9 @@ export const getSurveySummary = reactCache(
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
// Derive questions once from blocks
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
const batchSize = 5000;
|
||||
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
|
||||
|
||||
@@ -968,10 +1021,10 @@ export const getSurveySummary = reactCache(
|
||||
getQuotasSummary(surveyId),
|
||||
]);
|
||||
|
||||
const dropOff = getSurveySummaryDropOff(survey, responses, displayCount);
|
||||
const dropOff = getSurveySummaryDropOff(survey, questions, responses, displayCount);
|
||||
const [meta, questionWiseSummary] = await Promise.all([
|
||||
getSurveySummaryMeta(responses, displayCount, quotas),
|
||||
getQuestionSummary(survey, responses, dropOff),
|
||||
getQuestionSummary(survey, questions, responses, dropOff),
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
+39
-27
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { constructToastMessage, convertFloatTo2Decimal, convertFloatToNDecimal } from "./utils";
|
||||
|
||||
describe("Utils Tests", () => {
|
||||
@@ -34,29 +35,40 @@ describe("Utils Tests", () => {
|
||||
type: "app",
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Q1" },
|
||||
required: false,
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Q2" },
|
||||
required: false,
|
||||
choices: [{ id: "c1", label: { default: "Choice 1" } }],
|
||||
},
|
||||
{
|
||||
id: "q3",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Q3" },
|
||||
required: false,
|
||||
rows: [{ id: "r1", label: { default: "Row 1" } }],
|
||||
columns: [{ id: "col1", label: { default: "Col 1" } }],
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q1" },
|
||||
required: false,
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Q2" },
|
||||
required: false,
|
||||
choices: [{ id: "c1", label: { default: "Choice 1" } }],
|
||||
buttonLabel: { default: "Next" },
|
||||
shuffleOption: "none",
|
||||
},
|
||||
{
|
||||
id: "q3",
|
||||
type: TSurveyElementTypeEnum.Matrix,
|
||||
headline: { default: "Q3" },
|
||||
required: false,
|
||||
rows: [{ id: "r1", label: { default: "Row 1" } }],
|
||||
columns: [{ id: "col1", label: { default: "Col 1" } }],
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
@@ -74,7 +86,7 @@ describe("Utils Tests", () => {
|
||||
|
||||
test("should construct message for matrix question type", () => {
|
||||
const message = constructToastMessage(
|
||||
TSurveyQuestionTypeEnum.Matrix,
|
||||
TSurveyElementTypeEnum.Matrix,
|
||||
"is",
|
||||
mockSurvey,
|
||||
"q3",
|
||||
@@ -95,7 +107,7 @@ describe("Utils Tests", () => {
|
||||
});
|
||||
|
||||
test("should construct message for matrix question type with array filterComboBoxValue", () => {
|
||||
const message = constructToastMessage(TSurveyQuestionTypeEnum.Matrix, "is", mockSurvey, "q3", mockT, [
|
||||
const message = constructToastMessage(TSurveyElementTypeEnum.Matrix, "is", mockSurvey, "q3", mockT, [
|
||||
"MatrixValue1",
|
||||
"MatrixValue2",
|
||||
]);
|
||||
@@ -114,7 +126,7 @@ describe("Utils Tests", () => {
|
||||
|
||||
test("should construct message when filterComboBoxValue is undefined (skipped)", () => {
|
||||
const message = constructToastMessage(
|
||||
TSurveyQuestionTypeEnum.OpenText,
|
||||
TSurveyElementTypeEnum.OpenText,
|
||||
"is skipped",
|
||||
mockSurvey,
|
||||
"q1",
|
||||
@@ -134,7 +146,7 @@ describe("Utils Tests", () => {
|
||||
|
||||
test("should construct message for non-matrix question with string filterComboBoxValue", () => {
|
||||
const message = constructToastMessage(
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
"is",
|
||||
mockSurvey,
|
||||
"q2",
|
||||
@@ -156,7 +168,7 @@ describe("Utils Tests", () => {
|
||||
|
||||
test("should construct message for non-matrix question with array filterComboBoxValue", () => {
|
||||
const message = constructToastMessage(
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
"includes all of",
|
||||
mockSurvey,
|
||||
"q2", // Assuming q2 can be multi for this test case logic
|
||||
@@ -178,7 +190,7 @@ describe("Utils Tests", () => {
|
||||
|
||||
test("should handle questionId not found in survey", () => {
|
||||
const message = constructToastMessage(
|
||||
TSurveyQuestionTypeEnum.OpenText,
|
||||
TSurveyElementTypeEnum.OpenText,
|
||||
"is",
|
||||
mockSurvey,
|
||||
"qNonExistent",
|
||||
|
||||
+7
-3
@@ -1,5 +1,7 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { TSurvey, TSurveyQuestionId, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
|
||||
export const convertFloatToNDecimal = (num: number, N: number = 2) => {
|
||||
return Math.round(num * Math.pow(10, N)) / Math.pow(10, N);
|
||||
@@ -10,14 +12,16 @@ export const convertFloatTo2Decimal = (num: number) => {
|
||||
};
|
||||
|
||||
export const constructToastMessage = (
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
questionType: TSurveyElementTypeEnum,
|
||||
filterValue: string,
|
||||
survey: TSurvey,
|
||||
questionId: TSurveyQuestionId,
|
||||
t: TFunction,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => {
|
||||
const questionIdx = survey.questions.findIndex((question) => question.id === questionId);
|
||||
// Derive questions from blocks
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
const questionIdx = questions.findIndex((question) => question.id === questionId);
|
||||
if (questionType === "matrix") {
|
||||
return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question", {
|
||||
questionIdx: questionIdx + 1,
|
||||
|
||||
+14
-14
@@ -29,7 +29,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Fragment, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -55,7 +55,7 @@ export enum OptionsType {
|
||||
|
||||
export type QuestionOption = {
|
||||
label: string;
|
||||
questionType?: TSurveyQuestionTypeEnum;
|
||||
questionType?: TSurveyElementTypeEnum;
|
||||
type: OptionsType;
|
||||
id: string;
|
||||
};
|
||||
@@ -72,18 +72,18 @@ interface QuestionComboBoxProps {
|
||||
|
||||
const questionIcons = {
|
||||
// questions
|
||||
[TSurveyQuestionTypeEnum.OpenText]: MessageSquareTextIcon,
|
||||
[TSurveyQuestionTypeEnum.Rating]: StarIcon,
|
||||
[TSurveyQuestionTypeEnum.CTA]: MousePointerClickIcon,
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: ListIcon,
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: Rows3Icon,
|
||||
[TSurveyQuestionTypeEnum.NPS]: NetPromoterScoreIcon,
|
||||
[TSurveyQuestionTypeEnum.Consent]: CheckIcon,
|
||||
[TSurveyQuestionTypeEnum.PictureSelection]: ImageIcon,
|
||||
[TSurveyQuestionTypeEnum.Matrix]: GridIcon,
|
||||
[TSurveyQuestionTypeEnum.Ranking]: ListOrderedIcon,
|
||||
[TSurveyQuestionTypeEnum.Address]: HomeIcon,
|
||||
[TSurveyQuestionTypeEnum.ContactInfo]: ContactIcon,
|
||||
[TSurveyElementTypeEnum.OpenText]: MessageSquareTextIcon,
|
||||
[TSurveyElementTypeEnum.Rating]: StarIcon,
|
||||
[TSurveyElementTypeEnum.CTA]: MousePointerClickIcon,
|
||||
[TSurveyElementTypeEnum.MultipleChoiceMulti]: ListIcon,
|
||||
[TSurveyElementTypeEnum.MultipleChoiceSingle]: Rows3Icon,
|
||||
[TSurveyElementTypeEnum.NPS]: NetPromoterScoreIcon,
|
||||
[TSurveyElementTypeEnum.Consent]: CheckIcon,
|
||||
[TSurveyElementTypeEnum.PictureSelection]: ImageIcon,
|
||||
[TSurveyElementTypeEnum.Matrix]: GridIcon,
|
||||
[TSurveyElementTypeEnum.Ranking]: ListOrderedIcon,
|
||||
[TSurveyElementTypeEnum.Address]: HomeIcon,
|
||||
[TSurveyElementTypeEnum.ContactInfo]: ContactIcon,
|
||||
|
||||
// attributes
|
||||
[OptionsType.ATTRIBUTES]: User,
|
||||
|
||||
+3
-2
@@ -4,7 +4,8 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
SelectedFilterValue,
|
||||
TResponseStatus,
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
|
||||
|
||||
export type QuestionFilterOptions = {
|
||||
type: TSurveyQuestionTypeEnum | "Attributes" | "Tags" | "Languages" | "Quotas";
|
||||
type: TSurveyElementTypeEnum | "Attributes" | "Tags" | "Languages" | "Quotas";
|
||||
filterOptions: string[];
|
||||
filterComboBoxOptions: string[];
|
||||
id: string;
|
||||
|
||||
@@ -23,12 +23,8 @@ import {
|
||||
TIntegrationSlackCredential,
|
||||
} from "@formbricks/types/integration/slack";
|
||||
import { TResponse, TResponseMeta } from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyPictureSelectionQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { writeData as airtableWriteData } from "@/lib/airtable/service";
|
||||
import { writeData as googleSheetWriteData } from "@/lib/googleSheet/service";
|
||||
@@ -101,33 +97,47 @@ const mockPipelineInput = {
|
||||
const mockSurvey = {
|
||||
id: surveyId,
|
||||
name: "Test Survey",
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: questionId1,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Question 1 {{recall:q2}}" },
|
||||
required: true,
|
||||
} as unknown as TSurveyOpenTextQuestion,
|
||||
{
|
||||
id: questionId2,
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Question 2" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "choice1", label: { default: "Choice 1" } },
|
||||
{ id: "choice2", label: { default: "Choice 2" } },
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: questionId1,
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Question 1 {{recall:q2}}" },
|
||||
required: true,
|
||||
inputType: "text",
|
||||
charLimit: 1000,
|
||||
subheader: { default: "" },
|
||||
placeholder: { default: "" },
|
||||
},
|
||||
{
|
||||
id: questionId2,
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Question 2" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "choice1", label: { default: "Choice 1" } },
|
||||
{ id: "choice2", label: { default: "Choice 2" } },
|
||||
],
|
||||
shuffleOption: "none",
|
||||
subheader: { default: "" },
|
||||
},
|
||||
{
|
||||
id: questionId3,
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
headline: { default: "Question 3" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "picChoice1", imageUrl: "http://image.com/1" },
|
||||
{ id: "picChoice2", imageUrl: "http://image.com/2" },
|
||||
],
|
||||
allowMultiple: false,
|
||||
subheader: { default: "" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: questionId3,
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
headline: { default: "Question 3" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "picChoice1", imageUrl: "http://image.com/1" },
|
||||
{ id: "picChoice2", imageUrl: "http://image.com/2" },
|
||||
],
|
||||
} as unknown as TSurveyPictureSelectionQuestion,
|
||||
],
|
||||
hiddenFields: {
|
||||
enabled: true,
|
||||
|
||||
@@ -6,7 +6,8 @@ import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-s
|
||||
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
||||
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
||||
import { TResponseMeta } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { writeData as airtableWriteData } from "@/lib/airtable/service";
|
||||
@@ -16,6 +17,7 @@ import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { writeData as writeNotionData } from "@/lib/notion/service";
|
||||
import { processResponseData } from "@/lib/responses";
|
||||
import { writeDataToSlack } from "@/lib/slack/service";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { truncateText } from "@/lib/utils/strings";
|
||||
@@ -236,6 +238,9 @@ const extractResponses = async (
|
||||
const responses: string[] = [];
|
||||
const questions: string[] = [];
|
||||
|
||||
// Derive questions from blocks
|
||||
const surveyQuestions = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
for (const questionId of questionIds) {
|
||||
//check for hidden field Ids
|
||||
if (survey.hiddenFields.fieldIds?.includes(questionId)) {
|
||||
@@ -243,7 +248,7 @@ const extractResponses = async (
|
||||
questions.push(questionId);
|
||||
continue;
|
||||
}
|
||||
const question = survey?.questions.find((q) => q.id === questionId);
|
||||
const question = surveyQuestions.find((q) => q.id === questionId);
|
||||
if (!question) {
|
||||
continue;
|
||||
}
|
||||
@@ -252,7 +257,7 @@ const extractResponses = async (
|
||||
|
||||
if (responseValue !== undefined) {
|
||||
let answer: typeof responseValue;
|
||||
if (question.type === TSurveyQuestionTypeEnum.PictureSelection) {
|
||||
if (question.type === TSurveyElementTypeEnum.PictureSelection) {
|
||||
const selectedChoiceIds = responseValue as string[];
|
||||
answer = question?.choices
|
||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||
@@ -321,14 +326,17 @@ const buildNotionPayloadProperties = (
|
||||
const properties: any = {};
|
||||
const responses = data.response.data;
|
||||
|
||||
// Derive questions from blocks
|
||||
const surveyQuestions = getElementsFromBlocks(surveyData.blocks);
|
||||
|
||||
const mappingQIds = mapping
|
||||
.filter((m) => m.question.type === TSurveyQuestionTypeEnum.PictureSelection)
|
||||
.filter((m) => m.question.type === TSurveyElementTypeEnum.PictureSelection)
|
||||
.map((m) => m.question.id);
|
||||
|
||||
Object.keys(responses).forEach((resp) => {
|
||||
if (mappingQIds.find((qId) => qId === resp)) {
|
||||
const selectedChoiceIds = responses[resp] as string[];
|
||||
const pictureQuestion = surveyData.questions.find((q) => q.id === resp);
|
||||
const pictureQuestion = surveyQuestions.find((q) => q.id === resp);
|
||||
|
||||
responses[resp] = (pictureQuestion as any)?.choices
|
||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||
|
||||
@@ -92,6 +92,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
||||
welcomeCard: true,
|
||||
name: true,
|
||||
questions: true,
|
||||
blocks: true,
|
||||
variables: true,
|
||||
type: true,
|
||||
showLanguageSwitch: true,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
@@ -91,7 +92,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
// Validate response data for "other" options exceeding character limit
|
||||
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
|
||||
responseData: responseInputData.data,
|
||||
surveyQuestions: survey.questions,
|
||||
surveyQuestions: getElementsFromBlocks(survey.blocks),
|
||||
responseLanguage: responseInputData.language,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -2,12 +2,8 @@ import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { TLanguage } from "@formbricks/types/project";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyLanguage,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { type TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import {
|
||||
DateRange,
|
||||
@@ -26,13 +22,23 @@ describe("surveys", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Open Text Question" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Open Text Question" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
} as TSurveyElement,
|
||||
],
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
@@ -51,6 +57,7 @@ describe("surveys", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -74,6 +81,7 @@ describe("surveys", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -97,6 +105,7 @@ describe("surveys", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -120,6 +129,7 @@ describe("surveys", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -145,6 +155,7 @@ describe("surveys", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -164,59 +175,87 @@ describe("surveys", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Open Text" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Multiple Choice Single" },
|
||||
choices: [{ id: "c1", label: "Choice 1" }],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q3",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Multiple Choice Multi" },
|
||||
choices: [
|
||||
{ id: "c1", label: "Choice 1" },
|
||||
{ id: "other", label: "Other" },
|
||||
],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q4",
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: { default: "NPS" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q5",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rating" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q6",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q7",
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
headline: { default: "Picture Selection" },
|
||||
choices: [
|
||||
{ id: "p1", imageUrl: "url1" },
|
||||
{ id: "p2", imageUrl: "url2" },
|
||||
],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q8",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Matrix" },
|
||||
rows: [{ id: "r1", label: "Row 1" }],
|
||||
columns: [{ id: "c1", label: "Column 1" }],
|
||||
} as unknown as TSurveyQuestion,
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Open Text" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Multiple Choice Single" },
|
||||
required: false,
|
||||
choices: [{ id: "c1", label: { default: "Choice 1" } }],
|
||||
shuffleOption: "none",
|
||||
},
|
||||
{
|
||||
id: "q3",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Multiple Choice Multi" },
|
||||
required: false,
|
||||
choices: [
|
||||
{ id: "c1", label: { default: "Choice 1" } },
|
||||
{ id: "other", label: { default: "Other" } },
|
||||
],
|
||||
shuffleOption: "none",
|
||||
},
|
||||
{
|
||||
id: "q4",
|
||||
type: TSurveyElementTypeEnum.NPS,
|
||||
headline: { default: "NPS" },
|
||||
required: false,
|
||||
lowerLabel: { default: "Not likely" },
|
||||
upperLabel: { default: "Very likely" },
|
||||
},
|
||||
{
|
||||
id: "q5",
|
||||
type: TSurveyElementTypeEnum.Rating,
|
||||
headline: { default: "Rating" },
|
||||
required: false,
|
||||
scale: "number",
|
||||
range: 5,
|
||||
lowerLabel: { default: "Low" },
|
||||
upperLabel: { default: "High" },
|
||||
},
|
||||
{
|
||||
id: "q6",
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
required: false,
|
||||
buttonLabel: { default: "Click me" },
|
||||
buttonExternal: false,
|
||||
},
|
||||
{
|
||||
id: "q7",
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
headline: { default: "Picture Selection" },
|
||||
required: false,
|
||||
allowMultiple: false,
|
||||
choices: [
|
||||
{ id: "p1", imageUrl: "url1" },
|
||||
{ id: "p2", imageUrl: "url2" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "q8",
|
||||
type: TSurveyElementTypeEnum.Matrix,
|
||||
headline: { default: "Matrix" },
|
||||
required: false,
|
||||
rows: [{ id: "r1", label: { default: "Row 1" } }],
|
||||
columns: [{ id: "c1", label: { default: "Column 1" } }],
|
||||
},
|
||||
] as TSurveyElement[],
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
@@ -236,6 +275,7 @@ describe("surveys", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -274,76 +314,121 @@ describe("surveys", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "openTextQ",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Open Text" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "mcSingleQ",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Multiple Choice Single" },
|
||||
choices: [{ id: "c1", label: "Choice 1" }],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "mcMultiQ",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Multiple Choice Multi" },
|
||||
choices: [{ id: "c1", label: "Choice 1" }],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "npsQ",
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: { default: "NPS" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "ratingQ",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rating" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "ctaQ",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "consentQ",
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
headline: { default: "Consent" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "pictureQ",
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
headline: { default: "Picture Selection" },
|
||||
choices: [
|
||||
{ id: "p1", imageUrl: "url1" },
|
||||
{ id: "p2", imageUrl: "url2" },
|
||||
],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "matrixQ",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Matrix" },
|
||||
rows: [{ id: "r1", label: "Row 1" }],
|
||||
columns: [{ id: "c1", label: "Column 1" }],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "addressQ",
|
||||
type: TSurveyQuestionTypeEnum.Address,
|
||||
headline: { default: "Address" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "contactQ",
|
||||
type: TSurveyQuestionTypeEnum.ContactInfo,
|
||||
headline: { default: "Contact Info" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "rankingQ",
|
||||
type: TSurveyQuestionTypeEnum.Ranking,
|
||||
headline: { default: "Ranking" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "openTextQ",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Open Text" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
{
|
||||
id: "mcSingleQ",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Multiple Choice Single" },
|
||||
required: false,
|
||||
choices: [{ id: "c1", label: { default: "Choice 1" } }],
|
||||
shuffleOption: "none",
|
||||
},
|
||||
{
|
||||
id: "mcMultiQ",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Multiple Choice Multi" },
|
||||
required: false,
|
||||
choices: [{ id: "c1", label: { default: "Choice 1" } }],
|
||||
shuffleOption: "none",
|
||||
},
|
||||
{
|
||||
id: "npsQ",
|
||||
type: TSurveyElementTypeEnum.NPS,
|
||||
headline: { default: "NPS" },
|
||||
required: false,
|
||||
lowerLabel: { default: "Not likely" },
|
||||
upperLabel: { default: "Very likely" },
|
||||
},
|
||||
{
|
||||
id: "ratingQ",
|
||||
type: TSurveyElementTypeEnum.Rating,
|
||||
headline: { default: "Rating" },
|
||||
required: false,
|
||||
scale: "number",
|
||||
range: 5,
|
||||
lowerLabel: { default: "Low" },
|
||||
upperLabel: { default: "High" },
|
||||
},
|
||||
{
|
||||
id: "ctaQ",
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
required: false,
|
||||
buttonLabel: { default: "Click me" },
|
||||
buttonExternal: false,
|
||||
},
|
||||
{
|
||||
id: "consentQ",
|
||||
type: TSurveyElementTypeEnum.Consent,
|
||||
headline: { default: "Consent" },
|
||||
required: false,
|
||||
label: { default: "I agree" },
|
||||
},
|
||||
{
|
||||
id: "pictureQ",
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
headline: { default: "Picture Selection" },
|
||||
required: false,
|
||||
allowMultiple: false,
|
||||
choices: [
|
||||
{ id: "p1", imageUrl: "url1" },
|
||||
{ id: "p2", imageUrl: "url2" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "matrixQ",
|
||||
type: TSurveyElementTypeEnum.Matrix,
|
||||
headline: { default: "Matrix" },
|
||||
required: false,
|
||||
rows: [{ id: "r1", label: { default: "Row 1" } }],
|
||||
columns: [{ id: "c1", label: { default: "Column 1" } }],
|
||||
},
|
||||
{
|
||||
id: "addressQ",
|
||||
type: TSurveyElementTypeEnum.Address,
|
||||
headline: { default: "Address" },
|
||||
required: false,
|
||||
zip: { show: true, required: false, placeholder: { default: "Zip" } },
|
||||
city: { show: true, required: false, placeholder: { default: "City" } },
|
||||
state: { show: true, required: false, placeholder: { default: "State" } },
|
||||
country: { show: true, required: false, placeholder: { default: "Country" } },
|
||||
addressLine1: { show: true, required: false, placeholder: { default: "Address Line 1" } },
|
||||
addressLine2: { show: true, required: false, placeholder: { default: "Address Line 2" } },
|
||||
},
|
||||
{
|
||||
id: "contactQ",
|
||||
type: TSurveyElementTypeEnum.ContactInfo,
|
||||
headline: { default: "Contact Info" },
|
||||
required: false,
|
||||
firstName: { show: true, required: false, placeholder: { default: "First Name" } },
|
||||
lastName: { show: true, required: false, placeholder: { default: "Last Name" } },
|
||||
email: { show: true, required: false, placeholder: { default: "Email" } },
|
||||
phone: { show: true, required: false, placeholder: { default: "Phone" } },
|
||||
company: { show: true, required: false, placeholder: { default: "Company" } },
|
||||
},
|
||||
{
|
||||
id: "rankingQ",
|
||||
type: TSurveyElementTypeEnum.Ranking,
|
||||
headline: { default: "Ranking" },
|
||||
required: false,
|
||||
choices: [{ id: "r1", label: { default: "Option 1" } }],
|
||||
},
|
||||
] as TSurveyElement[],
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
@@ -420,7 +505,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "Open Text",
|
||||
id: "openTextQ",
|
||||
questionType: TSurveyQuestionTypeEnum.OpenText,
|
||||
questionType: TSurveyElementTypeEnum.OpenText,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Filled out" },
|
||||
},
|
||||
@@ -441,7 +526,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "Address",
|
||||
id: "addressQ",
|
||||
questionType: TSurveyQuestionTypeEnum.Address,
|
||||
questionType: TSurveyElementTypeEnum.Address,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Skipped" },
|
||||
},
|
||||
@@ -462,7 +547,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "Contact Info",
|
||||
id: "contactQ",
|
||||
questionType: TSurveyQuestionTypeEnum.ContactInfo,
|
||||
questionType: TSurveyElementTypeEnum.ContactInfo,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Filled out" },
|
||||
},
|
||||
@@ -483,7 +568,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "Ranking",
|
||||
id: "rankingQ",
|
||||
questionType: TSurveyQuestionTypeEnum.Ranking,
|
||||
questionType: TSurveyElementTypeEnum.Ranking,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Filled out" },
|
||||
},
|
||||
@@ -504,7 +589,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "MC Single",
|
||||
id: "mcSingleQ",
|
||||
questionType: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
questionType: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
},
|
||||
filterType: { filterValue: "Includes either", filterComboBoxValue: ["Choice 1"] },
|
||||
},
|
||||
@@ -525,7 +610,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "MC Multi",
|
||||
id: "mcMultiQ",
|
||||
questionType: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
questionType: TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
},
|
||||
filterType: { filterValue: "Includes all", filterComboBoxValue: ["Choice 1", "Choice 2"] },
|
||||
},
|
||||
@@ -546,7 +631,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "NPS",
|
||||
id: "npsQ",
|
||||
questionType: TSurveyQuestionTypeEnum.NPS,
|
||||
questionType: TSurveyElementTypeEnum.NPS,
|
||||
},
|
||||
filterType: { filterValue: "Is equal to", filterComboBoxValue: "7" },
|
||||
},
|
||||
@@ -567,7 +652,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "Rating",
|
||||
id: "ratingQ",
|
||||
questionType: TSurveyQuestionTypeEnum.Rating,
|
||||
questionType: TSurveyElementTypeEnum.Rating,
|
||||
},
|
||||
filterType: { filterValue: "Is less than", filterComboBoxValue: "4" },
|
||||
},
|
||||
@@ -588,7 +673,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "CTA",
|
||||
id: "ctaQ",
|
||||
questionType: TSurveyQuestionTypeEnum.CTA,
|
||||
questionType: TSurveyElementTypeEnum.CTA,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Clicked" },
|
||||
},
|
||||
@@ -609,7 +694,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "Consent",
|
||||
id: "consentQ",
|
||||
questionType: TSurveyQuestionTypeEnum.Consent,
|
||||
questionType: TSurveyElementTypeEnum.Consent,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Accepted" },
|
||||
},
|
||||
@@ -630,7 +715,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "Picture",
|
||||
id: "pictureQ",
|
||||
questionType: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
questionType: TSurveyElementTypeEnum.PictureSelection,
|
||||
},
|
||||
filterType: { filterValue: "Includes either", filterComboBoxValue: ["Picture 1"] },
|
||||
},
|
||||
@@ -651,7 +736,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "Matrix",
|
||||
id: "matrixQ",
|
||||
questionType: TSurveyQuestionTypeEnum.Matrix,
|
||||
questionType: TSurveyElementTypeEnum.Matrix,
|
||||
},
|
||||
filterType: { filterValue: "Row 1", filterComboBoxValue: "Column 1" },
|
||||
},
|
||||
@@ -736,7 +821,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "NPS",
|
||||
id: "npsQ",
|
||||
questionType: TSurveyQuestionTypeEnum.NPS,
|
||||
questionType: TSurveyElementTypeEnum.NPS,
|
||||
},
|
||||
filterType: { filterValue: "Is more than", filterComboBoxValue: "7" },
|
||||
},
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
TSurveyContactAttributes,
|
||||
TSurveyMetaFieldFilter,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
|
||||
const conditionOptions = {
|
||||
openText: ["is"],
|
||||
@@ -79,8 +81,9 @@ export const generateQuestionAndFilterOptions = (
|
||||
let questionFilterOptions: any = [];
|
||||
|
||||
let questionsOptions: any = [];
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
survey.questions.forEach((q) => {
|
||||
questions.forEach((q) => {
|
||||
if (Object.keys(conditionOptions).includes(q.type)) {
|
||||
questionsOptions.push({
|
||||
label: getTextContent(
|
||||
@@ -93,16 +96,16 @@ export const generateQuestionAndFilterOptions = (
|
||||
}
|
||||
});
|
||||
questionOptions = [...questionOptions, { header: OptionsType.QUESTIONS, option: questionsOptions }];
|
||||
survey.questions.forEach((q) => {
|
||||
questions.forEach((q) => {
|
||||
if (Object.keys(conditionOptions).includes(q.type)) {
|
||||
if (q.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle) {
|
||||
if (q.type === TSurveyElementTypeEnum.MultipleChoiceSingle) {
|
||||
questionFilterOptions.push({
|
||||
type: q.type,
|
||||
filterOptions: conditionOptions[q.type],
|
||||
filterComboBoxOptions: q?.choices ? q?.choices?.map((c) => c?.label) : [""],
|
||||
id: q.id,
|
||||
});
|
||||
} else if (q.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
|
||||
} else if (q.type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
|
||||
questionFilterOptions.push({
|
||||
type: q.type,
|
||||
filterOptions: conditionOptions[q.type],
|
||||
@@ -111,14 +114,14 @@ export const generateQuestionAndFilterOptions = (
|
||||
: [""],
|
||||
id: q.id,
|
||||
});
|
||||
} else if (q.type === TSurveyQuestionTypeEnum.PictureSelection) {
|
||||
} else if (q.type === TSurveyElementTypeEnum.PictureSelection) {
|
||||
questionFilterOptions.push({
|
||||
type: q.type,
|
||||
filterOptions: conditionOptions[q.type],
|
||||
filterComboBoxOptions: q?.choices ? q?.choices?.map((_, idx) => `Picture ${idx + 1}`) : [""],
|
||||
id: q.id,
|
||||
});
|
||||
} else if (q.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
} else if (q.type === TSurveyElementTypeEnum.Matrix) {
|
||||
questionFilterOptions.push({
|
||||
type: q.type,
|
||||
filterOptions: q.rows.flatMap((row) => Object.values(row)),
|
||||
@@ -311,12 +314,13 @@ export const getFormattedFilters = (
|
||||
|
||||
// for questions
|
||||
if (questions.length) {
|
||||
const surveyQuestions = getElementsFromBlocks(survey.blocks);
|
||||
questions.forEach(({ filterType, questionType }) => {
|
||||
if (!filters.data) filters.data = {};
|
||||
switch (questionType.questionType) {
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
case TSurveyQuestionTypeEnum.Address:
|
||||
case TSurveyQuestionTypeEnum.ContactInfo: {
|
||||
case TSurveyElementTypeEnum.OpenText:
|
||||
case TSurveyElementTypeEnum.Address:
|
||||
case TSurveyElementTypeEnum.ContactInfo: {
|
||||
if (filterType.filterComboBoxValue === "Filled out") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "filledOut",
|
||||
@@ -328,7 +332,7 @@ export const getFormattedFilters = (
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Ranking: {
|
||||
case TSurveyElementTypeEnum.Ranking: {
|
||||
if (filterType.filterComboBoxValue === "Filled out") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "submitted",
|
||||
@@ -340,8 +344,8 @@ export const getFormattedFilters = (
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti: {
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti: {
|
||||
if (filterType.filterValue === "Includes either") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "includesOne",
|
||||
@@ -355,8 +359,8 @@ export const getFormattedFilters = (
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.NPS:
|
||||
case TSurveyQuestionTypeEnum.Rating: {
|
||||
case TSurveyElementTypeEnum.NPS:
|
||||
case TSurveyElementTypeEnum.Rating: {
|
||||
if (filterType.filterValue === "Is equal to") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "equals",
|
||||
@@ -388,7 +392,7 @@ export const getFormattedFilters = (
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.CTA: {
|
||||
case TSurveyElementTypeEnum.CTA: {
|
||||
if (filterType.filterComboBoxValue === "Clicked") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "clicked",
|
||||
@@ -400,7 +404,7 @@ export const getFormattedFilters = (
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Consent: {
|
||||
case TSurveyElementTypeEnum.Consent: {
|
||||
if (filterType.filterComboBoxValue === "Accepted") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "accepted",
|
||||
@@ -412,12 +416,12 @@ export const getFormattedFilters = (
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.PictureSelection: {
|
||||
case TSurveyElementTypeEnum.PictureSelection: {
|
||||
const questionId = questionType.id ?? "";
|
||||
const question = survey.questions.find((q) => q.id === questionId);
|
||||
const question = surveyQuestions.find((q) => q.id === questionId);
|
||||
|
||||
if (
|
||||
question?.type !== TSurveyQuestionTypeEnum.PictureSelection ||
|
||||
question?.type !== TSurveyElementTypeEnum.PictureSelection ||
|
||||
!Array.isArray(filterType.filterComboBoxValue)
|
||||
) {
|
||||
return;
|
||||
@@ -441,7 +445,7 @@ export const getFormattedFilters = (
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Matrix: {
|
||||
case TSurveyElementTypeEnum.Matrix: {
|
||||
if (
|
||||
filterType.filterValue &&
|
||||
filterType.filterComboBoxValue &&
|
||||
|
||||
+3246
-2013
File diff suppressed because it is too large
Load Diff
+2
-4
@@ -915,15 +915,12 @@ checksums:
|
||||
environments/settings/billing/manage_subscription: 31cafd367fc70d656d8dd979d537dc96
|
||||
environments/settings/billing/monthly: 818f1192e32bb855597f930d3e78806e
|
||||
environments/settings/billing/monthly_identified_users: 0795735f6b241d31edac576a77dd7e55
|
||||
environments/settings/billing/per_month: 64e96490ee2d7811496cf04adae30aa4
|
||||
environments/settings/billing/per_year: bf02408d157486e53c15a521a5645617
|
||||
environments/settings/billing/plan_upgraded_successfully: 52e2a258cc9ca8a512c288bf6f18cf37
|
||||
environments/settings/billing/premium_support_with_slas: 2e33d4442c16bfececa6cae7b2081e5d
|
||||
environments/settings/billing/remove_branding: 88b6b818750e478bfa153b33dd658280
|
||||
environments/settings/billing/startup: 4c4ac5a0b9dc62100bca6c6465f31c4c
|
||||
environments/settings/billing/startup_description: 964fcb2c77f49b80266c94606e3f4506
|
||||
environments/settings/billing/switch_plan: fb3e1941051a4273ca29224803570f4b
|
||||
environments/settings/billing/switch_plan_confirmation_text: 910a6df56964619975c6ed5651a55db7
|
||||
environments/settings/billing/team_access_roles: 1cc4af14e589f6c09ab92a4f21958049
|
||||
environments/settings/billing/unable_to_upgrade_plan: 50fc725609411d139e534c85eeb2879e
|
||||
environments/settings/billing/unlimited_miu: 29c3f5bd01c2a09fdf1d3601665ce90f
|
||||
@@ -1535,6 +1532,7 @@ checksums:
|
||||
environments/surveys/edit/unlock_targeting_title: 6098caf969cac64cd54e217471ae42d4
|
||||
environments/surveys/edit/unsaved_changes_warning: a164f276c9f7344022aa4640b32abcf9
|
||||
environments/surveys/edit/until_they_submit_a_response: c980c520f5b5883ed46f2e1c006082b5
|
||||
environments/surveys/edit/untitled_block: fdaa045139deff5cc65fa027df0cc22e
|
||||
environments/surveys/edit/upgrade_notice_description: 32b66a4f257ad8d38bc38dcc95fe23c4
|
||||
environments/surveys/edit/upgrade_notice_title: 40866066ebc558ad0c92a4f19f12090c
|
||||
environments/surveys/edit/upload: 4a6c84aa16db0f4e5697f49b45257bc7
|
||||
@@ -2103,6 +2101,7 @@ checksums:
|
||||
templates/csat_survey_question_3_headline: 25974b7f1692cad41908fe305830b6c0
|
||||
templates/csat_survey_question_3_placeholder: 3ec4d0b6bb1f26bb2c32e9e8bd377ae3
|
||||
templates/cta_description: bc94a2ddc965b286a8677b0642696c7e
|
||||
templates/custom_survey_block_1_name: 5e1b4dce0cb70662441b663507a69454
|
||||
templates/custom_survey_description: 0492afdea2ef1bd683eaf48a2bad2caa
|
||||
templates/custom_survey_name: 6fc756927ca9ea22c26368cccd64a67e
|
||||
templates/custom_survey_question_1_headline: 0abf9d41e0b5c5567c3833fd63048398
|
||||
@@ -2512,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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { iso639Languages } from "@formbricks/i18n-utils/src/utils";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TLanguage } from "@formbricks/types/project";
|
||||
import { TI18nString, TSurveyLanguage } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyLanguage } from "@formbricks/types/surveys/types";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
|
||||
// Helper function to create an i18nString from a regular string.
|
||||
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
ZResponseFilterCriteria,
|
||||
ZResponseUpdateInput,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
|
||||
import { deleteFile } from "@/modules/storage/service";
|
||||
@@ -548,10 +550,10 @@ export const updateResponse = async (
|
||||
};
|
||||
|
||||
const findAndDeleteUploadedFilesInResponse = async (response: TResponse, survey: TSurvey): Promise<void> => {
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
const fileUploadQuestions = new Set(
|
||||
survey.questions
|
||||
.filter((question) => question.type === TSurveyQuestionTypeEnum.FileUpload)
|
||||
.map((q) => q.id)
|
||||
questions.filter((question) => question.type === TSurveyElementTypeEnum.FileUpload).map((q) => q.id)
|
||||
);
|
||||
|
||||
const fileUrls = Object.entries(response.data)
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
buildWhereClause,
|
||||
calculateTtcTotal,
|
||||
@@ -44,20 +40,8 @@ describe("Response Utils", () => {
|
||||
const mockSurvey: Partial<TSurvey> = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Question 1" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "1", label: { default: "Option 1" } },
|
||||
{ id: "other", label: { default: "Other" } },
|
||||
],
|
||||
shuffleOption: "none",
|
||||
isDraft: false,
|
||||
},
|
||||
],
|
||||
blocks: [],
|
||||
questions: [],
|
||||
type: "app",
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
createdAt: new Date(),
|
||||
@@ -115,6 +99,7 @@ describe("Response Utils", () => {
|
||||
const baseSurvey: Partial<TSurvey> = {
|
||||
id: "s1",
|
||||
name: "Survey",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
type: "app",
|
||||
hiddenFields: { enabled: false, fieldIds: [] },
|
||||
@@ -203,26 +188,33 @@ describe("Response Utils", () => {
|
||||
const textSurvey: Partial<TSurvey> = {
|
||||
id: "s2",
|
||||
name: "TextSurvey",
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "qText",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Text Q" },
|
||||
required: false,
|
||||
isDraft: false,
|
||||
charLimit: {},
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: "qNum",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Num Q" },
|
||||
required: false,
|
||||
isDraft: false,
|
||||
charLimit: {},
|
||||
inputType: "number",
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "qText",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Text Q" },
|
||||
required: false,
|
||||
isDraft: false,
|
||||
charLimit: {},
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: "qNum",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Num Q" },
|
||||
required: false,
|
||||
isDraft: false,
|
||||
charLimit: {},
|
||||
inputType: "number",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
type: "app",
|
||||
hiddenFields: { enabled: false, fieldIds: [] },
|
||||
createdAt: new Date(),
|
||||
@@ -232,7 +224,7 @@ describe("Response Utils", () => {
|
||||
status: "inProgress",
|
||||
};
|
||||
|
||||
const ops: Array<[keyof TSurveyQuestionTypeEnum | string, any, any]> = [
|
||||
const ops: Array<[keyof TSurveyElementTypeEnum | string, any, any]> = [
|
||||
["submitted", { op: "submitted" }, { path: ["qText"], not: Prisma.DbNull }],
|
||||
["filledOut", { op: "filledOut" }, { path: ["qText"], not: [] }],
|
||||
["skipped", { op: "skipped" }, "OR"],
|
||||
@@ -295,18 +287,25 @@ describe("Response Utils", () => {
|
||||
const matrixSurvey: Partial<TSurvey> = {
|
||||
id: "s3",
|
||||
name: "MatrixSurvey",
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "qM",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Matrix" },
|
||||
required: false,
|
||||
rows: [{ default: "R1" }],
|
||||
columns: [{ default: "C1" }],
|
||||
shuffleOption: "none",
|
||||
isDraft: false,
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "qM",
|
||||
type: TSurveyElementTypeEnum.Matrix,
|
||||
headline: { default: "Matrix" },
|
||||
required: false,
|
||||
rows: [{ id: "r1", label: { default: "R1" } }],
|
||||
columns: [{ id: "c1", label: { default: "C1" } }],
|
||||
shuffleOption: "none",
|
||||
isDraft: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
type: "app",
|
||||
hiddenFields: { enabled: false, fieldIds: [] },
|
||||
createdAt: new Date(),
|
||||
@@ -360,34 +359,48 @@ describe("Response Utils", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Fix this test after the survey editor poc is merged
|
||||
describe("extractSurveyDetails", () => {
|
||||
const mockSurvey: Partial<TSurvey> = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Question 1" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "1", label: { default: "Option 1" } },
|
||||
{ id: "2", label: { default: "Option 2" } },
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Question 1" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "1", label: { default: "Option 1" } },
|
||||
{ id: "2", label: { default: "Option 2" } },
|
||||
],
|
||||
shuffleOption: "none",
|
||||
isDraft: false,
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyElementTypeEnum.Matrix,
|
||||
headline: { default: "Matrix Question" },
|
||||
required: true,
|
||||
rows: [
|
||||
{ id: "r1", label: { default: "Row 1" } },
|
||||
{ id: "r2", label: { default: "Row 2" } },
|
||||
],
|
||||
columns: [
|
||||
{ id: "c1", label: { default: "Column 1" } },
|
||||
{ id: "c2", label: { default: "Column 2" } },
|
||||
],
|
||||
shuffleOption: "none",
|
||||
isDraft: false,
|
||||
},
|
||||
],
|
||||
shuffleOption: "none",
|
||||
isDraft: false,
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Matrix Question" },
|
||||
required: true,
|
||||
rows: [{ default: "Row 1" }, { default: "Row 2" }],
|
||||
columns: [{ default: "Column 1" }, { default: "Column 2" }],
|
||||
shuffleOption: "none",
|
||||
isDraft: false,
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
type: "app",
|
||||
hiddenFields: { enabled: true, fieldIds: ["hidden1"] },
|
||||
createdAt: new Date(),
|
||||
@@ -424,20 +437,27 @@ describe("Response Utils", () => {
|
||||
const mockSurvey: Partial<TSurvey> = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Question 1" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "1", label: { default: "Option 1" } },
|
||||
{ id: "2", label: { default: "Option 2" } },
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Question 1" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "1", label: { default: "Option 1" } },
|
||||
{ id: "2", label: { default: "Option 2" } },
|
||||
],
|
||||
shuffleOption: "none",
|
||||
isDraft: false,
|
||||
},
|
||||
],
|
||||
shuffleOption: "none",
|
||||
isDraft: false,
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
type: "app",
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
createdAt: new Date(),
|
||||
@@ -690,9 +710,9 @@ describe("Response Utils", () => {
|
||||
});
|
||||
|
||||
describe("extractChoiceIdsFromResponse", () => {
|
||||
const multipleChoiceMultiQuestion: TSurveyQuestion = {
|
||||
const multipleChoiceMultiQuestion = {
|
||||
id: "multi-choice-id",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceMulti as typeof TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Select multiple options" },
|
||||
required: false,
|
||||
choices: [
|
||||
@@ -709,11 +729,12 @@ describe("extractChoiceIdsFromResponse", () => {
|
||||
label: { default: "Option 3", es: "Opción 3" },
|
||||
},
|
||||
],
|
||||
shuffleOption: "none" as const,
|
||||
};
|
||||
|
||||
const multipleChoiceSingleQuestion: TSurveyQuestion = {
|
||||
const multipleChoiceSingleQuestion = {
|
||||
id: "single-choice-id",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle as typeof TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Select one option" },
|
||||
required: false,
|
||||
choices: [
|
||||
@@ -726,14 +747,15 @@ describe("extractChoiceIdsFromResponse", () => {
|
||||
label: { default: "Choice B", fr: "Choix B" },
|
||||
},
|
||||
],
|
||||
shuffleOption: "none" as const,
|
||||
};
|
||||
|
||||
const textQuestion: TSurveyOpenTextQuestion = {
|
||||
const textQuestion = {
|
||||
id: "text-id",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
type: TSurveyElementTypeEnum.OpenText as typeof TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "What do you think?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
inputType: "text" as const,
|
||||
charLimit: { enabled: false, min: 0, max: 0 },
|
||||
};
|
||||
|
||||
|
||||
@@ -10,15 +10,16 @@ import {
|
||||
TSurveyMetaFieldFilter,
|
||||
} from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyPictureSelectionQuestion,
|
||||
TSurveyQuestion,
|
||||
TSurveyRankingQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
TSurveyElement,
|
||||
TSurveyMultipleChoiceElement,
|
||||
TSurveyPictureSelectionElement,
|
||||
TSurveyRankingElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { processResponseData } from "../responses";
|
||||
import { getTodaysDateTimeFormatted } from "../time";
|
||||
import { getFormattedDateTimeString } from "../utils/datetime";
|
||||
@@ -33,7 +34,7 @@ import { sanitizeString } from "../utils/strings";
|
||||
*/
|
||||
export const extractChoiceIdsFromResponse = (
|
||||
responseValue: TResponseDataValue,
|
||||
question: TSurveyQuestion,
|
||||
question: TSurveyElement,
|
||||
language: string = "default"
|
||||
): string[] => {
|
||||
// Type guard to ensure the question has choices
|
||||
@@ -92,7 +93,7 @@ export const extractChoiceIdsFromResponse = (
|
||||
|
||||
export const getChoiceIdByValue = (
|
||||
value: string,
|
||||
question: TSurveyMultipleChoiceQuestion | TSurveyRankingQuestion | TSurveyPictureSelectionQuestion
|
||||
question: TSurveyMultipleChoiceElement | TSurveyRankingElement | TSurveyPictureSelectionElement
|
||||
) => {
|
||||
if (question.type === "pictureSelection") {
|
||||
return question.choices.find((choice) => choice.imageUrl === value)?.id ?? "other";
|
||||
@@ -329,7 +330,8 @@ export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilt
|
||||
const data: Prisma.ResponseWhereInput[] = [];
|
||||
|
||||
Object.entries(filterCriteria.data).forEach(([key, val]) => {
|
||||
const question = survey.questions.find((question) => question.id === key);
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
const question = questions.find((question) => question.id === key);
|
||||
|
||||
switch (val.op) {
|
||||
case "submitted":
|
||||
@@ -663,7 +665,9 @@ export const extractSurveyDetails = (survey: TSurvey, responses: TResponse[]) =>
|
||||
const metaDataFields = responses.length > 0 ? extracMetadataKeys(responses[0].meta) : [];
|
||||
const modifiedSurvey = replaceHeadlineRecall(survey, "default");
|
||||
|
||||
const questions = modifiedSurvey.questions.map((question, idx) => {
|
||||
const modifiedQuestions = getElementsFromBlocks(modifiedSurvey.blocks);
|
||||
|
||||
const questions = modifiedQuestions.map((question, idx) => {
|
||||
const headline = getTextContent(getLocalizedValue(question.headline, "default")) ?? question.id;
|
||||
if (question.type === "matrix") {
|
||||
return question.rows.map((row) => {
|
||||
@@ -731,7 +735,8 @@ export const getResponsesJson = (
|
||||
// survey response data
|
||||
questionsHeadlines.forEach((questionHeadline) => {
|
||||
const questionIndex = parseInt(questionHeadline[0]) - 1;
|
||||
const question = survey?.questions[questionIndex];
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
const question = questions[questionIndex];
|
||||
const answer = response.data[question.id];
|
||||
|
||||
if (question.type === "matrix") {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyQuestionType, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { convertResponseValue, getQuestionResponseMapping, processResponseData } from "./responses";
|
||||
|
||||
// Mock the recall and i18n utils
|
||||
@@ -63,7 +63,7 @@ describe("Response Processing", () => {
|
||||
describe("convertResponseValue", () => {
|
||||
const mockOpenTextQuestion = {
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText as const,
|
||||
type: TSurveyElementTypeEnum.OpenText as const,
|
||||
headline: { default: "Test Question" },
|
||||
required: true,
|
||||
inputType: "text" as const,
|
||||
@@ -73,7 +73,7 @@ describe("Response Processing", () => {
|
||||
|
||||
const mockRankingQuestion = {
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.Ranking as const,
|
||||
type: TSurveyElementTypeEnum.Ranking as const,
|
||||
headline: { default: "Test Question" },
|
||||
required: true,
|
||||
choices: [
|
||||
@@ -85,7 +85,7 @@ describe("Response Processing", () => {
|
||||
|
||||
const mockFileUploadQuestion = {
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.FileUpload as const,
|
||||
type: TSurveyElementTypeEnum.FileUpload as const,
|
||||
headline: { default: "Test Question" },
|
||||
required: true,
|
||||
allowMultipleFiles: true,
|
||||
@@ -93,7 +93,7 @@ describe("Response Processing", () => {
|
||||
|
||||
const mockPictureSelectionQuestion = {
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection as const,
|
||||
type: TSurveyElementTypeEnum.PictureSelection as const,
|
||||
headline: { default: "Test Question" },
|
||||
required: true,
|
||||
allowMulti: false,
|
||||
@@ -184,28 +184,36 @@ describe("Response Processing", () => {
|
||||
name: "Test Survey",
|
||||
environmentId: "env1",
|
||||
createdBy: null,
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText as const,
|
||||
headline: { default: "Question 1" },
|
||||
required: true,
|
||||
inputType: "text" as const,
|
||||
longAnswer: false,
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti as const,
|
||||
headline: { default: "Question 2" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "1", label: { default: "Option 1" } },
|
||||
{ id: "2", label: { default: "Option 2" } },
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyElementTypeEnum.OpenText as const,
|
||||
headline: { default: "Question 1" },
|
||||
required: true,
|
||||
inputType: "text" as const,
|
||||
longAnswer: false,
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceMulti as const,
|
||||
headline: { default: "Question 2" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "1", label: { default: "Option 1" } },
|
||||
{ id: "2", label: { default: "Option 2" } },
|
||||
],
|
||||
shuffleOption: "none" as const,
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
],
|
||||
shuffleOption: "none" as const,
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
hiddenFields: {
|
||||
enabled: false,
|
||||
fieldIds: [],
|
||||
@@ -255,6 +263,7 @@ describe("Response Processing", () => {
|
||||
enabled: false,
|
||||
isEncrypted: false,
|
||||
},
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
@@ -291,12 +300,12 @@ describe("Response Processing", () => {
|
||||
expect(mapping[0]).toEqual({
|
||||
question: "Question 1",
|
||||
response: "Answer 1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
});
|
||||
expect(mapping[1]).toEqual({
|
||||
question: "Question 2",
|
||||
response: "Option 1; Option 2",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -334,17 +343,24 @@ describe("Response Processing", () => {
|
||||
test("should handle different language", () => {
|
||||
const survey = {
|
||||
...mockSurvey,
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText as const,
|
||||
headline: { default: "Question 1", en: "Question 1 EN" },
|
||||
required: true,
|
||||
inputType: "text" as const,
|
||||
longAnswer: false,
|
||||
charLimit: { enabled: false },
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyElementTypeEnum.OpenText as const,
|
||||
headline: { default: "Question 1", en: "Question 1 EN" },
|
||||
required: true,
|
||||
inputType: "text" as const,
|
||||
longAnswer: false,
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
languages: [
|
||||
{
|
||||
language: {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { TResponse, TResponseDataValue } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { getLanguageCode, getLocalizedValue } from "./i18n/utils";
|
||||
|
||||
// function to convert response value of type string | number | string[] or Record<string, string> to string | string[]
|
||||
export const convertResponseValue = (
|
||||
answer: TResponseDataValue,
|
||||
question: TSurveyQuestion
|
||||
question: TSurveyElement
|
||||
): string | string[] => {
|
||||
switch (question.type) {
|
||||
case "ranking":
|
||||
@@ -34,15 +36,17 @@ export const convertResponseValue = (
|
||||
export const getQuestionResponseMapping = (
|
||||
survey: TSurvey,
|
||||
response: TResponse
|
||||
): { question: string; response: string | string[]; type: TSurveyQuestionType }[] => {
|
||||
): { question: string; response: string | string[]; type: TSurveyElementTypeEnum }[] => {
|
||||
const questionResponseMapping: {
|
||||
question: string;
|
||||
response: string | string[];
|
||||
type: TSurveyQuestionType;
|
||||
type: TSurveyElementTypeEnum;
|
||||
}[] = [];
|
||||
const responseLanguageCode = getLanguageCode(survey.languages, response.language);
|
||||
|
||||
for (const question of survey.questions) {
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
for (const question of questions) {
|
||||
const answer = response.data[question.id];
|
||||
|
||||
questionResponseMapping.push({
|
||||
|
||||
@@ -4,12 +4,11 @@ import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyCreateInput,
|
||||
TSurveyLanguage,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
@@ -172,12 +171,12 @@ export const mockContactAttributeKey: TContactAttributeKey = {
|
||||
...commonMockProperties,
|
||||
};
|
||||
|
||||
const mockQuestion: TSurveyQuestion = {
|
||||
const mockQuestion = {
|
||||
id: mockId,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
type: TSurveyElementTypeEnum.OpenText as typeof TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Question Text", de: "Fragetext" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
inputType: "text" as const,
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
@@ -200,7 +199,14 @@ const baseSurveyProperties = {
|
||||
recontactDays: 3,
|
||||
displayLimit: 3,
|
||||
welcomeCard: mockWelcomeCard,
|
||||
questions: [mockQuestion],
|
||||
questions: [],
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [mockQuestion],
|
||||
},
|
||||
],
|
||||
isBackButtonHidden: false,
|
||||
endings: [
|
||||
{
|
||||
@@ -297,22 +303,22 @@ export const updateSurveyInput: TSurvey = {
|
||||
type: "link",
|
||||
status: "inProgress",
|
||||
displayOption: "respondMultiple",
|
||||
metadata: {},
|
||||
triggers: [{ actionClass: mockActionClass }],
|
||||
projectOverwrites: null,
|
||||
styling: null,
|
||||
recaptcha: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
displayPercentage: null,
|
||||
createdBy: null,
|
||||
pin: null,
|
||||
recaptcha: null,
|
||||
segment: null,
|
||||
languages: [],
|
||||
showLanguageSwitch: null,
|
||||
variables: [],
|
||||
followUps: [],
|
||||
metadata: {},
|
||||
...commonMockProperties,
|
||||
...baseSurveyProperties,
|
||||
...commonMockProperties,
|
||||
};
|
||||
|
||||
export const mockTransformedSurveyOutput = {
|
||||
@@ -331,16 +337,78 @@ export const mockSurveyWithLogic: TSurvey = {
|
||||
type: "link",
|
||||
endings: [],
|
||||
hiddenFields: { enabled: true, fieldIds: ["name"] },
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
inputType: "text",
|
||||
headline: { default: "What is your favorite color?" },
|
||||
required: true,
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
inputType: "text" as const,
|
||||
headline: { default: "What is your favorite color?" },
|
||||
required: true,
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
inputType: "text" as const,
|
||||
headline: { default: "What is your favorite food?" },
|
||||
required: true,
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "q3",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
inputType: "text" as const,
|
||||
headline: { default: "What is your favorite movie?" },
|
||||
required: true,
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "q4",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Select a number:" },
|
||||
choices: [
|
||||
{ id: "mvedaklp0gxxycprpyhhwen7", label: { default: "lol" } },
|
||||
{ id: "i7ws8uqyj66q5x086vbqtm8n", label: { default: "lmao" } },
|
||||
{ id: "cy8hbbr9e2q6ywbfjbzwdsqn", label: { default: "XD" } },
|
||||
{ id: "sojc5wwxc5gxrnuib30w7t6s", label: { default: "hehe" } },
|
||||
],
|
||||
required: true,
|
||||
shuffleOption: "none" as const,
|
||||
},
|
||||
{
|
||||
id: "q5",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
inputType: "number" as const,
|
||||
headline: { default: "Select your age group:" },
|
||||
required: true,
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "q6",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Select your age group:" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "mvedaklp0gxxycprpyhhwen7", label: { default: "lol" } },
|
||||
{ id: "i7ws8uqyj66q5x086vbqtm8n", label: { default: "lmao" } },
|
||||
{ id: "cy8hbbr9e2q6ywbfjbzwdsqn", label: { default: "XD" } },
|
||||
{ id: "sojc5wwxc5gxrnuib30w7t6s", label: { default: "hehe" } },
|
||||
],
|
||||
shuffleOption: "none" as const,
|
||||
},
|
||||
],
|
||||
logic: [
|
||||
{
|
||||
id: "cdu9vgtmmd9b24l35pp9bodk",
|
||||
@@ -358,18 +426,6 @@ export const mockSurveyWithLogic: TSurvey = {
|
||||
},
|
||||
actions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
inputType: "text",
|
||||
headline: { default: "What is your favorite food?" },
|
||||
required: true,
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
logic: [
|
||||
{
|
||||
id: "uwlm6kazj5pbt6licpa1hw5c",
|
||||
conditions: {
|
||||
@@ -392,18 +448,6 @@ export const mockSurveyWithLogic: TSurvey = {
|
||||
},
|
||||
actions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "q3",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
inputType: "text",
|
||||
headline: { default: "What is your favorite movie?" },
|
||||
required: true,
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
logic: [
|
||||
{
|
||||
id: "dpi3zipezuo1idplztb1abes",
|
||||
conditions: {
|
||||
@@ -426,20 +470,6 @@ export const mockSurveyWithLogic: TSurvey = {
|
||||
},
|
||||
actions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "q4",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Select a number:" },
|
||||
choices: [
|
||||
{ id: "mvedaklp0gxxycprpyhhwen7", label: { default: "lol" } },
|
||||
{ id: "i7ws8uqyj66q5x086vbqtm8n", label: { default: "lmao" } },
|
||||
{ id: "cy8hbbr9e2q6ywbfjbzwdsqn", label: { default: "XD" } },
|
||||
{ id: "sojc5wwxc5gxrnuib30w7t6s", label: { default: "hehe" } },
|
||||
],
|
||||
required: true,
|
||||
logic: [
|
||||
{
|
||||
id: "fbim31ttxe1s7qkrjzkj1mtc",
|
||||
conditions: {
|
||||
@@ -456,18 +486,6 @@ export const mockSurveyWithLogic: TSurvey = {
|
||||
},
|
||||
actions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "q5",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
inputType: "number",
|
||||
headline: { default: "Select your age group:" },
|
||||
required: true,
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
logic: [
|
||||
{
|
||||
id: "o6n73uq9rysih9mpcbzlehfs",
|
||||
conditions: {
|
||||
@@ -490,24 +508,10 @@ export const mockSurveyWithLogic: TSurvey = {
|
||||
},
|
||||
actions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "q6",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Select your age group:" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "mvedaklp0gxxycprpyhhwen7", label: { default: "lol" } },
|
||||
{ id: "i7ws8uqyj66q5x086vbqtm8n", label: { default: "lmao" } },
|
||||
{ id: "cy8hbbr9e2q6ywbfjbzwdsqn", label: { default: "XD" } },
|
||||
{ id: "sojc5wwxc5gxrnuib30w7t6s", label: { default: "hehe" } },
|
||||
],
|
||||
logic: [
|
||||
{
|
||||
id: "o6n73uq9rysih9mpcbzlehfs",
|
||||
id: "o6n73uq9rysih9mpcbzlehfs2",
|
||||
conditions: {
|
||||
id: "szdkmtz17j9008n4i2d1t040",
|
||||
id: "szdkmtz17j9008n4i2d1t041",
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
@@ -562,6 +566,7 @@ export const mockSurveyWithLogic: TSurvey = {
|
||||
],
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
variables: [
|
||||
{ id: "siog1dabtpo3l0a3xoxw2922", type: "text", name: "var1", value: "lmao" },
|
||||
{ id: "km1srr55owtn2r7lkoh5ny1u", type: "number", name: "var2", value: 32 },
|
||||
|
||||
@@ -67,7 +67,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
|
||||
mockSurveyWithLogic,
|
||||
data,
|
||||
variablesData,
|
||||
mockSurveyWithLogic.questions[0].logic![0].conditions,
|
||||
mockSurveyWithLogic.blocks[0].logic![0].conditions,
|
||||
"default"
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
@@ -81,7 +81,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
|
||||
mockSurveyWithLogic,
|
||||
data,
|
||||
variablesData,
|
||||
mockSurveyWithLogic.questions[0].logic![0].conditions,
|
||||
mockSurveyWithLogic.blocks[0].logic![0].conditions,
|
||||
"default"
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
@@ -95,7 +95,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
|
||||
mockSurveyWithLogic,
|
||||
data,
|
||||
variablesData,
|
||||
mockSurveyWithLogic.questions[1].logic![0].conditions,
|
||||
mockSurveyWithLogic.blocks[0].logic![1].conditions,
|
||||
"default"
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
@@ -109,7 +109,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
|
||||
mockSurveyWithLogic,
|
||||
data,
|
||||
variablesData,
|
||||
mockSurveyWithLogic.questions[1].logic![0].conditions,
|
||||
mockSurveyWithLogic.blocks[0].logic![1].conditions,
|
||||
"default"
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
@@ -123,7 +123,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
|
||||
mockSurveyWithLogic,
|
||||
data,
|
||||
variablesData,
|
||||
mockSurveyWithLogic.questions[2].logic![0].conditions,
|
||||
mockSurveyWithLogic.blocks[0].logic![2].conditions,
|
||||
"default"
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
@@ -137,7 +137,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
|
||||
mockSurveyWithLogic,
|
||||
data,
|
||||
variablesData,
|
||||
mockSurveyWithLogic.questions[3].logic![0].conditions,
|
||||
mockSurveyWithLogic.blocks[0].logic![3].conditions,
|
||||
"default"
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
@@ -151,7 +151,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
|
||||
mockSurveyWithLogic,
|
||||
data,
|
||||
variablesData,
|
||||
mockSurveyWithLogic.questions[3].logic![0].conditions,
|
||||
mockSurveyWithLogic.blocks[0].logic![3].conditions,
|
||||
"default"
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
@@ -165,7 +165,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
|
||||
mockSurveyWithLogic,
|
||||
data,
|
||||
variablesData,
|
||||
mockSurveyWithLogic.questions[4].logic![0].conditions,
|
||||
mockSurveyWithLogic.blocks[0].logic![4].conditions,
|
||||
"default"
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
@@ -179,7 +179,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
|
||||
mockSurveyWithLogic,
|
||||
data,
|
||||
variablesData,
|
||||
mockSurveyWithLogic.questions[4].logic![0].conditions,
|
||||
mockSurveyWithLogic.blocks[0].logic![4].conditions,
|
||||
"default"
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
@@ -193,7 +193,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
|
||||
mockSurveyWithLogic,
|
||||
data,
|
||||
variablesData,
|
||||
mockSurveyWithLogic.questions[5].logic![0].conditions,
|
||||
mockSurveyWithLogic.blocks[0].logic![5].conditions,
|
||||
"default"
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
|
||||
@@ -15,7 +15,13 @@ import { getActionClasses } from "../actionClass/service";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { capturePosthogEnvironmentEvent } from "../posthogServer";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils";
|
||||
import {
|
||||
checkForInvalidImagesInQuestions,
|
||||
checkForInvalidMediaInBlocks,
|
||||
stripIsDraftFromBlocks,
|
||||
transformPrismaSurvey,
|
||||
validateMediaAndPrepareBlocks,
|
||||
} from "./utils";
|
||||
|
||||
interface TriggerUpdate {
|
||||
create?: Array<{ actionClassId: string }>;
|
||||
@@ -37,6 +43,7 @@ export const selectSurvey = {
|
||||
status: true,
|
||||
welcomeCard: true,
|
||||
questions: true,
|
||||
blocks: true,
|
||||
endings: true,
|
||||
hiddenFields: true,
|
||||
variables: true,
|
||||
@@ -297,6 +304,14 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
|
||||
checkForInvalidImagesInQuestions(questions);
|
||||
|
||||
// Add blocks media validation
|
||||
if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
|
||||
const blocksValidation = checkForInvalidMediaInBlocks(updatedSurvey.blocks);
|
||||
if (!blocksValidation.ok) {
|
||||
throw new InvalidInputError(blocksValidation.error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (languages) {
|
||||
// Process languages update logic here
|
||||
// Extract currentLanguageIds and updatedLanguageIds
|
||||
@@ -504,6 +519,11 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
return rest;
|
||||
});
|
||||
|
||||
// Strip isDraft from elements before saving
|
||||
if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
|
||||
data.blocks = stripIsDraftFromBlocks(updatedSurvey.blocks);
|
||||
}
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("Organization", null);
|
||||
@@ -608,6 +628,11 @@ export const createSurvey = async (
|
||||
checkForInvalidImagesInQuestions(data.questions);
|
||||
}
|
||||
|
||||
// Validate and prepare blocks for persistence
|
||||
if (data.blocks && data.blocks.length > 0) {
|
||||
data.blocks = validateMediaAndPrepareBlocks(data.blocks);
|
||||
}
|
||||
|
||||
const survey = await prisma.survey.create({
|
||||
data: {
|
||||
...data,
|
||||
@@ -622,14 +647,6 @@ export const createSurvey = async (
|
||||
|
||||
// if the survey created is an "app" survey, we also create a private segment for it.
|
||||
if (survey.type === "app") {
|
||||
// const newSegment = await createSegment({
|
||||
// environmentId: parsedEnvironmentId,
|
||||
// surveyId: survey.id,
|
||||
// filters: [],
|
||||
// title: survey.id,
|
||||
// isPrivate: true,
|
||||
// });
|
||||
|
||||
const newSegment = await prisma.segment.create({
|
||||
data: {
|
||||
title: survey.id,
|
||||
|
||||
@@ -2,9 +2,17 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import * as videoValidation from "@/lib/utils/video-upload";
|
||||
import * as fileValidation from "@/modules/storage/utils";
|
||||
import { anySurveyHasFilters, checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils";
|
||||
import {
|
||||
anySurveyHasFilters,
|
||||
checkForInvalidImagesInQuestions,
|
||||
checkForInvalidMediaInBlocks,
|
||||
transformPrismaSurvey,
|
||||
} from "./utils";
|
||||
|
||||
describe("transformPrismaSurvey", () => {
|
||||
test("transforms prisma survey without segment", () => {
|
||||
@@ -252,3 +260,418 @@ describe("checkForInvalidImagesInQuestions", () => {
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(3, "image3.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkForInvalidMediaInBlocks", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("returns ok when blocks array is empty", () => {
|
||||
const blocks: TSurveyBlock[] = [];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("returns ok when blocks have no images", () => {
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "elem-1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Question" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("returns ok when all element images are valid", () => {
|
||||
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "elem-1",
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
headline: { default: "Question" },
|
||||
required: false,
|
||||
choices: [
|
||||
{ id: "c1", label: { default: "Option 1" }, imageUrl: "image1.jpg" },
|
||||
{ id: "c2", label: { default: "Option 2" }, imageUrl: "image2.jpg" },
|
||||
],
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image1.jpg");
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image2.jpg");
|
||||
});
|
||||
|
||||
test("returns error when element image is invalid", () => {
|
||||
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(false);
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Welcome Block",
|
||||
elements: [
|
||||
{
|
||||
id: "welcome",
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
headline: { default: "Welcome" },
|
||||
required: false,
|
||||
choices: [
|
||||
{ id: "c1", label: { default: "Option 1" }, imageUrl: "image1.jpg" },
|
||||
{ id: "c2", label: { default: "Option 2" }, imageUrl: "image2.jpg" },
|
||||
],
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
console.log(result.error);
|
||||
expect(result.error.message).toBe(
|
||||
'Invalid image URL in choice 1 of question 1 of block "Welcome Block"'
|
||||
);
|
||||
}
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image1.jpg");
|
||||
});
|
||||
|
||||
test("returns ok when all choice images are valid", () => {
|
||||
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Choice Block",
|
||||
elements: [
|
||||
{
|
||||
id: "choice-q",
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
headline: { default: "Pick one" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "c1", imageUrl: "image1.jpg" },
|
||||
{ id: "c2", imageUrl: "image2.jpg" },
|
||||
],
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(2);
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "image1.jpg");
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "image2.jpg");
|
||||
});
|
||||
|
||||
test("returns error when choice image is invalid", () => {
|
||||
vi.spyOn(fileValidation, "isValidImageFile").mockImplementation((url) => url === "valid.jpg");
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Picture Selection",
|
||||
elements: [
|
||||
{
|
||||
id: "pic-select",
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
headline: { default: "Select a picture" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "c1", imageUrl: "valid.jpg" },
|
||||
{ id: "c2", imageUrl: "invalid.txt" },
|
||||
],
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe(
|
||||
'Invalid image URL in choice 2 of question 1 of block "Picture Selection"'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns ok when video URL is valid (YouTube)", () => {
|
||||
vi.spyOn(videoValidation, "isValidVideoUrl").mockReturnValue(true);
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Video Block",
|
||||
elements: [
|
||||
{
|
||||
id: "video-q",
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
headline: { default: "Watch this" },
|
||||
required: false,
|
||||
videoUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(videoValidation.isValidVideoUrl).toHaveBeenCalledWith(
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns error when video URL is invalid (not YouTube/Vimeo/Loom)", () => {
|
||||
vi.spyOn(videoValidation, "isValidVideoUrl").mockReturnValue(false);
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Video Block",
|
||||
elements: [
|
||||
{
|
||||
id: "video-q",
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
headline: { default: "Watch this" },
|
||||
required: false,
|
||||
videoUrl: "https://example.com/video.mp4",
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain("Invalid video URL");
|
||||
expect(result.error.message).toContain("question 1");
|
||||
expect(result.error.message).toContain("YouTube, Vimeo, and Loom");
|
||||
}
|
||||
});
|
||||
|
||||
test("validates images across multiple blocks", () => {
|
||||
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "elem-1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q1" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
imageUrl: "image1.jpg",
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "block-2",
|
||||
name: "Block 2",
|
||||
elements: [
|
||||
{
|
||||
id: "elem-2",
|
||||
type: TSurveyElementTypeEnum.Rating,
|
||||
headline: { default: "Q2" },
|
||||
required: true,
|
||||
range: 5,
|
||||
scale: "star",
|
||||
imageUrl: "image2.jpg",
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(2);
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "image1.jpg");
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "image2.jpg");
|
||||
});
|
||||
|
||||
test("stops at first invalid image and returns specific error", () => {
|
||||
vi.spyOn(fileValidation, "isValidImageFile").mockImplementation((url) => url !== "bad-image.gif");
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "elem-1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q1" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
imageUrl: "good.jpg",
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "block-2",
|
||||
name: "Block 2",
|
||||
elements: [
|
||||
{
|
||||
id: "elem-2",
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
headline: { default: "Q2" },
|
||||
required: false,
|
||||
imageUrl: "bad-image.gif",
|
||||
} as unknown as TSurveyElement,
|
||||
{
|
||||
id: "elem-3",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q3" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
imageUrl: "another.jpg",
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe('Invalid image URL in question 1 of block "Block 2" (block 2)');
|
||||
}
|
||||
// Should stop after finding first invalid image
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("validates choices without imageUrl (skips gracefully)", () => {
|
||||
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Choice Block",
|
||||
elements: [
|
||||
{
|
||||
id: "mc-q",
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
headline: { default: "Pick one" },
|
||||
required: true,
|
||||
choices: [{ id: "c1", imageUrl: "image.jpg" }],
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
// Only validates the one with imageUrl
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(1);
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image.jpg");
|
||||
});
|
||||
|
||||
test("handles multiple elements in single block", () => {
|
||||
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Multi-Element Block",
|
||||
elements: [
|
||||
{
|
||||
id: "elem-1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q1" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
imageUrl: "img1.jpg",
|
||||
} as unknown as TSurveyElement,
|
||||
{
|
||||
id: "elem-2",
|
||||
type: TSurveyElementTypeEnum.Rating,
|
||||
headline: { default: "Q2" },
|
||||
required: true,
|
||||
range: 5,
|
||||
scale: "number",
|
||||
imageUrl: "img2.jpg",
|
||||
} as unknown as TSurveyElement,
|
||||
{
|
||||
id: "elem-3",
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
headline: { default: "Q3" },
|
||||
required: false,
|
||||
imageUrl: "img3.jpg",
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(3);
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "img1.jpg");
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "img2.jpg");
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(3, "img3.jpg");
|
||||
});
|
||||
|
||||
test("validates both element imageUrl and choice imageUrls", () => {
|
||||
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Complex Block",
|
||||
elements: [
|
||||
{
|
||||
id: "elem-1",
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
headline: { default: "Choose" },
|
||||
required: true,
|
||||
imageUrl: "element-image.jpg",
|
||||
choices: [
|
||||
{ id: "c1", imageUrl: "choice1.jpg" },
|
||||
{ id: "c2", imageUrl: "choice2.jpg" },
|
||||
],
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(3);
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "element-image.jpg");
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "choice1.jpg");
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(3, "choice2.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import "server-only";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import {
|
||||
TSurveyElement,
|
||||
TSurveyElementTypeEnum,
|
||||
TSurveyPictureChoice,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { isValidVideoUrl } from "@/lib/utils/video-upload";
|
||||
import { isValidImageFile } from "@/modules/storage/utils";
|
||||
|
||||
export const transformPrismaSurvey = <T extends TSurvey | TJsEnvironmentStateSurvey>(
|
||||
@@ -56,3 +64,188 @@ export const checkForInvalidImagesInQuestions = (questions: TSurveyQuestion[]) =
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a single choice's image URL
|
||||
* @param choice - Choice to validate
|
||||
* @param choiceIdx - Index of the choice for error reporting
|
||||
* @param questionIdx - Index of the question for error reporting
|
||||
* @param blockName - Block name for error reporting
|
||||
* @returns Result with void data on success or Error on failure
|
||||
*/
|
||||
const validateChoiceImage = (
|
||||
choice: TSurveyPictureChoice,
|
||||
choiceIdx: number,
|
||||
questionIdx: number,
|
||||
blockName: string
|
||||
): Result<void, Error> => {
|
||||
if (choice.imageUrl && !isValidImageFile(choice.imageUrl)) {
|
||||
return err(
|
||||
new Error(
|
||||
`Invalid image URL in choice ${choiceIdx + 1} of question ${questionIdx + 1} of block "${blockName}"`
|
||||
)
|
||||
);
|
||||
}
|
||||
return ok(undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates choice images for picture selection elements
|
||||
* Only picture selection questions have imageUrl in choices
|
||||
* @param element - Element with choices to validate
|
||||
* @param questionIdx - Index of the question for error reporting
|
||||
* @param blockName - Block name for error reporting
|
||||
* @returns Result with void data on success or Error on failure
|
||||
*/
|
||||
const validatePictureSelectionChoiceImages = (
|
||||
element: TSurveyElement,
|
||||
questionIdx: number,
|
||||
blockName: string
|
||||
): Result<void, Error> => {
|
||||
// Only validate choices for picture selection questions
|
||||
if (element.type !== TSurveyElementTypeEnum.PictureSelection) {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
if (!("choices" in element) || !Array.isArray(element.choices)) {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
for (let choiceIdx = 0; choiceIdx < element.choices.length; choiceIdx++) {
|
||||
const result = validateChoiceImage(element.choices[choiceIdx], choiceIdx, questionIdx, blockName);
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return ok(undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a single element's image URL, video URL, and picture selection choice images
|
||||
* @param element - Element to validate
|
||||
* @param elementIdx - Index of the element for error reporting
|
||||
* @param blockIdx - Index of the block for error reporting
|
||||
* @param blockName - Block name for error reporting
|
||||
* @returns Result with void data on success or Error on failure
|
||||
*/
|
||||
const validateElement = (
|
||||
element: TSurveyElement,
|
||||
elementIdx: number,
|
||||
blockIdx: number,
|
||||
blockName: string
|
||||
): Result<void, Error> => {
|
||||
// Check element imageUrl
|
||||
if (element.imageUrl && !isValidImageFile(element.imageUrl)) {
|
||||
return err(
|
||||
new Error(
|
||||
`Invalid image URL in question ${elementIdx + 1} of block "${blockName}" (block ${blockIdx + 1})`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check element videoUrl
|
||||
if (element.videoUrl && !isValidVideoUrl(element.videoUrl)) {
|
||||
return err(
|
||||
new Error(
|
||||
`Invalid video URL in question ${elementIdx + 1} of block "${blockName}" (block ${blockIdx + 1}). Only YouTube, Vimeo, and Loom URLs are supported.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check choices for picture selection
|
||||
return validatePictureSelectionChoiceImages(element, elementIdx, blockName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that all media URLs (images and videos) in blocks are valid
|
||||
* - Validates element imageUrl
|
||||
* - Validates element videoUrl
|
||||
* - Validates choice imageUrl for picture selection elements
|
||||
* @param blocks - Array of survey blocks to validate
|
||||
* @returns Result with void data on success or Error on failure
|
||||
*/
|
||||
export const checkForInvalidMediaInBlocks = (blocks: TSurveyBlock[]): Result<void, Error> => {
|
||||
for (let blockIdx = 0; blockIdx < blocks.length; blockIdx++) {
|
||||
const block = blocks[blockIdx];
|
||||
|
||||
for (let elementIdx = 0; elementIdx < block.elements.length; elementIdx++) {
|
||||
const result = validateElement(block.elements[elementIdx], elementIdx, blockIdx, block.name);
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ok(undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* Strips isDraft field from elements before saving to database
|
||||
* Note: Blocks don't have isDraft since block IDs are CUIDs (not user-editable)
|
||||
* Only element IDs need protection as they're user-editable and used in responses
|
||||
* @param blocks - Array of survey blocks
|
||||
* @returns New array with isDraft stripped from all elements
|
||||
*/
|
||||
export const stripIsDraftFromBlocks = (blocks: TSurveyBlock[]): TSurveyBlock[] => {
|
||||
return blocks.map((block) => ({
|
||||
...block,
|
||||
elements: block.elements.map((element) => {
|
||||
const { isDraft, ...elementRest } = element;
|
||||
return elementRest;
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates and prepares blocks for persistence
|
||||
* - Validates all media URLs (images and videos) in blocks
|
||||
* - Strips isDraft flags from elements
|
||||
* @param blocks - Array of survey blocks to validate and prepare
|
||||
* @returns Prepared blocks ready for database persistence
|
||||
* @throws Error if any media validation fails
|
||||
*/
|
||||
export const validateMediaAndPrepareBlocks = (blocks: TSurveyBlock[]): TSurveyBlock[] => {
|
||||
// Validate media (images and videos)
|
||||
const validation = checkForInvalidMediaInBlocks(blocks);
|
||||
if (!validation.ok) {
|
||||
throw validation.error;
|
||||
}
|
||||
|
||||
// Strip isDraft
|
||||
return stripIsDraftFromBlocks(blocks);
|
||||
};
|
||||
|
||||
/**
|
||||
* Derives a flat array of elements from the survey's blocks structure
|
||||
* Useful for server-side processing where we need to iterate over all questions
|
||||
* Note: This is duplicated from the client-side survey utils since this file is server-only
|
||||
* @param blocks - Array of survey blocks
|
||||
* @returns Flat array of all elements across all blocks
|
||||
*/
|
||||
export const getElementsFromBlocks = (blocks: TSurveyBlock[]): TSurveyElement[] => {
|
||||
return blocks.flatMap((block) => block.elements);
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the location of an element within the survey blocks
|
||||
* @param survey - The survey object
|
||||
* @param elementId - The ID of the element to find
|
||||
* @returns Object containing blockId, blockIndex, elementIndex and the block
|
||||
*/
|
||||
export const findElementLocation = (
|
||||
survey: TSurvey,
|
||||
elementId: string
|
||||
): { blockId: string | null; blockIndex: number; elementIndex: number; block: TSurveyBlock | null } => {
|
||||
const blocks = survey.blocks;
|
||||
|
||||
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
|
||||
const block = blocks[blockIndex];
|
||||
const elementIndex = block.elements.findIndex((e) => e.id === elementId);
|
||||
if (elementIndex !== -1) {
|
||||
return { blockId: block.id, blockIndex, elementIndex, block };
|
||||
}
|
||||
}
|
||||
|
||||
return { blockId: null, blockIndex: -1, elementIndex: -1, block: null };
|
||||
};
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
|
||||
import {
|
||||
TConditionGroup,
|
||||
TSingleCondition,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicAction,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TSurveyBlockLogic, TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TConditionGroup, TSingleCondition } from "@formbricks/types/surveys/logic";
|
||||
import { TSurveyLogicAction } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
addConditionBelow,
|
||||
createGroupFromResource,
|
||||
@@ -36,9 +33,6 @@ describe("surveyLogic", () => {
|
||||
type: "link",
|
||||
status: "inProgress",
|
||||
welcomeCard: {
|
||||
html: {
|
||||
default: "Thanks for providing your feedback - let's go!",
|
||||
},
|
||||
enabled: false,
|
||||
headline: {
|
||||
default: "Welcome!",
|
||||
@@ -49,25 +43,28 @@ describe("surveyLogic", () => {
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
},
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "vjniuob08ggl8dewl0hwed41",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: {
|
||||
default: "What would you like to know?",
|
||||
},
|
||||
required: true,
|
||||
charLimit: {},
|
||||
inputType: "email",
|
||||
longAnswer: false,
|
||||
buttonLabel: {
|
||||
default: "Next",
|
||||
},
|
||||
placeholder: {
|
||||
default: "example@email.com",
|
||||
},
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "vjniuob08ggl8dewl0hwed41",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: {
|
||||
default: "What would you like to know?",
|
||||
},
|
||||
required: true,
|
||||
charLimit: { enabled: false },
|
||||
inputType: "email",
|
||||
placeholder: {
|
||||
default: "example@email.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
endings: [
|
||||
{
|
||||
id: "gt1yoaeb5a3istszxqbl08mk",
|
||||
@@ -132,7 +129,7 @@ describe("surveyLogic", () => {
|
||||
});
|
||||
|
||||
test("duplicateLogicItem duplicates IDs recursively", () => {
|
||||
const logic: TSurveyLogic = {
|
||||
const logic: TSurveyBlockLogic = {
|
||||
id: "L1",
|
||||
conditions: simpleGroup(),
|
||||
actions: [{ id: "A1", objective: "requireAnswer", target: "q1" }],
|
||||
@@ -211,13 +208,13 @@ describe("surveyLogic", () => {
|
||||
});
|
||||
|
||||
test("getUpdatedActionBody returns new action bodies correctly", () => {
|
||||
const base: TSurveyLogicAction = { id: "A", objective: "requireAnswer", target: "q" };
|
||||
const base: TSurveyBlockLogicAction = { id: "A", objective: "requireAnswer", target: "q" };
|
||||
const calc = getUpdatedActionBody(base, "calculate");
|
||||
expect(calc.objective).toBe("calculate");
|
||||
const req = getUpdatedActionBody(calc, "requireAnswer");
|
||||
expect(req.objective).toBe("requireAnswer");
|
||||
const jump = getUpdatedActionBody(req, "jumpToQuestion");
|
||||
expect(jump.objective).toBe("jumpToQuestion");
|
||||
const jump = getUpdatedActionBody(req, "jumpToBlock");
|
||||
expect(jump.objective).toBe("jumpToBlock");
|
||||
});
|
||||
|
||||
test("evaluateLogic handles AND/OR groups and single conditions", () => {
|
||||
@@ -249,7 +246,7 @@ describe("surveyLogic", () => {
|
||||
test("performActions calculates, requires, and jumps correctly", () => {
|
||||
const data: TResponseData = { q: "5" };
|
||||
const initialVars: TResponseVariables = {};
|
||||
const actions: TSurveyLogicAction[] = [
|
||||
const actions: TSurveyBlockLogicAction[] = [
|
||||
{
|
||||
id: "a1",
|
||||
objective: "calculate",
|
||||
@@ -258,7 +255,7 @@ describe("surveyLogic", () => {
|
||||
value: { type: "static", value: 3 },
|
||||
},
|
||||
{ id: "a2", objective: "requireAnswer", target: "q2" },
|
||||
{ id: "a3", objective: "jumpToQuestion", target: "q3" },
|
||||
{ id: "a3", objective: "jumpToBlock", target: "q3" },
|
||||
];
|
||||
const result = performActions(mockSurvey, actions, data, initialVars);
|
||||
expect(result.calculations.v).toBe(3);
|
||||
@@ -463,7 +460,7 @@ describe("surveyLogic", () => {
|
||||
variables: [{ id: "v", name: "num", type: "number", value: 0 }],
|
||||
};
|
||||
const data: TResponseData = { q: 2 };
|
||||
const actions: TSurveyLogicAction[] = [
|
||||
const actions: TSurveyBlockLogicAction[] = [
|
||||
{
|
||||
id: "a1",
|
||||
objective: "calculate",
|
||||
@@ -750,84 +747,87 @@ describe("surveyLogic", () => {
|
||||
test("getLeftOperandValue handles different question types", () => {
|
||||
const surveyWithQuestions: TJsEnvironmentStateSurvey = {
|
||||
...mockSurvey,
|
||||
questions: [
|
||||
...mockSurvey.questions,
|
||||
blocks: [
|
||||
{
|
||||
id: "numQuestion",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Number question" },
|
||||
required: true,
|
||||
inputType: "number",
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
{
|
||||
id: "mcSingle",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "MC Single" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "choice1", label: { default: "Choice 1" } },
|
||||
{ id: "choice2", label: { default: "Choice 2" } },
|
||||
{ id: "other", label: { default: "Other" } },
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
...mockSurvey.blocks[0].elements,
|
||||
{
|
||||
id: "numQuestion",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Number question" },
|
||||
required: true,
|
||||
inputType: "number",
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
{
|
||||
id: "mcSingle",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "MC Single" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "choice1", label: { default: "Choice 1" } },
|
||||
{ id: "choice2", label: { default: "Choice 2" } },
|
||||
{ id: "other", label: { default: "Other" } },
|
||||
],
|
||||
shuffleOption: "none",
|
||||
},
|
||||
{
|
||||
id: "mcMulti",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "MC Multi" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "choice1", label: { default: "Choice 1" } },
|
||||
{ id: "choice2", label: { default: "Choice 2" } },
|
||||
],
|
||||
shuffleOption: "none",
|
||||
},
|
||||
{
|
||||
id: "matrixQ",
|
||||
type: TSurveyElementTypeEnum.Matrix,
|
||||
headline: { default: "Matrix Question" },
|
||||
required: true,
|
||||
rows: [
|
||||
{ id: "row-1", label: { default: "Row 1" } },
|
||||
{ id: "row-2", label: { default: "Row 2" } },
|
||||
],
|
||||
columns: [
|
||||
{ id: "col-1", label: { default: "Column 1" } },
|
||||
{ id: "col-2", label: { default: "Column 2" } },
|
||||
],
|
||||
shuffleOption: "none",
|
||||
},
|
||||
{
|
||||
id: "pictureQ",
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
allowMulti: false,
|
||||
headline: { default: "Picture Selection" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "pic1", imageUrl: "url1" },
|
||||
{ id: "pic2", imageUrl: "url2" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "dateQ",
|
||||
type: TSurveyElementTypeEnum.Date,
|
||||
format: "M-d-y",
|
||||
headline: { default: "Date Question" },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "fileQ",
|
||||
type: TSurveyElementTypeEnum.FileUpload,
|
||||
allowMultipleFiles: false,
|
||||
headline: { default: "File Upload" },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
{
|
||||
id: "mcMulti",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "MC Multi" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "choice1", label: { default: "Choice 1" } },
|
||||
{ id: "choice2", label: { default: "Choice 2" } },
|
||||
],
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
{
|
||||
id: "matrixQ",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Matrix Question" },
|
||||
required: true,
|
||||
rows: [
|
||||
{ id: "row-1", label: { default: "Row 1" } },
|
||||
{ id: "row-2", label: { default: "Row 2" } },
|
||||
],
|
||||
columns: [
|
||||
{ id: "col-1", label: { default: "Column 1" } },
|
||||
{ id: "col-2", label: { default: "Column 2" } },
|
||||
],
|
||||
buttonLabel: { default: "Next" },
|
||||
shuffleOption: "none",
|
||||
},
|
||||
{
|
||||
id: "pictureQ",
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
allowMulti: false,
|
||||
headline: { default: "Picture Selection" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "pic1", imageUrl: "url1" },
|
||||
{ id: "pic2", imageUrl: "url2" },
|
||||
],
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
{
|
||||
id: "dateQ",
|
||||
type: TSurveyQuestionTypeEnum.Date,
|
||||
format: "M-d-y",
|
||||
headline: { default: "Date Question" },
|
||||
required: true,
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
{
|
||||
id: "fileQ",
|
||||
type: TSurveyQuestionTypeEnum.FileUpload,
|
||||
allowMultipleFiles: false,
|
||||
headline: { default: "File Upload" },
|
||||
required: true,
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
variables: [
|
||||
{ id: "numVar", name: "numberVar", type: "number", value: 5 },
|
||||
{ id: "textVar", name: "textVar", type: "text", value: "hello" },
|
||||
@@ -1008,17 +1008,24 @@ describe("surveyLogic", () => {
|
||||
test("getRightOperandValue handles different data types and sources", () => {
|
||||
const surveyWithVars: TJsEnvironmentStateSurvey = {
|
||||
...mockSurvey,
|
||||
questions: [
|
||||
...mockSurvey.questions,
|
||||
blocks: [
|
||||
{
|
||||
id: "question1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Question 1" },
|
||||
required: true,
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
...mockSurvey.blocks[0].elements,
|
||||
{
|
||||
id: "question1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Question 1" },
|
||||
required: true,
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
variables: [
|
||||
{ id: "numVar", name: "numberVar", type: "number", value: 5 },
|
||||
{ id: "textVar", name: "textVar", type: "text", value: "hello" },
|
||||
@@ -1319,19 +1326,24 @@ describe("surveyLogic", () => {
|
||||
test("getLeftOperandValue handles number input type with non-number value", () => {
|
||||
const surveyWithNumberInput: TJsEnvironmentStateSurvey = {
|
||||
...mockSurvey,
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "numQuestion",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Number question" },
|
||||
required: true,
|
||||
inputType: "number",
|
||||
placeholder: { default: "Enter a number" },
|
||||
buttonLabel: { default: "Next" },
|
||||
longAnswer: false,
|
||||
charLimit: {},
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "numQuestion",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Number question" },
|
||||
required: true,
|
||||
inputType: "number",
|
||||
placeholder: { default: "Enter a number" },
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
};
|
||||
|
||||
const condition: TSingleCondition = {
|
||||
|
||||
@@ -2,17 +2,15 @@ import { createId } from "@paralleldrive/cuid2";
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
|
||||
import {
|
||||
TActionCalculate,
|
||||
TActionObjective,
|
||||
TConditionGroup,
|
||||
TSingleCondition,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicAction,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyVariable,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
TSurveyBlockLogic,
|
||||
TSurveyBlockLogicAction,
|
||||
TSurveyBlockLogicActionObjective,
|
||||
} from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TConditionGroup, TSingleCondition } from "@formbricks/types/surveys/logic";
|
||||
import { TActionCalculate, TSurveyLogicAction, TSurveyVariable } from "@formbricks/types/surveys/types";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
|
||||
type TCondition = TSingleCondition | TConditionGroup;
|
||||
|
||||
@@ -20,7 +18,7 @@ export const isConditionGroup = (condition: TCondition): condition is TCondition
|
||||
return (condition as TConditionGroup).connector !== undefined;
|
||||
};
|
||||
|
||||
export const duplicateLogicItem = (logicItem: TSurveyLogic): TSurveyLogic => {
|
||||
export const duplicateLogicItem = (logicItem: TSurveyBlockLogic): TSurveyBlockLogic => {
|
||||
const duplicateConditionGroup = (group: TConditionGroup): TConditionGroup => {
|
||||
return {
|
||||
...group,
|
||||
@@ -42,7 +40,7 @@ export const duplicateLogicItem = (logicItem: TSurveyLogic): TSurveyLogic => {
|
||||
};
|
||||
};
|
||||
|
||||
const duplicateAction = (action: TSurveyLogicAction): TSurveyLogicAction => {
|
||||
const duplicateAction = (action: TSurveyBlockLogicAction): TSurveyBlockLogicAction => {
|
||||
return {
|
||||
...action,
|
||||
id: createId(),
|
||||
@@ -198,9 +196,9 @@ export const updateCondition = (
|
||||
};
|
||||
|
||||
export const getUpdatedActionBody = (
|
||||
action: TSurveyLogicAction,
|
||||
objective: TActionObjective
|
||||
): TSurveyLogicAction => {
|
||||
action: TSurveyBlockLogicAction,
|
||||
objective: TSurveyBlockLogicActionObjective
|
||||
): TSurveyBlockLogicAction => {
|
||||
if (objective === action.objective) return action;
|
||||
switch (objective) {
|
||||
case "calculate":
|
||||
@@ -217,12 +215,14 @@ export const getUpdatedActionBody = (
|
||||
objective: "requireAnswer",
|
||||
target: "",
|
||||
};
|
||||
case "jumpToQuestion":
|
||||
case "jumpToBlock":
|
||||
return {
|
||||
id: action.id,
|
||||
objective: "jumpToQuestion",
|
||||
objective: "jumpToBlock",
|
||||
target: "",
|
||||
};
|
||||
default:
|
||||
return action;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -263,14 +263,17 @@ const evaluateSingleCondition = (
|
||||
condition.leftOperand,
|
||||
selectedLanguage
|
||||
);
|
||||
|
||||
let rightValue = condition.rightOperand
|
||||
? getRightOperandValue(localSurvey, data, variablesData, condition.rightOperand)
|
||||
: undefined;
|
||||
|
||||
let leftField: TSurveyQuestion | TSurveyVariable | string;
|
||||
const questions = getElementsFromBlocks(localSurvey.blocks);
|
||||
|
||||
let leftField: TSurveyElement | TSurveyVariable | string;
|
||||
|
||||
if (condition.leftOperand?.type === "question") {
|
||||
leftField = localSurvey.questions.find((q) => q.id === condition.leftOperand?.value) as TSurveyQuestion;
|
||||
leftField = questions.find((q) => q.id === condition.leftOperand?.value) ?? "";
|
||||
} else if (condition.leftOperand?.type === "variable") {
|
||||
leftField = localSurvey.variables.find((v) => v.id === condition.leftOperand?.value) as TSurveyVariable;
|
||||
} else if (condition.leftOperand?.type === "hiddenField") {
|
||||
@@ -279,12 +282,10 @@ const evaluateSingleCondition = (
|
||||
leftField = "";
|
||||
}
|
||||
|
||||
let rightField: TSurveyQuestion | TSurveyVariable | string;
|
||||
let rightField: TSurveyElement | TSurveyVariable | string;
|
||||
|
||||
if (condition.rightOperand?.type === "question") {
|
||||
rightField = localSurvey.questions.find(
|
||||
(q) => q.id === condition.rightOperand?.value
|
||||
) as TSurveyQuestion;
|
||||
rightField = questions.find((q) => q.id === condition.rightOperand?.value) ?? "";
|
||||
} else if (condition.rightOperand?.type === "variable") {
|
||||
rightField = localSurvey.variables.find(
|
||||
(v) => v.id === condition.rightOperand?.value
|
||||
@@ -307,7 +308,7 @@ const evaluateSingleCondition = (
|
||||
case "equals":
|
||||
if (condition.leftOperand.type === "question") {
|
||||
if (
|
||||
(leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date &&
|
||||
(leftField as TSurveyElement).type === TSurveyElementTypeEnum.Date &&
|
||||
typeof leftValue === "string" &&
|
||||
typeof rightValue === "string"
|
||||
) {
|
||||
@@ -318,12 +319,12 @@ const evaluateSingleCondition = (
|
||||
|
||||
// when left value is of openText, hiddenField, variable and right value is of multichoice
|
||||
if (condition.rightOperand?.type === "question") {
|
||||
if ((rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
|
||||
if ((rightField as TSurveyElement).type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
|
||||
if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) {
|
||||
return rightValue.includes(leftValue as string);
|
||||
} else return false;
|
||||
} else if (
|
||||
(rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date &&
|
||||
(rightField as TSurveyElement).type === TSurveyElementTypeEnum.Date &&
|
||||
typeof leftValue === "string" &&
|
||||
typeof rightValue === "string"
|
||||
) {
|
||||
@@ -342,7 +343,7 @@ const evaluateSingleCondition = (
|
||||
// when left value is of picture selection question and right value is its option
|
||||
if (
|
||||
condition.leftOperand.type === "question" &&
|
||||
(leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.PictureSelection &&
|
||||
(leftField as TSurveyElement).type === TSurveyElementTypeEnum.PictureSelection &&
|
||||
Array.isArray(leftValue) &&
|
||||
leftValue.length > 0 &&
|
||||
typeof rightValue === "string"
|
||||
@@ -353,7 +354,7 @@ const evaluateSingleCondition = (
|
||||
// when left value is of date question and right value is string
|
||||
if (
|
||||
condition.leftOperand.type === "question" &&
|
||||
(leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date &&
|
||||
(leftField as TSurveyElement).type === TSurveyElementTypeEnum.Date &&
|
||||
typeof leftValue === "string" &&
|
||||
typeof rightValue === "string"
|
||||
) {
|
||||
@@ -362,12 +363,12 @@ const evaluateSingleCondition = (
|
||||
|
||||
// when left value is of openText, hiddenField, variable and right value is of multichoice
|
||||
if (condition.rightOperand?.type === "question") {
|
||||
if ((rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
|
||||
if ((rightField as TSurveyElement).type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
|
||||
if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) {
|
||||
return !rightValue.includes(leftValue as string);
|
||||
} else return false;
|
||||
} else if (
|
||||
(rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date &&
|
||||
(rightField as TSurveyElement).type === TSurveyElementTypeEnum.Date &&
|
||||
typeof leftValue === "string" &&
|
||||
typeof rightValue === "string"
|
||||
) {
|
||||
@@ -398,7 +399,7 @@ const evaluateSingleCondition = (
|
||||
if (typeof leftValue === "string") {
|
||||
if (
|
||||
condition.leftOperand.type === "question" &&
|
||||
(leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.FileUpload &&
|
||||
(leftField as TSurveyElement).type === TSurveyElementTypeEnum.FileUpload &&
|
||||
leftValue
|
||||
) {
|
||||
return leftValue !== "skipped";
|
||||
@@ -511,7 +512,8 @@ const getLeftOperandValue = (
|
||||
) => {
|
||||
switch (leftOperand.type) {
|
||||
case "question":
|
||||
const currentQuestion = localSurvey.questions.find((q) => q.id === leftOperand.value);
|
||||
const questions = getElementsFromBlocks(localSurvey.blocks);
|
||||
const currentQuestion = questions.find((q) => q.id === leftOperand.value);
|
||||
if (!currentQuestion) return undefined;
|
||||
|
||||
const responseValue = data[leftOperand.value];
|
||||
@@ -623,7 +625,7 @@ const getRightOperandValue = (
|
||||
|
||||
export const performActions = (
|
||||
survey: TJsEnvironmentStateSurvey,
|
||||
actions: TSurveyLogicAction[],
|
||||
actions: TSurveyBlockLogicAction[] | TSurveyLogicAction[],
|
||||
data: TResponseData,
|
||||
calculationResults: TResponseVariables
|
||||
): {
|
||||
@@ -644,7 +646,7 @@ export const performActions = (
|
||||
case "requireAnswer":
|
||||
requiredQuestionIds.push(action.target);
|
||||
break;
|
||||
case "jumpToQuestion":
|
||||
case "jumpToBlock":
|
||||
if (!jumpTarget) {
|
||||
jumpTarget = action.target;
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ describe("recall utility functions", () => {
|
||||
const headline = { en: "How do you like #recall:product/fallback:ournbspproduct#?" };
|
||||
const survey = {
|
||||
id: "test-survey",
|
||||
questions: [{ id: "product", headline: { en: "Product Question" } }],
|
||||
blocks: [{ id: "b1", elements: [{ id: "product", headline: { en: "Product Question" } }] }],
|
||||
hiddenFields: { fieldIds: [] },
|
||||
variables: [],
|
||||
} as any;
|
||||
@@ -158,7 +158,7 @@ describe("recall utility functions", () => {
|
||||
const headline = { en: "Rate #recall:product/fallback:ournbspproduct#" };
|
||||
const survey = {
|
||||
id: "test-survey",
|
||||
questions: [{ id: "product", headline: { en: "Product Question" } }],
|
||||
blocks: [{ id: "b1", elements: [{ id: "product", headline: { en: "Product Question" } }] }],
|
||||
hiddenFields: { fieldIds: [] },
|
||||
variables: [],
|
||||
} as any;
|
||||
@@ -171,7 +171,7 @@ describe("recall utility functions", () => {
|
||||
const headline = { en: "Your email is #recall:email/fallback:notnbspprovided#" };
|
||||
const survey: TSurvey = {
|
||||
id: "test-survey",
|
||||
questions: [],
|
||||
blocks: [],
|
||||
hiddenFields: { fieldIds: ["email"] },
|
||||
variables: [],
|
||||
} as unknown as TSurvey;
|
||||
@@ -184,7 +184,7 @@ describe("recall utility functions", () => {
|
||||
const headline = { en: "Your plan is #recall:plan/fallback:unknown#" };
|
||||
const survey: TSurvey = {
|
||||
id: "test-survey",
|
||||
questions: [],
|
||||
blocks: [],
|
||||
hiddenFields: { fieldIds: [] },
|
||||
variables: [{ id: "plan", name: "Subscription Plan" }],
|
||||
} as unknown as TSurvey;
|
||||
@@ -207,7 +207,7 @@ describe("recall utility functions", () => {
|
||||
};
|
||||
const survey = {
|
||||
id: "test-survey",
|
||||
questions: [{ id: "inner", headline: { en: "Inner with @outer" } }],
|
||||
blocks: [{ id: "b1", elements: [{ id: "inner", headline: { en: "Inner with @outer" } }] }],
|
||||
hiddenFields: { fieldIds: [] },
|
||||
variables: [],
|
||||
} as any;
|
||||
@@ -241,41 +241,56 @@ describe("recall utility functions", () => {
|
||||
test("identifies question with empty fallback value", () => {
|
||||
const questionHeadline = { en: "Question with #recall:id1/fallback:# empty fallback" };
|
||||
const survey = {
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "q1",
|
||||
headline: questionHeadline,
|
||||
id: "b1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
headline: questionHeadline,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = checkForEmptyFallBackValue(survey, "en");
|
||||
expect(result).toBe(survey.questions[0]);
|
||||
expect(result).toBe(survey.blocks[0].elements[0]);
|
||||
});
|
||||
|
||||
test("identifies question with empty fallback in subheader", () => {
|
||||
const questionSubheader = { en: "Subheader with #recall:id1/fallback:# empty fallback" };
|
||||
const survey = {
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "q1",
|
||||
headline: { en: "Normal question" },
|
||||
subheader: questionSubheader,
|
||||
id: "b1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
headline: { en: "Normal question" },
|
||||
subheader: questionSubheader,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = checkForEmptyFallBackValue(survey, "en");
|
||||
expect(result).toBe(survey.questions[0]);
|
||||
expect(result).toBe(survey.blocks[0].elements[0]);
|
||||
});
|
||||
|
||||
test("returns null when no empty fallback values are found", () => {
|
||||
const questionHeadline = { en: "Question with #recall:id1/fallback:default# valid fallback" };
|
||||
const survey = {
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "q1",
|
||||
headline: questionHeadline,
|
||||
id: "b1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
headline: questionHeadline,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
@@ -288,16 +303,21 @@ describe("recall utility functions", () => {
|
||||
describe("replaceHeadlineRecall", () => {
|
||||
test("processes all questions in a survey", () => {
|
||||
const survey: TSurvey = {
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "q1",
|
||||
headline: { en: "Question with #recall:id1/fallback:default#" },
|
||||
id: "b1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
headline: { en: "Question with #recall:id1/fallback:default#" },
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
headline: { en: "Another with #recall:id2/fallback:other#" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
headline: { en: "Another with #recall:id2/fallback:other#" },
|
||||
},
|
||||
] as unknown as TSurveyQuestion[],
|
||||
],
|
||||
hiddenFields: { fieldIds: [] },
|
||||
variables: [],
|
||||
} as unknown as TSurvey;
|
||||
@@ -308,8 +328,8 @@ describe("recall utility functions", () => {
|
||||
|
||||
// Verify recallToHeadline was called for each question
|
||||
expect(result).not.toBe(survey); // Should be a clone
|
||||
expect(result.questions[0].headline).not.toEqual(survey.questions[0].headline);
|
||||
expect(result.questions[1].headline).not.toEqual(survey.questions[1].headline);
|
||||
expect(result.blocks[0].elements[0].headline).not.toEqual(survey.blocks[0].elements[0].headline);
|
||||
expect(result.blocks[0].elements[1].headline).not.toEqual(survey.blocks[0].elements[1].headline);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -317,10 +337,15 @@ describe("recall utility functions", () => {
|
||||
test("extracts recall items from text", () => {
|
||||
const text = "Text with #recall:id1/fallback:val1# and #recall:id2/fallback:val2#";
|
||||
const survey: TSurvey = {
|
||||
questions: [
|
||||
{ id: "id1", headline: { en: "Question One" } },
|
||||
{ id: "id2", headline: { en: "Question Two" } },
|
||||
] as unknown as TSurveyQuestion[],
|
||||
blocks: [
|
||||
{
|
||||
id: "b1",
|
||||
elements: [
|
||||
{ id: "id1", headline: { en: "Question One" } },
|
||||
{ id: "id2", headline: { en: "Question Two" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
hiddenFields: { fieldIds: [] },
|
||||
variables: [],
|
||||
} as unknown as TSurvey;
|
||||
@@ -339,7 +364,7 @@ describe("recall utility functions", () => {
|
||||
test("handles hidden fields in recall items", () => {
|
||||
const text = "Text with #recall:hidden1/fallback:val1#";
|
||||
const survey: TSurvey = {
|
||||
questions: [],
|
||||
blocks: [],
|
||||
hiddenFields: { fieldIds: ["hidden1"] },
|
||||
variables: [],
|
||||
} as unknown as TSurvey;
|
||||
@@ -354,7 +379,7 @@ describe("recall utility functions", () => {
|
||||
test("handles variables in recall items", () => {
|
||||
const text = "Text with #recall:var1/fallback:val1#";
|
||||
const survey: TSurvey = {
|
||||
questions: [],
|
||||
blocks: [],
|
||||
hiddenFields: { fieldIds: [] },
|
||||
variables: [{ id: "var1", name: "Variable One" }],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TResponseData, TResponseDataValue, TResponseVariables } from "@formbricks/types/responses";
|
||||
import { TI18nString, TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
|
||||
|
||||
export interface fallbacks {
|
||||
@@ -59,7 +62,8 @@ const getRecallItemLabel = <T extends TSurvey>(
|
||||
const isHiddenField = survey.hiddenFields.fieldIds?.includes(recallItemId);
|
||||
if (isHiddenField) return recallItemId;
|
||||
|
||||
const surveyQuestion = survey.questions.find((question) => question.id === recallItemId);
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
const surveyQuestion = questions.find((question) => question.id === recallItemId);
|
||||
if (surveyQuestion) {
|
||||
const headline = getLocalizedValue(surveyQuestion.headline, languageCode);
|
||||
// Strip HTML tags to prevent raw HTML from showing in nested recalls
|
||||
@@ -122,13 +126,14 @@ export const replaceRecallInfoWithUnderline = (label: string): string => {
|
||||
};
|
||||
|
||||
// Checks for survey questions with a "recall" pattern but no fallback value.
|
||||
export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): TSurveyQuestion | null => {
|
||||
export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): TSurveyElement | null => {
|
||||
const doesTextHaveRecall = (text: string) => {
|
||||
const recalls = text.match(/#recall:[^ ]+/g);
|
||||
return recalls?.some((recall) => !extractFallbackValue(recall));
|
||||
};
|
||||
|
||||
for (const question of survey.questions) {
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
for (const question of questions) {
|
||||
if (
|
||||
doesTextHaveRecall(getLocalizedValue(question.headline, language)) ||
|
||||
(question.subheader && doesTextHaveRecall(getLocalizedValue(question.subheader, language)))
|
||||
@@ -142,7 +147,8 @@ export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): T
|
||||
// Processes each question in a survey to ensure headlines are formatted correctly for recall and return the modified survey.
|
||||
export const replaceHeadlineRecall = <T extends TSurvey>(survey: T, language: string): T => {
|
||||
const modifiedSurvey = structuredClone(survey);
|
||||
modifiedSurvey.questions.forEach((question) => {
|
||||
const questions = getElementsFromBlocks(modifiedSurvey.blocks);
|
||||
questions.forEach((question) => {
|
||||
question.headline = recallToHeadline(question.headline, modifiedSurvey, false, language);
|
||||
});
|
||||
return modifiedSurvey;
|
||||
@@ -156,7 +162,8 @@ export const getRecallItems = (text: string, survey: TSurvey, languageCode: stri
|
||||
let recallItems: TSurveyRecallItem[] = [];
|
||||
ids.forEach((recallItemId) => {
|
||||
const isHiddenField = survey.hiddenFields.fieldIds?.includes(recallItemId);
|
||||
const isSurveyQuestion = survey.questions.find((question) => question.id === recallItemId);
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
const isSurveyQuestion = questions.find((question) => question.id === recallItemId);
|
||||
const isVariable = survey.variables.find((variable) => variable.id === recallItemId);
|
||||
|
||||
const recallItemLabel = getRecallItemLabel(recallItemId, survey, languageCode);
|
||||
|
||||
@@ -1,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 };
|
||||
};
|
||||
|
||||
@@ -124,3 +124,12 @@ export const convertToEmbedUrl = (url: string): string | undefined => {
|
||||
// If no supported platform found, return undefined
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates if a URL is from a supported video platform (YouTube, Vimeo, or Loom)
|
||||
* @param url - URL to validate
|
||||
* @returns true if URL is from a supported platform, false otherwise
|
||||
*/
|
||||
export const isValidVideoUrl = (url: string): boolean => {
|
||||
return checkForYoutubeUrl(url) || checkForVimeoUrl(url) || checkForLoomUrl(url);
|
||||
};
|
||||
|
||||
@@ -1188,9 +1188,9 @@
|
||||
"add": "+ hinzufügen",
|
||||
"add_a_delay_or_auto_close_the_survey": "Füge eine Verzögerung hinzu oder schließe die Umfrage automatisch.",
|
||||
"add_a_four_digit_pin": "Füge eine vierstellige PIN hinzu",
|
||||
"add_a_new_question_to_your_survey": "Neue Frage hinzufügen",
|
||||
"add_a_variable_to_calculate": "Variable hinzufügen",
|
||||
"add_action_below": "Aktion unten hinzufügen",
|
||||
"add_block": "Block hinzufügen",
|
||||
"add_choice_below": "Auswahl unten hinzufügen",
|
||||
"add_color_coding": "Farbkodierung hinzufügen",
|
||||
"add_color_coding_description": "Füge rote, orange und grüne Farbcodes zu den Optionen hinzu.",
|
||||
@@ -1211,7 +1211,6 @@
|
||||
"add_other": "Anderes hinzufügen",
|
||||
"add_photo_or_video": "Foto oder Video hinzufügen",
|
||||
"add_pin": "PIN hinzufügen",
|
||||
"add_question": "Frage hinzufügen",
|
||||
"add_question_below": "Frage unten hinzufügen",
|
||||
"add_row": "Zeile hinzufügen",
|
||||
"add_variable": "Variable hinzufügen",
|
||||
@@ -1239,6 +1238,8 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Umfrage automatisch als abgeschlossen markieren nach",
|
||||
"back_button_label": "Zurück\"- Button ",
|
||||
"background_styling": "Hintergründe",
|
||||
"block_deleted": "Block gelöscht.",
|
||||
"block_duplicated": "Block dupliziert.",
|
||||
"bold": "Fett",
|
||||
"brand_color": "Markenfarbe",
|
||||
"brightness": "Helligkeit",
|
||||
@@ -1283,6 +1284,7 @@
|
||||
"character_limit_toggle_title": "Fügen Sie Zeichenbeschränkungen hinzu",
|
||||
"checkbox_label": "Checkbox-Beschriftung",
|
||||
"choose_the_actions_which_trigger_the_survey": "Aktionen auswählen, die die Umfrage auslösen.",
|
||||
"choose_the_first_question_on_your_block": "Wählen sie die erste frage in ihrem block",
|
||||
"choose_where_to_run_the_survey": "Wähle, wo die Umfrage durchgeführt werden soll.",
|
||||
"city": "Stadt",
|
||||
"close_survey_on_response_limit": "Umfrage bei Erreichen des Antwortlimits schließen",
|
||||
@@ -1311,6 +1313,7 @@
|
||||
"date_format": "Datumsformat",
|
||||
"days_before_showing_this_survey_again": "Tage, bevor diese Umfrage erneut angezeigt wird.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Entscheide, wie oft Leute diese Umfrage beantworten können.",
|
||||
"delete_block": "Block löschen",
|
||||
"delete_choice": "Auswahl löschen",
|
||||
"disable_the_visibility_of_survey_progress": "Deaktiviere die Sichtbarkeit des Umfragefortschritts.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Zeige eine Schätzung der Fertigstellungszeit für die Umfrage an",
|
||||
@@ -1322,6 +1325,7 @@
|
||||
"does_not_include_all_of": "Enthält nicht alle von",
|
||||
"does_not_include_one_of": "Enthält nicht eines von",
|
||||
"does_not_start_with": "Fängt nicht an mit",
|
||||
"duplicate_block": "Block duplizieren",
|
||||
"edit_link": "Bearbeitungslink",
|
||||
"edit_recall": "Erinnerung bearbeiten",
|
||||
"edit_translations": "{lang} -Übersetzungen bearbeiten",
|
||||
@@ -1619,6 +1623,7 @@
|
||||
"unlock_targeting_title": "Targeting mit einem höheren Plan freischalten",
|
||||
"unsaved_changes_warning": "Du hast ungespeicherte Änderungen in deiner Umfrage. Möchtest Du sie speichern, bevor Du gehst?",
|
||||
"until_they_submit_a_response": "Bis sie eine Antwort einreichen",
|
||||
"untitled_block": "Unbenannter Block",
|
||||
"upgrade_notice_description": "Erstelle mehrsprachige Umfragen und entdecke viele weitere Funktionen",
|
||||
"upgrade_notice_title": "Schalte mehrsprachige Umfragen mit einem höheren Plan frei",
|
||||
"upload": "Hochladen",
|
||||
@@ -2247,6 +2252,7 @@
|
||||
"csat_survey_question_3_headline": "Ugh, sorry! Können wir irgendwas tun, um deine Erfahrung zu verbessern?",
|
||||
"csat_survey_question_3_placeholder": "Tippe deine Antwort hier...",
|
||||
"cta_description": "Information anzeigen und Benutzer auffordern, eine bestimmte Aktion auszuführen",
|
||||
"custom_survey_block_1_name": "Block 1",
|
||||
"custom_survey_description": "Erstelle eine Umfrage ohne Vorlage.",
|
||||
"custom_survey_name": "Eigene Umfrage erstellen",
|
||||
"custom_survey_question_1_headline": "Was möchtest Du wissen?",
|
||||
@@ -2656,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",
|
||||
|
||||
@@ -1188,9 +1188,9 @@
|
||||
"add": "Add +",
|
||||
"add_a_delay_or_auto_close_the_survey": "Add a delay or auto-close the survey",
|
||||
"add_a_four_digit_pin": "Add a four digit PIN",
|
||||
"add_a_new_question_to_your_survey": "Add a new question to your survey",
|
||||
"add_a_variable_to_calculate": "Add a variable to calculate",
|
||||
"add_action_below": "Add action below",
|
||||
"add_block": "Add Block",
|
||||
"add_choice_below": "Add choice below",
|
||||
"add_color_coding": "Add color coding",
|
||||
"add_color_coding_description": "Add red, orange and green color codes to the options.",
|
||||
@@ -1211,7 +1211,6 @@
|
||||
"add_other": "Add \"Other\"",
|
||||
"add_photo_or_video": "Add photo or video",
|
||||
"add_pin": "Add PIN",
|
||||
"add_question": "Add question",
|
||||
"add_question_below": "Add question below",
|
||||
"add_row": "Add row",
|
||||
"add_variable": "Add variable",
|
||||
@@ -1239,6 +1238,8 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Automatically mark the survey as complete after",
|
||||
"back_button_label": "\"Back\" Button Label",
|
||||
"background_styling": "Background Styling",
|
||||
"block_deleted": "Block deleted.",
|
||||
"block_duplicated": "Block duplicated.",
|
||||
"bold": "Bold",
|
||||
"brand_color": "Brand color",
|
||||
"brightness": "Brightness",
|
||||
@@ -1283,6 +1284,7 @@
|
||||
"character_limit_toggle_title": "Add character limits",
|
||||
"checkbox_label": "Checkbox Label",
|
||||
"choose_the_actions_which_trigger_the_survey": "Choose the actions which trigger the survey.",
|
||||
"choose_the_first_question_on_your_block": "Choose the first question on your Block",
|
||||
"choose_where_to_run_the_survey": "Choose where to run the survey.",
|
||||
"city": "City",
|
||||
"close_survey_on_response_limit": "Close survey on response limit",
|
||||
@@ -1311,6 +1313,7 @@
|
||||
"date_format": "Date format",
|
||||
"days_before_showing_this_survey_again": "days before showing this survey again.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decide how often people can answer this survey.",
|
||||
"delete_block": "Delete block",
|
||||
"delete_choice": "Delete choice",
|
||||
"disable_the_visibility_of_survey_progress": "Disable the visibility of survey progress.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Display an estimate of completion time for survey",
|
||||
@@ -1322,6 +1325,7 @@
|
||||
"does_not_include_all_of": "Does not include all of",
|
||||
"does_not_include_one_of": "Does not include one of",
|
||||
"does_not_start_with": "Does not start with",
|
||||
"duplicate_block": "Duplicate block",
|
||||
"edit_link": "Edit link",
|
||||
"edit_recall": "Edit Recall",
|
||||
"edit_translations": "Edit {lang} translations",
|
||||
@@ -1619,6 +1623,7 @@
|
||||
"unlock_targeting_title": "Unlock targeting with a higher plan",
|
||||
"unsaved_changes_warning": "You have unsaved changes in your survey. Would you like to save them before leaving?",
|
||||
"until_they_submit_a_response": "Until they submit a response",
|
||||
"untitled_block": "Untitled Block",
|
||||
"upgrade_notice_description": "Create multilingual surveys and unlock many more features",
|
||||
"upgrade_notice_title": "Unlock multi-language surveys with a higher plan",
|
||||
"upload": "Upload",
|
||||
@@ -2247,6 +2252,7 @@
|
||||
"csat_survey_question_3_headline": "Ugh, sorry! Is there anything we can do to improve your experience?",
|
||||
"csat_survey_question_3_placeholder": "Type your answer here...",
|
||||
"cta_description": "Display information and prompt users to take a specific action",
|
||||
"custom_survey_block_1_name": "Block 1",
|
||||
"custom_survey_description": "Create a survey without template.",
|
||||
"custom_survey_name": "Start from scratch",
|
||||
"custom_survey_question_1_headline": "What would you like to know?",
|
||||
@@ -2656,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",
|
||||
|
||||
@@ -1188,9 +1188,9 @@
|
||||
"add": "Ajouter +",
|
||||
"add_a_delay_or_auto_close_the_survey": "Ajouter un délai ou fermer automatiquement l'enquête",
|
||||
"add_a_four_digit_pin": "Ajoutez un code PIN à quatre chiffres.",
|
||||
"add_a_new_question_to_your_survey": "Ajouter une nouvelle question à votre enquête",
|
||||
"add_a_variable_to_calculate": "Ajouter une variable à calculer",
|
||||
"add_action_below": "Ajouter une action ci-dessous",
|
||||
"add_block": "Ajouter un bloc",
|
||||
"add_choice_below": "Ajouter une option ci-dessous",
|
||||
"add_color_coding": "Ajouter un code couleur",
|
||||
"add_color_coding_description": "Ajoutez des codes de couleur rouge, orange et vert aux options.",
|
||||
@@ -1211,7 +1211,6 @@
|
||||
"add_other": "Ajouter \"Autre",
|
||||
"add_photo_or_video": "Ajouter une photo ou une vidéo",
|
||||
"add_pin": "Ajouter un code PIN",
|
||||
"add_question": "Ajouter une question",
|
||||
"add_question_below": "Ajouter une question ci-dessous",
|
||||
"add_row": "Ajouter une ligne",
|
||||
"add_variable": "Ajouter une variable",
|
||||
@@ -1239,6 +1238,8 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Marquer automatiquement l'enquête comme terminée après",
|
||||
"back_button_label": "Label du bouton \"Retour''",
|
||||
"background_styling": "Style de fond",
|
||||
"block_deleted": "Bloc supprimé.",
|
||||
"block_duplicated": "Bloc dupliqué.",
|
||||
"bold": "Gras",
|
||||
"brand_color": "Couleur de marque",
|
||||
"brightness": "Luminosité",
|
||||
@@ -1283,6 +1284,7 @@
|
||||
"character_limit_toggle_title": "Ajouter des limites de caractères",
|
||||
"checkbox_label": "Étiquette de case à cocher",
|
||||
"choose_the_actions_which_trigger_the_survey": "Choisissez les actions qui déclenchent l'enquête.",
|
||||
"choose_the_first_question_on_your_block": "Choisissez la première question de votre bloc",
|
||||
"choose_where_to_run_the_survey": "Choisissez où réaliser l'enquête.",
|
||||
"city": "Ville",
|
||||
"close_survey_on_response_limit": "Fermer l'enquête sur la limite de réponse",
|
||||
@@ -1311,6 +1313,7 @@
|
||||
"date_format": "Format de date",
|
||||
"days_before_showing_this_survey_again": "jours avant de montrer à nouveau cette enquête.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Décidez à quelle fréquence les gens peuvent répondre à cette enquête.",
|
||||
"delete_block": "Supprimer le bloc",
|
||||
"delete_choice": "Supprimer l'option",
|
||||
"disable_the_visibility_of_survey_progress": "Désactiver la visibilité de la progression du sondage.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Afficher une estimation du temps de complétion pour l'enquête.",
|
||||
@@ -1322,6 +1325,7 @@
|
||||
"does_not_include_all_of": "n'inclut pas tout",
|
||||
"does_not_include_one_of": "n'inclut pas un de",
|
||||
"does_not_start_with": "Ne commence pas par",
|
||||
"duplicate_block": "Dupliquer le bloc",
|
||||
"edit_link": "Modifier le lien",
|
||||
"edit_recall": "Modifier le rappel",
|
||||
"edit_translations": "Modifier les traductions {lang}",
|
||||
@@ -1619,6 +1623,7 @@
|
||||
"unlock_targeting_title": "Débloquez le ciblage avec un plan supérieur.",
|
||||
"unsaved_changes_warning": "Vous avez des modifications non enregistrées dans votre enquête. Souhaitez-vous les enregistrer avant de partir ?",
|
||||
"until_they_submit_a_response": "Jusqu'à ce qu'ils soumettent une réponse",
|
||||
"untitled_block": "Bloc sans titre",
|
||||
"upgrade_notice_description": "Créez des sondages multilingues et débloquez de nombreuses autres fonctionnalités",
|
||||
"upgrade_notice_title": "Débloquez les sondages multilingues avec un plan supérieur",
|
||||
"upload": "Télécharger",
|
||||
@@ -2247,6 +2252,7 @@
|
||||
"csat_survey_question_3_headline": "Ah, désolé ! Y a-t-il quelque chose que nous puissions faire pour améliorer votre expérience ?",
|
||||
"csat_survey_question_3_placeholder": "Entrez votre réponse ici...",
|
||||
"cta_description": "Afficher des informations et inciter les utilisateurs à effectuer une action spécifique",
|
||||
"custom_survey_block_1_name": "Bloc 1",
|
||||
"custom_survey_description": "Créez une enquête sans utiliser de modèle.",
|
||||
"custom_survey_name": "Tout créer moi-même",
|
||||
"custom_survey_question_1_headline": "Que voudriez-vous savoir ?",
|
||||
@@ -2656,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",
|
||||
|
||||
@@ -1188,9 +1188,9 @@
|
||||
"add": "追加 +",
|
||||
"add_a_delay_or_auto_close_the_survey": "遅延を追加するか、フォームを自動的に閉じる",
|
||||
"add_a_four_digit_pin": "4桁のPINを追加",
|
||||
"add_a_new_question_to_your_survey": "フォームに新しい質問を追加",
|
||||
"add_a_variable_to_calculate": "計算する変数を追加",
|
||||
"add_action_below": "以下にアクションを追加",
|
||||
"add_block": "ブロックを追加",
|
||||
"add_choice_below": "以下に選択肢を追加",
|
||||
"add_color_coding": "色分けを追加",
|
||||
"add_color_coding_description": "オプションに赤、オレンジ、緑の色コードを追加します。",
|
||||
@@ -1211,7 +1211,6 @@
|
||||
"add_other": "「その他」を追加",
|
||||
"add_photo_or_video": "写真または動画を追加",
|
||||
"add_pin": "PINを追加",
|
||||
"add_question": "質問を追加",
|
||||
"add_question_below": "以下に質問を追加",
|
||||
"add_row": "行を追加",
|
||||
"add_variable": "変数を追加",
|
||||
@@ -1239,6 +1238,8 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "フォームを自動的に完了としてマークする",
|
||||
"back_button_label": "「戻る」ボタンのラベル",
|
||||
"background_styling": "背景のスタイル",
|
||||
"block_deleted": "ブロックが削除されました。",
|
||||
"block_duplicated": "ブロックが複製されました。",
|
||||
"bold": "太字",
|
||||
"brand_color": "ブランドカラー",
|
||||
"brightness": "明るさ",
|
||||
@@ -1283,6 +1284,7 @@
|
||||
"character_limit_toggle_title": "文字数制限を追加",
|
||||
"checkbox_label": "チェックボックスのラベル",
|
||||
"choose_the_actions_which_trigger_the_survey": "フォームをトリガーするアクションを選択してください。",
|
||||
"choose_the_first_question_on_your_block": "ブロックの最初の質問を選択してください",
|
||||
"choose_where_to_run_the_survey": "フォームを実行する場所を選択してください。",
|
||||
"city": "市区町村",
|
||||
"close_survey_on_response_limit": "回答数の上限でフォームを閉じる",
|
||||
@@ -1311,6 +1313,7 @@
|
||||
"date_format": "日付形式",
|
||||
"days_before_showing_this_survey_again": "日後にこのフォームを再度表示します。",
|
||||
"decide_how_often_people_can_answer_this_survey": "このフォームに人々が何回回答できるかを決定します。",
|
||||
"delete_block": "ブロックを削除",
|
||||
"delete_choice": "選択肢を削除",
|
||||
"disable_the_visibility_of_survey_progress": "フォームの進捗状況の表示を無効にする。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "フォームの完了時間の目安を表示",
|
||||
@@ -1322,6 +1325,7 @@
|
||||
"does_not_include_all_of": "のすべてを含まない",
|
||||
"does_not_include_one_of": "のいずれも含まない",
|
||||
"does_not_start_with": "で始まらない",
|
||||
"duplicate_block": "ブロックを複製",
|
||||
"edit_link": "編集 リンク",
|
||||
"edit_recall": "リコールを編集",
|
||||
"edit_translations": "{lang} 翻訳を編集",
|
||||
@@ -1619,6 +1623,7 @@
|
||||
"unlock_targeting_title": "上位プランでターゲティングをアンロック",
|
||||
"unsaved_changes_warning": "フォームに未保存の変更があります。離れる前に保存しますか?",
|
||||
"until_they_submit_a_response": "回答を送信するまで",
|
||||
"untitled_block": "無題のブロック",
|
||||
"upgrade_notice_description": "多言語フォームを作成し、さらに多くの機能をアンロック",
|
||||
"upgrade_notice_title": "上位プランで多言語フォームをアンロック",
|
||||
"upload": "アップロード",
|
||||
@@ -2247,6 +2252,7 @@
|
||||
"csat_survey_question_3_headline": "申し訳ありません!体験を改善するために何かできることはありますか?",
|
||||
"csat_survey_question_3_placeholder": "ここに回答を入力してください...",
|
||||
"cta_description": "情報を表示し、特定の行動を促す",
|
||||
"custom_survey_block_1_name": "ブロック1",
|
||||
"custom_survey_description": "テンプレートを使わずにアンケートを作成する。",
|
||||
"custom_survey_name": "最初から始める",
|
||||
"custom_survey_question_1_headline": "何を知りたいですか?",
|
||||
@@ -2656,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",
|
||||
|
||||
@@ -1188,9 +1188,9 @@
|
||||
"add": "Adicionar +",
|
||||
"add_a_delay_or_auto_close_the_survey": "Adicione um atraso ou feche a pesquisa automaticamente",
|
||||
"add_a_four_digit_pin": "Adicione um PIN de quatro dígitos",
|
||||
"add_a_new_question_to_your_survey": "Adicionar uma nova pergunta à sua pesquisa",
|
||||
"add_a_variable_to_calculate": "Adicione uma variável para calcular",
|
||||
"add_action_below": "Adicionar ação abaixo",
|
||||
"add_block": "Adicionar bloco",
|
||||
"add_choice_below": "Adicionar opção abaixo",
|
||||
"add_color_coding": "Adicionar codificação por cores",
|
||||
"add_color_coding_description": "Adicione os códigos de cores vermelho, laranja e verde às opções.",
|
||||
@@ -1211,7 +1211,6 @@
|
||||
"add_other": "Adicionar \"Outro",
|
||||
"add_photo_or_video": "Adicionar foto ou video",
|
||||
"add_pin": "Adicionar PIN",
|
||||
"add_question": "Adicionar pergunta",
|
||||
"add_question_below": "Adicione a pergunta abaixo",
|
||||
"add_row": "Adicionar linha",
|
||||
"add_variable": "Adicionar variável",
|
||||
@@ -1239,6 +1238,8 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente a pesquisa como concluída após",
|
||||
"back_button_label": "Voltar",
|
||||
"background_styling": "Estilo de Fundo",
|
||||
"block_deleted": "Bloco excluído.",
|
||||
"block_duplicated": "Bloco duplicado.",
|
||||
"bold": "Negrito",
|
||||
"brand_color": "Cor da marca",
|
||||
"brightness": "brilho",
|
||||
@@ -1283,6 +1284,7 @@
|
||||
"character_limit_toggle_title": "Adicionar limites de caracteres",
|
||||
"checkbox_label": "Rótulo da Caixa de Seleção",
|
||||
"choose_the_actions_which_trigger_the_survey": "Escolha as ações que disparam a pesquisa.",
|
||||
"choose_the_first_question_on_your_block": "Escolha a primeira pergunta do seu bloco",
|
||||
"choose_where_to_run_the_survey": "Escolha onde realizar a pesquisa.",
|
||||
"city": "cidade",
|
||||
"close_survey_on_response_limit": "Fechar pesquisa ao atingir limite de respostas",
|
||||
@@ -1311,6 +1313,7 @@
|
||||
"date_format": "Formato de data",
|
||||
"days_before_showing_this_survey_again": "dias antes de mostrar essa pesquisa de novo.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decida com que frequência as pessoas podem responder a essa pesquisa.",
|
||||
"delete_block": "Excluir bloco",
|
||||
"delete_choice": "Deletar opção",
|
||||
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa de tempo de conclusão da pesquisa",
|
||||
@@ -1322,6 +1325,7 @@
|
||||
"does_not_include_all_of": "Não inclui todos de",
|
||||
"does_not_include_one_of": "Não inclui um de",
|
||||
"does_not_start_with": "Não começa com",
|
||||
"duplicate_block": "Duplicar bloco",
|
||||
"edit_link": "Editar link",
|
||||
"edit_recall": "Editar Lembrete",
|
||||
"edit_translations": "Editar traduções de {lang}",
|
||||
@@ -1619,6 +1623,7 @@
|
||||
"unlock_targeting_title": "Desbloqueie o direcionamento com um plano superior",
|
||||
"unsaved_changes_warning": "Você tem alterações não salvas na sua pesquisa. Quer salvar antes de sair?",
|
||||
"until_they_submit_a_response": "Até eles enviarem uma resposta",
|
||||
"untitled_block": "Bloco sem título",
|
||||
"upgrade_notice_description": "Crie pesquisas multilíngues e desbloqueie muitas outras funcionalidades",
|
||||
"upgrade_notice_title": "Desbloqueie pesquisas multilíngues com um plano superior",
|
||||
"upload": "Enviar",
|
||||
@@ -2247,6 +2252,7 @@
|
||||
"csat_survey_question_3_headline": "Ah, foi mal! Tem algo que a gente possa fazer pra melhorar sua experiência?",
|
||||
"csat_survey_question_3_placeholder": "Digite sua resposta aqui...",
|
||||
"cta_description": "Mostrar informações e pedir para os usuários tomarem uma ação específica",
|
||||
"custom_survey_block_1_name": "Bloco 1",
|
||||
"custom_survey_description": "Crie uma pesquisa sem modelo.",
|
||||
"custom_survey_name": "Começar do zero",
|
||||
"custom_survey_question_1_headline": "O que você gostaria de saber?",
|
||||
@@ -2656,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",
|
||||
|
||||
@@ -1188,9 +1188,9 @@
|
||||
"add": "Adicionar +",
|
||||
"add_a_delay_or_auto_close_the_survey": "Adicionar um atraso ou fechar automaticamente o inquérito",
|
||||
"add_a_four_digit_pin": "Adicione um PIN de quatro dígitos",
|
||||
"add_a_new_question_to_your_survey": "Adicionar uma nova pergunta ao seu inquérito",
|
||||
"add_a_variable_to_calculate": "Adicionar uma variável para calcular",
|
||||
"add_action_below": "Adicionar ação abaixo",
|
||||
"add_block": "Adicionar bloco",
|
||||
"add_choice_below": "Adicionar escolha abaixo",
|
||||
"add_color_coding": "Adicionar codificação de cores",
|
||||
"add_color_coding_description": "Adicionar códigos de cores vermelho, laranja e verde às opções.",
|
||||
@@ -1211,7 +1211,6 @@
|
||||
"add_other": "Adicionar \"Outro\"",
|
||||
"add_photo_or_video": "Adicionar foto ou vídeo",
|
||||
"add_pin": "Adicionar PIN",
|
||||
"add_question": "Adicionar pergunta",
|
||||
"add_question_below": "Adicionar pergunta abaixo",
|
||||
"add_row": "Adicionar linha",
|
||||
"add_variable": "Adicionar variável",
|
||||
@@ -1239,6 +1238,8 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente o inquérito como concluído após",
|
||||
"back_button_label": "Rótulo do botão \"Voltar\"",
|
||||
"background_styling": "Estilo de Fundo",
|
||||
"block_deleted": "Bloco eliminado.",
|
||||
"block_duplicated": "Bloco duplicado.",
|
||||
"bold": "Negrito",
|
||||
"brand_color": "Cor da marca",
|
||||
"brightness": "Brilho",
|
||||
@@ -1283,6 +1284,7 @@
|
||||
"character_limit_toggle_title": "Adicionar limites de caracteres",
|
||||
"checkbox_label": "Rótulo da Caixa de Seleção",
|
||||
"choose_the_actions_which_trigger_the_survey": "Escolha as ações que desencadeiam o inquérito.",
|
||||
"choose_the_first_question_on_your_block": "Escolha a primeira pergunta no seu bloco",
|
||||
"choose_where_to_run_the_survey": "Escolha onde realizar o inquérito.",
|
||||
"city": "Cidade",
|
||||
"close_survey_on_response_limit": "Fechar inquérito no limite de respostas",
|
||||
@@ -1311,6 +1313,7 @@
|
||||
"date_format": "Formato da data",
|
||||
"days_before_showing_this_survey_again": "dias antes de mostrar este inquérito novamente.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decida com que frequência as pessoas podem responder a este inquérito.",
|
||||
"delete_block": "Eliminar bloco",
|
||||
"delete_choice": "Eliminar escolha",
|
||||
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa do tempo de conclusão do inquérito",
|
||||
@@ -1322,6 +1325,7 @@
|
||||
"does_not_include_all_of": "Não inclui todos de",
|
||||
"does_not_include_one_of": "Não inclui um de",
|
||||
"does_not_start_with": "Não começa com",
|
||||
"duplicate_block": "Duplicar bloco",
|
||||
"edit_link": "Editar link",
|
||||
"edit_recall": "Editar Lembrete",
|
||||
"edit_translations": "Editar traduções {lang}",
|
||||
@@ -1619,6 +1623,7 @@
|
||||
"unlock_targeting_title": "Desbloqueie a segmentação com um plano superior",
|
||||
"unsaved_changes_warning": "Tem alterações não guardadas no seu inquérito. Gostaria de as guardar antes de sair?",
|
||||
"until_they_submit_a_response": "Até que enviem uma resposta",
|
||||
"untitled_block": "Bloco sem título",
|
||||
"upgrade_notice_description": "Crie inquéritos multilingues e desbloqueie muitas mais funcionalidades",
|
||||
"upgrade_notice_title": "Desbloqueie inquéritos multilingues com um plano superior",
|
||||
"upload": "Carregar",
|
||||
@@ -2247,6 +2252,7 @@
|
||||
"csat_survey_question_3_headline": "Oh, desculpe! Há algo que possamos fazer para melhorar a sua experiência?",
|
||||
"csat_survey_question_3_placeholder": "Escreva a sua resposta aqui...",
|
||||
"cta_description": "Exibir informações e solicitar aos utilizadores que tomem uma ação específica",
|
||||
"custom_survey_block_1_name": "Bloco 1",
|
||||
"custom_survey_description": "Crie um inquérito sem modelo.",
|
||||
"custom_survey_name": "Começar do zero",
|
||||
"custom_survey_question_1_headline": "O que gostaria de saber?",
|
||||
@@ -2656,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",
|
||||
|
||||
@@ -1188,9 +1188,9 @@
|
||||
"add": "Adaugă +",
|
||||
"add_a_delay_or_auto_close_the_survey": "Adăugați o întârziere sau închideți automat sondajul",
|
||||
"add_a_four_digit_pin": "Adăugați un cod PIN din patru cifre",
|
||||
"add_a_new_question_to_your_survey": "Adaugă o nouă întrebare la sondajul tău",
|
||||
"add_a_variable_to_calculate": "Adaugă o variabilă pentru calcul",
|
||||
"add_action_below": "Adăugați acțiune mai jos",
|
||||
"add_block": "Adaugă bloc",
|
||||
"add_choice_below": "Adaugă opțiunea de mai jos",
|
||||
"add_color_coding": "Adăugați codificare color",
|
||||
"add_color_coding_description": "Adăugați coduri de culoare roșu, portocaliu și verde la opțiuni.",
|
||||
@@ -1211,7 +1211,6 @@
|
||||
"add_other": "Adăugați \"Altele\"",
|
||||
"add_photo_or_video": "Adaugă fotografie sau video",
|
||||
"add_pin": "Adaugă PIN",
|
||||
"add_question": "Adaugă întrebare",
|
||||
"add_question_below": "Adaugă întrebare mai jos",
|
||||
"add_row": "Adăugați rând",
|
||||
"add_variable": "Adaugă variabilă",
|
||||
@@ -1239,6 +1238,8 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcați automat sondajul ca finalizat după",
|
||||
"back_button_label": "Etichetă buton \"Înapoi\"",
|
||||
"background_styling": "Stilizare fundal",
|
||||
"block_deleted": "Bloc șters.",
|
||||
"block_duplicated": "Bloc duplicat.",
|
||||
"bold": "Îngroșat",
|
||||
"brand_color": "Culoarea brandului",
|
||||
"brightness": "Luminozitate",
|
||||
@@ -1283,6 +1284,7 @@
|
||||
"character_limit_toggle_title": "Adăugați limite de caractere",
|
||||
"checkbox_label": "Etichetă casetă de selectare",
|
||||
"choose_the_actions_which_trigger_the_survey": "Alegeți acțiunile care declanșează sondajul.",
|
||||
"choose_the_first_question_on_your_block": "Alege prima întrebare din blocul tău",
|
||||
"choose_where_to_run_the_survey": "Alegeți unde să rulați chestionarul.",
|
||||
"city": "Oraș",
|
||||
"close_survey_on_response_limit": "Închideți sondajul la limită de răspunsuri",
|
||||
@@ -1311,6 +1313,7 @@
|
||||
"date_format": "Format dată",
|
||||
"days_before_showing_this_survey_again": "zile înainte de a afișa din nou acest sondaj.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decide cât de des pot răspunde oamenii la acest sondaj",
|
||||
"delete_block": "Șterge blocul",
|
||||
"delete_choice": "Șterge alegerea",
|
||||
"disable_the_visibility_of_survey_progress": "Dezactivați vizibilitatea progresului sondajului",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Afișează o estimare a timpului de finalizare pentru sondaj",
|
||||
@@ -1322,6 +1325,7 @@
|
||||
"does_not_include_all_of": "Nu include toate",
|
||||
"does_not_include_one_of": "Nu include una dintre",
|
||||
"does_not_start_with": "Nu începe cu",
|
||||
"duplicate_block": "Duplicați blocul",
|
||||
"edit_link": "Editare legătură",
|
||||
"edit_recall": "Editează Referințele",
|
||||
"edit_translations": "Editează traducerile {lang}",
|
||||
@@ -1619,6 +1623,7 @@
|
||||
"unlock_targeting_title": "Deblocați țintirea cu un plan superior",
|
||||
"unsaved_changes_warning": "Aveți modificări nesalvate în sondajul dumneavoastră. Doriți să le salvați înainte de a pleca?",
|
||||
"until_they_submit_a_response": "Până când vor furniza un răspuns",
|
||||
"untitled_block": "Bloc fără titlu",
|
||||
"upgrade_notice_description": "Creați sondaje multilingve și deblocați multe alte caracteristici",
|
||||
"upgrade_notice_title": "Deblocați sondajele multilingve cu un plan superior",
|
||||
"upload": "Încărcați",
|
||||
@@ -2247,6 +2252,7 @@
|
||||
"csat_survey_question_3_headline": "Of, îmi pare rău! Există ceva ce putem face pentru a-ți îmbunătăți experiența?",
|
||||
"csat_survey_question_3_placeholder": "Tastează răspunsul aici...",
|
||||
"cta_description": "Afișează informații și solicită utilizatorilor să ia o acțiune specifică",
|
||||
"custom_survey_block_1_name": "Bloc 1",
|
||||
"custom_survey_description": "Creează un sondaj fără șablon.",
|
||||
"custom_survey_name": "Începe de la zero",
|
||||
"custom_survey_question_1_headline": "Ce ați dori să știți?",
|
||||
@@ -2656,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",
|
||||
|
||||
@@ -1188,9 +1188,9 @@
|
||||
"add": "添加 +",
|
||||
"add_a_delay_or_auto_close_the_survey": "添加 延迟 或 自动 关闭 调查",
|
||||
"add_a_four_digit_pin": "添加 一个 四 位 数 PIN",
|
||||
"add_a_new_question_to_your_survey": "添加一个新问题到您的调查中",
|
||||
"add_a_variable_to_calculate": "添加 变量 以 计算",
|
||||
"add_action_below": "在下面添加操作",
|
||||
"add_block": "添加区块",
|
||||
"add_choice_below": "在下方添加选项",
|
||||
"add_color_coding": "添加 颜色 编码",
|
||||
"add_color_coding_description": "添加 红色 、橙色 和 绿色 颜色 编码 到 选项。",
|
||||
@@ -1211,7 +1211,6 @@
|
||||
"add_other": "添加 \"其他\"",
|
||||
"add_photo_or_video": "添加 照片 或 视频",
|
||||
"add_pin": "添加 PIN",
|
||||
"add_question": "添加问题",
|
||||
"add_question_below": "在下面 添加 问题",
|
||||
"add_row": "添加 行",
|
||||
"add_variable": "添加 变量",
|
||||
@@ -1239,6 +1238,8 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "自动 标记 调查 为 完成 在",
|
||||
"back_button_label": "\"返回\" 按钮标签",
|
||||
"background_styling": "背景 样式",
|
||||
"block_deleted": "区块已删除。",
|
||||
"block_duplicated": "区块已复制。",
|
||||
"bold": "粗体",
|
||||
"brand_color": "品牌 颜色",
|
||||
"brightness": "亮度",
|
||||
@@ -1283,6 +1284,7 @@
|
||||
"character_limit_toggle_title": "添加 字符限制",
|
||||
"checkbox_label": "复选框 标签",
|
||||
"choose_the_actions_which_trigger_the_survey": "选择 触发 调查 的 动作 。",
|
||||
"choose_the_first_question_on_your_block": "选择区块中的第一个问题",
|
||||
"choose_where_to_run_the_survey": "选择 调查 运行 的 位置 。",
|
||||
"city": "城市",
|
||||
"close_survey_on_response_limit": "在响应限制时关闭 调查",
|
||||
@@ -1311,6 +1313,7 @@
|
||||
"date_format": "日期格式",
|
||||
"days_before_showing_this_survey_again": "显示 此 调查 之前 的 天数。",
|
||||
"decide_how_often_people_can_answer_this_survey": "决定 人 可以 回答 这份 调查 的 频率 。",
|
||||
"delete_block": "删除区块",
|
||||
"delete_choice": "删除 选择",
|
||||
"disable_the_visibility_of_survey_progress": "禁用问卷 进度 的可见性。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "显示 调查 预计 完成 时间",
|
||||
@@ -1322,6 +1325,7 @@
|
||||
"does_not_include_all_of": "不包括所有 ",
|
||||
"does_not_include_one_of": "不包括一 个",
|
||||
"does_not_start_with": "不 以 开头",
|
||||
"duplicate_block": "复制区块",
|
||||
"edit_link": "编辑 链接",
|
||||
"edit_recall": "编辑 调用",
|
||||
"edit_translations": "编辑 {lang} 翻译",
|
||||
@@ -1619,6 +1623,7 @@
|
||||
"unlock_targeting_title": "通过 更 高级 划解锁 定位",
|
||||
"unsaved_changes_warning": "您在调查中有未保存的更改。离开前是否要保存?",
|
||||
"until_they_submit_a_response": "直到 他们 提交 回复",
|
||||
"untitled_block": "未命名区块",
|
||||
"upgrade_notice_description": "创建 多语言 调查 并 解锁 更多 功能",
|
||||
"upgrade_notice_title": "解锁 更高 计划 中 的 多语言 调查",
|
||||
"upload": "上传",
|
||||
@@ -2247,6 +2252,7 @@
|
||||
"csat_survey_question_3_headline": "糟糕, 对不起!我们可以做些什么来改善您的体验?",
|
||||
"csat_survey_question_3_placeholder": "在此输入您的答案...",
|
||||
"cta_description": "显示 信息 并 提示用户采取 特定行动",
|
||||
"custom_survey_block_1_name": "模块 1",
|
||||
"custom_survey_description": "创建 一个 没有 模板 的 调查。",
|
||||
"custom_survey_name": "从零开始",
|
||||
"custom_survey_question_1_headline": "你 想 知道 什么?",
|
||||
@@ -2656,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",
|
||||
|
||||
@@ -1188,9 +1188,9 @@
|
||||
"add": "新增 +",
|
||||
"add_a_delay_or_auto_close_the_survey": "新增延遲或自動關閉問卷",
|
||||
"add_a_four_digit_pin": "新增四位數 PIN 碼",
|
||||
"add_a_new_question_to_your_survey": "在您的問卷中新增一個新問題",
|
||||
"add_a_variable_to_calculate": "新增要計算的變數",
|
||||
"add_action_below": "在下方新增操作",
|
||||
"add_block": "新增區塊",
|
||||
"add_choice_below": "在下方新增選項",
|
||||
"add_color_coding": "新增顏色編碼",
|
||||
"add_color_coding_description": "為選項新增紅色、橘色和綠色顏色代碼。",
|
||||
@@ -1211,7 +1211,6 @@
|
||||
"add_other": "新增「其他」",
|
||||
"add_photo_or_video": "新增照片或影片",
|
||||
"add_pin": "新增 PIN 碼",
|
||||
"add_question": "新增問題",
|
||||
"add_question_below": "在下方新增問題",
|
||||
"add_row": "新增列",
|
||||
"add_variable": "新增變數",
|
||||
@@ -1239,6 +1238,8 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "在指定時間後自動將問卷標記為完成",
|
||||
"back_button_label": "「返回」按鈕標籤",
|
||||
"background_styling": "背景樣式設定",
|
||||
"block_deleted": "區塊已刪除。",
|
||||
"block_duplicated": "區塊已複製。",
|
||||
"bold": "粗體",
|
||||
"brand_color": "品牌顏色",
|
||||
"brightness": "亮度",
|
||||
@@ -1283,6 +1284,7 @@
|
||||
"character_limit_toggle_title": "新增字元限制",
|
||||
"checkbox_label": "核取方塊標籤",
|
||||
"choose_the_actions_which_trigger_the_survey": "選擇觸發問卷的操作。",
|
||||
"choose_the_first_question_on_your_block": "選擇此區塊的第一個問題",
|
||||
"choose_where_to_run_the_survey": "選擇在哪裡執行問卷。",
|
||||
"city": "城市",
|
||||
"close_survey_on_response_limit": "在回應次數上限關閉問卷",
|
||||
@@ -1311,6 +1313,7 @@
|
||||
"date_format": "日期格式",
|
||||
"days_before_showing_this_survey_again": "天後再次顯示此問卷。",
|
||||
"decide_how_often_people_can_answer_this_survey": "決定人們可以回答此問卷的頻率。",
|
||||
"delete_block": "刪除區塊",
|
||||
"delete_choice": "刪除選項",
|
||||
"disable_the_visibility_of_survey_progress": "停用問卷進度的可見性。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "顯示問卷的估計完成時間",
|
||||
@@ -1322,6 +1325,7 @@
|
||||
"does_not_include_all_of": "不包含全部",
|
||||
"does_not_include_one_of": "不包含其中之一",
|
||||
"does_not_start_with": "不以...開頭",
|
||||
"duplicate_block": "複製區塊",
|
||||
"edit_link": "編輯 連結",
|
||||
"edit_recall": "編輯回憶",
|
||||
"edit_translations": "編輯 '{'language'}' 翻譯",
|
||||
@@ -1619,6 +1623,7 @@
|
||||
"unlock_targeting_title": "使用更高等級的方案解鎖目標設定",
|
||||
"unsaved_changes_warning": "您的問卷中有未儲存的變更。您要先儲存它們再離開嗎?",
|
||||
"until_they_submit_a_response": "直到他們提交回應",
|
||||
"untitled_block": "未命名區塊",
|
||||
"upgrade_notice_description": "建立多語言問卷並解鎖更多功能",
|
||||
"upgrade_notice_title": "使用更高等級的方案解鎖多語言問卷",
|
||||
"upload": "上傳",
|
||||
@@ -2247,6 +2252,7 @@
|
||||
"csat_survey_question_3_headline": "唉,抱歉!我們是否有任何可以改善您體驗的地方?",
|
||||
"csat_survey_question_3_placeholder": "在此輸入您的答案...",
|
||||
"cta_description": "顯示資訊並提示使用者採取特定操作",
|
||||
"custom_survey_block_1_name": "區塊 1",
|
||||
"custom_survey_description": "建立沒有範本的問卷。",
|
||||
"custom_survey_name": "從頭開始",
|
||||
"custom_survey_question_1_headline": "您想瞭解什麼?",
|
||||
@@ -2656,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",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { CheckCircle2Icon, ChevronsDownIcon, XCircleIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
@@ -12,7 +12,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modu
|
||||
interface QuestionSkipProps {
|
||||
skippedQuestions: string[] | undefined;
|
||||
status: string;
|
||||
questions: TSurveyQuestion[];
|
||||
questions: TSurveyElement[];
|
||||
isFirstQuestionAnswered?: boolean;
|
||||
responseData: TResponseData;
|
||||
}
|
||||
|
||||
+20
-26
@@ -1,14 +1,8 @@
|
||||
import { CheckCheckIcon, MousePointerClickIcon, PhoneIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { TResponseDataValue } from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyMatrixQuestion,
|
||||
TSurveyPictureSelectionQuestion,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyRatingQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getChoiceIdByValue } from "@/lib/response/utils";
|
||||
@@ -24,7 +18,7 @@ import { ResponseBadges } from "@/modules/ui/components/response-badges";
|
||||
|
||||
interface RenderResponseProps {
|
||||
responseData: TResponseDataValue;
|
||||
question: TSurveyQuestion;
|
||||
question: TSurveyElement;
|
||||
survey: TSurvey;
|
||||
language: string | null;
|
||||
isExpanded?: boolean;
|
||||
@@ -56,19 +50,19 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
};
|
||||
const questionType = question.type;
|
||||
switch (questionType) {
|
||||
case TSurveyQuestionTypeEnum.Rating:
|
||||
case TSurveyElementTypeEnum.Rating:
|
||||
if (typeof responseData === "number") {
|
||||
return (
|
||||
<RatingResponse
|
||||
scale={question.scale}
|
||||
answer={responseData}
|
||||
range={question.range}
|
||||
addColors={(question as TSurveyRatingQuestion).isColorCodingEnabled}
|
||||
addColors={question.isColorCodingEnabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
case TSurveyQuestionTypeEnum.Date:
|
||||
case TSurveyElementTypeEnum.Date:
|
||||
if (typeof responseData === "string") {
|
||||
const parsedDate = new Date(responseData);
|
||||
|
||||
@@ -77,11 +71,11 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
return <p className="ph-no-capture my-1 truncate font-normal text-slate-700">{formattedDate}</p>;
|
||||
}
|
||||
break;
|
||||
case TSurveyQuestionTypeEnum.PictureSelection:
|
||||
case TSurveyElementTypeEnum.PictureSelection:
|
||||
if (Array.isArray(responseData)) {
|
||||
return (
|
||||
<PictureSelectionResponse
|
||||
choices={(question as TSurveyPictureSelectionQuestion).choices}
|
||||
choices={question.choices}
|
||||
selected={responseData}
|
||||
isExpanded={isExpanded}
|
||||
showId={showId}
|
||||
@@ -89,16 +83,16 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
);
|
||||
}
|
||||
break;
|
||||
case TSurveyQuestionTypeEnum.FileUpload:
|
||||
case TSurveyElementTypeEnum.FileUpload:
|
||||
if (Array.isArray(responseData)) {
|
||||
return <FileUploadResponse selected={responseData} />;
|
||||
}
|
||||
break;
|
||||
case TSurveyQuestionTypeEnum.Matrix:
|
||||
case TSurveyElementTypeEnum.Matrix:
|
||||
if (typeof responseData === "object" && !Array.isArray(responseData)) {
|
||||
return (
|
||||
<>
|
||||
{(question as TSurveyMatrixQuestion).rows.map((row) => {
|
||||
{question.rows.map((row) => {
|
||||
const languagCode = getLanguageCode(survey.languages, language);
|
||||
const rowValueInSelectedLanguage = getLocalizedValue(row.label, languagCode);
|
||||
if (!responseData[rowValueInSelectedLanguage]) return null;
|
||||
@@ -112,14 +106,14 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
);
|
||||
}
|
||||
break;
|
||||
case TSurveyQuestionTypeEnum.Address:
|
||||
case TSurveyQuestionTypeEnum.ContactInfo:
|
||||
case TSurveyElementTypeEnum.Address:
|
||||
case TSurveyElementTypeEnum.ContactInfo:
|
||||
if (Array.isArray(responseData)) {
|
||||
return <ArrayResponse value={responseData} />;
|
||||
}
|
||||
break;
|
||||
|
||||
case TSurveyQuestionTypeEnum.Cal:
|
||||
case TSurveyElementTypeEnum.Cal:
|
||||
if (typeof responseData === "string" || typeof responseData === "number") {
|
||||
return (
|
||||
<ResponseBadges
|
||||
@@ -131,7 +125,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
);
|
||||
}
|
||||
break;
|
||||
case TSurveyQuestionTypeEnum.Consent:
|
||||
case TSurveyElementTypeEnum.Consent:
|
||||
if (typeof responseData === "string" || typeof responseData === "number") {
|
||||
return (
|
||||
<ResponseBadges
|
||||
@@ -143,7 +137,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
);
|
||||
}
|
||||
break;
|
||||
case TSurveyQuestionTypeEnum.CTA:
|
||||
case TSurveyElementTypeEnum.CTA:
|
||||
if (typeof responseData === "string" || typeof responseData === "number") {
|
||||
return (
|
||||
<ResponseBadges
|
||||
@@ -155,9 +149,9 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
);
|
||||
}
|
||||
break;
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyQuestionTypeEnum.Ranking:
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti:
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyElementTypeEnum.Ranking:
|
||||
if (typeof responseData === "string" || typeof responseData === "number") {
|
||||
const choiceId = getChoiceIdByValue(responseData.toString(), question);
|
||||
return (
|
||||
@@ -174,7 +168,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
});
|
||||
return (
|
||||
<>
|
||||
{questionType === TSurveyQuestionTypeEnum.Ranking ? (
|
||||
{questionType === TSurveyElementTypeEnum.Ranking ? (
|
||||
<RankingResponse value={itemsArray} isExpanded={isExpanded} showId={showId} />
|
||||
) : (
|
||||
<ResponseBadges items={itemsArray} isExpanded={isExpanded} showId={showId} />
|
||||
|
||||
+6
-4
@@ -8,6 +8,7 @@ import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { ResponseCardQuotas } from "@/modules/ee/quotas/components/single-response-card-quotas";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { isValidValue } from "../util";
|
||||
import { HiddenFields } from "./HiddenFields";
|
||||
import { QuestionSkip } from "./QuestionSkip";
|
||||
@@ -26,7 +27,8 @@ export const SingleResponseCardBody = ({
|
||||
response,
|
||||
skippedQuestions,
|
||||
}: SingleResponseCardBodyProps) => {
|
||||
const isFirstQuestionAnswered = response.data[survey.questions[0].id] ? true : false;
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
const isFirstQuestionAnswered = questions[0] ? !!response.data[questions[0].id] : false;
|
||||
const { t } = useTranslation();
|
||||
const formatTextWithSlashes = (text: string) => {
|
||||
// Updated regex to match content between #/ and \#
|
||||
@@ -54,7 +56,7 @@ export const SingleResponseCardBody = ({
|
||||
{survey.welcomeCard.enabled && (
|
||||
<QuestionSkip
|
||||
skippedQuestions={[]}
|
||||
questions={survey.questions}
|
||||
questions={questions}
|
||||
status={"welcomeCard"}
|
||||
isFirstQuestionAnswered={isFirstQuestionAnswered}
|
||||
responseData={response.data}
|
||||
@@ -64,7 +66,7 @@ export const SingleResponseCardBody = ({
|
||||
{survey.isVerifyEmailEnabled && response.data["verifiedEmail"] && (
|
||||
<VerifiedEmail responseData={response.data} />
|
||||
)}
|
||||
{survey.questions.map((question) => {
|
||||
{questions.map((question) => {
|
||||
const skipped = skippedQuestions.find((skippedQuestionElement) =>
|
||||
skippedQuestionElement.includes(question.id)
|
||||
);
|
||||
@@ -103,7 +105,7 @@ export const SingleResponseCardBody = ({
|
||||
) : (
|
||||
<QuestionSkip
|
||||
skippedQuestions={skipped}
|
||||
questions={survey.questions}
|
||||
questions={questions}
|
||||
responseData={response.data}
|
||||
status={
|
||||
response.finished ||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TResponse, TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { DecrementQuotasCheckbox } from "@/modules/ui/components/decrement-quotas-checkbox";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { deleteResponseAction, getResponseAction } from "./actions";
|
||||
@@ -49,6 +50,9 @@ export const SingleResponseCard = ({
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const skippedQuestions: string[][] = useMemo(() => {
|
||||
// Derive questions from blocks
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
const flushTemp = (temp: string[], result: string[][], shouldReverse = false) => {
|
||||
if (temp.length > 0) {
|
||||
if (shouldReverse) temp.reverse();
|
||||
@@ -61,7 +65,7 @@ export const SingleResponseCard = ({
|
||||
const result: string[][] = [];
|
||||
let temp: string[] = [];
|
||||
|
||||
for (const question of survey.questions) {
|
||||
for (const question of questions) {
|
||||
if (isValidValue(response.data[question.id])) {
|
||||
flushTemp(temp, result);
|
||||
} else {
|
||||
@@ -76,8 +80,8 @@ export const SingleResponseCard = ({
|
||||
const result: string[][] = [];
|
||||
let temp: string[] = [];
|
||||
|
||||
for (let index = survey.questions.length - 1; index >= 0; index--) {
|
||||
const question = survey.questions[index];
|
||||
for (let index = questions.length - 1; index >= 0; index--) {
|
||||
const question = questions[index];
|
||||
const hasNoData = !response.data[question.id];
|
||||
const shouldSkip = hasNoData && (result.length === 0 || !isValidValue(response.data[question.id]));
|
||||
|
||||
@@ -92,7 +96,7 @@ export const SingleResponseCard = ({
|
||||
};
|
||||
|
||||
return response.finished ? processFinishedResponse() : processUnfinishedResponse();
|
||||
}, [response.id, response.finished, response.data, survey.questions]);
|
||||
}, [response.finished, response.data, survey.blocks]);
|
||||
|
||||
const handleDeleteResponse = async () => {
|
||||
setIsDeleting(true);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionChoice,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyQuestionChoice } from "@formbricks/types/surveys/types";
|
||||
import { MAX_OTHER_OPTION_LENGTH } from "@/lib/constants";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
|
||||
@@ -34,7 +31,7 @@ export const validateOtherOptionLengthForMultipleChoice = ({
|
||||
responseLanguage,
|
||||
}: {
|
||||
responseData?: TResponseData;
|
||||
surveyQuestions: TSurveyQuestion[];
|
||||
surveyQuestions: TSurveyElement[];
|
||||
responseLanguage?: string;
|
||||
}): string | undefined => {
|
||||
if (!responseData) return undefined;
|
||||
@@ -43,8 +40,8 @@ export const validateOtherOptionLengthForMultipleChoice = ({
|
||||
if (!question) continue;
|
||||
|
||||
const isMultiChoice =
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle;
|
||||
question.type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
|
||||
question.type === TSurveyElementTypeEnum.MultipleChoiceSingle;
|
||||
|
||||
if (!isMultiChoice) continue;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export const getSurveyQuestions = reactCache(async (surveyId: string) => {
|
||||
select: {
|
||||
environmentId: true,
|
||||
questions: true,
|
||||
blocks: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+20
-1
@@ -1,7 +1,8 @@
|
||||
import { Survey } from "@prisma/client";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const survey: Pick<Survey, "id" | "questions"> = {
|
||||
export const survey: Pick<Survey, "id" | "questions" | "blocks"> = {
|
||||
id: "rp2di001zicbm3mk8je1ue9u",
|
||||
questions: [
|
||||
{
|
||||
@@ -15,4 +16,22 @@ export const survey: Pick<Survey, "id" | "questions"> = {
|
||||
},
|
||||
},
|
||||
],
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "i0e9y9ya4pl9iyrurlrak3yq",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Question Text", de: "Fragetext" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: 1000,
|
||||
subheader: { default: "" },
|
||||
placeholder: { default: "" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("Survey Lib", () => {
|
||||
|
||||
describe("getSurveyQuestions", () => {
|
||||
test("return survey questions and environmentId when the survey is found", async () => {
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValue(survey);
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValue(survey as any);
|
||||
|
||||
const result = await getSurveyQuestions(survey.id);
|
||||
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
|
||||
@@ -26,6 +26,7 @@ describe("Survey Lib", () => {
|
||||
select: {
|
||||
environmentId: true,
|
||||
questions: true,
|
||||
blocks: true,
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
@@ -33,6 +33,7 @@ export const ZSurveyInput = ZSurveyWithoutQuestionType.pick({
|
||||
type: true,
|
||||
environmentId: true,
|
||||
questions: true,
|
||||
blocks: true,
|
||||
endings: true,
|
||||
hiddenFields: true,
|
||||
variables: true,
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TI18nString, TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
|
||||
import type { TI18nString } from "@formbricks/types/i18n";
|
||||
import type { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent, isValidHTML } from "@formbricks/types/surveys/validation";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { md } from "@/lib/markdownIt";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { isLabelValidForAllLanguages } from "@/modules/survey/editor/lib/validation";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { Editor } from "@/modules/ui/components/editor";
|
||||
import { LanguageIndicator } from "./language-indicator";
|
||||
|
||||
@@ -28,6 +30,7 @@ interface LocalizedEditorProps {
|
||||
isCard?: boolean; // Flag to indicate if this is a welcome/ending card
|
||||
autoFocus?: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
suppressUpdates?: () => boolean; // Function to check if updates should be suppressed (e.g., during deletion)
|
||||
}
|
||||
|
||||
const checkIfValueIsIncomplete = (
|
||||
@@ -60,7 +63,10 @@ export function LocalizedEditor({
|
||||
isCard,
|
||||
autoFocus,
|
||||
isExternalUrlsAllowed,
|
||||
suppressUpdates,
|
||||
}: Readonly<LocalizedEditorProps>) {
|
||||
// Derive questions from blocks for migrated surveys
|
||||
const questions = useMemo(() => getElementsFromBlocks(localSurvey.blocks), [localSurvey.blocks]);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isInComplete = useMemo(
|
||||
@@ -92,18 +98,24 @@ export function LocalizedEditor({
|
||||
key={`${questionId}-${id}-${selectedLanguageCode}`}
|
||||
setFirstRender={setFirstRender}
|
||||
setText={(v: string) => {
|
||||
// Early exit if updates are suppressed (e.g., during deletion)
|
||||
// This prevents race conditions where setText fires with stale props before React updates state
|
||||
if (suppressUpdates?.()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let sanitizedContent = v;
|
||||
if (!isExternalUrlsAllowed) {
|
||||
sanitizedContent = v.replaceAll(/<a[^>]*>(.*?)<\/a>/gi, "$1");
|
||||
}
|
||||
|
||||
// Check if the question still exists before updating
|
||||
const currentQuestion = localSurvey.questions[questionIdx];
|
||||
const currentQuestion = questions[questionIdx];
|
||||
|
||||
// if this is a card, we wanna check if the card exists in the localSurvey
|
||||
if (isCard) {
|
||||
const isWelcomeCard = questionIdx === -1;
|
||||
const isEndingCard = questionIdx >= localSurvey.questions.length;
|
||||
const isEndingCard = questionIdx >= questions.length;
|
||||
|
||||
// For ending cards, check if the field exists before updating
|
||||
if (isEndingCard) {
|
||||
@@ -127,7 +139,8 @@ export function LocalizedEditor({
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentQuestion && currentQuestion[id] !== undefined) {
|
||||
// Check if the field exists on the question (not just if it's not undefined)
|
||||
if (currentQuestion && id in currentQuestion && currentQuestion[id] !== undefined) {
|
||||
const translatedContent = {
|
||||
...value,
|
||||
[selectedLanguageCode]: sanitizedContent,
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
import { Language } from "@prisma/client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import type { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { LanguageToggle } from "./language-toggle";
|
||||
|
||||
interface SecondaryLanguageSelectProps {
|
||||
projectLanguages: Language[];
|
||||
defaultLanguage: Language;
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
setActiveQuestionId: (questionId: TSurveyQuestionId) => void;
|
||||
setActiveQuestionId: (questionId: string) => void;
|
||||
localSurvey: TSurvey;
|
||||
updateSurveyLanguages: (language: Language) => void;
|
||||
locale: TUserLocale;
|
||||
@@ -32,6 +33,8 @@ export function SecondaryLanguageSelect({
|
||||
);
|
||||
};
|
||||
|
||||
const questions = getElementsFromBlocks(localSurvey.blocks);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-slate-800">
|
||||
@@ -46,7 +49,7 @@ export function SecondaryLanguageSelect({
|
||||
language={language}
|
||||
onEdit={() => {
|
||||
setSelectedLanguageCode(language.code);
|
||||
setActiveQuestionId(localSurvey.questions[0]?.id);
|
||||
setActiveQuestionId(questions[0]?.id);
|
||||
}}
|
||||
onToggle={() => {
|
||||
updateSurveyLanguages(language);
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { HandshakeIcon, Undo2Icon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyEndings } from "@formbricks/types/surveys/types";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -14,16 +15,16 @@ import {
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface EndingCardSelectorProps {
|
||||
endings: TSurveyEndings;
|
||||
survey: TSurvey;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const EndingCardSelector = ({ endings, value, onChange }: EndingCardSelectorProps) => {
|
||||
const availableEndings = endings;
|
||||
export const EndingCardSelector = ({ survey, value, onChange }: EndingCardSelectorProps) => {
|
||||
const endings = survey.endings;
|
||||
const { t } = useTranslation();
|
||||
const endingCards = availableEndings.filter((ending) => ending.type === "endScreen");
|
||||
const redirectToUrls = availableEndings.filter((ending) => ending.type === "redirectToUrl");
|
||||
const endingCards = endings.filter((ending) => ending.type === "endScreen");
|
||||
const redirectToUrls = endings.filter((ending) => ending.type === "redirectToUrl");
|
||||
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
@@ -41,7 +42,9 @@ export const EndingCardSelector = ({ endings, value, onChange }: EndingCardSelec
|
||||
{/* Custom endings */}
|
||||
{endingCards.map((ending) => (
|
||||
<SelectItem key={ending.id} value={ending.id}>
|
||||
{getLocalizedValue(ending.headline, "default")}
|
||||
{getTextContent(
|
||||
recallToHeadline(ending.headline ?? {}, survey, false, "default")["default"]
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { createQuotaAction, updateQuotaAction } from "@/modules/ee/quotas/actions";
|
||||
import { EndingCardSelector } from "@/modules/ee/quotas/components/ending-card-selector";
|
||||
import { getDefaultOperatorForQuestion } from "@/modules/survey/editor/lib/utils";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||
import {
|
||||
@@ -80,7 +81,11 @@ export const QuotaModal = ({
|
||||
const { t } = useTranslation();
|
||||
const [openConfirmationModal, setOpenConfirmationModal] = useState(false);
|
||||
const [openConfirmChangesInInclusionCriteria, setOpenConfirmChangesInInclusionCriteria] = useState(false);
|
||||
|
||||
const questions = useMemo(() => getElementsFromBlocks(survey.blocks), [survey.blocks]);
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
const firstQuestion = questions[0];
|
||||
return {
|
||||
name: quota?.name || "",
|
||||
limit: quota?.limit || 1,
|
||||
@@ -89,8 +94,8 @@ export const QuotaModal = ({
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: { type: "question", value: survey.questions[0]?.id },
|
||||
operator: getDefaultOperatorForQuestion(survey.questions[0], t),
|
||||
leftOperand: { type: "question", value: firstQuestion?.id },
|
||||
operator: firstQuestion ? getDefaultOperatorForQuestion(firstQuestion, t) : "equals",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -99,7 +104,7 @@ export const QuotaModal = ({
|
||||
countPartialSubmissions: quota?.countPartialSubmissions || false,
|
||||
surveyId: survey.id,
|
||||
};
|
||||
}, [quota, survey]);
|
||||
}, [quota, survey, questions, t]);
|
||||
|
||||
const form = useForm<TSurveyQuotaInput>({
|
||||
defaultValues,
|
||||
@@ -361,7 +366,7 @@ export const QuotaModal = ({
|
||||
<div className="space-y-2">
|
||||
<FormControl>
|
||||
<EndingCardSelector
|
||||
endings={survey.endings}
|
||||
survey={survey}
|
||||
value={endingCardField.value || ""}
|
||||
onChange={(value) => {
|
||||
form.setValue("endingCardId", value, {
|
||||
|
||||
@@ -13,7 +13,8 @@ import { render } from "@react-email/render";
|
||||
import { TFunction } from "i18next";
|
||||
import { CalendarDaysIcon, UploadIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { type TSurvey, TSurveyQuestionTypeEnum, type TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { type TSurvey, type TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
@@ -21,6 +22,8 @@ import { COLOR_DEFAULTS } from "@/lib/styling/constants";
|
||||
import { isLight, mixColor } from "@/lib/utils/colors";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { RatingSmiley } from "@/modules/analysis/components/RatingSmiley";
|
||||
import { findElementLocation } from "@/modules/survey/editor/lib/blocks";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { getNPSOptionColor, getRatingNumberOptionColor } from "../lib/utils";
|
||||
import { QuestionHeader } from "./email-question-header";
|
||||
|
||||
@@ -77,13 +80,19 @@ export async function PreviewEmailTemplate({
|
||||
const url = `${surveyUrl}?preview=true`;
|
||||
const urlWithPrefilling = `${surveyUrl}?preview=true&skipPrefilled=true&`;
|
||||
const defaultLanguageCode = "default";
|
||||
const firstQuestion = survey.questions[0];
|
||||
|
||||
// Derive questions from blocks
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
const firstQuestion = questions[0];
|
||||
|
||||
const { block } = findElementLocation(survey, firstQuestion.id);
|
||||
|
||||
const headline = parseRecallInfo(getLocalizedValue(firstQuestion.headline, defaultLanguageCode));
|
||||
const subheader = parseRecallInfo(getLocalizedValue(firstQuestion.subheader, defaultLanguageCode));
|
||||
const brandColor = styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor;
|
||||
|
||||
switch (firstQuestion.type) {
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
case TSurveyElementTypeEnum.OpenText:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
@@ -91,7 +100,7 @@ export async function PreviewEmailTemplate({
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.Consent:
|
||||
case TSurveyElementTypeEnum.Consent:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
@@ -120,7 +129,7 @@ export async function PreviewEmailTemplate({
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.NPS:
|
||||
case TSurveyElementTypeEnum.NPS:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Section className="w-full justify-center">
|
||||
@@ -169,7 +178,7 @@ export async function PreviewEmailTemplate({
|
||||
</Section>
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.CTA:
|
||||
case TSurveyElementTypeEnum.CTA:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
@@ -187,13 +196,13 @@ export async function PreviewEmailTemplate({
|
||||
isLight(brandColor) ? "text-black" : "text-white"
|
||||
)}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=clicked`}>
|
||||
{getLocalizedValue(firstQuestion.buttonLabel, defaultLanguageCode)}
|
||||
{getLocalizedValue(block?.buttonLabel, defaultLanguageCode)}
|
||||
</EmailButton>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.Rating:
|
||||
case TSurveyElementTypeEnum.Rating:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Section className="w-full">
|
||||
@@ -246,7 +255,7 @@ export async function PreviewEmailTemplate({
|
||||
</Section>
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
@@ -262,7 +271,7 @@ export async function PreviewEmailTemplate({
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.Ranking:
|
||||
case TSurveyElementTypeEnum.Ranking:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
@@ -278,7 +287,7 @@ export async function PreviewEmailTemplate({
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
@@ -295,7 +304,7 @@ export async function PreviewEmailTemplate({
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.PictureSelection:
|
||||
case TSurveyElementTypeEnum.PictureSelection:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
@@ -321,7 +330,7 @@ export async function PreviewEmailTemplate({
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.Cal:
|
||||
case TSurveyElementTypeEnum.Cal:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Container>
|
||||
@@ -337,7 +346,7 @@ export async function PreviewEmailTemplate({
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.Date:
|
||||
case TSurveyElementTypeEnum.Date:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
@@ -350,7 +359,7 @@ export async function PreviewEmailTemplate({
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.Matrix:
|
||||
case TSurveyElementTypeEnum.Matrix:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
@@ -391,8 +400,8 @@ export async function PreviewEmailTemplate({
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.Address:
|
||||
case TSurveyQuestionTypeEnum.ContactInfo:
|
||||
case TSurveyElementTypeEnum.Address:
|
||||
case TSurveyElementTypeEnum.ContactInfo:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
@@ -407,7 +416,7 @@ export async function PreviewEmailTemplate({
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
|
||||
case TSurveyQuestionTypeEnum.FileUpload:
|
||||
case TSurveyElementTypeEnum.FileUpload:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Column, Container, Img, Link, Row, Text } from "@react-email/components";
|
||||
import { TFunction } from "i18next";
|
||||
import { FileIcon } from "lucide-react";
|
||||
import { TSurveyQuestionType, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
|
||||
|
||||
export const renderEmailResponseValue = async (
|
||||
response: string | string[],
|
||||
questionType: TSurveyQuestionType,
|
||||
questionType: TSurveyElementTypeEnum,
|
||||
t: TFunction,
|
||||
overrideFileUploadResponse = false
|
||||
): Promise<React.JSX.Element> => {
|
||||
switch (questionType) {
|
||||
case TSurveyQuestionTypeEnum.FileUpload:
|
||||
case TSurveyElementTypeEnum.FileUpload:
|
||||
return (
|
||||
<Container>
|
||||
{overrideFileUploadResponse ? (
|
||||
@@ -35,7 +35,7 @@ export const renderEmailResponseValue = async (
|
||||
</Container>
|
||||
);
|
||||
|
||||
case TSurveyQuestionTypeEnum.PictureSelection:
|
||||
case TSurveyElementTypeEnum.PictureSelection:
|
||||
return (
|
||||
<Container>
|
||||
<Row>
|
||||
@@ -49,7 +49,7 @@ export const renderEmailResponseValue = async (
|
||||
</Container>
|
||||
);
|
||||
|
||||
case TSurveyQuestionTypeEnum.Ranking:
|
||||
case TSurveyElementTypeEnum.Ranking:
|
||||
return (
|
||||
<Container>
|
||||
<Row className="mb-2 text-sm text-slate-700" dir="auto">
|
||||
|
||||
+2
-1
@@ -2,7 +2,8 @@
|
||||
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TI18nString, TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getEnabledLanguages } from "@/lib/i18n/utils";
|
||||
|
||||
+19
-21
@@ -15,14 +15,10 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyHiddenFields,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionId,
|
||||
TSurveyRecallItem,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElement, TSurveyElementId, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyHiddenFields, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { getTextContentWithRecallTruncated } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -46,7 +42,7 @@ const questionIconMapping = {
|
||||
|
||||
interface RecallItemSelectProps {
|
||||
localSurvey: TSurvey;
|
||||
questionId: TSurveyQuestionId;
|
||||
questionId: TSurveyElementId;
|
||||
addRecallItem: (question: TSurveyRecallItem) => void;
|
||||
setShowRecallItemSelect: (show: boolean) => void;
|
||||
recallItems: TSurveyRecallItem[];
|
||||
@@ -64,17 +60,19 @@ export const RecallItemSelect = ({
|
||||
}: RecallItemSelectProps) => {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const { t } = useTranslation();
|
||||
const isNotAllowedQuestionType = (question: TSurveyQuestion): boolean => {
|
||||
const isNotAllowedQuestionType = (question: TSurveyElement): boolean => {
|
||||
return (
|
||||
question.type === "fileUpload" ||
|
||||
question.type === "cta" ||
|
||||
question.type === "consent" ||
|
||||
question.type === "pictureSelection" ||
|
||||
question.type === "cal" ||
|
||||
question.type === "matrix"
|
||||
question.type === TSurveyElementTypeEnum.FileUpload ||
|
||||
question.type === TSurveyElementTypeEnum.CTA ||
|
||||
question.type === TSurveyElementTypeEnum.Consent ||
|
||||
question.type === TSurveyElementTypeEnum.PictureSelection ||
|
||||
question.type === TSurveyElementTypeEnum.Cal ||
|
||||
question.type === TSurveyElementTypeEnum.Matrix
|
||||
);
|
||||
};
|
||||
|
||||
const questions = useMemo(() => getElementsFromBlocks(localSurvey.blocks), [localSurvey.blocks]);
|
||||
|
||||
const recallItemIds = useMemo(() => {
|
||||
return recallItems.map((recallItem) => recallItem.id);
|
||||
}, [recallItems]);
|
||||
@@ -114,11 +112,11 @@ export const RecallItemSelect = ({
|
||||
const isWelcomeCard = questionId === "start";
|
||||
if (isWelcomeCard) return [];
|
||||
|
||||
const isEndingCard = !localSurvey.questions.map((question) => question.id).includes(questionId);
|
||||
const isEndingCard = !questions.map((question) => question.id).includes(questionId);
|
||||
const idx = isEndingCard
|
||||
? localSurvey.questions.length
|
||||
: localSurvey.questions.findIndex((recallQuestion) => recallQuestion.id === questionId);
|
||||
const filteredQuestions = localSurvey.questions
|
||||
? questions.length
|
||||
: questions.findIndex((recallQuestion) => recallQuestion.id === questionId);
|
||||
const filteredQuestions = questions
|
||||
.filter((question, index) => {
|
||||
const notAllowed = isNotAllowedQuestionType(question);
|
||||
return (
|
||||
@@ -130,7 +128,7 @@ export const RecallItemSelect = ({
|
||||
});
|
||||
|
||||
return filteredQuestions;
|
||||
}, [localSurvey.questions, questionId, recallItemIds, selectedLanguageCode]);
|
||||
}, [questionId, questions, recallItemIds, selectedLanguageCode]);
|
||||
|
||||
const filteredRecallItems: TSurveyRecallItem[] = useMemo(() => {
|
||||
return [...surveyQuestionRecallItems, ...hiddenFieldRecallItems, ...variableRecallItems].filter(
|
||||
@@ -146,7 +144,7 @@ export const RecallItemSelect = ({
|
||||
const getRecallItemIcon = (recallItem: TSurveyRecallItem) => {
|
||||
switch (recallItem.type) {
|
||||
case "question":
|
||||
const question = localSurvey.questions.find((question) => question.id === recallItem.id);
|
||||
const question = questions.find((question) => question.id === recallItem.id);
|
||||
if (question) {
|
||||
return questionIconMapping[question?.type as keyof typeof questionIconMapping];
|
||||
}
|
||||
|
||||
+3
-1
@@ -18,6 +18,7 @@ import {
|
||||
} from "@/lib/utils/recall";
|
||||
import { FallbackInput } from "@/modules/survey/components/question-form-input/components/fallback-input";
|
||||
import { RecallItemSelect } from "@/modules/survey/components/question-form-input/components/recall-item-select";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface RecallWrapperRenderProps {
|
||||
@@ -189,7 +190,8 @@ export const RecallWrapper = ({
|
||||
const info = extractRecallInfo(recallItem.label);
|
||||
if (info) {
|
||||
const recallItemId = extractId(info);
|
||||
const recallQuestion = localSurvey.questions.find((q) => q.id === recallItemId);
|
||||
const questions = getElementsFromBlocks(localSurvey.blocks);
|
||||
const recallQuestion = questions.find((q) => q.id === recallItemId);
|
||||
if (recallQuestion) {
|
||||
// replace nested recall with "___"
|
||||
return [recallItem.label.replace(info, "___")];
|
||||
|
||||
@@ -5,13 +5,12 @@ import { debounce } from "lodash";
|
||||
import { ImagePlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyEndScreenCard,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionChoice,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyRedirectUrlCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
@@ -21,6 +20,7 @@ import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
|
||||
import { MultiLangWrapper } from "@/modules/survey/components/question-form-input/components/multi-lang-wrapper";
|
||||
import { RecallWrapper } from "@/modules/survey/components/question-form-input/components/recall-wrapper";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FileInput } from "@/modules/ui/components/file-input";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
@@ -42,10 +42,10 @@ interface QuestionFormInputProps {
|
||||
value: TI18nString | undefined;
|
||||
localSurvey: TSurvey;
|
||||
questionIdx: number;
|
||||
updateQuestion?: (questionIdx: number, data: Partial<TSurveyQuestion>) => void;
|
||||
updateQuestion?: (questionIdx: number, data: Partial<TSurveyElement>) => void;
|
||||
updateSurvey?: (data: Partial<TSurveyEndScreenCard> | Partial<TSurveyRedirectUrlCard>) => void;
|
||||
updateChoice?: (choiceIdx: number, data: Partial<TSurveyQuestionChoice>) => void;
|
||||
updateMatrixLabel?: (index: number, type: "row" | "column", data: Partial<TSurveyQuestion>) => void;
|
||||
updateMatrixLabel?: (index: number, type: "row" | "column", data: Partial<TSurveyElement>) => void;
|
||||
isInvalid: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
@@ -92,7 +92,10 @@ export const QuestionFormInput = ({
|
||||
const defaultLanguageCode =
|
||||
localSurvey.languages.filter((lang) => lang.default)[0]?.language.code ?? "default";
|
||||
const usedLanguageCode = selectedLanguageCode === defaultLanguageCode ? "default" : selectedLanguageCode;
|
||||
const question: TSurveyQuestion = localSurvey.questions[questionIdx];
|
||||
|
||||
const questions = useMemo(() => getElementsFromBlocks(localSurvey.blocks), [localSurvey.blocks]);
|
||||
|
||||
const question: TSurveyElement = questions[questionIdx];
|
||||
const isChoice = id.includes("choice");
|
||||
const isMatrixLabelRow = id.includes("row");
|
||||
const isMatrixLabelColumn = id.includes("column");
|
||||
@@ -100,7 +103,7 @@ export const QuestionFormInput = ({
|
||||
return isChoice || isMatrixLabelColumn || isMatrixLabelRow ? id.split("-")[0] : id;
|
||||
}, [id, isChoice, isMatrixLabelColumn, isMatrixLabelRow]);
|
||||
|
||||
const isEndingCard = questionIdx >= localSurvey.questions.length;
|
||||
const isEndingCard = questionIdx >= questions.length;
|
||||
const isWelcomeCard = questionIdx === -1;
|
||||
const index = getIndex(id, isChoice || isMatrixLabelColumn || isMatrixLabelRow);
|
||||
|
||||
@@ -108,7 +111,7 @@ export const QuestionFormInput = ({
|
||||
return isWelcomeCard
|
||||
? "start"
|
||||
: isEndingCard
|
||||
? localSurvey.endings[questionIdx - localSurvey.questions.length].id
|
||||
? localSurvey.endings[questionIdx - questions.length].id
|
||||
: question.id;
|
||||
//eslint-disable-next-line
|
||||
}, [isWelcomeCard, isEndingCard, question?.id]);
|
||||
@@ -133,7 +136,7 @@ export const QuestionFormInput = ({
|
||||
}
|
||||
|
||||
if (isEndingCard) {
|
||||
return getEndingCardText(localSurvey, id, surveyLanguageCodes, questionIdx);
|
||||
return getEndingCardText(localSurvey, questions, id, surveyLanguageCodes, questionIdx);
|
||||
}
|
||||
|
||||
if ((isMatrixLabelColumn || isMatrixLabelRow) && typeof index === "number") {
|
||||
@@ -144,9 +147,9 @@ export const QuestionFormInput = ({
|
||||
(question &&
|
||||
(id.includes(".")
|
||||
? // Handle nested properties
|
||||
(question[id.split(".")[0] as keyof TSurveyQuestion] as any)?.[id.split(".")[1]]
|
||||
(question[id.split(".")[0] as keyof TSurveyElement] as any)?.[id.split(".")[1]]
|
||||
: // Original behavior
|
||||
(question[id as keyof TSurveyQuestion] as TI18nString))) ||
|
||||
(question[id as keyof TSurveyElement] as TI18nString))) ||
|
||||
createI18nString("", surveyLanguageCodes)
|
||||
);
|
||||
}, [
|
||||
@@ -160,12 +163,13 @@ export const QuestionFormInput = ({
|
||||
localSurvey,
|
||||
question,
|
||||
questionIdx,
|
||||
questions,
|
||||
surveyLanguageCodes,
|
||||
]);
|
||||
|
||||
const [text, setText] = useState(elementText);
|
||||
const [showImageUploader, setShowImageUploader] = useState<boolean>(
|
||||
determineImageUploaderVisibility(questionIdx, localSurvey)
|
||||
determineImageUploaderVisibility(questionIdx, questions)
|
||||
);
|
||||
|
||||
const highlightContainerRef = useRef<HTMLInputElement>(null);
|
||||
@@ -285,6 +289,7 @@ export const QuestionFormInput = ({
|
||||
|
||||
const [animationParent] = useAutoAnimate();
|
||||
const [internalFirstRender, setInternalFirstRender] = useState(true);
|
||||
const suppressEditorUpdatesRef = useRef(false);
|
||||
|
||||
// Use external firstRender state if provided, otherwise use internal state
|
||||
const firstRender = externalFirstRender ?? internalFirstRender;
|
||||
@@ -293,7 +298,7 @@ export const QuestionFormInput = ({
|
||||
const renderRemoveDescriptionButton = () => {
|
||||
if (
|
||||
question &&
|
||||
(question.type === TSurveyQuestionTypeEnum.CTA || question.type === TSurveyQuestionTypeEnum.Consent)
|
||||
(question.type === TSurveyElementTypeEnum.CTA || question.type === TSurveyElementTypeEnum.Consent)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -330,8 +335,8 @@ export const QuestionFormInput = ({
|
||||
if (url) {
|
||||
const update =
|
||||
fileType === "video"
|
||||
? { videoUrl: url[0], imageUrl: "" }
|
||||
: { imageUrl: url[0], videoUrl: "" };
|
||||
? { videoUrl: url[0], imageUrl: undefined }
|
||||
: { imageUrl: url[0], videoUrl: undefined };
|
||||
if ((isWelcomeCard || isEndingCard) && updateSurvey) {
|
||||
updateSurvey(update);
|
||||
} else if (updateQuestion) {
|
||||
@@ -366,6 +371,7 @@ export const QuestionFormInput = ({
|
||||
isCard={isWelcomeCard || isEndingCard}
|
||||
autoFocus={autoFocus}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
suppressUpdates={() => suppressEditorUpdatesRef.current}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -394,6 +400,12 @@ export const QuestionFormInput = ({
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Suppress Editor updates BEFORE calling updateQuestion to prevent race condition
|
||||
// Use ref for immediate synchronous access
|
||||
if (id === "subheader") {
|
||||
suppressEditorUpdatesRef.current = true;
|
||||
}
|
||||
|
||||
if (updateSurvey) {
|
||||
updateSurvey({ subheader: undefined });
|
||||
}
|
||||
@@ -401,6 +413,13 @@ export const QuestionFormInput = ({
|
||||
if (updateQuestion) {
|
||||
updateQuestion(questionIdx, { subheader: undefined });
|
||||
}
|
||||
|
||||
// Re-enable updates after a short delay to allow state to update
|
||||
if (id === "subheader") {
|
||||
setTimeout(() => {
|
||||
suppressEditorUpdatesRef.current = false;
|
||||
}, 100);
|
||||
}
|
||||
}}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
@@ -444,7 +463,7 @@ export const QuestionFormInput = ({
|
||||
onAddFallback={() => {
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
isRecallAllowed={id === "headline" || id === "subheader"}
|
||||
isRecallAllowed={false}
|
||||
usedLanguageCode={usedLanguageCode}
|
||||
render={({
|
||||
value,
|
||||
@@ -455,32 +474,6 @@ export const QuestionFormInput = ({
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 bg-white" ref={animationParent}>
|
||||
{showImageUploader && id === "headline" && (
|
||||
<FileInput
|
||||
id="question-image"
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
|
||||
environmentId={localSurvey.environmentId}
|
||||
onFileUpload={(url: string[] | undefined, fileType: "image" | "video") => {
|
||||
if (url) {
|
||||
const update =
|
||||
fileType === "video"
|
||||
? { videoUrl: url[0], imageUrl: "" }
|
||||
: { imageUrl: url[0], videoUrl: "" };
|
||||
if (isEndingCard && updateSurvey) {
|
||||
updateSurvey(update);
|
||||
} else if (updateQuestion) {
|
||||
updateQuestion(questionIdx, update);
|
||||
}
|
||||
}
|
||||
}}
|
||||
fileUrl={getFileUrl()}
|
||||
videoUrl={getVideoUrl()}
|
||||
isVideoAllowed={true}
|
||||
maxSizeInMB={5}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex w-full items-center space-x-2">
|
||||
<div className="group relative w-full">
|
||||
{languageIndicator}
|
||||
@@ -527,52 +520,11 @@ export const QuestionFormInput = ({
|
||||
isTranslationIncomplete
|
||||
}
|
||||
autoComplete={isRecallSelectVisible ? "off" : "on"}
|
||||
autoFocus={id === "headline"}
|
||||
autoFocus={false}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{recallComponents}
|
||||
</div>
|
||||
|
||||
<>
|
||||
{id === "headline" && !isWelcomeCard && (
|
||||
<TooltipRenderer tooltipContent={t("environments.surveys.edit.add_photo_or_video")}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Toggle image uploader"
|
||||
data-testid="toggle-image-uploader-button"
|
||||
className="ml-2"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowImageUploader((prev) => !prev);
|
||||
}}>
|
||||
<ImagePlusIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
{renderRemoveDescriptionButton() ? (
|
||||
<TooltipRenderer tooltipContent={t("environments.surveys.edit.remove_description")}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Remove description"
|
||||
className="ml-2"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (updateSurvey) {
|
||||
updateSurvey({ subheader: undefined });
|
||||
}
|
||||
|
||||
if (updateQuestion) {
|
||||
updateQuestion(questionIdx, { subheader: undefined });
|
||||
}
|
||||
}}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { TFunction } from "react-i18next";
|
||||
import { TFunction } from "i18next";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { createI18nString } from "@/lib/i18n/utils";
|
||||
import * as i18nUtils from "@/lib/i18n/utils";
|
||||
import {
|
||||
@@ -48,7 +44,7 @@ describe("utils", () => {
|
||||
describe("getChoiceLabel", () => {
|
||||
test("returns the choice label from a question", () => {
|
||||
const surveyLanguageCodes = ["en"];
|
||||
const choiceQuestion: TSurveyMultipleChoiceQuestion = {
|
||||
const choiceQuestion = {
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
headline: createI18nString("Question?", surveyLanguageCodes),
|
||||
@@ -57,7 +53,7 @@ describe("utils", () => {
|
||||
{ id: "c1", label: createI18nString("Choice 1", surveyLanguageCodes) },
|
||||
{ id: "c2", label: createI18nString("Choice 2", surveyLanguageCodes) },
|
||||
],
|
||||
};
|
||||
} as unknown as TSurveyElement;
|
||||
|
||||
const result = getChoiceLabel(choiceQuestion, 1, surveyLanguageCodes);
|
||||
expect(result).toEqual(createI18nString("Choice 2", surveyLanguageCodes));
|
||||
@@ -65,13 +61,13 @@ describe("utils", () => {
|
||||
|
||||
test("returns empty i18n string when choice doesn't exist", () => {
|
||||
const surveyLanguageCodes = ["en"];
|
||||
const choiceQuestion: TSurveyMultipleChoiceQuestion = {
|
||||
const choiceQuestion = {
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
headline: createI18nString("Question?", surveyLanguageCodes),
|
||||
required: true,
|
||||
choices: [],
|
||||
};
|
||||
} as unknown as TSurveyElement;
|
||||
|
||||
const result = getChoiceLabel(choiceQuestion, 0, surveyLanguageCodes);
|
||||
expect(result).toEqual(createI18nString("", surveyLanguageCodes));
|
||||
@@ -94,7 +90,7 @@ describe("utils", () => {
|
||||
{ id: "col-1", label: createI18nString("Column 1", surveyLanguageCodes) },
|
||||
{ id: "col-2", label: createI18nString("Column 2", surveyLanguageCodes) },
|
||||
],
|
||||
} as unknown as TSurveyQuestion;
|
||||
} as unknown as TSurveyElement;
|
||||
|
||||
const result = getMatrixLabel(matrixQuestion, 1, surveyLanguageCodes, "row");
|
||||
expect(result).toEqual(createI18nString("Row 2", surveyLanguageCodes));
|
||||
@@ -115,7 +111,7 @@ describe("utils", () => {
|
||||
{ id: "col-1", label: createI18nString("Column 1", surveyLanguageCodes) },
|
||||
{ id: "col-2", label: createI18nString("Column 2", surveyLanguageCodes) },
|
||||
],
|
||||
} as unknown as TSurveyQuestion;
|
||||
} as unknown as TSurveyElement;
|
||||
|
||||
const result = getMatrixLabel(matrixQuestion, 0, surveyLanguageCodes, "column");
|
||||
expect(result).toEqual(createI18nString("Column 1", surveyLanguageCodes));
|
||||
@@ -130,7 +126,7 @@ describe("utils", () => {
|
||||
required: true,
|
||||
rows: [],
|
||||
columns: [],
|
||||
} as unknown as TSurveyQuestion;
|
||||
} as unknown as TSurveyElement;
|
||||
|
||||
const result = getMatrixLabel(matrixQuestion, 0, surveyLanguageCodes, "row");
|
||||
expect(result).toEqual(createI18nString("", surveyLanguageCodes));
|
||||
@@ -225,7 +221,7 @@ describe("utils", () => {
|
||||
pin: null,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const result = getEndingCardText(survey, "headline", surveyLanguageCodes, 0);
|
||||
const result = getEndingCardText(survey, [], "headline", surveyLanguageCodes, 0);
|
||||
expect(result).toEqual(createI18nString("End Screen", surveyLanguageCodes));
|
||||
});
|
||||
|
||||
@@ -257,32 +253,14 @@ describe("utils", () => {
|
||||
pin: null,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const result = getEndingCardText(survey, "headline", surveyLanguageCodes, 0);
|
||||
const result = getEndingCardText(survey, [], "headline", surveyLanguageCodes, 0);
|
||||
expect(result).toEqual(createI18nString("", surveyLanguageCodes));
|
||||
});
|
||||
});
|
||||
|
||||
describe("determineImageUploaderVisibility", () => {
|
||||
test("returns false for welcome card", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: "draft",
|
||||
questions: [],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
styling: {},
|
||||
environmentId: "env1",
|
||||
type: "app",
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
endings: [],
|
||||
delay: 0,
|
||||
pin: null,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const result = determineImageUploaderVisibility(-1, survey);
|
||||
const result = determineImageUploaderVisibility(-1, []);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
@@ -294,14 +272,19 @@ describe("utils", () => {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: "draft",
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: createI18nString("Question?", surveyLanguageCodes),
|
||||
required: true,
|
||||
imageUrl: "https://example.com/image.jpg",
|
||||
} as unknown as TSurveyQuestion,
|
||||
id: "b1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: createI18nString("Question?", surveyLanguageCodes),
|
||||
required: true,
|
||||
imageUrl: "https://example.com/image.jpg",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
styling: {},
|
||||
@@ -314,7 +297,7 @@ describe("utils", () => {
|
||||
pin: null,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const result = determineImageUploaderVisibility(0, survey);
|
||||
const result = determineImageUploaderVisibility(0, survey.blocks[0].elements);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
@@ -326,14 +309,19 @@ describe("utils", () => {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: "draft",
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: createI18nString("Question?", surveyLanguageCodes),
|
||||
required: true,
|
||||
videoUrl: "https://example.com/video.mp4",
|
||||
} as unknown as TSurveyQuestion,
|
||||
id: "b1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: createI18nString("Question?", surveyLanguageCodes),
|
||||
required: true,
|
||||
videoUrl: "https://example.com/video.mp4",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
styling: {},
|
||||
@@ -346,7 +334,7 @@ describe("utils", () => {
|
||||
pin: null,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const result = determineImageUploaderVisibility(0, survey);
|
||||
const result = determineImageUploaderVisibility(0, survey.blocks[0].elements);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
@@ -358,13 +346,18 @@ describe("utils", () => {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: "draft",
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: createI18nString("Question?", surveyLanguageCodes),
|
||||
required: true,
|
||||
} as unknown as TSurveyQuestion,
|
||||
id: "b1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: createI18nString("Question?", surveyLanguageCodes),
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
styling: {},
|
||||
@@ -377,20 +370,20 @@ describe("utils", () => {
|
||||
pin: null,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const result = determineImageUploaderVisibility(0, survey);
|
||||
const result = determineImageUploaderVisibility(0, survey.blocks[0].elements);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPlaceHolderById", () => {
|
||||
test("returns placeholder for headline", () => {
|
||||
const t = vi.fn((key) => `Translated: ${key}`) as TFunction;
|
||||
const t = vi.fn((key) => `Translated: ${key}`) as unknown as TFunction;
|
||||
const result = getPlaceHolderById("headline", t);
|
||||
expect(result).toBe("Translated: environments.surveys.edit.your_question_here_recall_information_with");
|
||||
});
|
||||
|
||||
test("returns placeholder for subheader", () => {
|
||||
const t = vi.fn((key) => `Translated: ${key}`) as TFunction;
|
||||
const t = vi.fn((key) => `Translated: ${key}`) as unknown as TFunction;
|
||||
const result = getPlaceHolderById("subheader", t);
|
||||
expect(result).toBe(
|
||||
"Translated: environments.surveys.edit.your_description_here_recall_information_with"
|
||||
@@ -398,7 +391,7 @@ describe("utils", () => {
|
||||
});
|
||||
|
||||
test("returns empty string for unknown id", () => {
|
||||
const t = vi.fn((key) => `Translated: ${key}`) as TFunction;
|
||||
const t = vi.fn((key) => `Translated: ${key}`) as unknown as TFunction;
|
||||
const result = getPlaceHolderById("unknown", t);
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyMatrixQuestion,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
TSurveyElement,
|
||||
TSurveyMatrixElement,
|
||||
TSurveyMultipleChoiceElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { createI18nString } from "@/lib/i18n/utils";
|
||||
import { isLabelValidForAllLanguages } from "@/lib/i18n/utils";
|
||||
|
||||
@@ -21,21 +21,21 @@ export const getIndex = (id: string, isChoice: boolean) => {
|
||||
};
|
||||
|
||||
export const getChoiceLabel = (
|
||||
question: TSurveyQuestion,
|
||||
question: TSurveyElement,
|
||||
choiceIdx: number,
|
||||
surveyLanguageCodes: string[]
|
||||
): TI18nString => {
|
||||
const choiceQuestion = question as TSurveyMultipleChoiceQuestion;
|
||||
const choiceQuestion = question as TSurveyMultipleChoiceElement;
|
||||
return choiceQuestion.choices[choiceIdx]?.label || createI18nString("", surveyLanguageCodes);
|
||||
};
|
||||
|
||||
export const getMatrixLabel = (
|
||||
question: TSurveyQuestion,
|
||||
question: TSurveyElement,
|
||||
idx: number,
|
||||
surveyLanguageCodes: string[],
|
||||
type: "row" | "column"
|
||||
): TI18nString => {
|
||||
const matrixQuestion = question as TSurveyMatrixQuestion;
|
||||
const matrixQuestion = question as TSurveyMatrixElement;
|
||||
const matrixFields = type === "row" ? matrixQuestion.rows : matrixQuestion.columns;
|
||||
return matrixFields[idx]?.label || createI18nString("", surveyLanguageCodes);
|
||||
};
|
||||
@@ -51,27 +51,30 @@ export const getWelcomeCardText = (
|
||||
|
||||
export const getEndingCardText = (
|
||||
survey: TSurvey,
|
||||
questions: TSurveyElement[],
|
||||
id: string,
|
||||
surveyLanguageCodes: string[],
|
||||
questionIdx: number
|
||||
): TI18nString => {
|
||||
const endingCardIndex = questionIdx - survey.questions.length;
|
||||
const endingCardIndex = questionIdx - questions.length;
|
||||
const card = survey.endings[endingCardIndex];
|
||||
if (card.type === "endScreen") {
|
||||
|
||||
if (card?.type === "endScreen") {
|
||||
return (card[id as keyof typeof card] as TI18nString) || createI18nString("", surveyLanguageCodes);
|
||||
} else {
|
||||
return createI18nString("", surveyLanguageCodes);
|
||||
}
|
||||
};
|
||||
|
||||
export const determineImageUploaderVisibility = (questionIdx: number, localSurvey: TSurvey) => {
|
||||
export const determineImageUploaderVisibility = (questionIdx: number, questions: TSurveyElement[]) => {
|
||||
switch (questionIdx) {
|
||||
case -1: // Welcome Card
|
||||
return false;
|
||||
default:
|
||||
// Regular Survey Question
|
||||
const question = localSurvey.questions[questionIdx];
|
||||
default: {
|
||||
// Regular Survey Question - derive questions from blocks
|
||||
const question = questions[questionIdx];
|
||||
return (!!question && !!question.imageUrl) || (!!question && !!question.videoUrl);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
subscribeOrganizationMembersToSurveyResponses,
|
||||
} from "@/lib/organization/service";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
|
||||
import { validateMediaAndPrepareBlocks } from "@/lib/survey/utils";
|
||||
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
|
||||
import { getActionClasses } from "@/modules/survey/lib/action-class";
|
||||
import { selectSurvey } from "@/modules/survey/lib/survey";
|
||||
@@ -63,7 +63,10 @@ export const createSurvey = async (
|
||||
delete data.followUps;
|
||||
}
|
||||
|
||||
if (data.questions) checkForInvalidImagesInQuestions(data.questions);
|
||||
// Validate and prepare blocks
|
||||
if (data.blocks && data.blocks.length > 0) {
|
||||
data.blocks = validateMediaAndPrepareBlocks(data.blocks);
|
||||
}
|
||||
|
||||
const survey = await prisma.survey.create({
|
||||
data: {
|
||||
|
||||
@@ -1,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") },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user