Compare commits

...

50 Commits

Author SHA1 Message Date
pandeymangg f3d679d087 Merge branch 'epic/survey-mqp' into feat/survey-editor-blocks-ui 2025-11-12 22:37:12 +05:30
pandeymangg c79a600efc initial UI changes for the PoC 2025-11-12 22:23:47 +05:30
Anshuman Pandey 7a8da3b84b feat: migrate all templates from questions to blocks structure (#6798) 2025-11-12 16:15:51 +05:30
Matti Nannt 4b2d48397d chore: fix tests 2025-11-12 11:16:31 +01:00
Matti Nannt 3ea81dc7c1 chore: remove unused templates functions for questions, fix linting issues 2025-11-12 10:54:59 +01:00
pandeymangg d9b6b550a9 Merge branch 'epic/survey-mqp' into feat/migrate-templates-to-blocks 2025-11-12 10:32:45 +05:30
Anshuman Pandey 56a6ba08ba fix: moves the integrations code to blocks schema (#6800) 2025-11-11 23:23:07 +05:30
pandeymangg 1ba55ff66c fixes tests 2025-11-11 22:22:53 +05:30
pandeymangg 0cf621d76c chore: merge with the epic branch 2025-11-11 22:12:41 +05:30
pandeymangg 3dc615fdc0 chore: merge with the epic branch 2025-11-11 22:08:12 +05:30
Anshuman Pandey 7157b17901 feat: survey summary blocks (#6795) 2025-11-11 22:06:43 +05:30
pandeymangg 82c26941e4 fixes coderabbit feedback 2025-11-11 22:05:45 +05:30
pandeymangg 591d5fa3d4 fixed tests for the xm templates 2025-11-11 16:47:42 +05:30
pandeymangg 211bca1bd8 moves the xm-templates to blocks 2025-11-11 16:13:46 +05:30
pandeymangg 5a20839c5b fixes template logic 2025-11-11 14:35:35 +05:30
pandeymangg 85743bd3d0 fixes feedback 2025-11-11 11:58:59 +05:30
pandeymangg 335ec02361 moves the integrations code over to the blocks schema 2025-11-11 11:16:08 +05:30
Matti Nannt 7918523957 feat: migrate all templates from questions to blocks structure 2025-11-10 16:44:55 +01:00
pandeymangg 3b5fe4cb94 some build fixes 2025-11-10 16:48:32 +05:30
pandeymangg 6bbd5ec7ef Merge branch 'epic/survey-mqp' into feat/survey-summary-blocks 2025-11-10 16:18:43 +05:30
pandeymangg c9542dcf79 moving survey summary, responses, follow ups to blocks 2025-11-10 16:18:00 +05:30
Anshuman Pandey 4277a9dc34 feat: Moving surveys package logic to blocks (#6785) 2025-11-10 09:47:46 +05:30
pandeymangg b1da63e47d fixes description issue 2025-11-07 14:22:13 +05:30
pandeymangg 8c05154a86 fixes feedback 2025-11-07 12:30:25 +05:30
pandeymangg 45122de652 surveys package changes for supporting blocks 2025-11-06 19:10:58 +05:30
Anshuman Pandey 2180bf98ba feat: refactor survey editor logic to use blocks model (#6778) 2025-11-06 15:45:15 +05:30
pandeymangg 2d4a94721b removes log 2025-11-06 15:11:16 +05:30
pandeymangg b2b97c8bed fixes feedback comments 2025-11-06 12:02:25 +05:30
pandeymangg f349f7199d fixes unit tests 2025-11-05 11:57:35 +05:30
pandeymangg e7d8803a13 fixes coderabbit feedback 2025-11-05 11:04:17 +05:30
pandeymangg 53a9b218bc fixes coderabbit feedback 2025-11-05 10:26:20 +05:30
pandeymangg c618e7d473 survey mqp survey editor logic 2025-11-04 22:31:35 +05:30
Anshuman Pandey 3d0f703ae1 feat(blocks): add editor utilities, validation, and unit tests for bl… (#6768) 2025-11-03 20:40:52 +05:30
pandeymangg 33eadaaa7b feedback 2025-11-03 16:37:24 +05:30
pandeymangg 452617529c updates error message 2025-11-03 14:11:53 +05:30
pandeymangg 5951eea618 feedback 2025-11-03 13:10:30 +05:30
pandeymangg e314feb416 fix 2025-11-03 11:18:11 +05:30
pandeymangg 0910b0f1a7 fix: sonar issues 2025-11-03 10:59:58 +05:30
pandeymangg 10ba42eb31 fix: code duplication 2025-11-03 10:28:02 +05:30
pandeymangg 04f1e17e23 fix: tests 2025-11-03 10:13:19 +05:30
pandeymangg 4642cc60c9 fix: coderabbit feedback 2025-11-02 17:59:16 +05:30
pandeymangg 49fa5c587c feat(blocks): add editor utilities, validation, and unit tests for blocks support 2025-10-31 17:32:47 +05:30
Anshuman Pandey 4f9b48b5e5 feat: add blocks model to support multi-question pages (schema only) (#6754) 2025-10-31 11:52:35 +05:30
pandeymangg 80789327d0 fix: feedback 2025-10-31 11:32:01 +05:30
pandeymangg 38108a32d1 fix: feedback 2025-10-31 09:18:18 +05:30
pandeymangg ce4b64da0e fix(validation): fix cyclic logic detection and add choice ID validation in block logic 2025-10-30 15:33:46 +05:30
pandeymangg 9790b071d7 fix(validation): correct operator names to match enum definition
Update comparison operators from 'lessThan/lessEqual/greaterThan/greaterEqual'
to 'isLessThan/isLessThanOrEqual/isGreaterThan/isGreaterThanOrEqual' to match
ZSurveyLogicConditionsOperator enum in OpenText number, NPS, and Rating validation.
2025-10-30 15:15:20 +05:30
pandeymangg 1f5ba0e60e fix: sonar duplicate import issue 2025-10-30 14:07:05 +05:30
pandeymangg b502bbc91e refactor(types): extract i18n and logic types to resolve circular dependencies
Moves TI18nString to packages/types/i18n.ts and all logic types to packages/types/surveys/logic.ts, updating imports across codebase.
2025-10-30 13:58:00 +05:30
pandeymangg 6772ac7c20 feat: add blocks model to support multi-question pages (schema only) 2025-10-30 00:00:53 +05:30
217 changed files with 14053 additions and 7573 deletions
@@ -32,14 +32,22 @@ const mockProject: TProject = {
};
const mockTemplate: TXMTemplate = {
name: "$[projectName] Survey",
questions: [
blocks: [
{
id: "q1",
inputType: "text",
type: "email" as any,
headline: { default: "$[projectName] Question" },
required: false,
charLimit: { enabled: true, min: 400, max: 1000 },
id: "block1",
name: "Block 1",
elements: [
{
id: "q1",
type: "openText" as const,
inputType: "text" as const,
headline: { default: "$[projectName] Question" },
subheader: { default: "" },
required: false,
placeholder: { default: "" },
charLimit: 1000,
},
],
},
],
endings: [
@@ -66,9 +74,9 @@ describe("replacePresetPlaceholders", () => {
expect(result.name).toBe("Test Project Survey");
});
test("replaces projectName placeholder in question headline", () => {
test("replaces projectName placeholder in element headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.questions[0].headline.default).toBe("Test Project Question");
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
});
test("returns a new object without mutating the original template", () => {
@@ -1,13 +1,16 @@
import { TProject } from "@formbricks/types/project";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TXMTemplate } from "@formbricks/types/templates";
import { replaceQuestionPresetPlaceholders } from "@/lib/utils/templates";
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
// replace all occurences of projectName with the actual project name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject) => {
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => {
const survey = structuredClone(template);
survey.name = survey.name.replace("$[projectName]", project.name);
survey.questions = survey.questions.map((question) => {
return replaceQuestionPresetPlaceholders(question, project);
});
return { ...template, ...survey };
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
...block,
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
}));
return { ...survey, name: survey.name.replace("$[projectName]", project.name), blocks: modifiedBlocks };
};
@@ -20,7 +20,7 @@ describe("xm-templates", () => {
expect(result).toEqual({
name: "",
endings: expect.any(Array),
questions: [],
blocks: [],
styling: {
overwriteThemeStyling: true,
},
@@ -3,19 +3,21 @@ import { TFunction } from "i18next";
import { logger } from "@formbricks/logger";
import { TXMTemplate } from "@formbricks/types/templates";
import {
buildCTAQuestion,
buildNPSQuestion,
buildOpenTextQuestion,
buildRatingQuestion,
getDefaultEndingCard,
} from "@/app/lib/survey-builder";
buildBlock,
buildCTAElement,
buildNPSElement,
buildOpenTextElement,
buildRatingElement,
createBlockJumpLogic,
} from "@/app/lib/survey-block-builder";
import { getDefaultEndingCard } from "@/app/lib/survey-builder";
export const getXMSurveyDefault = (t: TFunction): TXMTemplate => {
try {
return {
name: "",
endings: [getDefaultEndingCard([], t)],
questions: [],
blocks: [],
styling: {
overwriteThemeStyling: true,
},
@@ -30,25 +32,40 @@ const npsSurvey = (t: TFunction): TXMTemplate => {
return {
...getXMSurveyDefault(t),
name: t("templates.nps_survey_name"),
questions: [
buildNPSQuestion({
headline: t("templates.nps_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
upperLabel: t("templates.nps_survey_question_1_upper_label"),
isColorCodingEnabled: true,
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildNPSElement({
headline: t("templates.nps_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
upperLabel: t("templates.nps_survey_question_1_upper_label"),
isColorCodingEnabled: true,
}),
],
t,
}),
buildOpenTextQuestion({
headline: t("templates.nps_survey_question_2_headline"),
required: false,
inputType: "text",
buildBlock({
name: "Block 2",
elements: [
buildOpenTextElement({
headline: t("templates.nps_survey_question_2_headline"),
required: false,
inputType: "text",
}),
],
t,
}),
buildOpenTextQuestion({
headline: t("templates.nps_survey_question_3_headline"),
required: false,
inputType: "text",
buildBlock({
name: "Block 3",
elements: [
buildOpenTextElement({
headline: t("templates.nps_survey_question_3_headline"),
required: false,
inputType: "text",
}),
],
t,
}),
],
@@ -56,15 +73,27 @@ const npsSurvey = (t: TFunction): TXMTemplate => {
};
const starRatingSurvey = (t: TFunction): TXMTemplate => {
const reusableQuestionIds = [createId(), createId(), createId()];
const reusableElementIds = [createId(), createId(), createId()];
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
const defaultSurvey = getXMSurveyDefault(t);
return {
...defaultSurvey,
name: t("templates.star_rating_survey_name"),
questions: [
buildRatingQuestion({
id: reusableQuestionIds[0],
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildRatingElement({
id: reusableElementIds[0],
range: 5,
scale: "number",
headline: t("templates.star_rating_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
}),
],
logic: [
{
id: createId(),
@@ -75,7 +104,7 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[0],
value: reusableElementIds[0],
type: "question",
},
operator: "isLessThanOrEqual",
@@ -89,64 +118,44 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: reusableQuestionIds[2],
objective: "jumpToBlock",
target: block3Id,
},
],
},
],
range: 5,
scale: "number",
headline: t("templates.star_rating_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
t,
}),
buildCTAQuestion({
id: reusableQuestionIds[1],
subheader: t("templates.star_rating_survey_question_2_html"),
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[1],
type: "question",
},
operator: "isClicked",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: defaultSurvey.endings[0].id,
},
],
},
buildBlock({
name: "Block 2",
elements: [
buildCTAElement({
id: reusableElementIds[1],
subheader: t("templates.star_rating_survey_question_2_html"),
headline: t("templates.star_rating_survey_question_2_headline"),
required: true,
buttonUrl: "https://formbricks.com/github",
buttonExternal: true,
}),
],
headline: t("templates.star_rating_survey_question_2_headline"),
required: true,
buttonUrl: "https://formbricks.com/github",
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
buttonLabel: t("templates.star_rating_survey_question_2_button_label"),
buttonExternal: true,
t,
}),
buildOpenTextQuestion({
id: reusableQuestionIds[2],
headline: t("templates.star_rating_survey_question_3_headline"),
required: true,
subheader: t("templates.star_rating_survey_question_3_subheader"),
buildBlock({
id: block3Id,
name: "Block 3",
elements: [
buildOpenTextElement({
id: reusableElementIds[2],
headline: t("templates.star_rating_survey_question_3_headline"),
required: true,
subheader: t("templates.star_rating_survey_question_3_subheader"),
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
inputType: "text",
}),
],
buttonLabel: t("templates.star_rating_survey_question_3_button_label"),
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
inputType: "text",
t,
}),
],
@@ -154,15 +163,27 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
};
const csatSurvey = (t: TFunction): TXMTemplate => {
const reusableQuestionIds = [createId(), createId(), createId()];
const reusableElementIds = [createId(), createId(), createId()];
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
const defaultSurvey = getXMSurveyDefault(t);
return {
...defaultSurvey,
name: t("templates.csat_survey_name"),
questions: [
buildRatingQuestion({
id: reusableQuestionIds[0],
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildRatingElement({
id: reusableElementIds[0],
range: 5,
scale: "smiley",
headline: t("templates.csat_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
upperLabel: t("templates.csat_survey_question_1_upper_label"),
}),
],
logic: [
{
id: createId(),
@@ -173,7 +194,7 @@ const csatSurvey = (t: TFunction): TXMTemplate => {
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[0],
value: reusableElementIds[0],
type: "question",
},
operator: "isLessThanOrEqual",
@@ -187,60 +208,40 @@ const csatSurvey = (t: TFunction): TXMTemplate => {
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: reusableQuestionIds[2],
objective: "jumpToBlock",
target: block3Id,
},
],
},
],
range: 5,
scale: "smiley",
headline: t("templates.csat_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
upperLabel: t("templates.csat_survey_question_1_upper_label"),
t,
}),
buildOpenTextQuestion({
id: reusableQuestionIds[1],
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[1],
type: "question",
},
operator: "isSubmitted",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: defaultSurvey.endings[0].id,
},
],
},
buildBlock({
name: "Block 2",
elements: [
buildOpenTextElement({
id: reusableElementIds[1],
headline: t("templates.csat_survey_question_2_headline"),
required: false,
placeholder: t("templates.csat_survey_question_2_placeholder"),
inputType: "text",
}),
],
headline: t("templates.csat_survey_question_2_headline"),
required: false,
placeholder: t("templates.csat_survey_question_2_placeholder"),
inputType: "text",
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isSubmitted")],
t,
}),
buildOpenTextQuestion({
id: reusableQuestionIds[2],
headline: t("templates.csat_survey_question_3_headline"),
required: false,
placeholder: t("templates.csat_survey_question_3_placeholder"),
inputType: "text",
buildBlock({
id: block3Id,
name: "Block 3",
elements: [
buildOpenTextElement({
id: reusableElementIds[2],
headline: t("templates.csat_survey_question_3_headline"),
required: false,
placeholder: t("templates.csat_survey_question_3_placeholder"),
inputType: "text",
}),
],
t,
}),
],
@@ -251,21 +252,31 @@ const cessSurvey = (t: TFunction): TXMTemplate => {
return {
...getXMSurveyDefault(t),
name: t("templates.cess_survey_name"),
questions: [
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.cess_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
upperLabel: t("templates.cess_survey_question_1_upper_label"),
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildRatingElement({
range: 5,
scale: "number",
headline: t("templates.cess_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
upperLabel: t("templates.cess_survey_question_1_upper_label"),
}),
],
t,
}),
buildOpenTextQuestion({
headline: t("templates.cess_survey_question_2_headline"),
required: true,
placeholder: t("templates.cess_survey_question_2_placeholder"),
inputType: "text",
buildBlock({
name: "Block 2",
elements: [
buildOpenTextElement({
headline: t("templates.cess_survey_question_2_headline"),
required: true,
placeholder: t("templates.cess_survey_question_2_placeholder"),
inputType: "text",
}),
],
t,
}),
],
@@ -273,15 +284,27 @@ const cessSurvey = (t: TFunction): TXMTemplate => {
};
const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
const reusableQuestionIds = [createId(), createId(), createId()];
const reusableElementIds = [createId(), createId(), createId()];
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
const defaultSurvey = getXMSurveyDefault(t);
return {
...defaultSurvey,
name: t("templates.smileys_survey_name"),
questions: [
buildRatingQuestion({
id: reusableQuestionIds[0],
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildRatingElement({
id: reusableElementIds[0],
range: 5,
scale: "smiley",
headline: t("templates.smileys_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
}),
],
logic: [
{
id: createId(),
@@ -292,7 +315,7 @@ const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[0],
value: reusableElementIds[0],
type: "question",
},
operator: "isLessThanOrEqual",
@@ -306,64 +329,44 @@ const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: reusableQuestionIds[2],
objective: "jumpToBlock",
target: block3Id,
},
],
},
],
range: 5,
scale: "smiley",
headline: t("templates.smileys_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
t,
}),
buildCTAQuestion({
id: reusableQuestionIds[1],
subheader: t("templates.smileys_survey_question_2_html"),
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[1],
type: "question",
},
operator: "isClicked",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: defaultSurvey.endings[0].id,
},
],
},
buildBlock({
name: "Block 2",
elements: [
buildCTAElement({
id: reusableElementIds[1],
subheader: t("templates.smileys_survey_question_2_html"),
headline: t("templates.smileys_survey_question_2_headline"),
required: true,
buttonUrl: "https://formbricks.com/github",
buttonExternal: true,
}),
],
headline: t("templates.smileys_survey_question_2_headline"),
required: true,
buttonUrl: "https://formbricks.com/github",
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
buttonLabel: t("templates.smileys_survey_question_2_button_label"),
buttonExternal: true,
t,
}),
buildOpenTextQuestion({
id: reusableQuestionIds[2],
headline: t("templates.smileys_survey_question_3_headline"),
required: true,
subheader: t("templates.smileys_survey_question_3_subheader"),
buildBlock({
id: block3Id,
name: "Block 3",
elements: [
buildOpenTextElement({
id: reusableElementIds[2],
headline: t("templates.smileys_survey_question_3_headline"),
required: true,
subheader: t("templates.smileys_survey_question_3_subheader"),
placeholder: t("templates.smileys_survey_question_3_placeholder"),
inputType: "text",
}),
],
buttonLabel: t("templates.smileys_survey_question_3_button_label"),
placeholder: t("templates.smileys_survey_question_3_placeholder"),
inputType: "text",
t,
}),
],
@@ -374,25 +377,40 @@ const enpsSurvey = (t: TFunction): TXMTemplate => {
return {
...getXMSurveyDefault(t),
name: t("templates.enps_survey_name"),
questions: [
buildNPSQuestion({
headline: t("templates.enps_survey_question_1_headline"),
required: false,
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
upperLabel: t("templates.enps_survey_question_1_upper_label"),
isColorCodingEnabled: true,
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildNPSElement({
headline: t("templates.enps_survey_question_1_headline"),
required: false,
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
upperLabel: t("templates.enps_survey_question_1_upper_label"),
isColorCodingEnabled: true,
}),
],
t,
}),
buildOpenTextQuestion({
headline: t("templates.enps_survey_question_2_headline"),
required: false,
inputType: "text",
buildBlock({
name: "Block 2",
elements: [
buildOpenTextElement({
headline: t("templates.enps_survey_question_2_headline"),
required: false,
inputType: "text",
}),
],
t,
}),
buildOpenTextQuestion({
headline: t("templates.enps_survey_question_3_headline"),
required: false,
inputType: "text",
buildBlock({
name: "Block 3",
elements: [
buildOpenTextElement({
headline: t("templates.enps_survey_question_3_headline"),
required: false,
inputType: "text",
}),
],
t,
}),
],
@@ -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,
@@ -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>
@@ -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]);
@@ -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>
@@ -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":
@@ -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,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,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;
}
@@ -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;
}
@@ -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,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;
@@ -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;
@@ -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;
@@ -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;
}
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
}
@@ -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;
@@ -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" />;
};
@@ -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}
@@ -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";
@@ -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 {
@@ -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",
@@ -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,
@@ -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,
@@ -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,
});
+308
View File
@@ -0,0 +1,308 @@
import { createId } from "@paralleldrive/cuid2";
import type { TFunction } from "i18next";
import type { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import type {
TSurveyCTAElement,
TSurveyConsentElement,
TSurveyElement,
TSurveyMultipleChoiceElement,
TSurveyNPSElement,
TSurveyOpenTextElement,
TSurveyOpenTextElementInputType,
TSurveyRatingElement,
} from "@formbricks/types/surveys/elements";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import type { TShuffleOption } from "@formbricks/types/surveys/types";
import { createI18nString } from "@/lib/i18n/utils";
const getDefaultButtonLabel = (label: string | undefined, t: TFunction) =>
createI18nString(label || t("common.next"), []);
const getDefaultBackButtonLabel = (label: string | undefined, t: TFunction) =>
createI18nString(label || t("common.back"), []);
export const buildMultipleChoiceElement = ({
id,
headline,
type,
subheader,
choices,
choiceIds,
shuffleOption,
required,
containsOther = false,
}: {
id?: string;
headline: string;
type: TSurveyElementTypeEnum.MultipleChoiceMulti | TSurveyElementTypeEnum.MultipleChoiceSingle;
subheader?: string;
choices: string[];
choiceIds?: string[];
shuffleOption?: TShuffleOption;
required?: boolean;
containsOther?: boolean;
}): TSurveyMultipleChoiceElement => {
return {
id: id ?? createId(),
type,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
choices: choices.map((choice, index) => {
const isLastIndex = index === choices.length - 1;
let choiceId: string;
if (containsOther && isLastIndex) {
choiceId = "other";
} else if (choiceIds) {
choiceId = choiceIds[index];
} else {
choiceId = createId();
}
return { id: choiceId, label: createI18nString(choice, []) };
}),
shuffleOption: shuffleOption || "none",
required: required ?? false,
};
};
export const buildOpenTextElement = ({
id,
headline,
subheader,
placeholder,
inputType,
required,
longAnswer,
}: {
id?: string;
headline: string;
subheader?: string;
placeholder?: string;
required?: boolean;
inputType: TSurveyOpenTextElementInputType;
longAnswer?: boolean;
}): TSurveyOpenTextElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.OpenText,
inputType,
subheader: subheader ? createI18nString(subheader, []) : undefined,
placeholder: placeholder ? createI18nString(placeholder, []) : undefined,
headline: createI18nString(headline, []),
required: required ?? false,
longAnswer,
charLimit: {
enabled: false,
},
};
};
export const buildRatingElement = ({
id,
headline,
subheader,
scale,
range,
lowerLabel,
upperLabel,
required,
isColorCodingEnabled = false,
}: {
id?: string;
headline: string;
scale: TSurveyRatingElement["scale"];
range: TSurveyRatingElement["range"];
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): TSurveyRatingElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.Rating,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
scale,
range,
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
};
};
export const buildConsentElement = ({
id,
headline,
subheader,
label,
required,
}: {
id?: string;
headline: string;
subheader: string;
required?: boolean;
label: string;
}): TSurveyConsentElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.Consent,
subheader: createI18nString(subheader, []),
headline: createI18nString(headline, []),
required: required ?? false,
label: createI18nString(label, []),
};
};
export const buildCTAElement = ({
id,
headline,
subheader,
buttonExternal,
required,
dismissButtonLabel,
buttonUrl,
}: {
id?: string;
headline: string;
buttonExternal: boolean;
subheader: string;
required?: boolean;
dismissButtonLabel?: string;
buttonUrl?: string;
}): TSurveyCTAElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.CTA,
subheader: createI18nString(subheader, []),
headline: createI18nString(headline, []),
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
required: required ?? false,
buttonExternal,
buttonUrl,
};
};
export const buildNPSElement = ({
id,
headline,
subheader,
lowerLabel,
upperLabel,
required,
isColorCodingEnabled = false,
}: {
id?: string;
headline: string;
subheader?: string;
lowerLabel?: string;
upperLabel?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): TSurveyNPSElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.NPS,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
};
};
// Helper function to create block-level jump logic based on operator
export const createBlockJumpLogic = (
sourceElementId: string,
targetBlockId: string,
operator: "isSkipped" | "isSubmitted" | "isClicked"
): TSurveyBlockLogic => ({
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: sourceElementId,
type: "question",
},
operator: operator,
},
],
},
actions: [
{
id: createId(),
objective: "jumpToBlock",
target: targetBlockId,
},
],
});
// Helper function to create block-level jump logic based on choice selection
export const createBlockChoiceJumpLogic = (
sourceElementId: string,
choiceId: string | number,
targetBlockId: string
): TSurveyBlockLogic => ({
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: sourceElementId,
type: "question",
},
operator: "equals",
rightOperand: {
type: "static",
value: choiceId,
},
},
],
},
actions: [
{
id: createId(),
objective: "jumpToBlock",
target: targetBlockId,
},
],
});
// Block builder function
export const buildBlock = ({
id,
name,
elements,
logic,
logicFallback,
buttonLabel,
backButtonLabel,
t,
}: {
id?: string;
name: string;
elements: TSurveyElement[];
logic?: TSurveyBlockLogic[];
logicFallback?: string;
buttonLabel?: string;
backButtonLabel?: string;
t: TFunction;
}): TSurveyBlock => {
return {
id: id ?? createId(),
name,
elements,
logic,
logicFallback,
buttonLabel: buttonLabel ? getDefaultButtonLabel(buttonLabel, t) : undefined,
backButtonLabel: backButtonLabel ? getDefaultBackButtonLabel(backButtonLabel, t) : undefined,
};
};
+60 -583
View File
@@ -1,15 +1,6 @@
import { describe, expect, test } from "vitest";
import { TShuffleOption, TSurveyLogic, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
buildCTAQuestion,
buildConsentQuestion,
buildMultipleChoiceQuestion,
buildNPSQuestion,
buildOpenTextQuestion,
buildRatingQuestion,
buildSurvey,
createChoiceJumpLogic,
createJumpLogic,
getDefaultEndingCard,
getDefaultSurveyPreset,
getDefaultWelcomeCard,
@@ -19,595 +10,81 @@ import {
const mockT = (props: any): string => (typeof props === "string" ? props : props.key);
describe("Survey Builder", () => {
describe("buildMultipleChoiceQuestion", () => {
test("creates a single choice question with required fields", () => {
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: ["Option 1", "Option 2", "Option 3"],
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Test Question" },
choices: expect.arrayContaining([
expect.objectContaining({ label: { default: "Option 1" } }),
expect.objectContaining({ label: { default: "Option 2" } }),
expect.objectContaining({ label: { default: "Option 3" } }),
]),
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
shuffleOption: "none",
required: false,
});
expect(question.choices.length).toBe(3);
expect(question.id).toBeDefined();
describe("Helper Functions", () => {
test("getDefaultSurveyPreset returns expected default survey preset", () => {
const preset = getDefaultSurveyPreset(mockT);
expect(preset.name).toBe("New Survey");
// test welcomeCard and endings
expect(preset.welcomeCard).toHaveProperty("headline");
expect(preset.endings).toHaveLength(1);
expect(preset.endings[0]).toHaveProperty("headline");
expect(preset.hiddenFields).toEqual(hiddenFieldsDefault);
expect(preset.blocks).toEqual([]);
});
test("creates a multiple choice question with provided ID", () => {
const customId = "custom-id-123";
const question = buildMultipleChoiceQuestion({
id: customId,
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
choices: ["Option 1", "Option 2"],
t: mockT,
test("getDefaultWelcomeCard returns expected welcome card", () => {
const welcomeCard = getDefaultWelcomeCard(mockT);
expect(welcomeCard).toMatchObject({
enabled: false,
headline: { default: "templates.default_welcome_card_headline" },
timeToFinish: false,
showResponseCount: false,
});
expect(question.id).toBe(customId);
expect(question.type).toBe(TSurveyQuestionTypeEnum.MultipleChoiceMulti);
// Check that the welcome card is properly structured
expect(welcomeCard).toHaveProperty("enabled");
expect(welcomeCard).toHaveProperty("headline");
expect(welcomeCard).toHaveProperty("showResponseCount");
expect(welcomeCard).toHaveProperty("timeToFinish");
});
test("handles 'other' option correctly", () => {
const choices = ["Option 1", "Option 2", "Other"];
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices,
containsOther: true,
t: mockT,
test("getDefaultEndingCard returns expected ending card", () => {
const languages: string[] = [];
const endingCard = getDefaultEndingCard(languages, mockT);
expect(endingCard).toMatchObject({
type: "endScreen",
headline: { default: "templates.default_ending_card_headline" },
subheader: { default: "templates.default_ending_card_subheader" },
});
expect(question.choices.length).toBe(3);
expect(question.choices[2].id).toBe("other");
expect(endingCard.id).toBeDefined();
expect(endingCard).toHaveProperty("buttonLabel");
expect(endingCard).toHaveProperty("buttonLink");
});
test("uses provided choice IDs when available", () => {
const choiceIds = ["id1", "id2", "id3"];
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: ["Option 1", "Option 2", "Option 3"],
choiceIds,
t: mockT,
test("hiddenFieldsDefault has expected structure", () => {
expect(hiddenFieldsDefault).toMatchObject({
enabled: true,
fieldIds: [],
});
expect(question.choices[0].id).toBe(choiceIds[0]);
expect(question.choices[1].id).toBe(choiceIds[1]);
expect(question.choices[2].id).toBe(choiceIds[2]);
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
test("buildSurvey returns built survey with overridden preset properties", () => {
const config = {
name: "Custom Survey",
role: "productManager" as const,
industries: ["saas" as const],
channels: ["link" as const],
description: "A custom survey description",
blocks: [],
endings: [getDefaultEndingCard([], mockT)],
hiddenFields: hiddenFieldsDefault,
};
const shuffleOption: TShuffleOption = "all";
const survey = buildSurvey(config, mockT);
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
subheader: "This is a subheader",
choices: ["Option 1", "Option 2"],
buttonLabel: "Custom Next",
backButtonLabel: "Custom Back",
shuffleOption,
required: false,
logic,
t: mockT,
});
// role, industries, channels, description
expect(survey.role).toBe(config.role);
expect(survey.industries).toEqual(config.industries);
expect(survey.channels).toEqual(config.channels);
expect(survey.description).toBe(config.description);
expect(question.subheader).toEqual({ default: "This is a subheader" });
expect(question.buttonLabel).toEqual({ default: "Custom Next" });
expect(question.backButtonLabel).toEqual({ default: "Custom Back" });
expect(question.shuffleOption).toBe("all");
expect(question.required).toBe(false);
expect(question.logic).toBe(logic);
});
});
// preset overrides
expect(survey.preset.name).toBe(config.name);
expect(survey.preset.endings).toEqual(config.endings);
expect(survey.preset.hiddenFields).toEqual(config.hiddenFields);
expect(survey.preset.blocks).toEqual(config.blocks);
describe("buildOpenTextQuestion", () => {
test("creates an open text question with required fields", () => {
const question = buildOpenTextQuestion({
headline: "Open Question",
inputType: "text",
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Open Question" },
inputType: "text",
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
charLimit: {
enabled: false,
},
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildOpenTextQuestion({
id: "custom-id",
headline: "Open Question",
subheader: "Answer this question",
placeholder: "Type here",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
longAnswer: true,
inputType: "email",
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "Answer this question" });
expect(question.placeholder).toEqual({ default: "Type here" });
expect(question.buttonLabel).toEqual({ default: "Submit" });
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.longAnswer).toBe(true);
expect(question.inputType).toBe("email");
expect(question.logic).toBe(logic);
});
});
describe("buildRatingQuestion", () => {
test("creates a rating question with required fields", () => {
const question = buildRatingQuestion({
headline: "Rating Question",
scale: "number",
range: 5,
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rating Question" },
scale: "number",
range: 5,
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
isColorCodingEnabled: false,
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildRatingQuestion({
id: "custom-id",
headline: "Rating Question",
subheader: "Rate us",
scale: "star",
range: 10,
lowerLabel: "Poor",
upperLabel: "Excellent",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
isColorCodingEnabled: true,
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "Rate us" });
expect(question.scale).toBe("star");
expect(question.range).toBe(10);
expect(question.lowerLabel).toEqual({ default: "Poor" });
expect(question.upperLabel).toEqual({ default: "Excellent" });
expect(question.buttonLabel).toEqual({ default: "Submit" });
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.isColorCodingEnabled).toBe(true);
expect(question.logic).toBe(logic);
});
});
describe("buildNPSQuestion", () => {
test("creates an NPS question with required fields", () => {
const question = buildNPSQuestion({
headline: "NPS Question",
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "NPS Question" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
isColorCodingEnabled: false,
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildNPSQuestion({
id: "custom-id",
headline: "NPS Question",
subheader: "How likely are you to recommend us?",
lowerLabel: "Not likely",
upperLabel: "Very likely",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
isColorCodingEnabled: true,
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "How likely are you to recommend us?" });
expect(question.lowerLabel).toEqual({ default: "Not likely" });
expect(question.upperLabel).toEqual({ default: "Very likely" });
expect(question.buttonLabel).toEqual({ default: "Submit" });
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.isColorCodingEnabled).toBe(true);
expect(question.logic).toBe(logic);
});
});
describe("buildConsentQuestion", () => {
test("creates a consent question with required fields", () => {
const question = buildConsentQuestion({
headline: "Consent Question",
subheader: "",
label: "I agree to terms",
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.Consent,
headline: { default: "Consent Question" },
subheader: { default: "" },
label: { default: "I agree to terms" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildConsentQuestion({
id: "custom-id",
headline: "Consent Question",
subheader: "Please read the terms",
label: "I agree to terms",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "Please read the terms" });
expect(question.label).toEqual({ default: "I agree to terms" });
expect(question.buttonLabel).toEqual({ default: "Submit" });
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.logic).toBe(logic);
});
});
describe("buildCTAQuestion", () => {
test("creates a CTA question with required fields", () => {
const question = buildCTAQuestion({
headline: "CTA Question",
subheader: "",
buttonExternal: false,
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA Question" },
subheader: { default: "" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
buttonExternal: false,
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildCTAQuestion({
id: "custom-id",
headline: "CTA Question",
subheader: "<p>Click the button</p>",
buttonLabel: "Click me",
buttonExternal: true,
buttonUrl: "https://example.com",
backButtonLabel: "Previous",
required: false,
dismissButtonLabel: "No thanks",
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "<p>Click the button</p>" });
expect(question.buttonLabel).toEqual({ default: "Click me" });
expect(question.buttonExternal).toBe(true);
expect(question.buttonUrl).toBe("https://example.com");
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.dismissButtonLabel).toEqual({ default: "No thanks" });
expect(question.logic).toBe(logic);
});
test("handles external button with URL", () => {
const question = buildCTAQuestion({
headline: "CTA Question",
subheader: "",
buttonExternal: true,
buttonUrl: "https://formbricks.com",
t: mockT,
});
expect(question.buttonExternal).toBe(true);
expect(question.buttonUrl).toBe("https://formbricks.com");
});
});
// Test combinations of parameters for edge cases
describe("Edge cases", () => {
test("multiple choice question with empty choices array", () => {
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [],
t: mockT,
});
expect(question.choices).toEqual([]);
});
test("open text question with all parameters", () => {
const question = buildOpenTextQuestion({
id: "custom-id",
headline: "Open Question",
subheader: "Answer this question",
placeholder: "Type here",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
longAnswer: true,
inputType: "email",
logic: [],
t: mockT,
});
expect(question).toMatchObject({
id: "custom-id",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Open Question" },
subheader: { default: "Answer this question" },
placeholder: { default: "Type here" },
buttonLabel: { default: "Submit" },
backButtonLabel: { default: "Previous" },
required: false,
longAnswer: true,
inputType: "email",
logic: [],
});
// default values from getDefaultSurveyPreset
expect(survey.preset.welcomeCard).toHaveProperty("headline");
});
});
});
describe("Helper Functions", () => {
test("createJumpLogic returns valid jump logic", () => {
const sourceId = "q1";
const targetId = "q2";
const operator: "isClicked" = "isClicked";
const logic = createJumpLogic(sourceId, targetId, operator);
// Check structure
expect(logic).toHaveProperty("id");
expect(logic).toHaveProperty("conditions");
expect(logic.conditions).toHaveProperty("conditions");
expect(Array.isArray(logic.conditions.conditions)).toBe(true);
// Check one of the inner conditions
const condition = logic.conditions.conditions[0];
// Need to use type checking to ensure condition is a TSingleCondition not a TConditionGroup
if (!("connector" in condition)) {
expect(condition.leftOperand.value).toBe(sourceId);
expect(condition.operator).toBe(operator);
}
// Check actions
expect(Array.isArray(logic.actions)).toBe(true);
const action = logic.actions[0];
if (action.objective === "jumpToQuestion") {
expect(action.target).toBe(targetId);
}
});
test("createChoiceJumpLogic returns valid jump logic based on choice selection", () => {
const sourceId = "q1";
const choiceId = "choice1";
const targetId = "q2";
const logic = createChoiceJumpLogic(sourceId, choiceId, targetId);
expect(logic).toHaveProperty("id");
expect(logic.conditions).toHaveProperty("conditions");
const condition = logic.conditions.conditions[0];
if (!("connector" in condition)) {
expect(condition.leftOperand.value).toBe(sourceId);
expect(condition.operator).toBe("equals");
expect(condition.rightOperand?.value).toBe(choiceId);
}
const action = logic.actions[0];
if (action.objective === "jumpToQuestion") {
expect(action.target).toBe(targetId);
}
});
test("getDefaultWelcomeCard returns expected welcome card", () => {
const card = getDefaultWelcomeCard(mockT);
expect(card.enabled).toBe(false);
expect(card.headline).toEqual({ default: "templates.default_welcome_card_headline" });
expect(card.subheader).toEqual({ default: "templates.default_welcome_card_html" });
expect(card.buttonLabel).toEqual({ default: "templates.default_welcome_card_button_label" });
// boolean flags
expect(card.timeToFinish).toBe(false);
expect(card.showResponseCount).toBe(false);
});
test("getDefaultEndingCard returns expected end screen card", () => {
// Pass empty languages array to simulate no languages
const card = getDefaultEndingCard([], mockT);
expect(card).toHaveProperty("id");
expect(card.type).toBe("endScreen");
expect(card.headline).toEqual({ default: "templates.default_ending_card_headline" });
expect(card.subheader).toEqual({ default: "templates.default_ending_card_subheader" });
expect(card.buttonLabel).toEqual({ default: "templates.default_ending_card_button_label" });
expect(card.buttonLink).toBe("https://formbricks.com");
});
test("getDefaultSurveyPreset returns expected default survey preset", () => {
const preset = getDefaultSurveyPreset(mockT);
expect(preset.name).toBe("New Survey");
expect(preset.questions).toEqual([]);
// test welcomeCard and endings
expect(preset.welcomeCard).toHaveProperty("headline");
expect(Array.isArray(preset.endings)).toBe(true);
expect(preset.hiddenFields).toEqual(hiddenFieldsDefault);
});
test("buildSurvey returns built survey with overridden preset properties", () => {
const config = {
name: "Custom Survey",
industries: ["eCommerce"] as string[],
channels: ["link"],
description: "Test survey",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText, // changed from "OpenText"
headline: { default: "Question 1" },
inputType: "text",
buttonLabel: { default: "Next" },
backButtonLabel: { default: "Back" },
required: true,
},
],
endings: [
{
id: "end1",
type: "endScreen",
headline: { default: "End Screen" },
subheader: { default: "Thanks" },
buttonLabel: { default: "Finish" },
buttonLink: "https://formbricks.com",
},
],
hiddenFields: { enabled: false, fieldIds: ["f1"] },
};
const survey = buildSurvey(config as any, mockT);
expect(survey.name).toBe(config.name);
expect(survey.industries).toEqual(config.industries);
expect(survey.channels).toEqual(config.channels);
expect(survey.description).toBe(config.description);
// preset overrides
expect(survey.preset.name).toBe(config.name);
expect(survey.preset.questions).toEqual(config.questions);
expect(survey.preset.endings).toEqual(config.endings);
expect(survey.preset.hiddenFields).toEqual(config.hiddenFields);
});
test("hiddenFieldsDefault has expected default configuration", () => {
expect(hiddenFieldsDefault).toEqual({ enabled: true, fieldIds: [] });
});
});
+10 -277
View File
@@ -1,284 +1,17 @@
import { createId } from "@paralleldrive/cuid2";
import { TFunction } from "i18next";
import {
TShuffleOption,
TSurveyCTAQuestion,
TSurveyConsentQuestion,
import type { TFunction } from "i18next";
import type { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import type {
TSurveyEndScreenCard,
TSurveyEnding,
TSurveyHiddenFields,
TSurveyLanguage,
TSurveyLogic,
TSurveyMultipleChoiceQuestion,
TSurveyNPSQuestion,
TSurveyOpenTextQuestion,
TSurveyOpenTextQuestionInputType,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveyRatingQuestion,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { TTemplate, TTemplateRole } from "@formbricks/types/templates";
import type { TTemplate, TTemplateRole } from "@formbricks/types/templates";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
const getDefaultButtonLabel = (label: string | undefined, t: TFunction) =>
createI18nString(label || t("common.next"), []);
const getDefaultBackButtonLabel = (label: string | undefined, t: TFunction) =>
createI18nString(label || t("common.back"), []);
export const buildMultipleChoiceQuestion = ({
id,
headline,
type,
subheader,
choices,
choiceIds,
buttonLabel,
backButtonLabel,
shuffleOption,
required,
logic,
containsOther = false,
t,
}: {
id?: string;
headline: string;
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti | TSurveyQuestionTypeEnum.MultipleChoiceSingle;
subheader?: string;
choices: string[];
choiceIds?: string[];
buttonLabel?: string;
backButtonLabel?: string;
shuffleOption?: TShuffleOption;
required?: boolean;
logic?: TSurveyLogic[];
containsOther?: boolean;
t: TFunction;
}): TSurveyMultipleChoiceQuestion => {
return {
id: id ?? createId(),
type,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
choices: choices.map((choice, index) => {
const isLastIndex = index === choices.length - 1;
const id = containsOther && isLastIndex ? "other" : choiceIds ? choiceIds[index] : createId();
return { id, label: createI18nString(choice, []) };
}),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
shuffleOption: shuffleOption || "none",
required: required ?? false,
logic,
};
};
export const buildOpenTextQuestion = ({
id,
headline,
subheader,
placeholder,
inputType,
buttonLabel,
backButtonLabel,
required,
logic,
longAnswer,
t,
}: {
id?: string;
headline: string;
subheader?: string;
placeholder?: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
logic?: TSurveyLogic[];
inputType: TSurveyOpenTextQuestionInputType;
longAnswer?: boolean;
t: TFunction;
}): TSurveyOpenTextQuestion => {
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.OpenText,
inputType,
subheader: subheader ? createI18nString(subheader, []) : undefined,
placeholder: placeholder ? createI18nString(placeholder, []) : undefined,
headline: createI18nString(headline, []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
required: required ?? false,
longAnswer,
logic,
charLimit: {
enabled: false,
},
};
};
export const buildRatingQuestion = ({
id,
headline,
subheader,
scale,
range,
lowerLabel,
upperLabel,
buttonLabel,
backButtonLabel,
required,
logic,
isColorCodingEnabled = false,
t,
}: {
id?: string;
headline: string;
scale: TSurveyRatingQuestion["scale"];
range: TSurveyRatingQuestion["range"];
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
placeholder?: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
logic?: TSurveyLogic[];
isColorCodingEnabled?: boolean;
t: TFunction;
}): TSurveyRatingQuestion => {
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.Rating,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
scale,
range,
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
logic,
};
};
export const buildNPSQuestion = ({
id,
headline,
subheader,
lowerLabel,
upperLabel,
buttonLabel,
backButtonLabel,
required,
logic,
isColorCodingEnabled = false,
t,
}: {
id?: string;
headline: string;
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
placeholder?: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
logic?: TSurveyLogic[];
isColorCodingEnabled?: boolean;
t: TFunction;
}): TSurveyNPSQuestion => {
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.NPS,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
logic,
};
};
export const buildConsentQuestion = ({
id,
headline,
subheader,
label,
buttonLabel,
backButtonLabel,
required,
logic,
t,
}: {
id?: string;
headline: string;
subheader: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
logic?: TSurveyLogic[];
label: string;
t: TFunction;
}): TSurveyConsentQuestion => {
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.Consent,
subheader: createI18nString(subheader, []),
headline: createI18nString(headline, []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
required: required ?? false,
label: createI18nString(label, []),
logic,
};
};
export const buildCTAQuestion = ({
id,
headline,
subheader,
buttonLabel,
buttonExternal,
backButtonLabel,
required,
logic,
dismissButtonLabel,
buttonUrl,
t,
}: {
id?: string;
headline: string;
buttonExternal: boolean;
subheader: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
logic?: TSurveyLogic[];
dismissButtonLabel?: string;
buttonUrl?: string;
t: TFunction;
}): TSurveyCTAQuestion => {
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.CTA,
subheader: createI18nString(subheader, []),
headline: createI18nString(headline, []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
required: required ?? false,
buttonExternal,
buttonUrl,
logic,
};
};
// Helper function to create standard jump logic based on operator
export const createJumpLogic = (
sourceQuestionId: string,
@@ -377,13 +110,13 @@ export const getDefaultSurveyPreset = (t: TFunction): TTemplate["preset"] => {
welcomeCard: getDefaultWelcomeCard(t),
endings: [getDefaultEndingCard([], t)],
hiddenFields: hiddenFieldsDefault,
questions: [],
blocks: [],
};
};
/**
* Generic builder for survey.
* @param config - The configuration for survey settings and questions.
* @param config - The configuration for survey settings and blocks.
* @param t - The translation function.
*/
export const buildSurvey = (
@@ -393,9 +126,9 @@ export const buildSurvey = (
channels: ("link" | "app" | "website")[];
role: TTemplateRole;
description: string;
questions: TSurveyQuestion[];
endings?: TSurveyEnding[];
hiddenFields?: TSurveyHiddenFields;
blocks: TSurveyBlock[];
endings: TSurveyEnding[];
hiddenFields: TSurveyHiddenFields;
},
t: TFunction
): TTemplate => {
@@ -409,7 +142,7 @@ export const buildSurvey = (
preset: {
...localSurvey,
name: config.name,
questions: config.questions,
blocks: config.blocks ?? [],
endings: config.endings ?? localSurvey.endings,
hiddenFields: config.hiddenFields ?? hiddenFieldsDefault,
},
+228 -143
View File
@@ -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" },
},
+25 -21
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -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
+2 -1
View File
@@ -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.
+6 -4
View File
@@ -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)
+106 -84
View File
@@ -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 },
};
+16 -11
View File
@@ -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") {
+49 -33
View File
@@ -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: {
+9 -5
View File
@@ -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({
+90 -85
View File
@@ -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 },
+10 -10
View File
@@ -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);
+26 -9
View File
@@ -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,
+424 -1
View File
@@ -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");
});
});
+193
View File
@@ -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 };
};
+137 -125
View File
@@ -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 = {
+36 -34
View File
@@ -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;
}
+58 -33
View File
@@ -145,7 +145,7 @@ describe("recall utility functions", () => {
const headline = { en: "How do you like #recall:product/fallback:ournbspproduct#?" };
const survey = {
id: "test-survey",
questions: [{ id: "product", headline: { en: "Product Question" } }],
blocks: [{ id: "b1", elements: [{ id: "product", headline: { en: "Product Question" } }] }],
hiddenFields: { fieldIds: [] },
variables: [],
} as any;
@@ -158,7 +158,7 @@ describe("recall utility functions", () => {
const headline = { en: "Rate #recall:product/fallback:ournbspproduct#" };
const survey = {
id: "test-survey",
questions: [{ id: "product", headline: { en: "Product Question" } }],
blocks: [{ id: "b1", elements: [{ id: "product", headline: { en: "Product Question" } }] }],
hiddenFields: { fieldIds: [] },
variables: [],
} as any;
@@ -171,7 +171,7 @@ describe("recall utility functions", () => {
const headline = { en: "Your email is #recall:email/fallback:notnbspprovided#" };
const survey: TSurvey = {
id: "test-survey",
questions: [],
blocks: [],
hiddenFields: { fieldIds: ["email"] },
variables: [],
} as unknown as TSurvey;
@@ -184,7 +184,7 @@ describe("recall utility functions", () => {
const headline = { en: "Your plan is #recall:plan/fallback:unknown#" };
const survey: TSurvey = {
id: "test-survey",
questions: [],
blocks: [],
hiddenFields: { fieldIds: [] },
variables: [{ id: "plan", name: "Subscription Plan" }],
} as unknown as TSurvey;
@@ -207,7 +207,7 @@ describe("recall utility functions", () => {
};
const survey = {
id: "test-survey",
questions: [{ id: "inner", headline: { en: "Inner with @outer" } }],
blocks: [{ id: "b1", elements: [{ id: "inner", headline: { en: "Inner with @outer" } }] }],
hiddenFields: { fieldIds: [] },
variables: [],
} as any;
@@ -241,41 +241,56 @@ describe("recall utility functions", () => {
test("identifies question with empty fallback value", () => {
const questionHeadline = { en: "Question with #recall:id1/fallback:# empty fallback" };
const survey = {
questions: [
blocks: [
{
id: "q1",
headline: questionHeadline,
id: "b1",
elements: [
{
id: "q1",
headline: questionHeadline,
},
],
},
],
} as any;
const result = checkForEmptyFallBackValue(survey, "en");
expect(result).toBe(survey.questions[0]);
expect(result).toBe(survey.blocks[0].elements[0]);
});
test("identifies question with empty fallback in subheader", () => {
const questionSubheader = { en: "Subheader with #recall:id1/fallback:# empty fallback" };
const survey = {
questions: [
blocks: [
{
id: "q1",
headline: { en: "Normal question" },
subheader: questionSubheader,
id: "b1",
elements: [
{
id: "q1",
headline: { en: "Normal question" },
subheader: questionSubheader,
},
],
},
],
} as any;
const result = checkForEmptyFallBackValue(survey, "en");
expect(result).toBe(survey.questions[0]);
expect(result).toBe(survey.blocks[0].elements[0]);
});
test("returns null when no empty fallback values are found", () => {
const questionHeadline = { en: "Question with #recall:id1/fallback:default# valid fallback" };
const survey = {
questions: [
blocks: [
{
id: "q1",
headline: questionHeadline,
id: "b1",
elements: [
{
id: "q1",
headline: questionHeadline,
},
],
},
],
} as any;
@@ -288,16 +303,21 @@ describe("recall utility functions", () => {
describe("replaceHeadlineRecall", () => {
test("processes all questions in a survey", () => {
const survey: TSurvey = {
questions: [
blocks: [
{
id: "q1",
headline: { en: "Question with #recall:id1/fallback:default#" },
id: "b1",
elements: [
{
id: "q1",
headline: { en: "Question with #recall:id1/fallback:default#" },
},
{
id: "q2",
headline: { en: "Another with #recall:id2/fallback:other#" },
},
],
},
{
id: "q2",
headline: { en: "Another with #recall:id2/fallback:other#" },
},
] as unknown as TSurveyQuestion[],
],
hiddenFields: { fieldIds: [] },
variables: [],
} as unknown as TSurvey;
@@ -308,8 +328,8 @@ describe("recall utility functions", () => {
// Verify recallToHeadline was called for each question
expect(result).not.toBe(survey); // Should be a clone
expect(result.questions[0].headline).not.toEqual(survey.questions[0].headline);
expect(result.questions[1].headline).not.toEqual(survey.questions[1].headline);
expect(result.blocks[0].elements[0].headline).not.toEqual(survey.blocks[0].elements[0].headline);
expect(result.blocks[0].elements[1].headline).not.toEqual(survey.blocks[0].elements[1].headline);
});
});
@@ -317,10 +337,15 @@ describe("recall utility functions", () => {
test("extracts recall items from text", () => {
const text = "Text with #recall:id1/fallback:val1# and #recall:id2/fallback:val2#";
const survey: TSurvey = {
questions: [
{ id: "id1", headline: { en: "Question One" } },
{ id: "id2", headline: { en: "Question Two" } },
] as unknown as TSurveyQuestion[],
blocks: [
{
id: "b1",
elements: [
{ id: "id1", headline: { en: "Question One" } },
{ id: "id2", headline: { en: "Question Two" } },
],
},
],
hiddenFields: { fieldIds: [] },
variables: [],
} as unknown as TSurvey;
@@ -339,7 +364,7 @@ describe("recall utility functions", () => {
test("handles hidden fields in recall items", () => {
const text = "Text with #recall:hidden1/fallback:val1#";
const survey: TSurvey = {
questions: [],
blocks: [],
hiddenFields: { fieldIds: ["hidden1"] },
variables: [],
} as unknown as TSurvey;
@@ -354,7 +379,7 @@ describe("recall utility functions", () => {
test("handles variables in recall items", () => {
const text = "Text with #recall:var1/fallback:val1#";
const survey: TSurvey = {
questions: [],
blocks: [],
hiddenFields: { fieldIds: [] },
variables: [{ id: "var1", name: "Variable One" }],
} as unknown as TSurvey;
+13 -6
View File
@@ -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);
+78 -123
View File
@@ -1,164 +1,119 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TTemplate } from "@formbricks/types/templates";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { type TProject } from "@formbricks/types/project";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TTemplate } from "@formbricks/types/templates";
import * as i18nUtils from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { replacePresetPlaceholders, replaceQuestionPresetPlaceholders } from "./templates";
import { replaceElementPresetPlaceholders, replacePresetPlaceholders } from "./templates";
// Mock the imported functions
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: vi.fn(),
}));
vi.mock("@/lib/i18n/utils");
vi.mock("@/lib/pollyfills/structuredClone");
vi.mock("@/lib/pollyfills/structuredClone", () => ({
structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
}));
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(structuredClone).mockImplementation((obj) => JSON.parse(JSON.stringify(obj)));
// Mock getLocalizedValue to return the value from the object
vi.mocked(i18nUtils.getLocalizedValue).mockImplementation((obj: any, lang: string) => obj?.[lang] || "");
});
describe("Template Utilities", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("replaceElementPresetPlaceholders", () => {
test("returns original element when project is not provided", () => {
const element = {
type: "openText",
headline: { default: "Question about $[projectName]?" },
} as unknown as TSurveyElement;
describe("replaceQuestionPresetPlaceholders", () => {
test("returns original question when project is not provided", () => {
const question: TSurveyQuestion = {
id: "test-id",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "Test Question $[projectName]",
},
} as unknown as TSurveyQuestion;
const result = replaceElementPresetPlaceholders(element, undefined as any);
const result = replaceQuestionPresetPlaceholders(question, undefined as unknown as TProject);
expect(result).toEqual(element);
});
expect(result).toEqual(question);
expect(structuredClone).not.toHaveBeenCalled();
test("replaces projectName placeholder in headline", () => {
const element = {
type: "openText",
headline: { default: "How do you like $[projectName]?" },
} as unknown as TSurveyElement;
const project = {
name: "TestProject",
} as unknown as TProject;
const result = replaceElementPresetPlaceholders(element, project);
// The function directly replaces without calling getLocalizedValue in the test scenario
expect(result.headline?.default).toBe("How do you like TestProject?");
});
test("replaces projectName placeholder in subheader", () => {
const question: TSurveyQuestion = {
id: "test-id",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "Test Question",
},
subheader: {
default: "Subheader for $[projectName]",
},
} as unknown as TSurveyQuestion;
const element = {
type: "openText",
headline: { default: "Question" },
subheader: { default: "Subheader for $[projectName]" },
} as unknown as TSurveyElement;
const project: TProject = {
id: "project-id",
name: "Test Project",
organizationId: "org-id",
const project = {
name: "TestProject",
} as unknown as TProject;
// Mock for headline and subheader with correct return values
vi.mocked(getLocalizedValue).mockReturnValueOnce("Test Question");
vi.mocked(getLocalizedValue).mockReturnValueOnce("Subheader for $[projectName]");
const result = replaceElementPresetPlaceholders(element, project);
const result = replaceQuestionPresetPlaceholders(question, project);
expect(vi.mocked(getLocalizedValue)).toHaveBeenCalledTimes(2);
expect(result.subheader?.default).toBe("Subheader for Test Project");
expect(result.headline?.default).toBe("Question");
expect(result.subheader?.default).toBe("Subheader for TestProject");
});
test("handles missing headline and subheader", () => {
const question: TSurveyQuestion = {
id: "test-id",
type: TSurveyQuestionTypeEnum.OpenText,
} as unknown as TSurveyQuestion;
const element = {
type: "openText",
} as unknown as TSurveyElement;
const project: TProject = {
id: "project-id",
name: "Test Project",
organizationId: "org-id",
const project = {
name: "TestProject",
} as unknown as TProject;
const result = replaceQuestionPresetPlaceholders(question, project);
const result = replaceElementPresetPlaceholders(element, project);
expect(structuredClone).toHaveBeenCalledWith(question);
expect(result).toEqual(question);
expect(getLocalizedValue).not.toHaveBeenCalled();
expect(structuredClone).toHaveBeenCalledWith(element);
expect(result).toEqual(element);
});
});
describe("replacePresetPlaceholders", () => {
test("replaces projectName placeholder in template name and questions", () => {
const template: TTemplate = {
id: "template-1",
name: "Test Template",
description: "Template Description",
test("replaces projectName placeholder in template name and blocks", () => {
const mockTemplate = {
name: "Template 1",
preset: {
name: "$[projectName] Feedback",
questions: [
welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false },
blocks: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "How do you like $[projectName]?",
},
},
{
id: "q2",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "Another question",
},
subheader: {
default: "About $[projectName]",
},
id: "block1",
name: "Block 1",
elements: [
{
id: "elem1",
type: "openText",
headline: { default: "How would you rate $[projectName]?" },
required: true,
inputType: "text",
},
],
},
],
endings: [],
hiddenFields: { enabled: true, fieldIds: [] },
},
category: "product",
} as unknown as TTemplate;
const project = {
name: "Awesome App",
};
name: "TestProject",
} as TProject;
// Mock getLocalizedValue to return the original strings with placeholders
vi.mocked(getLocalizedValue)
.mockReturnValueOnce("How do you like $[projectName]?")
.mockReturnValueOnce("Another question")
.mockReturnValueOnce("About $[projectName]");
const result = replacePresetPlaceholders(mockTemplate, project);
const result = replacePresetPlaceholders(template, project);
expect(result.preset.name).toBe("Awesome App Feedback");
expect(structuredClone).toHaveBeenCalledWith(template.preset);
// Verify that replaceQuestionPresetPlaceholders was applied to both questions
expect(vi.mocked(getLocalizedValue)).toHaveBeenCalledTimes(3);
expect(result.preset.questions[0].headline?.default).toBe("How do you like Awesome App?");
expect(result.preset.questions[1].subheader?.default).toBe("About Awesome App");
});
test("maintains other template properties", () => {
const template: TTemplate = {
id: "template-1",
name: "Test Template",
description: "Template Description",
preset: {
name: "$[projectName] Feedback",
questions: [],
},
category: "product",
} as unknown as TTemplate;
const project = {
name: "Awesome App",
};
const result = replacePresetPlaceholders(template, project) as unknown as {
name: string;
description: string;
};
expect(result.name).toBe(template.name);
expect(result.description).toBe(template.description);
expect(structuredClone).toHaveBeenCalledWith(mockTemplate.preset);
expect(result.preset.name).toBe("TestProject Feedback");
expect(result.preset.blocks[0].elements[0].headline?.default).toBe("How would you rate TestProject?");
});
});
});
+27 -18
View File
@@ -1,37 +1,46 @@
import { TProject } from "@formbricks/types/project";
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
import { TTemplate } from "@formbricks/types/templates";
import type { TProject } from "@formbricks/types/project";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TTemplate } from "@formbricks/types/templates";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
export const replaceQuestionPresetPlaceholders = (
question: TSurveyQuestion,
export const replaceElementPresetPlaceholders = (
element: TSurveyElement,
project: TProject
): TSurveyQuestion => {
if (!project) return question;
const newQuestion = structuredClone(question);
): TSurveyElement => {
if (!project) return element;
const newElement = structuredClone(element);
const defaultLanguageCode = "default";
if (newQuestion.headline) {
newQuestion.headline[defaultLanguageCode] = getLocalizedValue(
newQuestion.headline,
if (newElement.headline) {
newElement.headline[defaultLanguageCode] = getLocalizedValue(
newElement.headline,
defaultLanguageCode
).replace("$[projectName]", project.name);
}
if (newQuestion.subheader) {
newQuestion.subheader[defaultLanguageCode] = getLocalizedValue(
newQuestion.subheader,
if (newElement.subheader) {
newElement.subheader[defaultLanguageCode] = getLocalizedValue(
newElement.subheader,
defaultLanguageCode
)?.replace("$[projectName]", project.name);
}
return newQuestion;
return newElement;
};
// replace all occurences of projectName with the actual project name in the current template
export const replacePresetPlaceholders = (template: TTemplate, project: any) => {
const preset = structuredClone(template.preset);
preset.name = preset.name.replace("$[projectName]", project.name);
preset.questions = preset.questions.map((question) => {
return replaceQuestionPresetPlaceholders(question, project);
});
// Handle blocks if present
if (preset.blocks && preset.blocks.length > 0) {
preset.blocks = preset.blocks.map((block) => ({
...block,
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
}));
}
return { ...template, preset };
};
+9
View File
@@ -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);
};
+8 -3
View File
@@ -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",
+8 -3
View File
@@ -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",
+8 -3
View File
@@ -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",
+8 -3
View File
@@ -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",
+8 -3
View File
@@ -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",
+8 -3
View File
@@ -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",
+8 -3
View File
@@ -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",
+8 -3
View File
@@ -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",
+8 -3
View File
@@ -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;
}
@@ -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} />
@@ -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);
+5 -8
View File
@@ -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,
},
});
@@ -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" />
+5 -5
View File
@@ -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,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";
@@ -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];
}
@@ -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);
}
}
};
@@ -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