mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-03 21:59:38 -06:00
Compare commits
49 Commits
feat/surve
...
unit-test-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33f2bce9b8 | ||
|
|
33451ebc89 | ||
|
|
be4b54a827 | ||
|
|
e03df83e88 | ||
|
|
ed26427302 | ||
|
|
554809742b | ||
|
|
28adfb905c | ||
|
|
05c455ed62 | ||
|
|
f7687bc0ea | ||
|
|
af34391309 | ||
|
|
70978fbbdf | ||
|
|
f6683d1165 | ||
|
|
13be7a8970 | ||
|
|
0472d5e8f0 | ||
|
|
00a61f7abe | ||
|
|
6999abba3b | ||
|
|
9ae66f44ae | ||
|
|
7933d0077a | ||
|
|
cc8289fa33 | ||
|
|
c458051839 | ||
|
|
718a199d5b | ||
|
|
5ab9fdf1e3 | ||
|
|
5741209aa9 | ||
|
|
35d0d8ed54 | ||
|
|
5bce5c0a3b | ||
|
|
c61212964c | ||
|
|
b8d41a6e9b | ||
|
|
eedd5200a4 | ||
|
|
71a85c7126 | ||
|
|
341e2639e1 | ||
|
|
056470e6f0 | ||
|
|
e965ad4b97 | ||
|
|
12e703c02b | ||
|
|
07065f2675 | ||
|
|
7ca45cefeb | ||
|
|
4df28878db | ||
|
|
b355d05b25 | ||
|
|
e757e9aec9 | ||
|
|
cf4119baf6 | ||
|
|
6be2ae3071 | ||
|
|
600b793641 | ||
|
|
cde03b6997 | ||
|
|
00371bfb01 | ||
|
|
6be6782531 | ||
|
|
3ae4f8aa68 | ||
|
|
3d3c69a92b | ||
|
|
b1b94eaa66 | ||
|
|
67cc96449d | ||
|
|
bf41a53b86 |
@@ -179,14 +179,14 @@ For endpoints serving client SDKs, coordinate TTLs across layers:
|
||||
|
||||
```typescript
|
||||
// Client SDK cache (expiresAt) - longest TTL for fewer requests
|
||||
const CLIENT_TTL = 60 * 60; // 1 hour (seconds for client)
|
||||
const CLIENT_TTL = 60; // 1 minute (seconds for client)
|
||||
|
||||
// Server Redis cache - shorter TTL ensures fresh data for clients
|
||||
const SERVER_TTL = 60 * 30 * 1000; // 30 minutes in milliseconds
|
||||
const SERVER_TTL = 60 * 1000; // 1 minutes in milliseconds
|
||||
|
||||
// HTTP cache headers (seconds)
|
||||
const BROWSER_TTL = 60 * 60; // 1 hour (max-age)
|
||||
const CDN_TTL = 60 * 30; // 30 minutes (s-maxage)
|
||||
const BROWSER_TTL = 60; // 1 minute (max-age)
|
||||
const CDN_TTL = 60; // 1 minute (s-maxage)
|
||||
const CORS_TTL = 60 * 60; // 1 hour (balanced approach)
|
||||
```
|
||||
|
||||
|
||||
6
.github/workflows/formbricks-release.yml
vendored
6
.github/workflows/formbricks-release.yml
vendored
@@ -89,7 +89,7 @@ jobs:
|
||||
- check-latest-release
|
||||
with:
|
||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||
|
||||
docker-build-cloud:
|
||||
name: Build & push Formbricks Cloud to ECR
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
with:
|
||||
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
|
||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||
needs:
|
||||
- check-latest-release
|
||||
- docker-build-community
|
||||
@@ -154,4 +154,4 @@ jobs:
|
||||
release_tag: ${{ github.event.release.tag_name }}
|
||||
commit_sha: ${{ github.sha }}
|
||||
is_prerelease: ${{ github.event.release.prerelease }}
|
||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||
|
||||
@@ -124,7 +124,7 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||
RUN chmod -R 755 ./node_modules/zod
|
||||
|
||||
RUN npm install -g prisma
|
||||
RUN npm install -g prisma@6
|
||||
|
||||
# Create a startup script to handle the conditional logic
|
||||
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
||||
|
||||
@@ -32,22 +32,14 @@ const mockProject: TProject = {
|
||||
};
|
||||
const mockTemplate: TXMTemplate = {
|
||||
name: "$[projectName] Survey",
|
||||
blocks: [
|
||||
questions: [
|
||||
{
|
||||
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,
|
||||
},
|
||||
],
|
||||
id: "q1",
|
||||
inputType: "text",
|
||||
type: "email" as any,
|
||||
headline: { default: "$[projectName] Question" },
|
||||
required: false,
|
||||
charLimit: { enabled: true, min: 400, max: 1000 },
|
||||
},
|
||||
],
|
||||
endings: [
|
||||
@@ -74,9 +66,9 @@ describe("replacePresetPlaceholders", () => {
|
||||
expect(result.name).toBe("Test Project Survey");
|
||||
});
|
||||
|
||||
test("replaces projectName placeholder in element headline", () => {
|
||||
test("replaces projectName placeholder in question headline", () => {
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
|
||||
expect(result.questions[0].headline.default).toBe("Test Project Question");
|
||||
});
|
||||
|
||||
test("returns a new object without mutating the original template", () => {
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
|
||||
import { replaceQuestionPresetPlaceholders } 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): TXMTemplate => {
|
||||
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject) => {
|
||||
const survey = structuredClone(template);
|
||||
|
||||
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 };
|
||||
survey.name = survey.name.replace("$[projectName]", project.name);
|
||||
survey.questions = survey.questions.map((question) => {
|
||||
return replaceQuestionPresetPlaceholders(question, project);
|
||||
});
|
||||
return { ...template, ...survey };
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("xm-templates", () => {
|
||||
expect(result).toEqual({
|
||||
name: "",
|
||||
endings: expect.any(Array),
|
||||
blocks: [],
|
||||
questions: [],
|
||||
styling: {
|
||||
overwriteThemeStyling: true,
|
||||
},
|
||||
|
||||
@@ -3,21 +3,19 @@ import { TFunction } from "i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
import {
|
||||
buildBlock,
|
||||
buildCTAElement,
|
||||
buildNPSElement,
|
||||
buildOpenTextElement,
|
||||
buildRatingElement,
|
||||
createBlockJumpLogic,
|
||||
} from "@/app/lib/survey-block-builder";
|
||||
import { getDefaultEndingCard } from "@/app/lib/survey-builder";
|
||||
buildCTAQuestion,
|
||||
buildNPSQuestion,
|
||||
buildOpenTextQuestion,
|
||||
buildRatingQuestion,
|
||||
getDefaultEndingCard,
|
||||
} from "@/app/lib/survey-builder";
|
||||
|
||||
export const getXMSurveyDefault = (t: TFunction): TXMTemplate => {
|
||||
try {
|
||||
return {
|
||||
name: "",
|
||||
endings: [getDefaultEndingCard([], t)],
|
||||
blocks: [],
|
||||
questions: [],
|
||||
styling: {
|
||||
overwriteThemeStyling: true,
|
||||
},
|
||||
@@ -32,40 +30,25 @@ const npsSurvey = (t: TFunction): TXMTemplate => {
|
||||
return {
|
||||
...getXMSurveyDefault(t),
|
||||
name: t("templates.nps_survey_name"),
|
||||
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,
|
||||
}),
|
||||
],
|
||||
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,
|
||||
t,
|
||||
}),
|
||||
buildBlock({
|
||||
name: "Block 2",
|
||||
elements: [
|
||||
buildOpenTextElement({
|
||||
headline: t("templates.nps_survey_question_2_headline"),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
}),
|
||||
],
|
||||
buildOpenTextQuestion({
|
||||
headline: t("templates.nps_survey_question_2_headline"),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
t,
|
||||
}),
|
||||
buildBlock({
|
||||
name: "Block 3",
|
||||
elements: [
|
||||
buildOpenTextElement({
|
||||
headline: t("templates.nps_survey_question_3_headline"),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
}),
|
||||
],
|
||||
buildOpenTextQuestion({
|
||||
headline: t("templates.nps_survey_question_3_headline"),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
t,
|
||||
}),
|
||||
],
|
||||
@@ -73,27 +56,15 @@ const npsSurvey = (t: TFunction): TXMTemplate => {
|
||||
};
|
||||
|
||||
const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||
const reusableElementIds = [createId(), createId(), createId()];
|
||||
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
|
||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
||||
const defaultSurvey = getXMSurveyDefault(t);
|
||||
|
||||
return {
|
||||
...defaultSurvey,
|
||||
name: t("templates.star_rating_survey_name"),
|
||||
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"),
|
||||
}),
|
||||
],
|
||||
questions: [
|
||||
buildRatingQuestion({
|
||||
id: reusableQuestionIds[0],
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -104,7 +75,7 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableElementIds[0],
|
||||
value: reusableQuestionIds[0],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isLessThanOrEqual",
|
||||
@@ -118,44 +89,64 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToBlock",
|
||||
target: block3Id,
|
||||
objective: "jumpToQuestion",
|
||||
target: reusableQuestionIds[2],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
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,
|
||||
}),
|
||||
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,
|
||||
}),
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
|
||||
headline: t("templates.star_rating_survey_question_2_headline"),
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonLabel: t("templates.star_rating_survey_question_2_button_label"),
|
||||
buttonExternal: true,
|
||||
t,
|
||||
}),
|
||||
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",
|
||||
}),
|
||||
],
|
||||
buildOpenTextQuestion({
|
||||
id: reusableQuestionIds[2],
|
||||
headline: t("templates.star_rating_survey_question_3_headline"),
|
||||
required: true,
|
||||
subheader: t("templates.star_rating_survey_question_3_subheader"),
|
||||
buttonLabel: t("templates.star_rating_survey_question_3_button_label"),
|
||||
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
|
||||
inputType: "text",
|
||||
t,
|
||||
}),
|
||||
],
|
||||
@@ -163,27 +154,15 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||
};
|
||||
|
||||
const csatSurvey = (t: TFunction): TXMTemplate => {
|
||||
const reusableElementIds = [createId(), createId(), createId()];
|
||||
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
|
||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
||||
const defaultSurvey = getXMSurveyDefault(t);
|
||||
|
||||
return {
|
||||
...defaultSurvey,
|
||||
name: t("templates.csat_survey_name"),
|
||||
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"),
|
||||
}),
|
||||
],
|
||||
questions: [
|
||||
buildRatingQuestion({
|
||||
id: reusableQuestionIds[0],
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -194,7 +173,7 @@ const csatSurvey = (t: TFunction): TXMTemplate => {
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableElementIds[0],
|
||||
value: reusableQuestionIds[0],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isLessThanOrEqual",
|
||||
@@ -208,40 +187,60 @@ const csatSurvey = (t: TFunction): TXMTemplate => {
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToBlock",
|
||||
target: block3Id,
|
||||
objective: "jumpToQuestion",
|
||||
target: reusableQuestionIds[2],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
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,
|
||||
}),
|
||||
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",
|
||||
}),
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isSubmitted")],
|
||||
headline: t("templates.csat_survey_question_2_headline"),
|
||||
required: false,
|
||||
placeholder: t("templates.csat_survey_question_2_placeholder"),
|
||||
inputType: "text",
|
||||
t,
|
||||
}),
|
||||
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",
|
||||
}),
|
||||
],
|
||||
buildOpenTextQuestion({
|
||||
id: reusableQuestionIds[2],
|
||||
headline: t("templates.csat_survey_question_3_headline"),
|
||||
required: false,
|
||||
placeholder: t("templates.csat_survey_question_3_placeholder"),
|
||||
inputType: "text",
|
||||
t,
|
||||
}),
|
||||
],
|
||||
@@ -252,31 +251,21 @@ const cessSurvey = (t: TFunction): TXMTemplate => {
|
||||
return {
|
||||
...getXMSurveyDefault(t),
|
||||
name: t("templates.cess_survey_name"),
|
||||
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"),
|
||||
}),
|
||||
],
|
||||
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"),
|
||||
t,
|
||||
}),
|
||||
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",
|
||||
}),
|
||||
],
|
||||
buildOpenTextQuestion({
|
||||
headline: t("templates.cess_survey_question_2_headline"),
|
||||
required: true,
|
||||
placeholder: t("templates.cess_survey_question_2_placeholder"),
|
||||
inputType: "text",
|
||||
t,
|
||||
}),
|
||||
],
|
||||
@@ -284,27 +273,15 @@ const cessSurvey = (t: TFunction): TXMTemplate => {
|
||||
};
|
||||
|
||||
const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||
const reusableElementIds = [createId(), createId(), createId()];
|
||||
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
|
||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
||||
const defaultSurvey = getXMSurveyDefault(t);
|
||||
|
||||
return {
|
||||
...defaultSurvey,
|
||||
name: t("templates.smileys_survey_name"),
|
||||
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"),
|
||||
}),
|
||||
],
|
||||
questions: [
|
||||
buildRatingQuestion({
|
||||
id: reusableQuestionIds[0],
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -315,7 +292,7 @@ const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableElementIds[0],
|
||||
value: reusableQuestionIds[0],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isLessThanOrEqual",
|
||||
@@ -329,44 +306,64 @@ const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToBlock",
|
||||
target: block3Id,
|
||||
objective: "jumpToQuestion",
|
||||
target: reusableQuestionIds[2],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
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,
|
||||
}),
|
||||
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,
|
||||
}),
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
|
||||
headline: t("templates.smileys_survey_question_2_headline"),
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonLabel: t("templates.smileys_survey_question_2_button_label"),
|
||||
buttonExternal: true,
|
||||
t,
|
||||
}),
|
||||
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",
|
||||
}),
|
||||
],
|
||||
buildOpenTextQuestion({
|
||||
id: reusableQuestionIds[2],
|
||||
headline: t("templates.smileys_survey_question_3_headline"),
|
||||
required: true,
|
||||
subheader: t("templates.smileys_survey_question_3_subheader"),
|
||||
buttonLabel: t("templates.smileys_survey_question_3_button_label"),
|
||||
placeholder: t("templates.smileys_survey_question_3_placeholder"),
|
||||
inputType: "text",
|
||||
t,
|
||||
}),
|
||||
],
|
||||
@@ -377,40 +374,25 @@ const enpsSurvey = (t: TFunction): TXMTemplate => {
|
||||
return {
|
||||
...getXMSurveyDefault(t),
|
||||
name: t("templates.enps_survey_name"),
|
||||
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,
|
||||
}),
|
||||
],
|
||||
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,
|
||||
t,
|
||||
}),
|
||||
buildBlock({
|
||||
name: "Block 2",
|
||||
elements: [
|
||||
buildOpenTextElement({
|
||||
headline: t("templates.enps_survey_question_2_headline"),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
}),
|
||||
],
|
||||
buildOpenTextQuestion({
|
||||
headline: t("templates.enps_survey_question_2_headline"),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
t,
|
||||
}),
|
||||
buildBlock({
|
||||
name: "Block 3",
|
||||
elements: [
|
||||
buildOpenTextElement({
|
||||
headline: t("templates.enps_survey_question_3_headline"),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
}),
|
||||
],
|
||||
buildOpenTextQuestion({
|
||||
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, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Control, Controller, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -14,15 +14,14 @@ 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 { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
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";
|
||||
@@ -72,7 +71,6 @@ const NoBaseFoundError = () => {
|
||||
const renderQuestionSelection = ({
|
||||
t,
|
||||
selectedSurvey,
|
||||
questions,
|
||||
control,
|
||||
includeVariables,
|
||||
setIncludeVariables,
|
||||
@@ -85,7 +83,6 @@ const renderQuestionSelection = ({
|
||||
}: {
|
||||
t: TFunction;
|
||||
selectedSurvey: TSurvey;
|
||||
questions: TSurveyElement[];
|
||||
control: Control<IntegrationModalInputs>;
|
||||
includeVariables: boolean;
|
||||
setIncludeVariables: (value: boolean) => void;
|
||||
@@ -102,7 +99,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">
|
||||
{questions.map((question) => (
|
||||
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
|
||||
<Controller
|
||||
key={question.id}
|
||||
control={control}
|
||||
@@ -123,9 +120,7 @@ const renderQuestionSelection = ({
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">
|
||||
{getTextContent(
|
||||
recallToHeadline(question.headline, selectedSurvey, false, "default")["default"]
|
||||
)}
|
||||
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -199,11 +194,6 @@ 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 === "") {
|
||||
@@ -228,7 +218,7 @@ export const AddIntegrationModal = ({
|
||||
surveyName: selectedSurvey.name,
|
||||
questionIds: data.questions,
|
||||
questions:
|
||||
data.questions.length === questions.length
|
||||
data.questions.length === selectedSurvey.questions.length
|
||||
? t("common.all_questions")
|
||||
: t("common.selected_questions"),
|
||||
createdAt: new Date(),
|
||||
@@ -405,7 +395,6 @@ export const AddIntegrationModal = ({
|
||||
renderQuestionSelection({
|
||||
t,
|
||||
selectedSurvey,
|
||||
questions,
|
||||
control,
|
||||
includeVariables,
|
||||
setIncludeVariables,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -16,7 +15,6 @@ interface AirtableWrapperProps {
|
||||
airtableArray: TIntegrationItem[];
|
||||
airtableIntegration?: TIntegrationAirtable;
|
||||
surveys: TSurvey[];
|
||||
environment: TEnvironment;
|
||||
isEnabled: boolean;
|
||||
webAppUrl: string;
|
||||
locale: TUserLocale;
|
||||
@@ -27,7 +25,6 @@ export const AirtableWrapper = ({
|
||||
airtableArray,
|
||||
airtableIntegration,
|
||||
surveys,
|
||||
environment,
|
||||
isEnabled,
|
||||
webAppUrl,
|
||||
locale,
|
||||
@@ -48,7 +45,6 @@ export const AirtableWrapper = ({
|
||||
<ManageIntegration
|
||||
airtableArray={airtableArray}
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
airtableIntegration={airtableIntegration}
|
||||
setIsConnected={setIsConnected}
|
||||
surveys={surveys}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -15,12 +14,11 @@ import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { IntegrationModalInputs } from "../lib/types";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
airtableIntegration: TIntegrationAirtable;
|
||||
environment: TEnvironment;
|
||||
environmentId: string;
|
||||
setIsConnected: (data: boolean) => void;
|
||||
surveys: TSurvey[];
|
||||
@@ -29,7 +27,7 @@ interface ManageIntegrationProps {
|
||||
}
|
||||
|
||||
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tableHeaders = [
|
||||
@@ -132,12 +130,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 w-full">
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage={t("environments.integrations.airtable.no_integrations_yet")}
|
||||
/>
|
||||
<EmptyState text={t("environments.integrations.airtable.no_integrations_yet")} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ const Page = async (props) => {
|
||||
airtableArray={airtableArray}
|
||||
environmentId={environment.id}
|
||||
surveys={surveys}
|
||||
environment={environment}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, 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 { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
@@ -86,17 +86,12 @@ export const AddIntegrationModal = ({
|
||||
},
|
||||
};
|
||||
|
||||
const questions = useMemo(
|
||||
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
||||
[selectedSurvey]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSurvey && !selectedIntegration) {
|
||||
const questionIds = questions.map((question) => question.id);
|
||||
const questionIds = selectedSurvey.questions.map((question) => question.id);
|
||||
setSelectedQuestions(questionIds);
|
||||
}
|
||||
}, [questions, selectedIntegration, selectedSurvey]);
|
||||
}, [selectedIntegration, selectedSurvey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIntegration) {
|
||||
@@ -150,7 +145,7 @@ export const AddIntegrationModal = ({
|
||||
integrationData.surveyName = selectedSurvey.name;
|
||||
integrationData.questionIds = selectedQuestions;
|
||||
integrationData.questions =
|
||||
selectedQuestions.length === questions.length
|
||||
selectedQuestions.length === selectedSurvey?.questions.length
|
||||
? t("common.all_questions")
|
||||
: t("common.selected_questions");
|
||||
integrationData.createdAt = new Date();
|
||||
@@ -268,7 +263,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">
|
||||
{questions.map((question) => (
|
||||
{replaceHeadlineRecall(selectedSurvey, "default")?.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
|
||||
@@ -282,11 +277,7 @@ export const AddIntegrationModal = ({
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2 w-[30rem] truncate">
|
||||
{getTextContent(
|
||||
recallToHeadline(question.headline, selectedSurvey, false, "default")[
|
||||
"default"
|
||||
]
|
||||
)}
|
||||
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,6 @@ export const GoogleSheetWrapper = ({
|
||||
selectedIntegration={selectedIntegration}
|
||||
/>
|
||||
<ManageIntegration
|
||||
environment={environment}
|
||||
googleSheetIntegration={googleSheetIntegration}
|
||||
setOpenAddIntegrationModal={setIsModalOpen}
|
||||
setIsConnected={setIsConnected}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
TIntegrationGoogleSheetsConfigData,
|
||||
@@ -15,10 +14,9 @@ import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
environment: TEnvironment;
|
||||
googleSheetIntegration: TIntegrationGoogleSheets;
|
||||
setOpenAddIntegrationModal: (v: boolean) => void;
|
||||
setIsConnected: (v: boolean) => void;
|
||||
@@ -27,7 +25,6 @@ interface ManageIntegrationProps {
|
||||
}
|
||||
|
||||
export const ManageIntegration = ({
|
||||
environment,
|
||||
googleSheetIntegration,
|
||||
setOpenAddIntegrationModal,
|
||||
setIsConnected,
|
||||
@@ -90,12 +87,7 @@ export const ManageIntegration = ({
|
||||
</div>
|
||||
{!integrationArray || integrationArray.length === 0 ? (
|
||||
<div className="mt-4 w-full">
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage={t("environments.integrations.google_sheets.no_integrations_yet")}
|
||||
/>
|
||||
<EmptyState text={t("environments.integrations.google_sheets.no_integrations_yet")} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||
|
||||
@@ -21,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 { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -92,11 +92,6 @@ export const AddIntegrationModal = ({
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const questions = useMemo(
|
||||
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
||||
[selectedSurvey]
|
||||
);
|
||||
|
||||
const notionIntegrationData: TIntegrationInput = {
|
||||
type: "notion",
|
||||
config: {
|
||||
@@ -125,10 +120,10 @@ export const AddIntegrationModal = ({
|
||||
}, [selectedDatabase?.id]);
|
||||
|
||||
const questionItems = useMemo(() => {
|
||||
const mappedQuestions = selectedSurvey
|
||||
? questions.map((q) => ({
|
||||
const questions = selectedSurvey
|
||||
? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
|
||||
id: q.id,
|
||||
name: getTextContent(recallToHeadline(q.headline, selectedSurvey, false, "default")["default"]),
|
||||
name: getTextContent(getLocalizedValue(q.headline, "default")),
|
||||
type: q.type,
|
||||
}))
|
||||
: [];
|
||||
@@ -161,7 +156,7 @@ export const AddIntegrationModal = ({
|
||||
},
|
||||
];
|
||||
|
||||
return [...mappedQuestions, ...variables, ...hiddenFields, ...Metadata, ...createdAt];
|
||||
return [...questions, ...variables, ...hiddenFields, ...Metadata, ...createdAt];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedSurvey?.id]);
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
@@ -12,11 +11,10 @@ import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
environment: TEnvironment;
|
||||
notionIntegration: TIntegrationNotion;
|
||||
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@@ -28,7 +26,6 @@ interface ManageIntegrationProps {
|
||||
}
|
||||
|
||||
export const ManageIntegration = ({
|
||||
environment,
|
||||
notionIntegration,
|
||||
setOpenAddIntegrationModal,
|
||||
setIsConnected,
|
||||
@@ -101,12 +98,7 @@ export const ManageIntegration = ({
|
||||
</div>
|
||||
{!integrationArray || integrationArray.length === 0 ? (
|
||||
<div className="mt-4 w-full">
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage={t("environments.integrations.notion.no_databases_found")}
|
||||
/>
|
||||
<EmptyState text={t("environments.integrations.notion.no_databases_found")} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||
|
||||
@@ -64,7 +64,6 @@ export const NotionWrapper = ({
|
||||
selectedIntegration={selectedIntegration}
|
||||
/>
|
||||
<ManageIntegration
|
||||
environment={environment}
|
||||
notionIntegration={notionIntegration}
|
||||
setOpenAddIntegrationModal={setIsModalOpen}
|
||||
setIsConnected={setIsConnected}
|
||||
|
||||
@@ -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 { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
@@ -73,19 +73,14 @@ export const AddChannelMappingModal = ({
|
||||
},
|
||||
};
|
||||
|
||||
const questions = useMemo(
|
||||
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
||||
[selectedSurvey]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSurvey) {
|
||||
const questionIds = questions.map((question) => question.id);
|
||||
const questionIds = selectedSurvey.questions.map((question) => question.id);
|
||||
if (!selectedIntegration) {
|
||||
setSelectedQuestions(questionIds);
|
||||
}
|
||||
}
|
||||
}, [questions, selectedIntegration, selectedSurvey]);
|
||||
}, [selectedIntegration, selectedSurvey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIntegration) {
|
||||
@@ -274,7 +269,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">
|
||||
{questions.map((question) => (
|
||||
{replaceHeadlineRecall(selectedSurvey, "default")?.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
|
||||
@@ -288,11 +283,7 @@ export const AddChannelMappingModal = ({
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">
|
||||
{getTextContent(
|
||||
recallToHeadline(question.headline, selectedSurvey, false, "default")[
|
||||
"default"
|
||||
]
|
||||
)}
|
||||
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Trash2Icon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
@@ -12,10 +11,9 @@ import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
environment: TEnvironment;
|
||||
slackIntegration: TIntegrationSlack;
|
||||
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@@ -29,7 +27,6 @@ interface ManageIntegrationProps {
|
||||
}
|
||||
|
||||
export const ManageIntegration = ({
|
||||
environment,
|
||||
slackIntegration,
|
||||
setOpenAddIntegrationModal,
|
||||
setIsConnected,
|
||||
@@ -106,12 +103,7 @@ export const ManageIntegration = ({
|
||||
</div>
|
||||
{!integrationArray || integrationArray.length === 0 ? (
|
||||
<div className="mt-4 w-full">
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage={t("environments.integrations.slack.connect_your_first_slack_channel")}
|
||||
/>
|
||||
<EmptyState text={t("environments.integrations.slack.connect_your_first_slack_channel")} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||
|
||||
@@ -78,7 +78,6 @@ export const SlackWrapper = ({
|
||||
selectedIntegration={selectedIntegration}
|
||||
/>
|
||||
<ManageIntegration
|
||||
environment={environment}
|
||||
slackIntegration={slackIntegration}
|
||||
setOpenAddIntegrationModal={setIsModalOpen}
|
||||
setIsConnected={setIsConnected}
|
||||
|
||||
@@ -215,7 +215,7 @@ export const EditProfileDetailsForm = ({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-slate-50 text-slate-700"
|
||||
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
|
||||
align="start">
|
||||
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
|
||||
{appLanguages.map((lang) => (
|
||||
|
||||
@@ -10,7 +10,6 @@ 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;
|
||||
@@ -56,10 +55,7 @@ export const formatContactInfoData = (responseValue: TResponseDataValue): Record
|
||||
export const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => {
|
||||
const responseData: Record<string, any> = {};
|
||||
|
||||
// Derive questions from blocks
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
for (const question of questions) {
|
||||
for (const question of survey.questions) {
|
||||
const responseValue = response.data[question.id];
|
||||
switch (question.type) {
|
||||
case "matrix":
|
||||
|
||||
@@ -26,6 +26,7 @@ interface ResponsePageProps {
|
||||
isReadOnly: boolean;
|
||||
isQuotasAllowed: boolean;
|
||||
quotas: TSurveyQuota[];
|
||||
initialResponses?: TResponseWithQuotas[];
|
||||
}
|
||||
|
||||
export const ResponsePage = ({
|
||||
@@ -39,11 +40,12 @@ export const ResponsePage = ({
|
||||
isReadOnly,
|
||||
isQuotasAllowed,
|
||||
quotas,
|
||||
initialResponses = [],
|
||||
}: ResponsePageProps) => {
|
||||
const [responses, setResponses] = useState<TResponseWithQuotas[]>([]);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||
const [isFetchingFirstPage, setFetchingFirstPage] = useState<boolean>(true);
|
||||
const [responses, setResponses] = useState<TResponseWithQuotas[]>(initialResponses);
|
||||
const [page, setPage] = useState<number | null>(null);
|
||||
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
|
||||
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const filters = useMemo(
|
||||
@@ -56,6 +58,7 @@ export const ResponsePage = ({
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const fetchNextPage = useCallback(async () => {
|
||||
if (page === null) return;
|
||||
const newPage = page + 1;
|
||||
|
||||
let newResponses: TResponseWithQuotas[] = [];
|
||||
@@ -93,10 +96,22 @@ export const ResponsePage = ({
|
||||
}
|
||||
}, [searchParams, resetState]);
|
||||
|
||||
// Only fetch if filters are applied (not on initial mount with no filters)
|
||||
const hasFilters =
|
||||
selectedFilter?.responseStatus !== "all" ||
|
||||
(selectedFilter?.filter && selectedFilter.filter.length > 0) ||
|
||||
(dateRange.from && dateRange.to);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchInitialResponses = async () => {
|
||||
const fetchFilteredResponses = async () => {
|
||||
try {
|
||||
setFetchingFirstPage(true);
|
||||
// skip call for initial mount
|
||||
if (page === null && !hasFilters) {
|
||||
setPage(1);
|
||||
return;
|
||||
}
|
||||
setPage(1);
|
||||
setIsFetchingFirstPage(true);
|
||||
let responses: TResponseWithQuotas[] = [];
|
||||
|
||||
const getResponsesActionResponse = await getResponsesAction({
|
||||
@@ -110,19 +125,16 @@ export const ResponsePage = ({
|
||||
|
||||
if (responses.length < responsesPerPage) {
|
||||
setHasMore(false);
|
||||
} else {
|
||||
setHasMore(true);
|
||||
}
|
||||
setResponses(responses);
|
||||
} finally {
|
||||
setFetchingFirstPage(false);
|
||||
setIsFetchingFirstPage(false);
|
||||
}
|
||||
};
|
||||
fetchInitialResponses();
|
||||
}, [surveyId, filters, responsesPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
}, [filters]);
|
||||
fetchFilteredResponses();
|
||||
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -5,8 +5,7 @@ import { TFunction } from "i18next";
|
||||
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
||||
@@ -14,7 +13,6 @@ 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";
|
||||
@@ -31,7 +29,7 @@ import {
|
||||
} from "../lib/utils";
|
||||
|
||||
const getQuestionColumnsData = (
|
||||
question: TSurveyElement,
|
||||
question: TSurveyQuestion,
|
||||
survey: TSurvey,
|
||||
isExpanded: boolean,
|
||||
t: TFunction
|
||||
@@ -56,7 +54,7 @@ const getQuestionColumnsData = (
|
||||
};
|
||||
|
||||
// Helper function to get localized question headline
|
||||
const getQuestionHeadline = (question: TSurveyElement, survey: TSurvey) => {
|
||||
const getQuestionHeadline = (question: TSurveyQuestion, survey: TSurvey) => {
|
||||
return getTextContent(
|
||||
getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default")
|
||||
);
|
||||
@@ -267,8 +265,7 @@ export const generateResponseTableColumns = (
|
||||
t: TFunction,
|
||||
showQuotasColumn: boolean
|
||||
): ColumnDef<TResponseTableData>[] => {
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
const questionColumns = questions.flatMap((question) =>
|
||||
const questionColumns = survey.questions.flatMap((question) =>
|
||||
getQuestionColumnsData(question, survey, isExpanded, t)
|
||||
);
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
@@ -14,7 +13,6 @@ import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -23,45 +21,44 @@ const Page = async (props) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
|
||||
const { session, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const survey = await getSurvey(params.surveyId);
|
||||
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
|
||||
getSurvey(params.surveyId),
|
||||
getUser(session.user.id),
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getIsContactsEnabled(),
|
||||
getResponseCountBySurveyId(params.surveyId),
|
||||
findMatchingLocale(),
|
||||
]);
|
||||
|
||||
if (!survey) {
|
||||
throw new Error(t("common.survey_not_found"));
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
const tags = await getTagsByEnvironmentId(params.environmentId);
|
||||
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
||||
|
||||
// Get response count for the CTA component
|
||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
const displayCount = await getDisplayCountBySurveyId(params.surveyId);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||
if (!organizationId) {
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||
|
||||
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
const organizationBilling = await getOrganizationBilling(organization.id);
|
||||
if (!organizationBilling) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
|
||||
|
||||
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
|
||||
|
||||
// Fetch initial responses on the server to prevent duplicate client-side fetch
|
||||
const initialResponses = await getResponses(params.surveyId, RESPONSES_PER_PAGE, 0);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader
|
||||
@@ -74,7 +71,6 @@ const Page = async (props) => {
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={responseCount}
|
||||
displayCount={displayCount}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
@@ -94,6 +90,7 @@ const Page = async (props) => {
|
||||
isReadOnly={isReadOnly}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
quotas={quotas}
|
||||
initialResponses={initialResponses}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyElementSummaryAddress } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestionSummaryAddress } 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: TSurveyElementSummaryAddress;
|
||||
questionSummary: TSurveyQuestionSummaryAddress;
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
locale: TUserLocale;
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyElementSummaryCta } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestionSummaryCta } 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: TSurveyElementSummaryCta;
|
||||
questionSummary: TSurveyQuestionSummaryCta;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyElementSummaryCal } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestionSummaryCal } 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: TSurveyElementSummaryCal;
|
||||
questionSummary: TSurveyQuestionSummaryCal;
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { CSSProperties, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface ClickableBarSegmentProps {
|
||||
children: ReactNode;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const ClickableBarSegment = ({
|
||||
children,
|
||||
onClick,
|
||||
className = "",
|
||||
style,
|
||||
}: ClickableBarSegmentProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button className={className} style={style} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.click_to_filter")}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -1,20 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyElementSummaryConsent, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryConsent,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} 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: TSurveyElementSummaryConsent;
|
||||
questionSummary: TSurveyQuestionSummaryConsent;
|
||||
survey: TSurvey;
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyElementTypeEnum,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyElementSummaryContactInfo } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestionSummaryContactInfo } 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: TSurveyElementSummaryContactInfo;
|
||||
questionSummary: TSurveyQuestionSummaryContactInfo;
|
||||
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, TSurveyElementSummaryDate } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestionSummaryDate } 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: TSurveyElementSummaryDate;
|
||||
questionSummary: TSurveyQuestionSummaryDate;
|
||||
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, TSurveyElementSummaryFileUpload } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestionSummaryFileUpload } 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: TSurveyElementSummaryFileUpload;
|
||||
questionSummary: TSurveyQuestionSummaryFileUpload;
|
||||
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 { TSurveyElementSummaryHiddenFields } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyQuestionSummaryHiddenFields } 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: TSurveyElementSummaryHiddenFields;
|
||||
questionSummary: TSurveyQuestionSummaryHiddenFields;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyElementSummaryMatrix, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryMatrix,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface MatrixQuestionSummaryProps {
|
||||
questionSummary: TSurveyElementSummaryMatrix;
|
||||
questionSummary: TSurveyQuestionSummaryMatrix;
|
||||
survey: TSurvey;
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyElementTypeEnum,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
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: TSurveyElementSummaryMultipleChoice;
|
||||
questionSummary: TSurveyQuestionSummaryMultipleChoice;
|
||||
environmentId: string;
|
||||
surveyType: TSurveyType;
|
||||
survey: TSurvey;
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyElementTypeEnum,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
|
||||
@@ -1,27 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart, BarChartHorizontal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyElementSummaryNps, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryNps,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { HalfCircle, ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||
import { TooltipProvider } from "@/modules/ui/components/tooltip";
|
||||
import { convertFloatToNDecimal } from "../lib/utils";
|
||||
import { ClickableBarSegment } from "./ClickableBarSegment";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
import { SatisfactionIndicator } from "./SatisfactionIndicator";
|
||||
|
||||
interface NPSSummaryProps {
|
||||
questionSummary: TSurveyElementSummaryNps;
|
||||
questionSummary: TSurveyQuestionSummaryNps;
|
||||
survey: TSurvey;
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyElementTypeEnum,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
const calculateNPSOpacity = (rating: number): number => {
|
||||
if (rating <= 6) {
|
||||
return 0.3 + (rating / 6) * 0.3;
|
||||
}
|
||||
if (rating <= 8) {
|
||||
return 0.6 + ((rating - 6) / 2) * 0.2;
|
||||
}
|
||||
return 0.8 + ((rating - 8) / 2) * 0.2;
|
||||
};
|
||||
|
||||
export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
|
||||
|
||||
const applyFilter = (group: string) => {
|
||||
const filters = {
|
||||
promoters: {
|
||||
@@ -57,38 +79,110 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
key={group}
|
||||
onClick={() => applyFilter(group)}>
|
||||
<div
|
||||
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<p
|
||||
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||
{group}
|
||||
</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
|
||||
<QuestionSummaryHeader
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
additionalInfo={
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
<SatisfactionIndicator percentage={questionSummary.promoters.percentage} />
|
||||
<div>
|
||||
{t("environments.surveys.summary.promoters")}:{" "}
|
||||
{convertFloatToNDecimal(questionSummary.promoters.percentage, 2)}%
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
|
||||
<div className="flex justify-end px-4 md:px-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
|
||||
{t("environments.surveys.summary.aggregated")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
|
||||
{t("environments.surveys.summary.individual")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="aggregated" className="mt-4">
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
key={group}
|
||||
onClick={() => applyFilter(group)}>
|
||||
<div
|
||||
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<p
|
||||
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||
{group}
|
||||
</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{questionSummary[group]?.count}{" "}
|
||||
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{questionSummary[group]?.count}{" "}
|
||||
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
<ProgressBar
|
||||
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
|
||||
progress={questionSummary[group]?.percentage / 100}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="individual" className="mt-4">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="grid grid-cols-11 gap-2 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{questionSummary.choices.map((choice) => {
|
||||
const opacity = calculateNPSOpacity(choice.rating);
|
||||
|
||||
return (
|
||||
<ClickableBarSegment
|
||||
key={choice.rating}
|
||||
className="group flex cursor-pointer flex-col items-center"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
t("environments.surveys.summary.is_equal_to"),
|
||||
choice.rating.toString()
|
||||
)
|
||||
}>
|
||||
<div className="flex h-32 w-full flex-col items-center justify-end">
|
||||
<div
|
||||
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
|
||||
style={{
|
||||
height: `${Math.max(choice.percentage, 2)}%`,
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center rounded-b-lg border border-t-0 border-slate-200 bg-slate-50 px-1 py-2">
|
||||
<div className="mb-1.5 text-xs font-medium text-slate-500">{choice.rating}</div>
|
||||
<div className="mb-1 flex items-center space-x-1">
|
||||
<div className="text-base font-semibold text-slate-700">{choice.count}</div>
|
||||
<div className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600">
|
||||
{convertFloatToNDecimal(choice.percentage, 1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ClickableBarSegment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ProgressBar
|
||||
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
|
||||
progress={questionSummary[group]?.percentage / 100}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex justify-center pb-4 pt-4">
|
||||
<HalfCircle value={questionSummary.score} />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyElementSummaryOpenText } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestionSummaryOpenText } 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: TSurveyElementSummaryOpenText;
|
||||
questionSummary: TSurveyQuestionSummaryOpenText;
|
||||
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: TSurveyElementSummaryPictureSelection;
|
||||
questionSummary: TSurveyQuestionSummaryPictureSelection;
|
||||
survey: TSurvey;
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyElementTypeEnum,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
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, TSurveyElementSummary } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestionSummary } 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: TSurveyElementSummary;
|
||||
questionSummary: TSurveyQuestionSummary;
|
||||
showResponses?: boolean;
|
||||
additionalInfo?: JSX.Element;
|
||||
survey: TSurvey;
|
||||
@@ -57,8 +57,8 @@ export const QuestionSummaryHeader = ({
|
||||
{t("environments.surveys.edit.optional")}
|
||||
</div>
|
||||
)}
|
||||
<IdBadge id={questionSummary.question.id} />
|
||||
</div>
|
||||
<IdBadge id={questionSummary.question.id} label={t("common.question_id")} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyElementSummaryRanking } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestionSummaryRanking } 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: TSurveyElementSummaryRanking;
|
||||
questionSummary: TSurveyQuestionSummaryRanking;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { TSurveyRatingQuestion } from "@formbricks/types/surveys/types";
|
||||
import { RatingResponse } from "@/modules/ui/components/rating-response";
|
||||
|
||||
interface RatingScaleLegendProps {
|
||||
scale: TSurveyRatingQuestion["scale"];
|
||||
range: number;
|
||||
}
|
||||
|
||||
export const RatingScaleLegend = ({ scale, range }: RatingScaleLegendProps) => {
|
||||
return (
|
||||
<div className="mt-3 flex w-full items-start justify-between px-1">
|
||||
<div className="flex items-center space-x-1">
|
||||
<RatingResponse scale={scale} answer={1} range={range} addColors={false} variant="scale" />
|
||||
<span className="text-xs text-slate-500">1</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="text-xs text-slate-500">{range}</span>
|
||||
<RatingResponse scale={scale} answer={range} range={range} addColors={false} variant="scale" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,23 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { BarChart, BarChartHorizontal, CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyElementSummaryRating, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryRating,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||
import { TooltipProvider } from "@/modules/ui/components/tooltip";
|
||||
import { ClickableBarSegment } from "./ClickableBarSegment";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
import { RatingScaleLegend } from "./RatingScaleLegend";
|
||||
import { SatisfactionIndicator } from "./SatisfactionIndicator";
|
||||
|
||||
interface RatingSummaryProps {
|
||||
questionSummary: TSurveyElementSummaryRating;
|
||||
questionSummary: TSurveyQuestionSummaryRating;
|
||||
survey: TSurvey;
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyElementTypeEnum,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
@@ -25,6 +34,8 @@ interface RatingSummaryProps {
|
||||
|
||||
export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
|
||||
|
||||
const getIconBasedOnScale = useMemo(() => {
|
||||
const scale = questionSummary.question.scale;
|
||||
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
|
||||
@@ -38,52 +49,174 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
additionalInfo={
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
{getIconBasedOnScale}
|
||||
<div>
|
||||
{t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
{getIconBasedOnScale}
|
||||
<div>
|
||||
{t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
<SatisfactionIndicator percentage={questionSummary.csat.satisfiedPercentage} />
|
||||
<div>
|
||||
CSAT: {questionSummary.csat.satisfiedPercentage}%{" "}
|
||||
{t("environments.surveys.summary.satisfied")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{questionSummary.choices.map((result) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
key={result.rating}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
t("environments.surveys.summary.is_equal_to"),
|
||||
result.rating.toString()
|
||||
)
|
||||
}>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex items-center space-x-1">
|
||||
<div className="font-semibold text-slate-700">
|
||||
<RatingResponse
|
||||
scale={questionSummary.question.scale}
|
||||
answer={result.rating}
|
||||
range={questionSummary.question.range}
|
||||
addColors={questionSummary.question.isColorCodingEnabled}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
|
||||
<div className="flex justify-end px-4 md:px-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
|
||||
{t("environments.surveys.summary.aggregated")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
|
||||
{t("environments.surveys.summary.individual")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="aggregated" className="mt-4">
|
||||
<div className="px-4 pb-6 pt-4 md:px-6">
|
||||
{questionSummary.responseCount === 0 ? (
|
||||
<>
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-8 text-center">
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("environments.surveys.summary.no_responses_found")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
<RatingScaleLegend
|
||||
scale={questionSummary.question.scale}
|
||||
range={questionSummary.question.range}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
|
||||
{questionSummary.choices.map((result, index) => {
|
||||
if (result.percentage === 0) return null;
|
||||
|
||||
const range = questionSummary.question.range;
|
||||
const opacity = 0.3 + (result.rating / range) * 0.8;
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === questionSummary.choices.length - 1;
|
||||
|
||||
return (
|
||||
<ClickableBarSegment
|
||||
key={result.rating}
|
||||
className="relative h-full cursor-pointer transition-opacity hover:brightness-110"
|
||||
style={{
|
||||
width: `${result.percentage}%`,
|
||||
borderRight: isLast ? "none" : "1px solid rgb(226, 232, 240)",
|
||||
}}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
t("environments.surveys.summary.is_equal_to"),
|
||||
result.rating.toString()
|
||||
)
|
||||
}>
|
||||
<div
|
||||
className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
|
||||
style={{ opacity }}
|
||||
/>
|
||||
</ClickableBarSegment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50">
|
||||
{questionSummary.choices.map((result, index) => {
|
||||
if (result.percentage === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.rating}
|
||||
className="flex flex-col items-center justify-center py-2"
|
||||
style={{
|
||||
width: `${result.percentage}%`,
|
||||
borderRight:
|
||||
index < questionSummary.choices.length - 1
|
||||
? "1px solid rgb(226, 232, 240)"
|
||||
: "none",
|
||||
}}>
|
||||
<div className="mb-1 flex items-center justify-center">
|
||||
<RatingResponse
|
||||
scale={questionSummary.question.scale}
|
||||
answer={result.rating}
|
||||
range={questionSummary.question.range}
|
||||
addColors={false}
|
||||
variant="aggregated"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs font-medium text-slate-600">
|
||||
{convertFloatToNDecimal(result.percentage, 1)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<RatingScaleLegend
|
||||
scale={questionSummary.question.scale}
|
||||
range={questionSummary.question.range}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="individual" className="mt-4">
|
||||
<div className="px-4 pb-6 pt-4 md:px-6">
|
||||
<div className="space-y-5 text-sm md:text-base">
|
||||
{questionSummary.choices.map((result) => (
|
||||
<div key={result.rating}>
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
t("environments.surveys.summary.is_equal_to"),
|
||||
result.rating.toString()
|
||||
)
|
||||
}>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex items-center space-x-1">
|
||||
<div className="font-semibold text-slate-700">
|
||||
<RatingResponse
|
||||
scale={questionSummary.question.scale}
|
||||
answer={result.rating}
|
||||
range={questionSummary.question.range}
|
||||
addColors={questionSummary.question.isColorCodingEnabled}
|
||||
variant="individual"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
|
||||
<div className="rounded-b-lg border-t bg-white px-6 py-4">
|
||||
<div key="dismissed">
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
interface SatisfactionIndicatorProps {
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export const SatisfactionIndicator = ({ percentage }: SatisfactionIndicatorProps) => {
|
||||
let colorClass = "";
|
||||
|
||||
if (percentage > 80) {
|
||||
colorClass = "bg-emerald-500";
|
||||
} else if (percentage >= 55) {
|
||||
colorClass = "bg-orange-500";
|
||||
} else {
|
||||
colorClass = "bg-rose-500";
|
||||
}
|
||||
|
||||
return <div className={`h-3 w-3 rounded-full ${colorClass}`} />;
|
||||
};
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { TimerIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestionType, 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";
|
||||
@@ -16,7 +15,7 @@ interface SummaryDropOffsProps {
|
||||
|
||||
export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const getIcon = (questionType: TSurveyElementTypeEnum) => {
|
||||
const getIcon = (questionType: TSurveyQuestionType) => {
|
||||
const Icon = getQuestionIcon(questionType, t);
|
||||
return <Icon className="mt-[3px] h-5 w-5 shrink-0 text-slate-600" />;
|
||||
};
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
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 {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveySummary,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import {
|
||||
SelectedFilterValue,
|
||||
@@ -30,7 +34,7 @@ import { RatingSummary } from "@/app/(app)/environments/[environmentId]/surveys/
|
||||
import { constructToastMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
|
||||
import { AddressSummary } from "./AddressSummary";
|
||||
|
||||
@@ -46,16 +50,16 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
const { setSelectedFilter, selectedFilter } = useResponseFilter();
|
||||
const { t } = useTranslation();
|
||||
const setFilter = (
|
||||
questionId: string,
|
||||
questionId: TSurveyQuestionId,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyElementTypeEnum,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => {
|
||||
const filterObject: SelectedFilterValue = { ...selectedFilter };
|
||||
const value = {
|
||||
id: questionId,
|
||||
label: getLocalizedValue(label, "default"),
|
||||
label: getTextContent(getLocalizedValue(label, "default")),
|
||||
questionType: questionType,
|
||||
type: OptionsType.QUESTIONS,
|
||||
};
|
||||
@@ -104,15 +108,10 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
) : summary.length === 0 ? (
|
||||
<SkeletonLoader type="summary" />
|
||||
) : responseCount === 0 ? (
|
||||
<EmptySpaceFiller
|
||||
type="response"
|
||||
environment={environment}
|
||||
noWidgetRequired={survey.type === "link"}
|
||||
emptyMessage={t("environments.surveys.summary.no_responses_found")}
|
||||
/>
|
||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} />
|
||||
) : (
|
||||
summary.map((questionSummary) => {
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.OpenText) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.OpenText) {
|
||||
return (
|
||||
<OpenTextSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -124,8 +123,8 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
);
|
||||
}
|
||||
if (
|
||||
questionSummary.type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
|
||||
questionSummary.type === TSurveyElementTypeEnum.MultipleChoiceMulti
|
||||
questionSummary.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||
questionSummary.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
|
||||
) {
|
||||
return (
|
||||
<MultipleChoiceSummary
|
||||
@@ -138,7 +137,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.NPS) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.NPS) {
|
||||
return (
|
||||
<NPSSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -148,7 +147,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.CTA) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.CTA) {
|
||||
return (
|
||||
<CTASummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -157,7 +156,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.Rating) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Rating) {
|
||||
return (
|
||||
<RatingSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -167,7 +166,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.Consent) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Consent) {
|
||||
return (
|
||||
<ConsentSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -177,7 +176,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.PictureSelection) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.PictureSelection) {
|
||||
return (
|
||||
<PictureChoiceSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -187,7 +186,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.Date) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Date) {
|
||||
return (
|
||||
<DateQuestionSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -198,7 +197,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.FileUpload) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.FileUpload) {
|
||||
return (
|
||||
<FileUploadSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -209,7 +208,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.Cal) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Cal) {
|
||||
return (
|
||||
<CalSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -219,7 +218,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.Matrix) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
return (
|
||||
<MatrixQuestionSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -229,7 +228,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.Address) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Address) {
|
||||
return (
|
||||
<AddressSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -240,7 +239,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.Ranking) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Ranking) {
|
||||
return (
|
||||
<RankingSummary
|
||||
key={questionSummary.question.id}
|
||||
@@ -259,7 +258,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyElementTypeEnum.ContactInfo) {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.ContactInfo) {
|
||||
return (
|
||||
<ContactInfoSummary
|
||||
key={questionSummary.question.id}
|
||||
|
||||
@@ -29,7 +29,6 @@ interface SurveyAnalysisCTAProps {
|
||||
user: TUser;
|
||||
publicDomain: string;
|
||||
responseCount: number;
|
||||
displayCount: number;
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -48,7 +47,6 @@ export const SurveyAnalysisCTA = ({
|
||||
user,
|
||||
publicDomain,
|
||||
responseCount,
|
||||
displayCount,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
@@ -96,7 +94,6 @@ export const SurveyAnalysisCTA = ({
|
||||
const duplicateSurveyAndRoute = async (surveyId: string) => {
|
||||
setLoading(true);
|
||||
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
|
||||
environmentId: environment.id,
|
||||
surveyId: surveyId,
|
||||
targetEnvironmentId: environment.id,
|
||||
});
|
||||
@@ -170,7 +167,7 @@ export const SurveyAnalysisCTA = ({
|
||||
icon: ListRestart,
|
||||
tooltip: t("environments.surveys.summary.reset_survey"),
|
||||
onClick: () => setIsResetModalOpen(true),
|
||||
isVisible: !isReadOnly && (responseCount > 0 || displayCount > 0),
|
||||
isVisible: !isReadOnly,
|
||||
},
|
||||
{
|
||||
icon: SquarePenIcon,
|
||||
|
||||
@@ -5,8 +5,7 @@ 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 } from "@formbricks/types/i18n";
|
||||
import { TSurvey, TSurveyMetadata } from "@formbricks/types/surveys/types";
|
||||
import { TI18nString, 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";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,26 +14,23 @@ import {
|
||||
TResponseVariables,
|
||||
ZResponseFilterCriteria,
|
||||
} from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurveyAddressElement,
|
||||
TSurveyContactInfoElement,
|
||||
TSurveyElement,
|
||||
TSurveyElementTypeEnum,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyElementSummaryAddress,
|
||||
TSurveyElementSummaryContactInfo,
|
||||
TSurveyElementSummaryDate,
|
||||
TSurveyElementSummaryFileUpload,
|
||||
TSurveyElementSummaryHiddenFields,
|
||||
TSurveyElementSummaryMultipleChoice,
|
||||
TSurveyElementSummaryOpenText,
|
||||
TSurveyElementSummaryPictureSelection,
|
||||
TSurveyElementSummaryRanking,
|
||||
TSurveyElementSummaryRating,
|
||||
TSurveyContactInfoQuestion,
|
||||
TSurveyLanguage,
|
||||
TSurveyQuestionChoice,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryAddress,
|
||||
TSurveyQuestionSummaryDate,
|
||||
TSurveyQuestionSummaryFileUpload,
|
||||
TSurveyQuestionSummaryHiddenFields,
|
||||
TSurveyQuestionSummaryMultipleChoice,
|
||||
TSurveyQuestionSummaryOpenText,
|
||||
TSurveyQuestionSummaryPictureSelection,
|
||||
TSurveyQuestionSummaryRanking,
|
||||
TSurveyQuestionSummaryRating,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveySummary,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
@@ -43,7 +40,6 @@ 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";
|
||||
@@ -101,26 +97,25 @@ export const getSurveySummaryMeta = (
|
||||
|
||||
const evaluateLogicAndGetNextQuestionId = (
|
||||
localSurvey: TSurvey,
|
||||
questions: TSurveyElement[],
|
||||
data: TResponseData,
|
||||
localVariables: TResponseVariables,
|
||||
currentQuestionIndex: number,
|
||||
currQuesTemp: TSurveyElement,
|
||||
currQuesTemp: TSurveyQuestion,
|
||||
selectedLanguage: string | null
|
||||
): {
|
||||
nextQuestionId: string | undefined;
|
||||
nextQuestionId: TSurveyQuestionId | undefined;
|
||||
updatedSurvey: TSurvey;
|
||||
updatedVariables: TResponseVariables;
|
||||
} => {
|
||||
const questions = localSurvey.questions;
|
||||
|
||||
let updatedSurvey = { ...localSurvey };
|
||||
let updatedVariables = { ...localVariables };
|
||||
|
||||
let firstJumpTarget: string | undefined;
|
||||
|
||||
const { block: currentBlock } = findElementLocation(localSurvey, currQuesTemp.id);
|
||||
|
||||
if (currentBlock?.logic && currentBlock.logic.length > 0) {
|
||||
for (const logic of currentBlock.logic) {
|
||||
if (currQuesTemp.logic && currQuesTemp.logic.length > 0) {
|
||||
for (const logic of currQuesTemp.logic) {
|
||||
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
|
||||
const { jumpTarget, requiredQuestionIds, calculations } = performActions(
|
||||
updatedSurvey,
|
||||
@@ -130,13 +125,9 @@ const evaluateLogicAndGetNextQuestionId = (
|
||||
);
|
||||
|
||||
if (requiredQuestionIds.length > 0) {
|
||||
// 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
|
||||
),
|
||||
}));
|
||||
updatedSurvey.questions = updatedSurvey.questions.map((q) =>
|
||||
requiredQuestionIds.includes(q.id) ? { ...q, required: true } : q
|
||||
);
|
||||
}
|
||||
updatedVariables = { ...updatedVariables, ...calculations };
|
||||
|
||||
@@ -148,8 +139,8 @@ const evaluateLogicAndGetNextQuestionId = (
|
||||
}
|
||||
|
||||
// If no jump target was set, check for a fallback logic
|
||||
if (!firstJumpTarget && currentBlock?.logicFallback) {
|
||||
firstJumpTarget = currentBlock.logicFallback;
|
||||
if (!firstJumpTarget && currQuesTemp.logicFallback) {
|
||||
firstJumpTarget = currQuesTemp.logicFallback;
|
||||
}
|
||||
|
||||
// Return the first jump target if found, otherwise go to the next question
|
||||
@@ -160,11 +151,10 @@ const evaluateLogicAndGetNextQuestionId = (
|
||||
|
||||
export const getSurveySummaryDropOff = (
|
||||
survey: TSurvey,
|
||||
questions: TSurveyElement[],
|
||||
responses: TSurveySummaryResponse[],
|
||||
displayCount: number
|
||||
): TSurveySummary["dropOff"] => {
|
||||
const initialTtc = questions.reduce((acc: Record<string, number>, question) => {
|
||||
const initialTtc = survey.questions.reduce((acc: Record<string, number>, question) => {
|
||||
acc[question.id] = 0;
|
||||
return acc;
|
||||
}, {});
|
||||
@@ -172,9 +162,9 @@ export const getSurveySummaryDropOff = (
|
||||
let totalTtc = { ...initialTtc };
|
||||
let responseCounts = { ...initialTtc };
|
||||
|
||||
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[];
|
||||
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[];
|
||||
|
||||
const surveyVariablesData = survey.variables?.reduce(
|
||||
(acc, variable) => {
|
||||
@@ -201,8 +191,8 @@ export const getSurveySummaryDropOff = (
|
||||
|
||||
let currQuesIdx = 0;
|
||||
|
||||
while (currQuesIdx < questions.length) {
|
||||
const currQues = questions[currQuesIdx];
|
||||
while (currQuesIdx < localSurvey.questions.length) {
|
||||
const currQues = localSurvey.questions[currQuesIdx];
|
||||
if (!currQues) break;
|
||||
|
||||
// question is not answered and required
|
||||
@@ -216,7 +206,6 @@ export const getSurveySummaryDropOff = (
|
||||
|
||||
const { nextQuestionId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextQuestionId(
|
||||
localSurvey,
|
||||
questions,
|
||||
localResponseData,
|
||||
localVariables,
|
||||
currQuesIdx,
|
||||
@@ -228,7 +217,7 @@ export const getSurveySummaryDropOff = (
|
||||
localVariables = updatedVariables;
|
||||
|
||||
if (nextQuestionId) {
|
||||
const nextQuesIdx = questions.findIndex((q) => q.id === nextQuestionId);
|
||||
const nextQuesIdx = survey.questions.findIndex((q) => q.id === nextQuestionId);
|
||||
if (!response.data[nextQuestionId] && !response.finished) {
|
||||
dropOffArr[nextQuesIdx]++;
|
||||
impressionsArr[nextQuesIdx]++;
|
||||
@@ -261,13 +250,13 @@ export const getSurveySummaryDropOff = (
|
||||
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
|
||||
}
|
||||
|
||||
for (let i = 1; i < questions.length; i++) {
|
||||
for (let i = 1; i < survey.questions.length; i++) {
|
||||
if (impressionsArr[i] !== 0) {
|
||||
dropOffPercentageArr[i] = (dropOffArr[i] / impressionsArr[i]) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
const dropOff = questions.map((question, index) => {
|
||||
const dropOff = survey.questions.map((question, index) => {
|
||||
return {
|
||||
questionId: question.id,
|
||||
questionType: question.type,
|
||||
@@ -288,22 +277,13 @@ const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: strin
|
||||
return language?.default ? "default" : language?.language.code || "default";
|
||||
};
|
||||
|
||||
const checkForI18n = (
|
||||
responseData: TResponseData,
|
||||
id: string,
|
||||
questions: TSurveyElement[],
|
||||
languageCode: string
|
||||
) => {
|
||||
const question = questions.find((question) => question.id === id);
|
||||
const checkForI18n = (responseData: TResponseData, id: string, survey: TSurvey, languageCode: string) => {
|
||||
const question = survey.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[])
|
||||
@@ -321,31 +301,25 @@ const checkForI18n = (
|
||||
}
|
||||
|
||||
// Return the localized value of the choice fo multiSelect single question
|
||||
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];
|
||||
}
|
||||
const choice = (question as TSurveyMultipleChoiceQuestion)?.choices.find(
|
||||
(choice) => choice.label[languageCode] === responseData[id]
|
||||
);
|
||||
|
||||
return responseData[id];
|
||||
return getLocalizedValue(choice?.label, "default") || 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 questions) {
|
||||
for (const question of survey.questions) {
|
||||
switch (question.type) {
|
||||
case TSurveyElementTypeEnum.OpenText: {
|
||||
let values: TSurveyElementSummaryOpenText["samples"] = [];
|
||||
case TSurveyQuestionTypeEnum.OpenText: {
|
||||
let values: TSurveyQuestionSummaryOpenText["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (answer && typeof answer === "string") {
|
||||
@@ -361,7 +335,7 @@ export const getQuestionSummary = async (
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question: question,
|
||||
question,
|
||||
responseCount: values.length,
|
||||
samples: values.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
@@ -369,9 +343,9 @@ export const getQuestionSummary = async (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti: {
|
||||
let values: TSurveyElementSummaryMultipleChoice["choices"] = [];
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti: {
|
||||
let values: TSurveyQuestionSummaryMultipleChoice["choices"] = [];
|
||||
|
||||
const otherOption = question.choices.find((choice) => choice.id === "other");
|
||||
const noneOption = question.choices.find((choice) => choice.id === "none");
|
||||
@@ -389,7 +363,7 @@ export const getQuestionSummary = async (
|
||||
const noneLabel = noneOption ? getLocalizedValue(noneOption.label, "default") : null;
|
||||
let noneCount = 0;
|
||||
|
||||
const otherValues: TSurveyElementSummaryMultipleChoice["choices"][number]["others"] = [];
|
||||
const otherValues: TSurveyQuestionSummaryMultipleChoice["choices"][number]["others"] = [];
|
||||
let totalSelectionCount = 0;
|
||||
let totalResponseCount = 0;
|
||||
responses.forEach((response) => {
|
||||
@@ -398,11 +372,11 @@ export const getQuestionSummary = async (
|
||||
const answer =
|
||||
responseLanguageCode === "default"
|
||||
? response.data[question.id]
|
||||
: checkForI18n(response.data, question.id, questions, responseLanguageCode);
|
||||
: checkForI18n(response.data, question.id, survey, responseLanguageCode);
|
||||
|
||||
let hasValidAnswer = false;
|
||||
|
||||
if (Array.isArray(answer) && question.type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
|
||||
if (Array.isArray(answer) && question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
|
||||
answer.forEach((value) => {
|
||||
if (value) {
|
||||
totalSelectionCount++;
|
||||
@@ -422,7 +396,7 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
} else if (
|
||||
typeof answer === "string" &&
|
||||
question.type === TSurveyElementTypeEnum.MultipleChoiceSingle
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle
|
||||
) {
|
||||
if (answer) {
|
||||
totalSelectionCount++;
|
||||
@@ -488,8 +462,8 @@ export const getQuestionSummary = async (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.PictureSelection: {
|
||||
let values: TSurveyElementSummaryPictureSelection["choices"] = [];
|
||||
case TSurveyQuestionTypeEnum.PictureSelection: {
|
||||
let values: TSurveyQuestionSummaryPictureSelection["choices"] = [];
|
||||
const choiceCountMap: Record<string, number> = {};
|
||||
|
||||
question.choices.forEach((choice) => {
|
||||
@@ -532,8 +506,8 @@ export const getQuestionSummary = async (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.Rating: {
|
||||
let values: TSurveyElementSummaryRating["choices"] = [];
|
||||
case TSurveyQuestionTypeEnum.Rating: {
|
||||
let values: TSurveyQuestionSummaryRating["choices"] = [];
|
||||
const choiceCountMap: Record<number, number> = {};
|
||||
const range = question.range;
|
||||
|
||||
@@ -558,13 +532,31 @@ export const getQuestionSummary = async (
|
||||
|
||||
Object.entries(choiceCountMap).forEach(([label, count]) => {
|
||||
values.push({
|
||||
rating: parseInt(label),
|
||||
rating: Number.parseInt(label),
|
||||
count,
|
||||
percentage:
|
||||
totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate CSAT based on range
|
||||
let satisfiedCount = 0;
|
||||
if (range === 3) {
|
||||
satisfiedCount = choiceCountMap[3] || 0;
|
||||
} else if (range === 4) {
|
||||
satisfiedCount = (choiceCountMap[3] || 0) + (choiceCountMap[4] || 0);
|
||||
} else if (range === 5) {
|
||||
satisfiedCount = (choiceCountMap[4] || 0) + (choiceCountMap[5] || 0);
|
||||
} else if (range === 6) {
|
||||
satisfiedCount = (choiceCountMap[5] || 0) + (choiceCountMap[6] || 0);
|
||||
} else if (range === 7) {
|
||||
satisfiedCount = (choiceCountMap[6] || 0) + (choiceCountMap[7] || 0);
|
||||
} else if (range === 10) {
|
||||
satisfiedCount = (choiceCountMap[8] || 0) + (choiceCountMap[9] || 0) + (choiceCountMap[10] || 0);
|
||||
}
|
||||
const satisfiedPercentage =
|
||||
totalResponseCount > 0 ? Math.round((satisfiedCount / totalResponseCount) * 100) : 0;
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
@@ -574,12 +566,16 @@ export const getQuestionSummary = async (
|
||||
dismissed: {
|
||||
count: dismissed,
|
||||
},
|
||||
csat: {
|
||||
satisfiedCount,
|
||||
satisfiedPercentage,
|
||||
},
|
||||
});
|
||||
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.NPS: {
|
||||
case TSurveyQuestionTypeEnum.NPS: {
|
||||
const data = {
|
||||
promoters: 0,
|
||||
passives: 0,
|
||||
@@ -589,10 +585,17 @@ export const getQuestionSummary = async (
|
||||
score: 0,
|
||||
};
|
||||
|
||||
// Track individual score counts (0-10)
|
||||
const scoreCountMap: Record<number, number> = {};
|
||||
for (let i = 0; i <= 10; i++) {
|
||||
scoreCountMap[i] = 0;
|
||||
}
|
||||
|
||||
responses.forEach((response) => {
|
||||
const value = response.data[question.id];
|
||||
if (typeof value === "number") {
|
||||
data.total++;
|
||||
scoreCountMap[value]++;
|
||||
if (value >= 9) {
|
||||
data.promoters++;
|
||||
} else if (value >= 7) {
|
||||
@@ -611,6 +614,13 @@ export const getQuestionSummary = async (
|
||||
? convertFloatTo2Decimal(((data.promoters - data.detractors) / data.total) * 100)
|
||||
: 0;
|
||||
|
||||
// Build choices array with individual score breakdown
|
||||
const choices = Object.entries(scoreCountMap).map(([rating, count]) => ({
|
||||
rating: Number.parseInt(rating),
|
||||
count,
|
||||
percentage: data.total > 0 ? convertFloatTo2Decimal((count / data.total) * 100) : 0,
|
||||
}));
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
@@ -633,10 +643,11 @@ export const getQuestionSummary = async (
|
||||
count: data.dismissed,
|
||||
percentage: data.total > 0 ? convertFloatTo2Decimal((data.dismissed / data.total) * 100) : 0,
|
||||
},
|
||||
choices,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.CTA: {
|
||||
case TSurveyQuestionTypeEnum.CTA: {
|
||||
const data = {
|
||||
clicked: 0,
|
||||
dismissed: 0,
|
||||
@@ -652,7 +663,7 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
|
||||
const totalResponses = data.clicked + data.dismissed;
|
||||
const idx = questions.findIndex((q) => q.id === question.id);
|
||||
const idx = survey.questions.findIndex((q) => q.id === question.id);
|
||||
const impressions = dropOff[idx].impressions;
|
||||
|
||||
summary.push({
|
||||
@@ -669,7 +680,7 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.Consent: {
|
||||
case TSurveyQuestionTypeEnum.Consent: {
|
||||
const data = {
|
||||
accepted: 0,
|
||||
dismissed: 0,
|
||||
@@ -704,8 +715,8 @@ export const getQuestionSummary = async (
|
||||
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.Date: {
|
||||
let values: TSurveyElementSummaryDate["samples"] = [];
|
||||
case TSurveyQuestionTypeEnum.Date: {
|
||||
let values: TSurveyQuestionSummaryDate["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (answer && typeof answer === "string") {
|
||||
@@ -729,8 +740,8 @@ export const getQuestionSummary = async (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.FileUpload: {
|
||||
let values: TSurveyElementSummaryFileUpload["files"] = [];
|
||||
case TSurveyQuestionTypeEnum.FileUpload: {
|
||||
let values: TSurveyQuestionSummaryFileUpload["files"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (Array.isArray(answer)) {
|
||||
@@ -754,7 +765,7 @@ export const getQuestionSummary = async (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.Cal: {
|
||||
case TSurveyQuestionTypeEnum.Cal: {
|
||||
const data = {
|
||||
booked: 0,
|
||||
skipped: 0,
|
||||
@@ -787,7 +798,7 @@ export const getQuestionSummary = async (
|
||||
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.Matrix: {
|
||||
case TSurveyQuestionTypeEnum.Matrix: {
|
||||
const rows = question.rows.map((row) => getLocalizedValue(row.label, "default"));
|
||||
const columns = question.columns.map((column) => getLocalizedValue(column.label, "default"));
|
||||
let totalResponseCount = 0;
|
||||
@@ -848,8 +859,9 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.Address: {
|
||||
let values: TSurveyElementSummaryAddress["samples"] = [];
|
||||
case TSurveyQuestionTypeEnum.Address:
|
||||
case TSurveyQuestionTypeEnum.ContactInfo: {
|
||||
let values: TSurveyQuestionSummaryAddress["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (Array.isArray(answer) && answer.length > 0) {
|
||||
@@ -864,8 +876,8 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: TSurveyElementTypeEnum.Address,
|
||||
question: question as TSurveyAddressElement,
|
||||
type: question.type as TSurveyQuestionTypeEnum.ContactInfo,
|
||||
question: question as TSurveyContactInfoQuestion,
|
||||
responseCount: values.length,
|
||||
samples: values.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
@@ -873,38 +885,13 @@ export const getQuestionSummary = async (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
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"] = [];
|
||||
case TSurveyQuestionTypeEnum.Ranking: {
|
||||
let values: TSurveyQuestionSummaryRanking["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: string) => {
|
||||
questionChoices.forEach((choice) => {
|
||||
choiceRankSums[choice] = 0;
|
||||
choiceCountMap[choice] = 0;
|
||||
});
|
||||
@@ -915,7 +902,7 @@ export const getQuestionSummary = async (
|
||||
const answer =
|
||||
responseLanguageCode === "default"
|
||||
? response.data[question.id]
|
||||
: checkForI18n(response.data, question.id, questions, responseLanguageCode);
|
||||
: checkForI18n(response.data, question.id, survey, responseLanguageCode);
|
||||
|
||||
if (Array.isArray(answer)) {
|
||||
totalResponseCount++;
|
||||
@@ -929,7 +916,7 @@ export const getQuestionSummary = async (
|
||||
}
|
||||
});
|
||||
|
||||
questionChoices.forEach((choice: string) => {
|
||||
questionChoices.forEach((choice) => {
|
||||
const count = choiceCountMap[choice];
|
||||
const avgRanking = count > 0 ? choiceRankSums[choice] / count : 0;
|
||||
values.push({
|
||||
@@ -952,7 +939,7 @@ export const getQuestionSummary = async (
|
||||
}
|
||||
|
||||
survey.hiddenFields?.fieldIds?.forEach((hiddenFieldId) => {
|
||||
let values: TSurveyElementSummaryHiddenFields["samples"] = [];
|
||||
let values: TSurveyQuestionSummaryHiddenFields["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[hiddenFieldId];
|
||||
if (answer && typeof answer === "string") {
|
||||
@@ -988,9 +975,6 @@ 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;
|
||||
|
||||
@@ -1021,10 +1005,10 @@ export const getSurveySummary = reactCache(
|
||||
getQuotasSummary(surveyId),
|
||||
]);
|
||||
|
||||
const dropOff = getSurveySummaryDropOff(survey, questions, responses, displayCount);
|
||||
const dropOff = getSurveySummaryDropOff(survey, responses, displayCount);
|
||||
const [meta, questionWiseSummary] = await Promise.all([
|
||||
getSurveySummaryMeta(responses, displayCount, quotas),
|
||||
getQuestionSummary(survey, questions, responses, dropOff),
|
||||
getQuestionSummary(survey, responses, dropOff),
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { constructToastMessage, convertFloatTo2Decimal, convertFloatToNDecimal } from "./utils";
|
||||
|
||||
describe("Utils Tests", () => {
|
||||
@@ -35,40 +34,29 @@ describe("Utils Tests", () => {
|
||||
type: "app",
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
blocks: [
|
||||
questions: [
|
||||
{
|
||||
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" },
|
||||
},
|
||||
],
|
||||
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" } }],
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
@@ -86,7 +74,7 @@ describe("Utils Tests", () => {
|
||||
|
||||
test("should construct message for matrix question type", () => {
|
||||
const message = constructToastMessage(
|
||||
TSurveyElementTypeEnum.Matrix,
|
||||
TSurveyQuestionTypeEnum.Matrix,
|
||||
"is",
|
||||
mockSurvey,
|
||||
"q3",
|
||||
@@ -107,7 +95,7 @@ describe("Utils Tests", () => {
|
||||
});
|
||||
|
||||
test("should construct message for matrix question type with array filterComboBoxValue", () => {
|
||||
const message = constructToastMessage(TSurveyElementTypeEnum.Matrix, "is", mockSurvey, "q3", mockT, [
|
||||
const message = constructToastMessage(TSurveyQuestionTypeEnum.Matrix, "is", mockSurvey, "q3", mockT, [
|
||||
"MatrixValue1",
|
||||
"MatrixValue2",
|
||||
]);
|
||||
@@ -126,7 +114,7 @@ describe("Utils Tests", () => {
|
||||
|
||||
test("should construct message when filterComboBoxValue is undefined (skipped)", () => {
|
||||
const message = constructToastMessage(
|
||||
TSurveyElementTypeEnum.OpenText,
|
||||
TSurveyQuestionTypeEnum.OpenText,
|
||||
"is skipped",
|
||||
mockSurvey,
|
||||
"q1",
|
||||
@@ -146,7 +134,7 @@ describe("Utils Tests", () => {
|
||||
|
||||
test("should construct message for non-matrix question with string filterComboBoxValue", () => {
|
||||
const message = constructToastMessage(
|
||||
TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
"is",
|
||||
mockSurvey,
|
||||
"q2",
|
||||
@@ -168,7 +156,7 @@ describe("Utils Tests", () => {
|
||||
|
||||
test("should construct message for non-matrix question with array filterComboBoxValue", () => {
|
||||
const message = constructToastMessage(
|
||||
TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
"includes all of",
|
||||
mockSurvey,
|
||||
"q2", // Assuming q2 can be multi for this test case logic
|
||||
@@ -190,7 +178,7 @@ describe("Utils Tests", () => {
|
||||
|
||||
test("should handle questionId not found in survey", () => {
|
||||
const message = constructToastMessage(
|
||||
TSurveyElementTypeEnum.OpenText,
|
||||
TSurveyQuestionTypeEnum.OpenText,
|
||||
"is",
|
||||
mockSurvey,
|
||||
"qNonExistent",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { TSurvey, TSurveyQuestionId, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const convertFloatToNDecimal = (num: number, N: number = 2) => {
|
||||
return Math.round(num * Math.pow(10, N)) / Math.pow(10, N);
|
||||
@@ -12,16 +10,14 @@ export const convertFloatTo2Decimal = (num: number) => {
|
||||
};
|
||||
|
||||
export const constructToastMessage = (
|
||||
questionType: TSurveyElementTypeEnum,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
survey: TSurvey,
|
||||
questionId: TSurveyQuestionId,
|
||||
t: TFunction,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => {
|
||||
// Derive questions from blocks
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
const questionIdx = questions.findIndex((question) => question.id === questionId);
|
||||
const questionIdx = survey.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,
|
||||
|
||||
@@ -70,7 +70,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
|
||||
displayCount={initialSurveySummary?.meta.displayCount ?? 0}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
|
||||
@@ -4,7 +4,7 @@ import clsx from "clsx";
|
||||
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TI18nString, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
||||
@@ -26,8 +26,8 @@ import {
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
type QuestionFilterComboBoxProps = {
|
||||
filterOptions: string[] | undefined;
|
||||
filterComboBoxOptions: string[] | undefined;
|
||||
filterOptions: (string | TI18nString)[] | undefined;
|
||||
filterComboBoxOptions: (string | TI18nString)[] | undefined;
|
||||
filterValue: string | undefined;
|
||||
filterComboBoxValue: string | string[] | undefined;
|
||||
onChangeFilterValue: (o: string) => void;
|
||||
@@ -74,7 +74,7 @@ export const QuestionFilterComboBox = ({
|
||||
if (!isMultiple) return filterComboBoxOptions;
|
||||
|
||||
return filterComboBoxOptions?.filter((o) => {
|
||||
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
const optionValue = typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return !filterComboBoxValue?.includes(optionValue);
|
||||
});
|
||||
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue, defaultLanguageCode]);
|
||||
@@ -91,14 +91,15 @@ export const QuestionFilterComboBox = ({
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
options?.filter((o) => {
|
||||
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
const optionValue =
|
||||
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return optionValue.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
}),
|
||||
[options, searchQuery, defaultLanguageCode]
|
||||
);
|
||||
|
||||
const handleCommandItemSelect = (o: string) => {
|
||||
const value = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
const handleCommandItemSelect = (o: string | TI18nString) => {
|
||||
const value = typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
|
||||
if (isMultiple) {
|
||||
const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value];
|
||||
@@ -200,14 +201,18 @@ export const QuestionFilterComboBox = ({
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-white">
|
||||
{filterOptions?.map((o, index) => (
|
||||
<DropdownMenuItem
|
||||
key={`${o}-${index}`}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onChangeFilterValue(o)}>
|
||||
{o}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{filterOptions?.map((o, index) => {
|
||||
const optionValue =
|
||||
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={`${optionValue}-${index}`}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onChangeFilterValue(optionValue)}>
|
||||
{optionValue}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
@@ -269,7 +274,8 @@ export const QuestionFilterComboBox = ({
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredOptions?.map((o) => {
|
||||
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
const optionValue =
|
||||
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return (
|
||||
<CommandItem
|
||||
key={optionValue}
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Fragment, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
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?: TSurveyElementTypeEnum;
|
||||
questionType?: TSurveyQuestionTypeEnum;
|
||||
type: OptionsType;
|
||||
id: string;
|
||||
};
|
||||
@@ -72,18 +72,18 @@ interface QuestionComboBoxProps {
|
||||
|
||||
const questionIcons = {
|
||||
// questions
|
||||
[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,
|
||||
[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,
|
||||
|
||||
// attributes
|
||||
[OptionsType.ATTRIBUTES]: User,
|
||||
|
||||
@@ -4,8 +4,7 @@ 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 { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TI18nString, TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
SelectedFilterValue,
|
||||
TResponseStatus,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox";
|
||||
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
import {
|
||||
@@ -26,9 +26,17 @@ import {
|
||||
import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
|
||||
|
||||
export type QuestionFilterOptions = {
|
||||
type: TSurveyElementTypeEnum | "Attributes" | "Tags" | "Languages" | "Quotas";
|
||||
filterOptions: string[];
|
||||
filterComboBoxOptions: string[];
|
||||
type:
|
||||
| TSurveyQuestionTypeEnum
|
||||
| "Attributes"
|
||||
| "Tags"
|
||||
| "Languages"
|
||||
| "Quotas"
|
||||
| "Hidden Fields"
|
||||
| "Meta"
|
||||
| OptionsType.OTHERS;
|
||||
filterOptions: (string | TI18nString)[];
|
||||
filterComboBoxOptions: (string | TI18nString)[];
|
||||
id: string;
|
||||
};
|
||||
|
||||
@@ -70,6 +78,12 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [filterValue, setFilterValue] = useState<SelectedFilterValue>(selectedFilter);
|
||||
|
||||
const getDefaultFilterValue = (option?: QuestionFilterOptions): string | undefined => {
|
||||
if (!option || option.filterOptions.length === 0) return undefined;
|
||||
const firstOption = option.filterOptions[0];
|
||||
return typeof firstOption === "object" ? getLocalizedValue(firstOption, "default") : firstOption;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch the initial data for the filter and load it into the state
|
||||
const handleInitialData = async () => {
|
||||
@@ -95,15 +109,18 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
}, [isOpen, setSelectedOptions, survey]);
|
||||
|
||||
const handleOnChangeQuestionComboBoxValue = (value: QuestionOption, index: number) => {
|
||||
const matchingFilterOption = selectedOptions.questionFilterOptions.find(
|
||||
(q) => q.type === value.type || q.type === value.questionType
|
||||
);
|
||||
const defaultFilterValue = getDefaultFilterValue(matchingFilterOption);
|
||||
|
||||
if (filterValue.filter[index].questionType) {
|
||||
// Create a new array and copy existing values from SelectedFilter
|
||||
filterValue.filter[index] = {
|
||||
questionType: value,
|
||||
filterType: {
|
||||
filterComboBoxValue: undefined,
|
||||
filterValue: selectedOptions.questionFilterOptions.find(
|
||||
(q) => q.type === value.type || q.type === value.questionType
|
||||
)?.filterOptions[0],
|
||||
filterValue: defaultFilterValue,
|
||||
},
|
||||
};
|
||||
setFilterValue({ filter: [...filterValue.filter], responseStatus: filterValue.responseStatus });
|
||||
@@ -112,9 +129,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
filterValue.filter[index].questionType = value;
|
||||
filterValue.filter[index].filterType = {
|
||||
filterComboBoxValue: undefined,
|
||||
filterValue: selectedOptions.questionFilterOptions.find(
|
||||
(q) => q.type === value.type || q.type === value.questionType
|
||||
)?.filterOptions[0],
|
||||
filterValue: defaultFilterValue,
|
||||
};
|
||||
setFilterValue({ ...filterValue });
|
||||
}
|
||||
@@ -218,11 +233,13 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
setFilterValue(selectedFilter);
|
||||
}, [selectedFilter]);
|
||||
|
||||
const activeFilterCount = filterValue.filter.length + (filterValue.responseStatus === "all" ? 0 : 1);
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<PopoverTriggerButton isOpen={isOpen}>
|
||||
Filter <b>{filterValue.filter.length > 0 && `(${filterValue.filter.length})`}</b>
|
||||
Filter <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
|
||||
</PopoverTriggerButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
||||
@@ -23,8 +23,12 @@ import {
|
||||
TIntegrationSlackCredential,
|
||||
} from "@formbricks/types/integration/slack";
|
||||
import { TResponse, TResponseMeta } from "@formbricks/types/responses";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyPictureSelectionQuestion,
|
||||
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";
|
||||
@@ -97,47 +101,33 @@ const mockPipelineInput = {
|
||||
const mockSurvey = {
|
||||
id: surveyId,
|
||||
name: "Test Survey",
|
||||
blocks: [
|
||||
questions: [
|
||||
{
|
||||
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: 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: 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,8 +6,7 @@ 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 { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } 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";
|
||||
@@ -17,7 +16,6 @@ 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";
|
||||
@@ -238,9 +236,6 @@ 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)) {
|
||||
@@ -248,7 +243,7 @@ const extractResponses = async (
|
||||
questions.push(questionId);
|
||||
continue;
|
||||
}
|
||||
const question = surveyQuestions.find((q) => q.id === questionId);
|
||||
const question = survey?.questions.find((q) => q.id === questionId);
|
||||
if (!question) {
|
||||
continue;
|
||||
}
|
||||
@@ -257,7 +252,7 @@ const extractResponses = async (
|
||||
|
||||
if (responseValue !== undefined) {
|
||||
let answer: typeof responseValue;
|
||||
if (question.type === TSurveyElementTypeEnum.PictureSelection) {
|
||||
if (question.type === TSurveyQuestionTypeEnum.PictureSelection) {
|
||||
const selectedChoiceIds = responseValue as string[];
|
||||
answer = question?.choices
|
||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||
@@ -326,17 +321,14 @@ 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 === TSurveyElementTypeEnum.PictureSelection)
|
||||
.filter((m) => m.question.type === TSurveyQuestionTypeEnum.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 = surveyQuestions.find((q) => q.id === resp);
|
||||
const pictureQuestion = surveyData.questions.find((q) => q.id === resp);
|
||||
|
||||
responses[resp] = (pictureQuestion as any)?.choices
|
||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||
|
||||
@@ -92,7 +92,6 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
||||
welcomeCard: true,
|
||||
name: true,
|
||||
questions: true,
|
||||
blocks: true,
|
||||
variables: true,
|
||||
type: true,
|
||||
showLanguageSwitch: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
@@ -28,15 +29,38 @@ export const GET = withV1ApiWrapper({
|
||||
const params = await props.params;
|
||||
|
||||
try {
|
||||
// Simple validation for environmentId (faster than Zod for high-frequency endpoint)
|
||||
// Basic type check for environmentId
|
||||
if (typeof params.environmentId !== "string") {
|
||||
return {
|
||||
response: responses.badRequestResponse("Environment ID is required", undefined, true),
|
||||
};
|
||||
}
|
||||
|
||||
const environmentId = params.environmentId.trim();
|
||||
|
||||
// Validate CUID v1 format using Zod (matches Prisma schema @default(cuid()))
|
||||
// This catches all invalid formats including:
|
||||
// - null/undefined passed as string "null" or "undefined"
|
||||
// - HTML-encoded placeholders like <environmentId> or %3C...%3E
|
||||
// - Empty or whitespace-only IDs
|
||||
// - Any other invalid CUID v1 format
|
||||
const cuidValidation = ZEnvironmentId.safeParse(environmentId);
|
||||
if (!cuidValidation.success) {
|
||||
logger.warn(
|
||||
{
|
||||
environmentId: params.environmentId,
|
||||
url: req.url,
|
||||
validationError: cuidValidation.error.errors[0]?.message,
|
||||
},
|
||||
"Invalid CUID v1 format detected"
|
||||
);
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid environment ID format", undefined, true),
|
||||
};
|
||||
}
|
||||
|
||||
// Use optimized environment state fetcher with new caching approach
|
||||
const environmentState = await getEnvironmentState(params.environmentId);
|
||||
const environmentState = await getEnvironmentState(environmentId);
|
||||
const { data } = environmentState;
|
||||
|
||||
return {
|
||||
@@ -46,12 +70,12 @@ export const GET = withV1ApiWrapper({
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck
|
||||
},
|
||||
true,
|
||||
// Optimized cache headers for Cloudflare CDN and browser caching
|
||||
// max-age=3600: 1hr browser cache (per guidelines)
|
||||
// s-maxage=1800: 30min Cloudflare cache (per guidelines)
|
||||
// stale-while-revalidate=1800: 30min stale serving during revalidation
|
||||
// stale-if-error=3600: 1hr stale serving on origin errors
|
||||
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
|
||||
// Cache headers aligned with Redis cache TTL (1 minute)
|
||||
// max-age=60: 1min browser cache
|
||||
// s-maxage=60: 1min Cloudflare CDN cache
|
||||
// stale-while-revalidate=60: 1min stale serving during revalidation
|
||||
// stale-if-error=60: 1min stale serving on origin errors
|
||||
"public, s-maxage=60, max-age=60, stale-while-revalidate=60, stale-if-error=60"
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { headers } from "next/headers";
|
||||
import { NextRequest } from "next/server";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
@@ -51,7 +51,7 @@ export const POST = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
const { environmentId } = params;
|
||||
const environmentIdValidation = ZId.safeParse(environmentId);
|
||||
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
|
||||
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
|
||||
|
||||
if (!environmentIdValidation.success) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { headers } from "next/headers";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
|
||||
@@ -10,7 +10,6 @@ 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";
|
||||
@@ -44,7 +43,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
}
|
||||
|
||||
const { environmentId } = params;
|
||||
const environmentIdValidation = ZId.safeParse(environmentId);
|
||||
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
|
||||
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
|
||||
|
||||
if (!environmentIdValidation.success) {
|
||||
@@ -92,7 +91,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: getElementsFromBlocks(survey.blocks),
|
||||
surveyQuestions: survey.questions,
|
||||
responseLanguage: responseInputData.language,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
|
||||
import type {
|
||||
TSurveyCTAElement,
|
||||
TSurveyConsentElement,
|
||||
TSurveyElement,
|
||||
TSurveyMultipleChoiceElement,
|
||||
TSurveyNPSElement,
|
||||
TSurveyOpenTextElement,
|
||||
TSurveyOpenTextElementInputType,
|
||||
TSurveyRatingElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import type { TShuffleOption } from "@formbricks/types/surveys/types";
|
||||
import { createI18nString } from "@/lib/i18n/utils";
|
||||
|
||||
const getDefaultButtonLabel = (label: string | undefined, t: TFunction) =>
|
||||
createI18nString(label || t("common.next"), []);
|
||||
|
||||
const getDefaultBackButtonLabel = (label: string | undefined, t: TFunction) =>
|
||||
createI18nString(label || t("common.back"), []);
|
||||
|
||||
export const buildMultipleChoiceElement = ({
|
||||
id,
|
||||
headline,
|
||||
type,
|
||||
subheader,
|
||||
choices,
|
||||
choiceIds,
|
||||
shuffleOption,
|
||||
required,
|
||||
containsOther = false,
|
||||
}: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceMulti | TSurveyElementTypeEnum.MultipleChoiceSingle;
|
||||
subheader?: string;
|
||||
choices: string[];
|
||||
choiceIds?: string[];
|
||||
shuffleOption?: TShuffleOption;
|
||||
required?: boolean;
|
||||
containsOther?: boolean;
|
||||
}): TSurveyMultipleChoiceElement => {
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type,
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
choices: choices.map((choice, index) => {
|
||||
const isLastIndex = index === choices.length - 1;
|
||||
let choiceId: string;
|
||||
if (containsOther && isLastIndex) {
|
||||
choiceId = "other";
|
||||
} else if (choiceIds) {
|
||||
choiceId = choiceIds[index];
|
||||
} else {
|
||||
choiceId = createId();
|
||||
}
|
||||
return { id: choiceId, label: createI18nString(choice, []) };
|
||||
}),
|
||||
shuffleOption: shuffleOption || "none",
|
||||
required: required ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildOpenTextElement = ({
|
||||
id,
|
||||
headline,
|
||||
subheader,
|
||||
placeholder,
|
||||
inputType,
|
||||
required,
|
||||
longAnswer,
|
||||
}: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
subheader?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
inputType: TSurveyOpenTextElementInputType;
|
||||
longAnswer?: boolean;
|
||||
}): TSurveyOpenTextElement => {
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
inputType,
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
placeholder: placeholder ? createI18nString(placeholder, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
required: required ?? false,
|
||||
longAnswer,
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const buildRatingElement = ({
|
||||
id,
|
||||
headline,
|
||||
subheader,
|
||||
scale,
|
||||
range,
|
||||
lowerLabel,
|
||||
upperLabel,
|
||||
required,
|
||||
isColorCodingEnabled = false,
|
||||
}: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
scale: TSurveyRatingElement["scale"];
|
||||
range: TSurveyRatingElement["range"];
|
||||
lowerLabel?: string;
|
||||
upperLabel?: string;
|
||||
subheader?: string;
|
||||
required?: boolean;
|
||||
isColorCodingEnabled?: boolean;
|
||||
}): TSurveyRatingElement => {
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type: TSurveyElementTypeEnum.Rating,
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
scale,
|
||||
range,
|
||||
required: required ?? false,
|
||||
isColorCodingEnabled,
|
||||
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
|
||||
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildConsentElement = ({
|
||||
id,
|
||||
headline,
|
||||
subheader,
|
||||
label,
|
||||
required,
|
||||
}: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
subheader: string;
|
||||
required?: boolean;
|
||||
label: string;
|
||||
}): TSurveyConsentElement => {
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type: TSurveyElementTypeEnum.Consent,
|
||||
subheader: createI18nString(subheader, []),
|
||||
headline: createI18nString(headline, []),
|
||||
required: required ?? false,
|
||||
label: createI18nString(label, []),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildCTAElement = ({
|
||||
id,
|
||||
headline,
|
||||
subheader,
|
||||
buttonExternal,
|
||||
required,
|
||||
dismissButtonLabel,
|
||||
buttonUrl,
|
||||
}: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
buttonExternal: boolean;
|
||||
subheader: string;
|
||||
required?: boolean;
|
||||
dismissButtonLabel?: string;
|
||||
buttonUrl?: string;
|
||||
}): TSurveyCTAElement => {
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
subheader: createI18nString(subheader, []),
|
||||
headline: createI18nString(headline, []),
|
||||
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
|
||||
required: required ?? false,
|
||||
buttonExternal,
|
||||
buttonUrl,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildNPSElement = ({
|
||||
id,
|
||||
headline,
|
||||
subheader,
|
||||
lowerLabel,
|
||||
upperLabel,
|
||||
required,
|
||||
isColorCodingEnabled = false,
|
||||
}: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
subheader?: string;
|
||||
lowerLabel?: string;
|
||||
upperLabel?: string;
|
||||
required?: boolean;
|
||||
isColorCodingEnabled?: boolean;
|
||||
}): TSurveyNPSElement => {
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type: TSurveyElementTypeEnum.NPS,
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
required: required ?? false,
|
||||
isColorCodingEnabled,
|
||||
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
|
||||
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to create block-level jump logic based on operator
|
||||
export const createBlockJumpLogic = (
|
||||
sourceElementId: string,
|
||||
targetBlockId: string,
|
||||
operator: "isSkipped" | "isSubmitted" | "isClicked"
|
||||
): TSurveyBlockLogic => ({
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: sourceElementId,
|
||||
type: "question",
|
||||
},
|
||||
operator: operator,
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToBlock",
|
||||
target: targetBlockId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Helper function to create block-level jump logic based on choice selection
|
||||
export const createBlockChoiceJumpLogic = (
|
||||
sourceElementId: string,
|
||||
choiceId: string | number,
|
||||
targetBlockId: string
|
||||
): TSurveyBlockLogic => ({
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: sourceElementId,
|
||||
type: "question",
|
||||
},
|
||||
operator: "equals",
|
||||
rightOperand: {
|
||||
type: "static",
|
||||
value: choiceId,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToBlock",
|
||||
target: targetBlockId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Block builder function
|
||||
export const buildBlock = ({
|
||||
id,
|
||||
name,
|
||||
elements,
|
||||
logic,
|
||||
logicFallback,
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
t,
|
||||
}: {
|
||||
id?: string;
|
||||
name: string;
|
||||
elements: TSurveyElement[];
|
||||
logic?: TSurveyBlockLogic[];
|
||||
logicFallback?: string;
|
||||
buttonLabel?: string;
|
||||
backButtonLabel?: string;
|
||||
t: TFunction;
|
||||
}): TSurveyBlock => {
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
name,
|
||||
elements,
|
||||
logic,
|
||||
logicFallback,
|
||||
buttonLabel: buttonLabel ? getDefaultButtonLabel(buttonLabel, t) : undefined,
|
||||
backButtonLabel: backButtonLabel ? getDefaultBackButtonLabel(backButtonLabel, t) : undefined,
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,15 @@
|
||||
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,
|
||||
@@ -10,81 +19,595 @@ import {
|
||||
const mockT = (props: any): string => (typeof props === "string" ? props : props.key);
|
||||
|
||||
describe("Survey Builder", () => {
|
||||
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("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,
|
||||
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,
|
||||
});
|
||||
// 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("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).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(endingCard.id).toBeDefined();
|
||||
expect(endingCard).toHaveProperty("buttonLabel");
|
||||
expect(endingCard).toHaveProperty("buttonLink");
|
||||
expect(question.choices.length).toBe(3);
|
||||
expect(question.id).toBeDefined();
|
||||
});
|
||||
|
||||
test("hiddenFieldsDefault has expected structure", () => {
|
||||
expect(hiddenFieldsDefault).toMatchObject({
|
||||
enabled: true,
|
||||
fieldIds: [],
|
||||
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,
|
||||
});
|
||||
|
||||
expect(question.id).toBe(customId);
|
||||
expect(question.type).toBe(TSurveyQuestionTypeEnum.MultipleChoiceMulti);
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
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,
|
||||
});
|
||||
|
||||
const survey = buildSurvey(config, mockT);
|
||||
expect(question.choices.length).toBe(3);
|
||||
expect(question.choices[2].id).toBe("other");
|
||||
});
|
||||
|
||||
// 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);
|
||||
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,
|
||||
});
|
||||
|
||||
// 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);
|
||||
expect(question.choices[0].id).toBe(choiceIds[0]);
|
||||
expect(question.choices[1].id).toBe(choiceIds[1]);
|
||||
expect(question.choices[2].id).toBe(choiceIds[2]);
|
||||
});
|
||||
|
||||
// default values from getDefaultSurveyPreset
|
||||
expect(survey.preset.welcomeCard).toHaveProperty("headline");
|
||||
test("applies all optional parameters correctly", () => {
|
||||
const logic: TSurveyLogic[] = [
|
||||
{
|
||||
id: "logic-1",
|
||||
conditions: {
|
||||
id: "cond-1",
|
||||
connector: "and",
|
||||
conditions: [],
|
||||
},
|
||||
actions: [],
|
||||
},
|
||||
];
|
||||
|
||||
const shuffleOption: TShuffleOption = "all";
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Helper Functions", () => {
|
||||
test("createJumpLogic returns valid jump logic", () => {
|
||||
const sourceId = "q1";
|
||||
const targetId = "q2";
|
||||
const operator: "isClicked" = "isClicked";
|
||||
const logic = createJumpLogic(sourceId, targetId, operator);
|
||||
|
||||
// Check structure
|
||||
expect(logic).toHaveProperty("id");
|
||||
expect(logic).toHaveProperty("conditions");
|
||||
expect(logic.conditions).toHaveProperty("conditions");
|
||||
expect(Array.isArray(logic.conditions.conditions)).toBe(true);
|
||||
|
||||
// Check one of the inner conditions
|
||||
const condition = logic.conditions.conditions[0];
|
||||
// Need to use type checking to ensure condition is a TSingleCondition not a TConditionGroup
|
||||
if (!("connector" in condition)) {
|
||||
expect(condition.leftOperand.value).toBe(sourceId);
|
||||
expect(condition.operator).toBe(operator);
|
||||
}
|
||||
|
||||
// Check actions
|
||||
expect(Array.isArray(logic.actions)).toBe(true);
|
||||
const action = logic.actions[0];
|
||||
if (action.objective === "jumpToQuestion") {
|
||||
expect(action.target).toBe(targetId);
|
||||
}
|
||||
});
|
||||
|
||||
test("createChoiceJumpLogic returns valid jump logic based on choice selection", () => {
|
||||
const sourceId = "q1";
|
||||
const choiceId = "choice1";
|
||||
const targetId = "q2";
|
||||
const logic = createChoiceJumpLogic(sourceId, choiceId, targetId);
|
||||
|
||||
expect(logic).toHaveProperty("id");
|
||||
expect(logic.conditions).toHaveProperty("conditions");
|
||||
|
||||
const condition = logic.conditions.conditions[0];
|
||||
if (!("connector" in condition)) {
|
||||
expect(condition.leftOperand.value).toBe(sourceId);
|
||||
expect(condition.operator).toBe("equals");
|
||||
expect(condition.rightOperand?.value).toBe(choiceId);
|
||||
}
|
||||
|
||||
const action = logic.actions[0];
|
||||
if (action.objective === "jumpToQuestion") {
|
||||
expect(action.target).toBe(targetId);
|
||||
}
|
||||
});
|
||||
|
||||
test("getDefaultWelcomeCard returns expected welcome card", () => {
|
||||
const card = getDefaultWelcomeCard(mockT);
|
||||
expect(card.enabled).toBe(false);
|
||||
expect(card.headline).toEqual({ default: "templates.default_welcome_card_headline" });
|
||||
expect(card.subheader).toEqual({ default: "templates.default_welcome_card_html" });
|
||||
expect(card.buttonLabel).toEqual({ default: "templates.default_welcome_card_button_label" });
|
||||
// boolean flags
|
||||
expect(card.timeToFinish).toBe(false);
|
||||
expect(card.showResponseCount).toBe(false);
|
||||
});
|
||||
|
||||
test("getDefaultEndingCard returns expected end screen card", () => {
|
||||
// Pass empty languages array to simulate no languages
|
||||
const card = getDefaultEndingCard([], mockT);
|
||||
expect(card).toHaveProperty("id");
|
||||
expect(card.type).toBe("endScreen");
|
||||
expect(card.headline).toEqual({ default: "templates.default_ending_card_headline" });
|
||||
expect(card.subheader).toEqual({ default: "templates.default_ending_card_subheader" });
|
||||
expect(card.buttonLabel).toEqual({ default: "templates.default_ending_card_button_label" });
|
||||
expect(card.buttonLink).toBe("https://formbricks.com");
|
||||
});
|
||||
|
||||
test("getDefaultSurveyPreset returns expected default survey preset", () => {
|
||||
const preset = getDefaultSurveyPreset(mockT);
|
||||
expect(preset.name).toBe("New Survey");
|
||||
expect(preset.questions).toEqual([]);
|
||||
// test welcomeCard and endings
|
||||
expect(preset.welcomeCard).toHaveProperty("headline");
|
||||
expect(Array.isArray(preset.endings)).toBe(true);
|
||||
expect(preset.hiddenFields).toEqual(hiddenFieldsDefault);
|
||||
});
|
||||
|
||||
test("buildSurvey returns built survey with overridden preset properties", () => {
|
||||
const config = {
|
||||
name: "Custom Survey",
|
||||
industries: ["eCommerce"] as string[],
|
||||
channels: ["link"],
|
||||
description: "Test survey",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText, // changed from "OpenText"
|
||||
headline: { default: "Question 1" },
|
||||
inputType: "text",
|
||||
buttonLabel: { default: "Next" },
|
||||
backButtonLabel: { default: "Back" },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
endings: [
|
||||
{
|
||||
id: "end1",
|
||||
type: "endScreen",
|
||||
headline: { default: "End Screen" },
|
||||
subheader: { default: "Thanks" },
|
||||
buttonLabel: { default: "Finish" },
|
||||
buttonLink: "https://formbricks.com",
|
||||
},
|
||||
],
|
||||
hiddenFields: { enabled: false, fieldIds: ["f1"] },
|
||||
};
|
||||
|
||||
const survey = buildSurvey(config as any, mockT);
|
||||
expect(survey.name).toBe(config.name);
|
||||
expect(survey.industries).toEqual(config.industries);
|
||||
expect(survey.channels).toEqual(config.channels);
|
||||
expect(survey.description).toBe(config.description);
|
||||
// preset overrides
|
||||
expect(survey.preset.name).toBe(config.name);
|
||||
expect(survey.preset.questions).toEqual(config.questions);
|
||||
expect(survey.preset.endings).toEqual(config.endings);
|
||||
expect(survey.preset.hiddenFields).toEqual(config.hiddenFields);
|
||||
});
|
||||
|
||||
test("hiddenFieldsDefault has expected default configuration", () => {
|
||||
expect(hiddenFieldsDefault).toEqual({ enabled: true, fieldIds: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,284 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import type {
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
TShuffleOption,
|
||||
TSurveyCTAQuestion,
|
||||
TSurveyConsentQuestion,
|
||||
TSurveyEndScreenCard,
|
||||
TSurveyEnding,
|
||||
TSurveyHiddenFields,
|
||||
TSurveyLanguage,
|
||||
TSurveyLogic,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyNPSQuestion,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyOpenTextQuestionInputType,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyRatingQuestion,
|
||||
TSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import type { TTemplate, TTemplateRole } from "@formbricks/types/templates";
|
||||
import { 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,
|
||||
@@ -110,13 +377,13 @@ export const getDefaultSurveyPreset = (t: TFunction): TTemplate["preset"] => {
|
||||
welcomeCard: getDefaultWelcomeCard(t),
|
||||
endings: [getDefaultEndingCard([], t)],
|
||||
hiddenFields: hiddenFieldsDefault,
|
||||
blocks: [],
|
||||
questions: [],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic builder for survey.
|
||||
* @param config - The configuration for survey settings and blocks.
|
||||
* @param config - The configuration for survey settings and questions.
|
||||
* @param t - The translation function.
|
||||
*/
|
||||
export const buildSurvey = (
|
||||
@@ -126,9 +393,9 @@ export const buildSurvey = (
|
||||
channels: ("link" | "app" | "website")[];
|
||||
role: TTemplateRole;
|
||||
description: string;
|
||||
blocks: TSurveyBlock[];
|
||||
endings: TSurveyEnding[];
|
||||
hiddenFields: TSurveyHiddenFields;
|
||||
questions: TSurveyQuestion[];
|
||||
endings?: TSurveyEnding[];
|
||||
hiddenFields?: TSurveyHiddenFields;
|
||||
},
|
||||
t: TFunction
|
||||
): TTemplate => {
|
||||
@@ -142,7 +409,7 @@ export const buildSurvey = (
|
||||
preset: {
|
||||
...localSurvey,
|
||||
name: config.name,
|
||||
blocks: config.blocks ?? [],
|
||||
questions: config.questions,
|
||||
endings: config.endings ?? localSurvey.endings,
|
||||
hiddenFields: config.hiddenFields ?? hiddenFieldsDefault,
|
||||
},
|
||||
|
||||
@@ -2,46 +2,45 @@ 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 { type TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyLanguage,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import {
|
||||
DateRange,
|
||||
SelectedFilterValue,
|
||||
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { TEST_IDS } from "@/lib/testing/constants";
|
||||
import { setupTestEnvironment } from "@/lib/testing/setup";
|
||||
import { generateQuestionAndFilterOptions, getFormattedFilters, getTodayDate } from "./surveys";
|
||||
|
||||
describe("surveys", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
setupTestEnvironment();
|
||||
|
||||
// Cleanup React components after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("surveys", () => {
|
||||
describe("generateQuestionAndFilterOptions", () => {
|
||||
test("should return question options for basic survey without additional options", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
id: TEST_IDS.survey,
|
||||
name: "Test Survey",
|
||||
blocks: [
|
||||
questions: [
|
||||
{
|
||||
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,
|
||||
],
|
||||
},
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Open Text Question" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
environmentId: TEST_IDS.environment,
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
@@ -55,18 +54,23 @@ describe("surveys", () => {
|
||||
|
||||
test("should include tags in options when provided", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
id: TEST_IDS.survey,
|
||||
name: "Test Survey",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
environmentId: TEST_IDS.environment,
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const tags: TTag[] = [
|
||||
{ id: "tag1", name: "Tag 1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() },
|
||||
{
|
||||
id: TEST_IDS.team,
|
||||
name: "Tag 1",
|
||||
environmentId: TEST_IDS.environment,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
const result = generateQuestionAndFilterOptions(survey, tags, {}, {}, {}, []);
|
||||
@@ -79,13 +83,12 @@ describe("surveys", () => {
|
||||
|
||||
test("should include attributes in options when provided", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
id: TEST_IDS.survey,
|
||||
name: "Test Survey",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
environmentId: TEST_IDS.environment,
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
@@ -103,13 +106,12 @@ describe("surveys", () => {
|
||||
|
||||
test("should include meta in options when provided", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
id: TEST_IDS.survey,
|
||||
name: "Test Survey",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
environmentId: TEST_IDS.environment,
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
@@ -127,13 +129,12 @@ describe("surveys", () => {
|
||||
|
||||
test("should include hidden fields in options when provided", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
id: TEST_IDS.survey,
|
||||
name: "Test Survey",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
environmentId: TEST_IDS.environment,
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
@@ -153,13 +154,12 @@ describe("surveys", () => {
|
||||
|
||||
test("should include language options when survey has languages", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
id: TEST_IDS.survey,
|
||||
name: "Test Survey",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
environmentId: TEST_IDS.environment,
|
||||
status: "draft",
|
||||
languages: [{ language: { code: "en" } as unknown as TLanguage } as unknown as TSurveyLanguage],
|
||||
} as unknown as TSurvey;
|
||||
@@ -173,92 +173,64 @@ describe("surveys", () => {
|
||||
|
||||
test("should handle all question types correctly", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
id: TEST_IDS.survey,
|
||||
name: "Test Survey",
|
||||
blocks: [
|
||||
questions: [
|
||||
{
|
||||
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[],
|
||||
},
|
||||
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: { default: "Row 1" } }],
|
||||
columns: [{ id: "c1", label: { default: "Column 1" } }],
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
environmentId: TEST_IDS.environment,
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
@@ -273,13 +245,12 @@ describe("surveys", () => {
|
||||
|
||||
test("should provide extended filter options for URL meta field", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
id: TEST_IDS.survey,
|
||||
name: "Test Survey",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
environmentId: TEST_IDS.environment,
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
@@ -312,126 +283,81 @@ describe("surveys", () => {
|
||||
|
||||
describe("getFormattedFilters", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
id: TEST_IDS.survey,
|
||||
name: "Test Survey",
|
||||
blocks: [
|
||||
questions: [
|
||||
{
|
||||
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[],
|
||||
},
|
||||
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,
|
||||
],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
environmentId: TEST_IDS.environment,
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
@@ -505,7 +431,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "Open Text",
|
||||
id: "openTextQ",
|
||||
questionType: TSurveyElementTypeEnum.OpenText,
|
||||
questionType: TSurveyQuestionTypeEnum.OpenText,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Filled out" },
|
||||
},
|
||||
@@ -526,7 +452,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "Address",
|
||||
id: "addressQ",
|
||||
questionType: TSurveyElementTypeEnum.Address,
|
||||
questionType: TSurveyQuestionTypeEnum.Address,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Skipped" },
|
||||
},
|
||||
@@ -547,7 +473,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "Contact Info",
|
||||
id: "contactQ",
|
||||
questionType: TSurveyElementTypeEnum.ContactInfo,
|
||||
questionType: TSurveyQuestionTypeEnum.ContactInfo,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Filled out" },
|
||||
},
|
||||
@@ -568,7 +494,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "Ranking",
|
||||
id: "rankingQ",
|
||||
questionType: TSurveyElementTypeEnum.Ranking,
|
||||
questionType: TSurveyQuestionTypeEnum.Ranking,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Filled out" },
|
||||
},
|
||||
@@ -589,7 +515,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "MC Single",
|
||||
id: "mcSingleQ",
|
||||
questionType: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
questionType: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
},
|
||||
filterType: { filterValue: "Includes either", filterComboBoxValue: ["Choice 1"] },
|
||||
},
|
||||
@@ -610,7 +536,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "MC Multi",
|
||||
id: "mcMultiQ",
|
||||
questionType: TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
questionType: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
},
|
||||
filterType: { filterValue: "Includes all", filterComboBoxValue: ["Choice 1", "Choice 2"] },
|
||||
},
|
||||
@@ -631,7 +557,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "NPS",
|
||||
id: "npsQ",
|
||||
questionType: TSurveyElementTypeEnum.NPS,
|
||||
questionType: TSurveyQuestionTypeEnum.NPS,
|
||||
},
|
||||
filterType: { filterValue: "Is equal to", filterComboBoxValue: "7" },
|
||||
},
|
||||
@@ -652,7 +578,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "Rating",
|
||||
id: "ratingQ",
|
||||
questionType: TSurveyElementTypeEnum.Rating,
|
||||
questionType: TSurveyQuestionTypeEnum.Rating,
|
||||
},
|
||||
filterType: { filterValue: "Is less than", filterComboBoxValue: "4" },
|
||||
},
|
||||
@@ -673,7 +599,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "CTA",
|
||||
id: "ctaQ",
|
||||
questionType: TSurveyElementTypeEnum.CTA,
|
||||
questionType: TSurveyQuestionTypeEnum.CTA,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Clicked" },
|
||||
},
|
||||
@@ -694,7 +620,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "Consent",
|
||||
id: "consentQ",
|
||||
questionType: TSurveyElementTypeEnum.Consent,
|
||||
questionType: TSurveyQuestionTypeEnum.Consent,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Accepted" },
|
||||
},
|
||||
@@ -715,7 +641,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "Picture",
|
||||
id: "pictureQ",
|
||||
questionType: TSurveyElementTypeEnum.PictureSelection,
|
||||
questionType: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
},
|
||||
filterType: { filterValue: "Includes either", filterComboBoxValue: ["Picture 1"] },
|
||||
},
|
||||
@@ -736,7 +662,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "Matrix",
|
||||
id: "matrixQ",
|
||||
questionType: TSurveyElementTypeEnum.Matrix,
|
||||
questionType: TSurveyQuestionTypeEnum.Matrix,
|
||||
},
|
||||
filterType: { filterValue: "Row 1", filterComboBoxValue: "Column 1" },
|
||||
},
|
||||
@@ -821,7 +747,7 @@ describe("surveys", () => {
|
||||
type: "Questions",
|
||||
label: "NPS",
|
||||
id: "npsQ",
|
||||
questionType: TSurveyElementTypeEnum.NPS,
|
||||
questionType: TSurveyQuestionTypeEnum.NPS,
|
||||
},
|
||||
filterType: { filterValue: "Is more than", filterComboBoxValue: "7" },
|
||||
},
|
||||
|
||||
@@ -5,8 +5,7 @@ import {
|
||||
TSurveyContactAttributes,
|
||||
TSurveyMetaFieldFilter,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import {
|
||||
@@ -22,7 +21,6 @@ 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"],
|
||||
@@ -78,12 +76,11 @@ export const generateQuestionAndFilterOptions = (
|
||||
questionFilterOptions: QuestionFilterOptions[];
|
||||
} => {
|
||||
let questionOptions: QuestionOptions[] = [];
|
||||
let questionFilterOptions: any = [];
|
||||
let questionFilterOptions: QuestionFilterOptions[] = [];
|
||||
|
||||
let questionsOptions: any = [];
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
let questionsOptions: QuestionOption[] = [];
|
||||
|
||||
questions.forEach((q) => {
|
||||
survey.questions.forEach((q) => {
|
||||
if (Object.keys(conditionOptions).includes(q.type)) {
|
||||
questionsOptions.push({
|
||||
label: getTextContent(
|
||||
@@ -96,16 +93,16 @@ export const generateQuestionAndFilterOptions = (
|
||||
}
|
||||
});
|
||||
questionOptions = [...questionOptions, { header: OptionsType.QUESTIONS, option: questionsOptions }];
|
||||
questions.forEach((q) => {
|
||||
survey.questions.forEach((q) => {
|
||||
if (Object.keys(conditionOptions).includes(q.type)) {
|
||||
if (q.type === TSurveyElementTypeEnum.MultipleChoiceSingle) {
|
||||
if (q.type === TSurveyQuestionTypeEnum.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 === TSurveyElementTypeEnum.MultipleChoiceMulti) {
|
||||
} else if (q.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
|
||||
questionFilterOptions.push({
|
||||
type: q.type,
|
||||
filterOptions: conditionOptions[q.type],
|
||||
@@ -114,18 +111,18 @@ export const generateQuestionAndFilterOptions = (
|
||||
: [""],
|
||||
id: q.id,
|
||||
});
|
||||
} else if (q.type === TSurveyElementTypeEnum.PictureSelection) {
|
||||
} else if (q.type === TSurveyQuestionTypeEnum.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 === TSurveyElementTypeEnum.Matrix) {
|
||||
} else if (q.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
questionFilterOptions.push({
|
||||
type: q.type,
|
||||
filterOptions: q.rows.flatMap((row) => Object.values(row)),
|
||||
filterComboBoxOptions: q.columns.flatMap((column) => Object.values(column)),
|
||||
filterOptions: q.rows.map((row) => getLocalizedValue(row.label, "default")),
|
||||
filterComboBoxOptions: q.columns.map((column) => getLocalizedValue(column.label, "default")),
|
||||
id: q.id,
|
||||
});
|
||||
} else {
|
||||
@@ -314,13 +311,12 @@ 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 TSurveyElementTypeEnum.OpenText:
|
||||
case TSurveyElementTypeEnum.Address:
|
||||
case TSurveyElementTypeEnum.ContactInfo: {
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
case TSurveyQuestionTypeEnum.Address:
|
||||
case TSurveyQuestionTypeEnum.ContactInfo: {
|
||||
if (filterType.filterComboBoxValue === "Filled out") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "filledOut",
|
||||
@@ -332,7 +328,7 @@ export const getFormattedFilters = (
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.Ranking: {
|
||||
case TSurveyQuestionTypeEnum.Ranking: {
|
||||
if (filterType.filterComboBoxValue === "Filled out") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "submitted",
|
||||
@@ -344,8 +340,8 @@ export const getFormattedFilters = (
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti: {
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti: {
|
||||
if (filterType.filterValue === "Includes either") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "includesOne",
|
||||
@@ -359,8 +355,8 @@ export const getFormattedFilters = (
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.NPS:
|
||||
case TSurveyElementTypeEnum.Rating: {
|
||||
case TSurveyQuestionTypeEnum.NPS:
|
||||
case TSurveyQuestionTypeEnum.Rating: {
|
||||
if (filterType.filterValue === "Is equal to") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "equals",
|
||||
@@ -392,7 +388,7 @@ export const getFormattedFilters = (
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.CTA: {
|
||||
case TSurveyQuestionTypeEnum.CTA: {
|
||||
if (filterType.filterComboBoxValue === "Clicked") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "clicked",
|
||||
@@ -404,7 +400,7 @@ export const getFormattedFilters = (
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.Consent: {
|
||||
case TSurveyQuestionTypeEnum.Consent: {
|
||||
if (filterType.filterComboBoxValue === "Accepted") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "accepted",
|
||||
@@ -416,12 +412,12 @@ export const getFormattedFilters = (
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.PictureSelection: {
|
||||
case TSurveyQuestionTypeEnum.PictureSelection: {
|
||||
const questionId = questionType.id ?? "";
|
||||
const question = surveyQuestions.find((q) => q.id === questionId);
|
||||
const question = survey.questions.find((q) => q.id === questionId);
|
||||
|
||||
if (
|
||||
question?.type !== TSurveyElementTypeEnum.PictureSelection ||
|
||||
question?.type !== TSurveyQuestionTypeEnum.PictureSelection ||
|
||||
!Array.isArray(filterType.filterComboBoxValue)
|
||||
) {
|
||||
return;
|
||||
@@ -445,7 +441,7 @@ export const getFormattedFilters = (
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.Matrix: {
|
||||
case TSurveyQuestionTypeEnum.Matrix: {
|
||||
if (
|
||||
filterType.filterValue &&
|
||||
filterType.filterComboBoxValue &&
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
||||
import { LinkSurveyLoading } from "@/modules/survey/link/loading";
|
||||
|
||||
export default LinkSurveyLoading;
|
||||
@@ -7,7 +7,18 @@
|
||||
},
|
||||
"locale": {
|
||||
"source": "en-US",
|
||||
"targets": ["de-DE", "fr-FR", "ja-JP", "pt-BR", "pt-PT", "ro-RO", "zh-Hans-CN", "zh-Hant-TW"]
|
||||
"targets": [
|
||||
"de-DE",
|
||||
"fr-FR",
|
||||
"ja-JP",
|
||||
"pt-BR",
|
||||
"pt-PT",
|
||||
"ro-RO",
|
||||
"zh-Hans-CN",
|
||||
"zh-Hant-TW",
|
||||
"nl-NL",
|
||||
"es-ES"
|
||||
]
|
||||
},
|
||||
"version": 1.8
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ checksums:
|
||||
common/clear_filters: 8f40ab5af527e4b190da94e7b6221379
|
||||
common/clear_selection: af5d720527735d4253e289400d29ec9e
|
||||
common/click: 9c2744de6b5ac7333d9dae1d5cf4a76d
|
||||
common/click_to_filter: 527714113ca5fd3504e7d0bd31bca303
|
||||
common/clicks: f9e154545f87d8ede27b529e5fdf2015
|
||||
common/close: 2c2e22f8424a1031de89063bd0022e16
|
||||
common/code: 343bc5386149b97cece2b093c39034b2
|
||||
@@ -183,6 +184,7 @@ checksums:
|
||||
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
|
||||
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
|
||||
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
|
||||
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
|
||||
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
|
||||
common/failed_to_load_projects: 0bba9f9b2b38c189706a486a1bb134c3
|
||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||
@@ -191,6 +193,7 @@ checksums:
|
||||
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
|
||||
common/gathering_responses: c5914490ed81bd77f13d411739f0c9ef
|
||||
common/general: b891e8f15579fc5d97bcaf3637f5ae59
|
||||
common/generate: 0345bf322c191e70d01fd6607ec5c2f8
|
||||
common/go_back: b917ea82facb90c88c523b255d29f84b
|
||||
common/go_to_dashboard: a6efa97d25e36fedc0af794f6ba610f2
|
||||
common/hidden: fa290c6ada5869d744ed35e9cca64699
|
||||
@@ -398,6 +401,7 @@ checksums:
|
||||
common/user_id: 37f5ba37f71cb50607af32a6a203b1d4
|
||||
common/user_not_found: 5903581136ac6c1c1351a482a6d8fdf7
|
||||
common/variable: c13db5775ba9791b1522cc55c9c7acce
|
||||
common/variable_ids: 44bf93b70703b7699fa9f21bc6c8eed4
|
||||
common/variables: ffd3eec5497af36d7b4e4185bad1313a
|
||||
common/verified_email: d4a9e5e47d622c6ef2fede44233076c7
|
||||
common/video: 8050c90e4289b105a0780f0fdda6ff66
|
||||
@@ -491,6 +495,7 @@ checksums:
|
||||
environments/actions/add_css_class_or_id: cfc4d88412c5b9ef1157e28db4afdcc5
|
||||
environments/actions/add_regular_expression_here: 797fde3681996b85bc63c3550dec1fd4
|
||||
environments/actions/add_url: 8eba7972136a42da78a8fa4798da8e87
|
||||
environments/actions/and: 53e8eb67a396fcb5e419bb4cbf0008df
|
||||
environments/actions/click: 9c2744de6b5ac7333d9dae1d5cf4a76d
|
||||
environments/actions/contains: 41c8c25407527a5336404313f4c8d650
|
||||
environments/actions/create_action: 3abcc6dbbca18d3218ba49f90c4a66fd
|
||||
@@ -521,6 +526,7 @@ checksums:
|
||||
environments/actions/limit_to_specific_pages: f8ba95b2fc68d965689594b8a545417c
|
||||
environments/actions/matches_regex: 208b4d02b38714b4523923239e4a66b0
|
||||
environments/actions/on_all_pages: ccb8ee531a55e21eb8157c36fa75ad9a
|
||||
environments/actions/or: 0208d355f231c386b19390f0bea41b95
|
||||
environments/actions/page_filter: fe98a0bcbedb938e58cc3730589caa95
|
||||
environments/actions/page_view: 019c12b6739f6f7b1500f96ee275d47c
|
||||
environments/actions/select_match_type: b555dce1cb5c61538d3fbd792b2c71a2
|
||||
@@ -557,9 +563,18 @@ checksums:
|
||||
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
|
||||
environments/contacts/delete_contact_confirmation: 4304d36277daa205b4aa09f5e0d494ab
|
||||
environments/contacts/delete_contact_confirmation_with_quotas: 7c0e2e223ca55101270ac2988c53e616
|
||||
environments/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8
|
||||
environments/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6
|
||||
environments/contacts/no_published_link_surveys_available: 9c1abc5b21aba827443cdf87dd6c8bfe
|
||||
environments/contacts/no_published_surveys: bd945b0e2e2328c17615c94143bdd62b
|
||||
environments/contacts/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||
environments/contacts/not_provided: a09e4d61bbeb04b927406a50116445e2
|
||||
environments/contacts/personal_link_generated: efb7a0420bd459847eb57bca41a4ab0d
|
||||
environments/contacts/personal_link_generated_but_clipboard_failed: 4eb1e208e729bd5ac00c33f72fc38d53
|
||||
environments/contacts/personal_survey_link: 5b3f1afc53733718c4ed5b1443b6a604
|
||||
environments/contacts/please_select_a_survey: 465aa7048773079c8ffdde8b333b78eb
|
||||
environments/contacts/search_contact: 020205a93846ab3e12c203ac4fa97c12
|
||||
environments/contacts/select_a_survey: 1f49086dfb874307aae1136e88c3d514
|
||||
environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79
|
||||
environments/contacts/unlock_contacts_description: c5572047f02b4c39e5109f9de715499d
|
||||
environments/contacts/unlock_contacts_title: a8b3d7db03eb404d9267fd5cdd6d5ddb
|
||||
@@ -721,14 +736,14 @@ checksums:
|
||||
environments/project/api_keys/secret: f041e5eb96121c8b4f2b8af7e0f83a9b
|
||||
environments/project/api_keys/unable_to_delete_api_key: 1fd76d9a22c5f5f8c241c4891fca8295
|
||||
environments/project/app-connection/app_connection: 778d2305e1a9c8efe91c2c7b4af37ae4
|
||||
environments/project/app-connection/app_connection_description: 01327bfae3da950d796890b6605afed2
|
||||
environments/project/app-connection/cache_update_delay_description: 1cb2c46fdb6762ccb348d21086063a4f
|
||||
environments/project/app-connection/cache_update_delay_title: fef7f99f0228f9e30093574ac7770e7e
|
||||
environments/project/app-connection/app_connection_description: dde226414bd2265cbd0daf6635efcfdd
|
||||
environments/project/app-connection/cache_update_delay_description: 3368e4a8090b7684117a16c94f0c409c
|
||||
environments/project/app-connection/cache_update_delay_title: 60e4a0fcfbd8850bddf29b5c3f59550c
|
||||
environments/project/app-connection/environment_id: 3dba898b081c18cd4cae131765ef411f
|
||||
environments/project/app-connection/environment_id_description: 8b4a763d069b000cfa1a2025a13df80c
|
||||
environments/project/app-connection/formbricks_sdk_connected: 29e8a40ad6a7fdb5af5ee9451a70a9aa
|
||||
environments/project/app-connection/formbricks_sdk_not_connected: 557c534e665750978ba6edb0eacb428e
|
||||
environments/project/app-connection/formbricks_sdk_not_connected_description: 666b2b25f06e76554cc2d60f925bcd4b
|
||||
environments/project/app-connection/formbricks_sdk_not_connected_description: 4ddbacae084238bd0cefeded0fe9dbb9
|
||||
environments/project/app-connection/how_to_setup: 3bad40037f280b47fe6418fcbeb4c717
|
||||
environments/project/app-connection/how_to_setup_description: 2ae5cd9456a8acd3986e3d3678e70ed2
|
||||
environments/project/app-connection/receiving_data: 9f2a48c0b0278861add70b526061264c
|
||||
@@ -745,7 +760,7 @@ checksums:
|
||||
environments/project/general/project_deleted_successfully: dbedf0f0739b822f3951de4aeb2fc26f
|
||||
environments/project/general/project_name_settings_description: 079c6380ad539543a9aa8772bc1b0fa2
|
||||
environments/project/general/project_name_updated_successfully: f95f70f4a49d451dc0441a51d05a3aa3
|
||||
environments/project/general/recontact_waiting_time: 9c5ebb18960dec73def053de89e63272
|
||||
environments/project/general/recontact_waiting_time: 0566dc710b4b9644e276e311b419c4c0
|
||||
environments/project/general/recontact_waiting_time_settings_description: 8922cde1f95777f9a2747fb4bed57ab5
|
||||
environments/project/general/this_action_cannot_be_undone: 3d8b13374ffd3cefc0f3f7ce077bd9c9
|
||||
environments/project/general/wait_x_days_before_showing_next_survey: d96228788d32ec23dc0d8c8ba77150a6
|
||||
@@ -808,7 +823,6 @@ checksums:
|
||||
environments/project/tags/add_tag: 2cfa04ceea966149f2b5d40d9c131141
|
||||
environments/project/tags/count: 9c5848662eb8024ddf360f7e4001a968
|
||||
environments/project/tags/delete_tag_confirmation: a9fb98064cd156242899643f3d2ef032
|
||||
environments/project/tags/empty_message: da71bd7c7b5bf634469d20e010d25503
|
||||
environments/project/tags/manage_tags: 2761d558b82b6104befbc240ae2379c6
|
||||
environments/project/tags/manage_tags_description: ce7cc42da3646fba960502d7e4e49cd2
|
||||
environments/project/tags/merge: 95051c859b8778be51226b43be6f1075
|
||||
@@ -1141,7 +1155,6 @@ checksums:
|
||||
environments/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
|
||||
environments/surveys/edit/allow_multiple_files: dbd99f9d1026e4f7c5a5d03f71ba379d
|
||||
environments/surveys/edit/allow_users_to_select_more_than_one_image: d683e0b538d1366400292a771f3fbd08
|
||||
environments/surveys/edit/always_show_survey: b0ae6a873ce2eeb0aea2e6d4cb04c540
|
||||
environments/surveys/edit/and_launch_surveys_in_your_website_or_app: a3edcdb4aea792a27d90aad1930f001a
|
||||
environments/surveys/edit/animation: 66a18eacfb92fc9fc9db188d2dde4f81
|
||||
environments/surveys/edit/app_survey_description: bdfacfce478e97f70b700a1382dfa687
|
||||
@@ -1224,8 +1237,7 @@ checksums:
|
||||
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
|
||||
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
|
||||
environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a
|
||||
environments/surveys/edit/days_before_showing_this_survey_again: 8b4623eab862615fa60064400008eb23
|
||||
environments/surveys/edit/decide_how_often_people_can_answer_this_survey: 58427b0f0a7a258c24fa2acd9913e95e
|
||||
environments/surveys/edit/days_before_showing_this_survey_again: 354fb28c5ff076f022d82a20c749ee46
|
||||
environments/surveys/edit/delete_choice: fd750208d414b9ad8c980c161a0199e1
|
||||
environments/surveys/edit/disable_the_visibility_of_survey_progress: 2af631010114307ac2a91612559c9618
|
||||
environments/surveys/edit/display_an_estimate_of_completion_time_for_survey: 03f0a816569399c1c61d08dbc913de06
|
||||
@@ -1240,7 +1252,7 @@ checksums:
|
||||
environments/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54
|
||||
environments/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318
|
||||
environments/surveys/edit/edit_translations: 2b21bea4b53e88342559272701e9fbf3
|
||||
environments/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: 71977f91ec151b61ee3528ac2618afed
|
||||
environments/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: c70466147d49dcbb3686452f35c46428
|
||||
environments/surveys/edit/enable_recaptcha_to_protect_your_survey_from_spam: 4483a5763718d201ac97caa1e1216e13
|
||||
environments/surveys/edit/enable_spam_protection: e1fb0dd0723044bf040b92d8fc58015d
|
||||
environments/surveys/edit/end_screen_card: 6146c2bcb87291e25ecb03abd2d9a479
|
||||
@@ -1253,7 +1265,7 @@ checksums:
|
||||
environments/surveys/edit/equals_one_of: 369a451add4b79bc003f952f0e1bfcc9
|
||||
environments/surveys/edit/error_publishing_survey: bf9fab1d8ea7132a2e9b4b7b09f18b1f
|
||||
environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7
|
||||
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: c6668f9cf127fd922bec695dc548fe12
|
||||
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113
|
||||
environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413
|
||||
environments/surveys/edit/external_urls_paywall_tooltip: 0dbb62557e8a6fa817f0e74709eeb3d2
|
||||
environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722
|
||||
@@ -1324,8 +1336,9 @@ checksums:
|
||||
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
|
||||
environments/surveys/edit/how_funky_do_you_want_your_cards_in_survey_type_derived_surveys: 3cb16b37510c01af20a80f51b598346e
|
||||
environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768
|
||||
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 33f0320ec85067a06198a841348e9fc6
|
||||
environments/surveys/edit/ignore_waiting_time_between_surveys: 8145b6aef535fde5ee54dea63e66f64a
|
||||
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 31c18a8c7c578db2ba49eed663d1739f
|
||||
environments/surveys/edit/ignore_global_waiting_time: 1e7f1465aeb6d26c325ad7f135b207a8
|
||||
environments/surveys/edit/ignore_global_waiting_time_description: 37d173a4d537622de40677389238d859
|
||||
environments/surveys/edit/image: 048ba7a239de0fbd883ade8558415830
|
||||
environments/surveys/edit/includes_all_of: ec72f90c0839d4c3bb518deb03894031
|
||||
environments/surveys/edit/includes_one_of: 6d5be5d7c2494179e88bd7302b247884
|
||||
@@ -1392,9 +1405,10 @@ checksums:
|
||||
environments/surveys/edit/optional: 396fb9a0472daf401c392bdc3e248943
|
||||
environments/surveys/edit/options: 59156082418d80acb211f973b1218f11
|
||||
environments/surveys/edit/override_theme_with_individual_styles_for_this_survey: edffc97f5d3372419fe0444de0a5aa3f
|
||||
environments/surveys/edit/overwrite_global_waiting_time: 7bc23bd502b6bd048356b67acd956d9d
|
||||
environments/surveys/edit/overwrite_global_waiting_time_description: 795cf6e93d4c01d2e43aa0ebab601c6e
|
||||
environments/surveys/edit/overwrite_placement: d7278be243e52c5091974e0fc4a7c342
|
||||
environments/surveys/edit/overwrite_the_global_placement_of_the_survey: 874075712254b1ce92e099d89f675a48
|
||||
environments/surveys/edit/overwrites_waiting_period_between_surveys_to_x_days: 8d5596b024cbe8c82b021dcf6c73ba05
|
||||
environments/surveys/edit/pick_a_background_from_our_library_or_upload_your_own: b83bcbdc8131fc9524d272ff5dede754
|
||||
environments/surveys/edit/picture_idx: 55e053ad1ade5d17c582406706036028
|
||||
environments/surveys/edit/pin_can_only_contain_numbers: 417c854d44620a7229ebd9ab8cbb3613
|
||||
@@ -1451,7 +1465,8 @@ checksums:
|
||||
environments/surveys/edit/range: 1fad969ecf3de1c21df046b93053c422
|
||||
environments/surveys/edit/recall_data: 39beabd626c0af15316885cff5d5d9b8
|
||||
environments/surveys/edit/recall_information_from: 884cfd143456fab1a91f0744cc92f0c8
|
||||
environments/surveys/edit/recontact_options: 0f570378a531da60448fde37abd50214
|
||||
environments/surveys/edit/recontact_options_section: 57a23e1bcab6baa484b27b615e6c906a
|
||||
environments/surveys/edit/recontact_options_section_description: 1e04011440c339a3b5cfff12d55b7f12
|
||||
environments/surveys/edit/redirect_thank_you_card: 09f721c4b62e2584e40a53507092ea83
|
||||
environments/surveys/edit/redirect_to_url: f17d726bbc3391561447b3f4010635cf
|
||||
environments/surveys/edit/remove_description: b52de820b4bbcb354eb62246c4112a9a
|
||||
@@ -1460,6 +1475,8 @@ checksums:
|
||||
environments/surveys/edit/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
|
||||
environments/surveys/edit/reset_to_theme_styles: f9edc3970ec23d6c4d2d7accc292ef3a
|
||||
environments/surveys/edit/reset_to_theme_styles_main_text: d86fb2213d3b2efbd0361526dc6cb27b
|
||||
environments/surveys/edit/respect_global_waiting_time: 850e7e64ec890c591b2d07741ef26e11
|
||||
environments/surveys/edit/respect_global_waiting_time_description: 5235fee102d619cb391c5aa2c75b61be
|
||||
environments/surveys/edit/response_limit_can_t_be_set_to_0: 278664873ee3b1046dbcb58848efc12a
|
||||
environments/surveys/edit/response_limit_needs_to_exceed_number_of_received_responses: 9a9c223c0918ded716ddfaa84fbaa8d9
|
||||
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
|
||||
@@ -1484,7 +1501,7 @@ checksums:
|
||||
environments/surveys/edit/show_advanced_settings: b6f5bbbb84f34e51cd72ccd332e9613e
|
||||
environments/surveys/edit/show_button: 6b364aac9d7ac71f34a438607c9693bc
|
||||
environments/surveys/edit/show_language_switch: b6915a7f26d7079f2d4d844d74440413
|
||||
environments/surveys/edit/show_multiple_times: 5e6e0244c20feca78723c79aa1ddcf62
|
||||
environments/surveys/edit/show_multiple_times: 05239c532c9c05ef5d2990ba6ce12f60
|
||||
environments/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af
|
||||
environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e
|
||||
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
|
||||
@@ -1514,13 +1531,12 @@ checksums:
|
||||
environments/surveys/edit/switch_multi_lanugage_on_to_get_started: d2ca06684af26bd6b5121a4656bb6458
|
||||
environments/surveys/edit/targeted: ca615f1fc3b490d5a2187b27fb4a2073
|
||||
environments/surveys/edit/ten_points: a1317b82003859f77fb3138c55450d63
|
||||
environments/surveys/edit/the_survey_will_be_shown_multiple_times_until_they_respond: 219b15081cbafaa391e266bd2cc4c9d4
|
||||
environments/surveys/edit/the_survey_will_be_shown_once_even_if_person_doesnt_respond: c145b7be481ae1fe6f66298d9a5cf838
|
||||
environments/surveys/edit/the_survey_will_be_shown_multiple_times_until_they_respond: 2d8d7d2351bd7533eb3788cce228c654
|
||||
environments/surveys/edit/the_survey_will_be_shown_once_even_if_person_doesnt_respond: 6062aaa5cf8e58e79b75b6b588ae9598
|
||||
environments/surveys/edit/then: 5e941fb7dd51a18651fcfb865edd5ba6
|
||||
environments/surveys/edit/this_action_will_remove_all_the_translations_from_this_survey: 3340c89696f10bdc01b9a1047ff0b987
|
||||
environments/surveys/edit/this_extension_is_already_added: 201d636539836c95958e28cecd8f3240
|
||||
environments/surveys/edit/this_file_type_is_not_supported: f365b9a2e05aa062ab0bc1af61f642e2
|
||||
environments/surveys/edit/this_setting_overwrites_your: 6f980149a5a4adc2cfe3dac4f367e7e5
|
||||
environments/surveys/edit/three_points: d7f299aec752d7d690ef0ab6373327ae
|
||||
environments/surveys/edit/times: 5ab156c13df6bfd75c0b17ad0a92c78a
|
||||
environments/surveys/edit/to_keep_the_placement_over_all_surveys_consistent_you_can: 7a078e6a39d4c30b465137d2b6ef3e67
|
||||
@@ -1531,8 +1547,7 @@ checksums:
|
||||
environments/surveys/edit/unlock_targeting_description: 8e315dc41c2849754839a1460643c5fb
|
||||
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/until_they_submit_a_response: 2a0fd5dcc6cc40a72ed9b974f22eaf68
|
||||
environments/surveys/edit/upgrade_notice_description: 32b66a4f257ad8d38bc38dcc95fe23c4
|
||||
environments/surveys/edit/upgrade_notice_title: 40866066ebc558ad0c92a4f19f12090c
|
||||
environments/surveys/edit/upload: 4a6c84aa16db0f4e5697f49b45257bc7
|
||||
@@ -1540,7 +1555,6 @@ checksums:
|
||||
environments/surveys/edit/upper_label: 1fa48bce3fade6ffc1a52d9fdddf9e17
|
||||
environments/surveys/edit/url_filters: e524879d2eb74463d7fd06a7e0f53421
|
||||
environments/surveys/edit/url_not_supported: af8a753467c617b596aadef1aaaed664
|
||||
environments/surveys/edit/use_with_caution: 7c35d3ad68dd001e53cbd9d57c96af91
|
||||
environments/surveys/edit/variable_is_used_in_logic_of_question_please_remove_it_from_logic_first: bd9d9c7cf0be671c4e8cf67e2ae6659e
|
||||
environments/surveys/edit/variable_is_used_in_quota_please_remove_it_from_quota_first: 0d36e5b2713f5450fe346e0af0aaa29c
|
||||
environments/surveys/edit/variable_name_is_already_taken_please_choose_another: 6da42fe8733c6379158bce9a176f76d7
|
||||
@@ -1550,11 +1564,13 @@ checksums:
|
||||
environments/surveys/edit/variable_used_in_recall_welcome: 60321b2f40ae01cd10f99ed77bb986ba
|
||||
environments/surveys/edit/verify_email_before_submission: c05d345dc35f2d33839e4cfd72d11eb2
|
||||
environments/surveys/edit/verify_email_before_submission_description: 434ab3ee6134367513b633a9d4f7d772
|
||||
environments/surveys/edit/visibility_and_recontact: c27cb4ff3a4262266902a335c3ad5d84
|
||||
environments/surveys/edit/visibility_and_recontact_description: 2969ab679e1f6111dd96e95cee26e219
|
||||
environments/surveys/edit/wait: 014d18ade977bf08d75b995076596708
|
||||
environments/surveys/edit/wait_a_few_seconds_after_the_trigger_before_showing_the_survey: 13d5521cf73be5afeba71f5db5847919
|
||||
environments/surveys/edit/waiting_period: 21775d12b2cb831134b1f47450eaf1f3
|
||||
environments/surveys/edit/waiting_time_across_surveys: 5c5a7653d797c86c4008f13a40434ad8
|
||||
environments/surveys/edit/waiting_time_across_surveys_description: 1bbee2fee49f842056547c336f8fd788
|
||||
environments/surveys/edit/welcome_message: 986a434e3895c8ee0b267df95cc40051
|
||||
environments/surveys/edit/when_conditions_match_waiting_time_will_be_ignored_and_survey_shown: e7fe9c56664da4670e52e38656d8705d
|
||||
environments/surveys/edit/without_a_filter_all_of_your_users_can_be_surveyed: 451990569c61f25d01044cc45b1ce122
|
||||
environments/surveys/edit/you_have_not_created_a_segment_yet: c6658bd1cee9c5c957c675db044708dd
|
||||
environments/surveys/edit/you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations: b12b28699e02ff9ba69bcbae838ba5da
|
||||
@@ -1595,7 +1611,7 @@ checksums:
|
||||
environments/surveys/responses/last_name: 2c9a7de7738ca007ba9023c385149c26
|
||||
environments/surveys/responses/not_completed: df34eab65a6291f2c5e15a0e349c4eba
|
||||
environments/surveys/responses/os: a4c753bb2c004a58d02faeed6b4da476
|
||||
environments/surveys/responses/person_attributes: 8f7f8a9040ce8efb3cb54ce33b590866
|
||||
environments/surveys/responses/person_attributes: 07ae67ae73d7a2a7c67008694a83f0a3
|
||||
environments/surveys/responses/phone: b9537ee90fc5b0116942e0af29d926cc
|
||||
environments/surveys/responses/respondent_skipped_questions: d85daf579ade534dc7e639689156fcd5
|
||||
environments/surveys/responses/response_deleted_successfully: 6cec5427c271800619fee8c812d7db18
|
||||
@@ -1690,6 +1706,7 @@ checksums:
|
||||
environments/surveys/share/social_media/title: 1bf4899b063ee8f02f7188576555828b
|
||||
environments/surveys/summary/added_filter_for_responses_where_answer_to_question: 5bddf0d4f771efd06d58441d11fa5091
|
||||
environments/surveys/summary/added_filter_for_responses_where_answer_to_question_is_skipped: 74ca713c491cfc33751a5db3de972821
|
||||
environments/surveys/summary/aggregated: 9d4e77225d5952abed414fffd828c078
|
||||
environments/surveys/summary/all_responses_csv: 16c0c211853f0839a79f1127ec679ca2
|
||||
environments/surveys/summary/all_responses_excel: 8bf18916ab127f16bfcf9f38956710b0
|
||||
environments/surveys/summary/all_time: 62258944e7c2e83f3ebf69074b2c2156
|
||||
@@ -1713,7 +1730,6 @@ checksums:
|
||||
environments/surveys/summary/filtered_responses_csv: aad66a98be6a09cac8bef9e4db4a75cf
|
||||
environments/surveys/summary/filtered_responses_excel: 06e57bae9e41979fd7fc4b8bfe3466f9
|
||||
environments/surveys/summary/generating_qr_code: 5026d4a76f995db458195e5215d9bbd9
|
||||
environments/surveys/summary/go_to_setup_checklist: d70bd018d651d01c41ae10370e71d0be
|
||||
environments/surveys/summary/impressions: 7fe38d42d68a64d3fd8436a063751584
|
||||
environments/surveys/summary/impressions_tooltip: 4d0823cbf360304770c7c5913e33fdc8
|
||||
environments/surveys/summary/in_app/connection_description: 9710bbf8048a8a5c3b2b56db9d946b73
|
||||
@@ -1745,7 +1761,7 @@ checksums:
|
||||
environments/surveys/summary/in_app/title: a2d1b633244d0e0504ec6f8f561c7a6b
|
||||
environments/surveys/summary/includes_all: b0e3679282417c62d511c258362f860e
|
||||
environments/surveys/summary/includes_either: 186d6923c1693e80d7b664b8367d4221
|
||||
environments/surveys/summary/install_widget: 55d403de32e3d0da7513ab199f1d1934
|
||||
environments/surveys/summary/individual: 52ebce389ed97a13b6089802055ed667
|
||||
environments/surveys/summary/is_equal_to: f4aab30ef188eb25dcc0e392cf8e86bb
|
||||
environments/surveys/summary/is_less_than: 6109d595ba21497c59b1c91d7fd09a13
|
||||
environments/surveys/summary/last_30_days: a738894cfc5e592052f1e16787744568
|
||||
@@ -1758,6 +1774,7 @@ checksums:
|
||||
environments/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||
environments/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
|
||||
environments/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
|
||||
environments/surveys/summary/promoters: 41fbb8d0439227661253a82fda39f521
|
||||
environments/surveys/summary/qr_code: 48cb2a8c07a3d1647f766f93bb9e9382
|
||||
environments/surveys/summary/qr_code_description: 19f48dcf473809f178abf4212657ef14
|
||||
environments/surveys/summary/qr_code_download_failed: 2764b5615112800da27eecafc21e3472
|
||||
@@ -1767,6 +1784,7 @@ checksums:
|
||||
environments/surveys/summary/quotas_completed_tooltip: ec5c4dc67eda27c06764354f695db613
|
||||
environments/surveys/summary/reset_survey: 8c88ddb81f5f787d183d2e7cb43e7c64
|
||||
environments/surveys/summary/reset_survey_warning: 6b44be171d7e2716f234387b100b173d
|
||||
environments/surveys/summary/satisfied: 4d542ba354b85e644acbca5691d2ce45
|
||||
environments/surveys/summary/selected_responses_csv: 9cef3faccd54d4f24647791e6359db90
|
||||
environments/surveys/summary/selected_responses_excel: a0ade8b2658e887a4a3f2ad3bdb0c686
|
||||
environments/surveys/summary/setup_integrations: 70de06d73be671a0cd58a3fd4fa62e53
|
||||
@@ -1782,7 +1800,6 @@ checksums:
|
||||
environments/surveys/summary/ttc_tooltip: 9b1cbe32cc81111314bd3b6fd050c2e7
|
||||
environments/surveys/summary/unknown_question_type: e4152a7457d2b94f48dcc70aaba9922f
|
||||
environments/surveys/summary/use_personal_links: da2b3e7e1aaf2ea2bd4efed2dda4247c
|
||||
environments/surveys/summary/waiting_for_response: 0194a84e0850b8e98435632d5331a916
|
||||
environments/surveys/summary/whats_next: d920145bfa2147014062f6f2d1d451a4
|
||||
environments/surveys/summary/your_survey_is_public: 3f5cb5949a5f4020a3d4d74fdfc95e83
|
||||
environments/surveys/summary/youre_not_plugged_in_yet: 9217467742cdcf7edf8d59cc1472ede6
|
||||
@@ -2101,7 +2118,6 @@ 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
|
||||
@@ -2511,6 +2527,7 @@ 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
|
||||
|
||||
@@ -19,8 +19,7 @@ export const ENCRYPTION_KEY = env.ENCRYPTION_KEY;
|
||||
// Other
|
||||
export const CRON_SECRET = env.CRON_SECRET;
|
||||
export const DEFAULT_BRAND_COLOR = "#64748b";
|
||||
export const FB_LOGO_URL =
|
||||
"https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Formbricks-Light-transparent.png";
|
||||
export const FB_LOGO_URL = `${WEBAPP_URL}/logo-transparent.png`;
|
||||
|
||||
export const PRIVACY_URL = env.PRIVACY_URL;
|
||||
export const TERMS_URL = env.TERMS_URL;
|
||||
@@ -170,11 +169,13 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
|
||||
"de-DE",
|
||||
"pt-BR",
|
||||
"fr-FR",
|
||||
"nl-NL",
|
||||
"zh-Hant-TW",
|
||||
"pt-PT",
|
||||
"ro-RO",
|
||||
"ja-JP",
|
||||
"zh-Hans-CN",
|
||||
"es-ES",
|
||||
];
|
||||
|
||||
// Billing constants
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { iso639Languages } from "@formbricks/i18n-utils/src/utils";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TLanguage } from "@formbricks/types/project";
|
||||
import { TSurveyLanguage } from "@formbricks/types/surveys/types";
|
||||
import { TI18nString, TSurveyLanguage } from "@formbricks/types/surveys/types";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
|
||||
// Helper function to create an i18nString from a regular string.
|
||||
@@ -138,6 +137,8 @@ export const appLanguages = [
|
||||
"ro-RO": "Engleză (SUA)",
|
||||
"ja-JP": "英語(米国)",
|
||||
"zh-Hans-CN": "英语(美国)",
|
||||
"nl-NL": "Engels (VS)",
|
||||
"es-ES": "Inglés (EE.UU.)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -152,6 +153,8 @@ export const appLanguages = [
|
||||
"ro-RO": "Germană",
|
||||
"ja-JP": "ドイツ語",
|
||||
"zh-Hans-CN": "德语",
|
||||
"nl-NL": "Duits",
|
||||
"es-ES": "Alemán",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -166,6 +169,8 @@ export const appLanguages = [
|
||||
"ro-RO": "Portugheză (Brazilia)",
|
||||
"ja-JP": "ポルトガル語(ブラジル)",
|
||||
"zh-Hans-CN": "葡萄牙语(巴西)",
|
||||
"nl-NL": "Portugees (Brazilië)",
|
||||
"es-ES": "Portugués (Brasil)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -180,6 +185,8 @@ export const appLanguages = [
|
||||
"ro-RO": "Franceză",
|
||||
"ja-JP": "フランス語",
|
||||
"zh-Hans-CN": "法语",
|
||||
"nl-NL": "Frans",
|
||||
"es-ES": "Francés",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -194,6 +201,8 @@ export const appLanguages = [
|
||||
"ro-RO": "Chineză (Tradicională)",
|
||||
"ja-JP": "中国語(繁体字)",
|
||||
"zh-Hans-CN": "繁体中文",
|
||||
"nl-NL": "Chinees (Traditioneel)",
|
||||
"es-ES": "Chino (Tradicional)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -208,6 +217,8 @@ export const appLanguages = [
|
||||
"ro-RO": "Portugheză (Portugalia)",
|
||||
"ja-JP": "ポルトガル語(ポルトガル)",
|
||||
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
|
||||
"nl-NL": "Portugees (Portugal)",
|
||||
"es-ES": "Portugués (Portugal)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -222,6 +233,8 @@ export const appLanguages = [
|
||||
"ro-RO": "Română",
|
||||
"ja-JP": "ルーマニア語",
|
||||
"zh-Hans-CN": "罗马尼亚语",
|
||||
"nl-NL": "Roemeens",
|
||||
"es-ES": "Rumano",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -236,6 +249,8 @@ export const appLanguages = [
|
||||
"ro-RO": "Japoneză",
|
||||
"ja-JP": "日本語",
|
||||
"zh-Hans-CN": "日语",
|
||||
"nl-NL": "Japans",
|
||||
"es-ES": "Japonés",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -250,6 +265,40 @@ export const appLanguages = [
|
||||
"ro-RO": "Chineză (Simplificată)",
|
||||
"ja-JP": "中国語(簡体字)",
|
||||
"zh-Hans-CN": "简体中文",
|
||||
"nl-NL": "Chinees (Vereenvoudigd)",
|
||||
"es-ES": "Chino (Simplificado)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "nl-NL",
|
||||
label: {
|
||||
"en-US": "Dutch",
|
||||
"de-DE": "Niederländisch",
|
||||
"pt-BR": "Holandês",
|
||||
"fr-FR": "Néerlandais",
|
||||
"zh-Hant-TW": "荷蘭語",
|
||||
"pt-PT": "Holandês",
|
||||
"ro-RO": "Olandeză",
|
||||
"ja-JP": "オランダ語",
|
||||
"zh-Hans-CN": "荷兰语",
|
||||
"nl-NL": "Nederlands",
|
||||
"es-ES": "Neerlandés",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "es-ES",
|
||||
label: {
|
||||
"en-US": "Spanish",
|
||||
"de-DE": "Spanisch",
|
||||
"pt-BR": "Espanhol",
|
||||
"fr-FR": "Espagnol",
|
||||
"zh-Hant-TW": "西班牙語",
|
||||
"pt-PT": "Espanhol",
|
||||
"ro-RO": "Spaniol",
|
||||
"ja-JP": "スペイン語",
|
||||
"zh-Hans-CN": "西班牙语",
|
||||
"nl-NL": "Spaans",
|
||||
"es-ES": "Español",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -15,10 +15,8 @@ import {
|
||||
ZResponseFilterCriteria,
|
||||
ZResponseUpdateInput,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } 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";
|
||||
@@ -550,10 +548,10 @@ export const updateResponse = async (
|
||||
};
|
||||
|
||||
const findAndDeleteUploadedFilesInResponse = async (response: TResponse, survey: TSurvey): Promise<void> => {
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
const fileUploadQuestions = new Set(
|
||||
questions.filter((question) => question.type === TSurveyElementTypeEnum.FileUpload).map((q) => q.id)
|
||||
survey.questions
|
||||
.filter((question) => question.type === TSurveyQuestionTypeEnum.FileUpload)
|
||||
.map((q) => q.id)
|
||||
);
|
||||
|
||||
const fileUrls = Object.entries(response.data)
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
buildWhereClause,
|
||||
calculateTtcTotal,
|
||||
@@ -40,8 +44,20 @@ describe("Response Utils", () => {
|
||||
const mockSurvey: Partial<TSurvey> = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
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,
|
||||
},
|
||||
],
|
||||
type: "app",
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
createdAt: new Date(),
|
||||
@@ -99,7 +115,6 @@ describe("Response Utils", () => {
|
||||
const baseSurvey: Partial<TSurvey> = {
|
||||
id: "s1",
|
||||
name: "Survey",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
type: "app",
|
||||
hiddenFields: { enabled: false, fieldIds: [] },
|
||||
@@ -188,33 +203,26 @@ describe("Response Utils", () => {
|
||||
const textSurvey: Partial<TSurvey> = {
|
||||
id: "s2",
|
||||
name: "TextSurvey",
|
||||
blocks: [
|
||||
questions: [
|
||||
{
|
||||
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",
|
||||
},
|
||||
],
|
||||
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",
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
type: "app",
|
||||
hiddenFields: { enabled: false, fieldIds: [] },
|
||||
createdAt: new Date(),
|
||||
@@ -224,7 +232,7 @@ describe("Response Utils", () => {
|
||||
status: "inProgress",
|
||||
};
|
||||
|
||||
const ops: Array<[keyof TSurveyElementTypeEnum | string, any, any]> = [
|
||||
const ops: Array<[keyof TSurveyQuestionTypeEnum | string, any, any]> = [
|
||||
["submitted", { op: "submitted" }, { path: ["qText"], not: Prisma.DbNull }],
|
||||
["filledOut", { op: "filledOut" }, { path: ["qText"], not: [] }],
|
||||
["skipped", { op: "skipped" }, "OR"],
|
||||
@@ -287,25 +295,18 @@ describe("Response Utils", () => {
|
||||
const matrixSurvey: Partial<TSurvey> = {
|
||||
id: "s3",
|
||||
name: "MatrixSurvey",
|
||||
blocks: [
|
||||
questions: [
|
||||
{
|
||||
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,
|
||||
},
|
||||
],
|
||||
id: "qM",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Matrix" },
|
||||
required: false,
|
||||
rows: [{ default: "R1" }],
|
||||
columns: [{ default: "C1" }],
|
||||
shuffleOption: "none",
|
||||
isDraft: false,
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
type: "app",
|
||||
hiddenFields: { enabled: false, fieldIds: [] },
|
||||
createdAt: new Date(),
|
||||
@@ -359,48 +360,34 @@ 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",
|
||||
blocks: [
|
||||
questions: [
|
||||
{
|
||||
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,
|
||||
},
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.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: 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(),
|
||||
@@ -437,27 +424,20 @@ describe("Response Utils", () => {
|
||||
const mockSurvey: Partial<TSurvey> = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
blocks: [
|
||||
questions: [
|
||||
{
|
||||
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: "q1",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Question 1" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "1", label: { default: "Option 1" } },
|
||||
{ id: "2", label: { default: "Option 2" } },
|
||||
],
|
||||
shuffleOption: "none",
|
||||
isDraft: false,
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
type: "app",
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
createdAt: new Date(),
|
||||
@@ -710,9 +690,9 @@ describe("Response Utils", () => {
|
||||
});
|
||||
|
||||
describe("extractChoiceIdsFromResponse", () => {
|
||||
const multipleChoiceMultiQuestion = {
|
||||
const multipleChoiceMultiQuestion: TSurveyQuestion = {
|
||||
id: "multi-choice-id",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceMulti as typeof TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Select multiple options" },
|
||||
required: false,
|
||||
choices: [
|
||||
@@ -729,12 +709,11 @@ describe("extractChoiceIdsFromResponse", () => {
|
||||
label: { default: "Option 3", es: "Opción 3" },
|
||||
},
|
||||
],
|
||||
shuffleOption: "none" as const,
|
||||
};
|
||||
|
||||
const multipleChoiceSingleQuestion = {
|
||||
const multipleChoiceSingleQuestion: TSurveyQuestion = {
|
||||
id: "single-choice-id",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle as typeof TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Select one option" },
|
||||
required: false,
|
||||
choices: [
|
||||
@@ -747,15 +726,14 @@ describe("extractChoiceIdsFromResponse", () => {
|
||||
label: { default: "Choice B", fr: "Choix B" },
|
||||
},
|
||||
],
|
||||
shuffleOption: "none" as const,
|
||||
};
|
||||
|
||||
const textQuestion = {
|
||||
const textQuestion: TSurveyOpenTextQuestion = {
|
||||
id: "text-id",
|
||||
type: TSurveyElementTypeEnum.OpenText as typeof TSurveyElementTypeEnum.OpenText,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "What do you think?" },
|
||||
required: false,
|
||||
inputType: "text" as const,
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false, min: 0, max: 0 },
|
||||
};
|
||||
|
||||
|
||||
@@ -10,16 +10,15 @@ import {
|
||||
TSurveyMetaFieldFilter,
|
||||
} from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurveyElement,
|
||||
TSurveyMultipleChoiceElement,
|
||||
TSurveyPictureSelectionElement,
|
||||
TSurveyRankingElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
TSurvey,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyPictureSelectionQuestion,
|
||||
TSurveyQuestion,
|
||||
TSurveyRankingQuestion,
|
||||
} 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";
|
||||
@@ -34,7 +33,7 @@ import { sanitizeString } from "../utils/strings";
|
||||
*/
|
||||
export const extractChoiceIdsFromResponse = (
|
||||
responseValue: TResponseDataValue,
|
||||
question: TSurveyElement,
|
||||
question: TSurveyQuestion,
|
||||
language: string = "default"
|
||||
): string[] => {
|
||||
// Type guard to ensure the question has choices
|
||||
@@ -93,7 +92,7 @@ export const extractChoiceIdsFromResponse = (
|
||||
|
||||
export const getChoiceIdByValue = (
|
||||
value: string,
|
||||
question: TSurveyMultipleChoiceElement | TSurveyRankingElement | TSurveyPictureSelectionElement
|
||||
question: TSurveyMultipleChoiceQuestion | TSurveyRankingQuestion | TSurveyPictureSelectionQuestion
|
||||
) => {
|
||||
if (question.type === "pictureSelection") {
|
||||
return question.choices.find((choice) => choice.imageUrl === value)?.id ?? "other";
|
||||
@@ -330,8 +329,7 @@ export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilt
|
||||
const data: Prisma.ResponseWhereInput[] = [];
|
||||
|
||||
Object.entries(filterCriteria.data).forEach(([key, val]) => {
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
const question = questions.find((question) => question.id === key);
|
||||
const question = survey.questions.find((question) => question.id === key);
|
||||
|
||||
switch (val.op) {
|
||||
case "submitted":
|
||||
@@ -665,9 +663,7 @@ export const extractSurveyDetails = (survey: TSurvey, responses: TResponse[]) =>
|
||||
const metaDataFields = responses.length > 0 ? extracMetadataKeys(responses[0].meta) : [];
|
||||
const modifiedSurvey = replaceHeadlineRecall(survey, "default");
|
||||
|
||||
const modifiedQuestions = getElementsFromBlocks(modifiedSurvey.blocks);
|
||||
|
||||
const questions = modifiedQuestions.map((question, idx) => {
|
||||
const questions = modifiedSurvey.questions.map((question, idx) => {
|
||||
const headline = getTextContent(getLocalizedValue(question.headline, "default")) ?? question.id;
|
||||
if (question.type === "matrix") {
|
||||
return question.rows.map((row) => {
|
||||
@@ -735,8 +731,7 @@ export const getResponsesJson = (
|
||||
// survey response data
|
||||
questionsHeadlines.forEach((questionHeadline) => {
|
||||
const questionIndex = parseInt(questionHeadline[0]) - 1;
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
const question = questions[questionIndex];
|
||||
const question = survey?.questions[questionIndex];
|
||||
const answer = response.data[question.id];
|
||||
|
||||
if (question.type === "matrix") {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyQuestionType, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
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: TSurveyElementTypeEnum.OpenText as const,
|
||||
type: TSurveyQuestionTypeEnum.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: TSurveyElementTypeEnum.Ranking as const,
|
||||
type: TSurveyQuestionTypeEnum.Ranking as const,
|
||||
headline: { default: "Test Question" },
|
||||
required: true,
|
||||
choices: [
|
||||
@@ -85,7 +85,7 @@ describe("Response Processing", () => {
|
||||
|
||||
const mockFileUploadQuestion = {
|
||||
id: "q1",
|
||||
type: TSurveyElementTypeEnum.FileUpload as const,
|
||||
type: TSurveyQuestionTypeEnum.FileUpload as const,
|
||||
headline: { default: "Test Question" },
|
||||
required: true,
|
||||
allowMultipleFiles: true,
|
||||
@@ -93,7 +93,7 @@ describe("Response Processing", () => {
|
||||
|
||||
const mockPictureSelectionQuestion = {
|
||||
id: "q1",
|
||||
type: TSurveyElementTypeEnum.PictureSelection as const,
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection as const,
|
||||
headline: { default: "Test Question" },
|
||||
required: true,
|
||||
allowMulti: false,
|
||||
@@ -184,36 +184,28 @@ describe("Response Processing", () => {
|
||||
name: "Test Survey",
|
||||
environmentId: "env1",
|
||||
createdBy: null,
|
||||
blocks: [
|
||||
questions: [
|
||||
{
|
||||
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" },
|
||||
},
|
||||
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" } },
|
||||
],
|
||||
shuffleOption: "none" as const,
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
hiddenFields: {
|
||||
enabled: false,
|
||||
fieldIds: [],
|
||||
@@ -263,7 +255,6 @@ describe("Response Processing", () => {
|
||||
enabled: false,
|
||||
isEncrypted: false,
|
||||
},
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
@@ -300,12 +291,12 @@ describe("Response Processing", () => {
|
||||
expect(mapping[0]).toEqual({
|
||||
question: "Question 1",
|
||||
response: "Answer 1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
});
|
||||
expect(mapping[1]).toEqual({
|
||||
question: "Question 2",
|
||||
response: "Option 1; Option 2",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -343,24 +334,17 @@ describe("Response Processing", () => {
|
||||
test("should handle different language", () => {
|
||||
const survey = {
|
||||
...mockSurvey,
|
||||
blocks: [
|
||||
questions: [
|
||||
{
|
||||
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 },
|
||||
},
|
||||
],
|
||||
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 },
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
languages: [
|
||||
{
|
||||
language: {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { TResponse, TResponseDataValue } from "@formbricks/types/responses";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionType } 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: TSurveyElement
|
||||
question: TSurveyQuestion
|
||||
): string | string[] => {
|
||||
switch (question.type) {
|
||||
case "ranking":
|
||||
@@ -36,17 +34,15 @@ export const convertResponseValue = (
|
||||
export const getQuestionResponseMapping = (
|
||||
survey: TSurvey,
|
||||
response: TResponse
|
||||
): { question: string; response: string | string[]; type: TSurveyElementTypeEnum }[] => {
|
||||
): { question: string; response: string | string[]; type: TSurveyQuestionType }[] => {
|
||||
const questionResponseMapping: {
|
||||
question: string;
|
||||
response: string | string[];
|
||||
type: TSurveyElementTypeEnum;
|
||||
type: TSurveyQuestionType;
|
||||
}[] = [];
|
||||
const responseLanguageCode = getLanguageCode(survey.languages, response.language);
|
||||
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
for (const question of questions) {
|
||||
for (const question of survey.questions) {
|
||||
const answer = response.data[question.id];
|
||||
|
||||
questionResponseMapping.push({
|
||||
|
||||
@@ -4,11 +4,12 @@ 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";
|
||||
@@ -171,12 +172,12 @@ export const mockContactAttributeKey: TContactAttributeKey = {
|
||||
...commonMockProperties,
|
||||
};
|
||||
|
||||
const mockQuestion = {
|
||||
const mockQuestion: TSurveyQuestion = {
|
||||
id: mockId,
|
||||
type: TSurveyElementTypeEnum.OpenText as typeof TSurveyElementTypeEnum.OpenText,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Question Text", de: "Fragetext" },
|
||||
required: false,
|
||||
inputType: "text" as const,
|
||||
inputType: "text",
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
@@ -199,14 +200,7 @@ const baseSurveyProperties = {
|
||||
recontactDays: 3,
|
||||
displayLimit: 3,
|
||||
welcomeCard: mockWelcomeCard,
|
||||
questions: [],
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [mockQuestion],
|
||||
},
|
||||
],
|
||||
questions: [mockQuestion],
|
||||
isBackButtonHidden: false,
|
||||
endings: [
|
||||
{
|
||||
@@ -303,22 +297,22 @@ export const updateSurveyInput: TSurvey = {
|
||||
type: "link",
|
||||
status: "inProgress",
|
||||
displayOption: "respondMultiple",
|
||||
metadata: {},
|
||||
triggers: [{ actionClass: mockActionClass }],
|
||||
projectOverwrites: null,
|
||||
recaptcha: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
singleUse: null,
|
||||
displayPercentage: null,
|
||||
createdBy: null,
|
||||
pin: null,
|
||||
recaptcha: null,
|
||||
segment: null,
|
||||
languages: [],
|
||||
showLanguageSwitch: null,
|
||||
variables: [],
|
||||
followUps: [],
|
||||
...baseSurveyProperties,
|
||||
metadata: {},
|
||||
...commonMockProperties,
|
||||
...baseSurveyProperties,
|
||||
};
|
||||
|
||||
export const mockTransformedSurveyOutput = {
|
||||
@@ -337,78 +331,16 @@ export const mockSurveyWithLogic: TSurvey = {
|
||||
type: "link",
|
||||
endings: [],
|
||||
hiddenFields: { enabled: true, fieldIds: ["name"] },
|
||||
blocks: [
|
||||
questions: [
|
||||
{
|
||||
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,
|
||||
},
|
||||
],
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
inputType: "text",
|
||||
headline: { default: "What is your favorite color?" },
|
||||
required: true,
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
logic: [
|
||||
{
|
||||
id: "cdu9vgtmmd9b24l35pp9bodk",
|
||||
@@ -426,6 +358,18 @@ 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: {
|
||||
@@ -448,6 +392,18 @@ 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: {
|
||||
@@ -470,6 +426,20 @@ 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: {
|
||||
@@ -486,6 +456,18 @@ 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: {
|
||||
@@ -508,10 +490,24 @@ 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: "o6n73uq9rysih9mpcbzlehfs2",
|
||||
id: "o6n73uq9rysih9mpcbzlehfs",
|
||||
conditions: {
|
||||
id: "szdkmtz17j9008n4i2d1t041",
|
||||
id: "szdkmtz17j9008n4i2d1t040",
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
@@ -566,7 +562,6 @@ export const mockSurveyWithLogic: TSurvey = {
|
||||
],
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
variables: [
|
||||
{ id: "siog1dabtpo3l0a3xoxw2922", type: "text", name: "var1", value: "lmao" },
|
||||
{ id: "km1srr55owtn2r7lkoh5ny1u", type: "number", name: "var2", value: 32 },
|
||||
|
||||
@@ -67,7 +67,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
|
||||
mockSurveyWithLogic,
|
||||
data,
|
||||
variablesData,
|
||||
mockSurveyWithLogic.blocks[0].logic![0].conditions,
|
||||
mockSurveyWithLogic.questions[0].logic![0].conditions,
|
||||
"default"
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
@@ -81,7 +81,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
|
||||
mockSurveyWithLogic,
|
||||
data,
|
||||
variablesData,
|
||||
mockSurveyWithLogic.blocks[0].logic![0].conditions,
|
||||
mockSurveyWithLogic.questions[0].logic![0].conditions,
|
||||
"default"
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
@@ -95,7 +95,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
|
||||
mockSurveyWithLogic,
|
||||
data,
|
||||
variablesData,
|
||||
mockSurveyWithLogic.blocks[0].logic![1].conditions,
|
||||
mockSurveyWithLogic.questions[1].logic![0].conditions,
|
||||
"default"
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
@@ -109,7 +109,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
|
||||
mockSurveyWithLogic,
|
||||
data,
|
||||
variablesData,
|
||||
mockSurveyWithLogic.blocks[0].logic![1].conditions,
|
||||
mockSurveyWithLogic.questions[1].logic![0].conditions,
|
||||
"default"
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
@@ -123,7 +123,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
|
||||
mockSurveyWithLogic,
|
||||
data,
|
||||
variablesData,
|
||||
mockSurveyWithLogic.blocks[0].logic![2].conditions,
|
||||
mockSurveyWithLogic.questions[2].logic![0].conditions,
|
||||
"default"
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
@@ -137,7 +137,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
|
||||
mockSurveyWithLogic,
|
||||
data,
|
||||
variablesData,
|
||||
mockSurveyWithLogic.blocks[0].logic![3].conditions,
|
||||
mockSurveyWithLogic.questions[3].logic![0].conditions,
|
||||
"default"
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
@@ -151,7 +151,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
|
||||
mockSurveyWithLogic,
|
||||
data,
|
||||
variablesData,
|
||||
mockSurveyWithLogic.blocks[0].logic![3].conditions,
|
||||
mockSurveyWithLogic.questions[3].logic![0].conditions,
|
||||
"default"
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
@@ -165,7 +165,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
|
||||
mockSurveyWithLogic,
|
||||
data,
|
||||
variablesData,
|
||||
mockSurveyWithLogic.blocks[0].logic![4].conditions,
|
||||
mockSurveyWithLogic.questions[4].logic![0].conditions,
|
||||
"default"
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
@@ -179,7 +179,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
|
||||
mockSurveyWithLogic,
|
||||
data,
|
||||
variablesData,
|
||||
mockSurveyWithLogic.blocks[0].logic![4].conditions,
|
||||
mockSurveyWithLogic.questions[4].logic![0].conditions,
|
||||
"default"
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
@@ -193,7 +193,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
|
||||
mockSurveyWithLogic,
|
||||
data,
|
||||
variablesData,
|
||||
mockSurveyWithLogic.blocks[0].logic![5].conditions,
|
||||
mockSurveyWithLogic.questions[5].logic![0].conditions,
|
||||
"default"
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
|
||||
@@ -15,13 +15,7 @@ import { getActionClasses } from "../actionClass/service";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { capturePosthogEnvironmentEvent } from "../posthogServer";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import {
|
||||
checkForInvalidImagesInQuestions,
|
||||
checkForInvalidMediaInBlocks,
|
||||
stripIsDraftFromBlocks,
|
||||
transformPrismaSurvey,
|
||||
validateMediaAndPrepareBlocks,
|
||||
} from "./utils";
|
||||
import { checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils";
|
||||
|
||||
interface TriggerUpdate {
|
||||
create?: Array<{ actionClassId: string }>;
|
||||
@@ -43,7 +37,6 @@ export const selectSurvey = {
|
||||
status: true,
|
||||
welcomeCard: true,
|
||||
questions: true,
|
||||
blocks: true,
|
||||
endings: true,
|
||||
hiddenFields: true,
|
||||
variables: true,
|
||||
@@ -304,14 +297,6 @@ 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
|
||||
@@ -519,11 +504,6 @@ 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);
|
||||
@@ -628,11 +608,6 @@ 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,
|
||||
@@ -647,6 +622,14 @@ export const createSurvey = async (
|
||||
|
||||
// if the survey created is an "app" survey, we also create a private segment for it.
|
||||
if (survey.type === "app") {
|
||||
// const newSegment = await createSegment({
|
||||
// environmentId: parsedEnvironmentId,
|
||||
// surveyId: survey.id,
|
||||
// filters: [],
|
||||
// title: survey.id,
|
||||
// isPrivate: true,
|
||||
// });
|
||||
|
||||
const newSegment = await prisma.segment.create({
|
||||
data: {
|
||||
title: survey.id,
|
||||
|
||||
@@ -2,17 +2,9 @@ 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,
|
||||
checkForInvalidMediaInBlocks,
|
||||
transformPrismaSurvey,
|
||||
} from "./utils";
|
||||
import { anySurveyHasFilters, checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils";
|
||||
|
||||
describe("transformPrismaSurvey", () => {
|
||||
test("transforms prisma survey without segment", () => {
|
||||
@@ -260,418 +252,3 @@ describe("checkForInvalidImagesInQuestions", () => {
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(3, "image3.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkForInvalidMediaInBlocks", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("returns ok when blocks array is empty", () => {
|
||||
const blocks: TSurveyBlock[] = [];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("returns ok when blocks have no images", () => {
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "elem-1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Question" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("returns ok when all element images are valid", () => {
|
||||
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "elem-1",
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
headline: { default: "Question" },
|
||||
required: false,
|
||||
choices: [
|
||||
{ id: "c1", label: { default: "Option 1" }, imageUrl: "image1.jpg" },
|
||||
{ id: "c2", label: { default: "Option 2" }, imageUrl: "image2.jpg" },
|
||||
],
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image1.jpg");
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image2.jpg");
|
||||
});
|
||||
|
||||
test("returns error when element image is invalid", () => {
|
||||
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(false);
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Welcome Block",
|
||||
elements: [
|
||||
{
|
||||
id: "welcome",
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
headline: { default: "Welcome" },
|
||||
required: false,
|
||||
choices: [
|
||||
{ id: "c1", label: { default: "Option 1" }, imageUrl: "image1.jpg" },
|
||||
{ id: "c2", label: { default: "Option 2" }, imageUrl: "image2.jpg" },
|
||||
],
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
console.log(result.error);
|
||||
expect(result.error.message).toBe(
|
||||
'Invalid image URL in choice 1 of question 1 of block "Welcome Block"'
|
||||
);
|
||||
}
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image1.jpg");
|
||||
});
|
||||
|
||||
test("returns ok when all choice images are valid", () => {
|
||||
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Choice Block",
|
||||
elements: [
|
||||
{
|
||||
id: "choice-q",
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
headline: { default: "Pick one" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "c1", imageUrl: "image1.jpg" },
|
||||
{ id: "c2", imageUrl: "image2.jpg" },
|
||||
],
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(2);
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "image1.jpg");
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "image2.jpg");
|
||||
});
|
||||
|
||||
test("returns error when choice image is invalid", () => {
|
||||
vi.spyOn(fileValidation, "isValidImageFile").mockImplementation((url) => url === "valid.jpg");
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Picture Selection",
|
||||
elements: [
|
||||
{
|
||||
id: "pic-select",
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
headline: { default: "Select a picture" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "c1", imageUrl: "valid.jpg" },
|
||||
{ id: "c2", imageUrl: "invalid.txt" },
|
||||
],
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe(
|
||||
'Invalid image URL in choice 2 of question 1 of block "Picture Selection"'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns ok when video URL is valid (YouTube)", () => {
|
||||
vi.spyOn(videoValidation, "isValidVideoUrl").mockReturnValue(true);
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Video Block",
|
||||
elements: [
|
||||
{
|
||||
id: "video-q",
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
headline: { default: "Watch this" },
|
||||
required: false,
|
||||
videoUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(videoValidation.isValidVideoUrl).toHaveBeenCalledWith(
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns error when video URL is invalid (not YouTube/Vimeo/Loom)", () => {
|
||||
vi.spyOn(videoValidation, "isValidVideoUrl").mockReturnValue(false);
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Video Block",
|
||||
elements: [
|
||||
{
|
||||
id: "video-q",
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
headline: { default: "Watch this" },
|
||||
required: false,
|
||||
videoUrl: "https://example.com/video.mp4",
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain("Invalid video URL");
|
||||
expect(result.error.message).toContain("question 1");
|
||||
expect(result.error.message).toContain("YouTube, Vimeo, and Loom");
|
||||
}
|
||||
});
|
||||
|
||||
test("validates images across multiple blocks", () => {
|
||||
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "elem-1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q1" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
imageUrl: "image1.jpg",
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "block-2",
|
||||
name: "Block 2",
|
||||
elements: [
|
||||
{
|
||||
id: "elem-2",
|
||||
type: TSurveyElementTypeEnum.Rating,
|
||||
headline: { default: "Q2" },
|
||||
required: true,
|
||||
range: 5,
|
||||
scale: "star",
|
||||
imageUrl: "image2.jpg",
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(2);
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "image1.jpg");
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "image2.jpg");
|
||||
});
|
||||
|
||||
test("stops at first invalid image and returns specific error", () => {
|
||||
vi.spyOn(fileValidation, "isValidImageFile").mockImplementation((url) => url !== "bad-image.gif");
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "elem-1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q1" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
imageUrl: "good.jpg",
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "block-2",
|
||||
name: "Block 2",
|
||||
elements: [
|
||||
{
|
||||
id: "elem-2",
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
headline: { default: "Q2" },
|
||||
required: false,
|
||||
imageUrl: "bad-image.gif",
|
||||
} as unknown as TSurveyElement,
|
||||
{
|
||||
id: "elem-3",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q3" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
imageUrl: "another.jpg",
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe('Invalid image URL in question 1 of block "Block 2" (block 2)');
|
||||
}
|
||||
// Should stop after finding first invalid image
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("validates choices without imageUrl (skips gracefully)", () => {
|
||||
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Choice Block",
|
||||
elements: [
|
||||
{
|
||||
id: "mc-q",
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
headline: { default: "Pick one" },
|
||||
required: true,
|
||||
choices: [{ id: "c1", imageUrl: "image.jpg" }],
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
// Only validates the one with imageUrl
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(1);
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image.jpg");
|
||||
});
|
||||
|
||||
test("handles multiple elements in single block", () => {
|
||||
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Multi-Element Block",
|
||||
elements: [
|
||||
{
|
||||
id: "elem-1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q1" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
imageUrl: "img1.jpg",
|
||||
} as unknown as TSurveyElement,
|
||||
{
|
||||
id: "elem-2",
|
||||
type: TSurveyElementTypeEnum.Rating,
|
||||
headline: { default: "Q2" },
|
||||
required: true,
|
||||
range: 5,
|
||||
scale: "number",
|
||||
imageUrl: "img2.jpg",
|
||||
} as unknown as TSurveyElement,
|
||||
{
|
||||
id: "elem-3",
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
headline: { default: "Q3" },
|
||||
required: false,
|
||||
imageUrl: "img3.jpg",
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(3);
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "img1.jpg");
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "img2.jpg");
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(3, "img3.jpg");
|
||||
});
|
||||
|
||||
test("validates both element imageUrl and choice imageUrls", () => {
|
||||
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
|
||||
|
||||
const blocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block-1",
|
||||
name: "Complex Block",
|
||||
elements: [
|
||||
{
|
||||
id: "elem-1",
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
headline: { default: "Choose" },
|
||||
required: true,
|
||||
imageUrl: "element-image.jpg",
|
||||
choices: [
|
||||
{ id: "c1", imageUrl: "choice1.jpg" },
|
||||
{ id: "c2", imageUrl: "choice2.jpg" },
|
||||
],
|
||||
} as unknown as TSurveyElement,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForInvalidMediaInBlocks(blocks);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(3);
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "element-image.jpg");
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "choice1.jpg");
|
||||
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(3, "choice2.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
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>(
|
||||
@@ -64,188 +56,3 @@ export const checkForInvalidImagesInQuestions = (questions: TSurveyQuestion[]) =
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a single choice's image URL
|
||||
* @param choice - Choice to validate
|
||||
* @param choiceIdx - Index of the choice for error reporting
|
||||
* @param questionIdx - Index of the question for error reporting
|
||||
* @param blockName - Block name for error reporting
|
||||
* @returns Result with void data on success or Error on failure
|
||||
*/
|
||||
const validateChoiceImage = (
|
||||
choice: TSurveyPictureChoice,
|
||||
choiceIdx: number,
|
||||
questionIdx: number,
|
||||
blockName: string
|
||||
): Result<void, Error> => {
|
||||
if (choice.imageUrl && !isValidImageFile(choice.imageUrl)) {
|
||||
return err(
|
||||
new Error(
|
||||
`Invalid image URL in choice ${choiceIdx + 1} of question ${questionIdx + 1} of block "${blockName}"`
|
||||
)
|
||||
);
|
||||
}
|
||||
return ok(undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates choice images for picture selection elements
|
||||
* Only picture selection questions have imageUrl in choices
|
||||
* @param element - Element with choices to validate
|
||||
* @param questionIdx - Index of the question for error reporting
|
||||
* @param blockName - Block name for error reporting
|
||||
* @returns Result with void data on success or Error on failure
|
||||
*/
|
||||
const validatePictureSelectionChoiceImages = (
|
||||
element: TSurveyElement,
|
||||
questionIdx: number,
|
||||
blockName: string
|
||||
): Result<void, Error> => {
|
||||
// Only validate choices for picture selection questions
|
||||
if (element.type !== TSurveyElementTypeEnum.PictureSelection) {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
if (!("choices" in element) || !Array.isArray(element.choices)) {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
for (let choiceIdx = 0; choiceIdx < element.choices.length; choiceIdx++) {
|
||||
const result = validateChoiceImage(element.choices[choiceIdx], choiceIdx, questionIdx, blockName);
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return ok(undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a single element's image URL, video URL, and picture selection choice images
|
||||
* @param element - Element to validate
|
||||
* @param elementIdx - Index of the element for error reporting
|
||||
* @param blockIdx - Index of the block for error reporting
|
||||
* @param blockName - Block name for error reporting
|
||||
* @returns Result with void data on success or Error on failure
|
||||
*/
|
||||
const validateElement = (
|
||||
element: TSurveyElement,
|
||||
elementIdx: number,
|
||||
blockIdx: number,
|
||||
blockName: string
|
||||
): Result<void, Error> => {
|
||||
// Check element imageUrl
|
||||
if (element.imageUrl && !isValidImageFile(element.imageUrl)) {
|
||||
return err(
|
||||
new Error(
|
||||
`Invalid image URL in question ${elementIdx + 1} of block "${blockName}" (block ${blockIdx + 1})`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check element videoUrl
|
||||
if (element.videoUrl && !isValidVideoUrl(element.videoUrl)) {
|
||||
return err(
|
||||
new Error(
|
||||
`Invalid video URL in question ${elementIdx + 1} of block "${blockName}" (block ${blockIdx + 1}). Only YouTube, Vimeo, and Loom URLs are supported.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check choices for picture selection
|
||||
return validatePictureSelectionChoiceImages(element, elementIdx, blockName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that all media URLs (images and videos) in blocks are valid
|
||||
* - Validates element imageUrl
|
||||
* - Validates element videoUrl
|
||||
* - Validates choice imageUrl for picture selection elements
|
||||
* @param blocks - Array of survey blocks to validate
|
||||
* @returns Result with void data on success or Error on failure
|
||||
*/
|
||||
export const checkForInvalidMediaInBlocks = (blocks: TSurveyBlock[]): Result<void, Error> => {
|
||||
for (let blockIdx = 0; blockIdx < blocks.length; blockIdx++) {
|
||||
const block = blocks[blockIdx];
|
||||
|
||||
for (let elementIdx = 0; elementIdx < block.elements.length; elementIdx++) {
|
||||
const result = validateElement(block.elements[elementIdx], elementIdx, blockIdx, block.name);
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ok(undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* Strips isDraft field from elements before saving to database
|
||||
* Note: Blocks don't have isDraft since block IDs are CUIDs (not user-editable)
|
||||
* Only element IDs need protection as they're user-editable and used in responses
|
||||
* @param blocks - Array of survey blocks
|
||||
* @returns New array with isDraft stripped from all elements
|
||||
*/
|
||||
export const stripIsDraftFromBlocks = (blocks: TSurveyBlock[]): TSurveyBlock[] => {
|
||||
return blocks.map((block) => ({
|
||||
...block,
|
||||
elements: block.elements.map((element) => {
|
||||
const { isDraft, ...elementRest } = element;
|
||||
return elementRest;
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates and prepares blocks for persistence
|
||||
* - Validates all media URLs (images and videos) in blocks
|
||||
* - Strips isDraft flags from elements
|
||||
* @param blocks - Array of survey blocks to validate and prepare
|
||||
* @returns Prepared blocks ready for database persistence
|
||||
* @throws Error if any media validation fails
|
||||
*/
|
||||
export const validateMediaAndPrepareBlocks = (blocks: TSurveyBlock[]): TSurveyBlock[] => {
|
||||
// Validate media (images and videos)
|
||||
const validation = checkForInvalidMediaInBlocks(blocks);
|
||||
if (!validation.ok) {
|
||||
throw validation.error;
|
||||
}
|
||||
|
||||
// Strip isDraft
|
||||
return stripIsDraftFromBlocks(blocks);
|
||||
};
|
||||
|
||||
/**
|
||||
* Derives a flat array of elements from the survey's blocks structure
|
||||
* Useful for server-side processing where we need to iterate over all questions
|
||||
* Note: This is duplicated from the client-side survey utils since this file is server-only
|
||||
* @param blocks - Array of survey blocks
|
||||
* @returns Flat array of all elements across all blocks
|
||||
*/
|
||||
export const getElementsFromBlocks = (blocks: TSurveyBlock[]): TSurveyElement[] => {
|
||||
return blocks.flatMap((block) => block.elements);
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the location of an element within the survey blocks
|
||||
* @param survey - The survey object
|
||||
* @param elementId - The ID of the element to find
|
||||
* @returns Object containing blockId, blockIndex, elementIndex and the block
|
||||
*/
|
||||
export const findElementLocation = (
|
||||
survey: TSurvey,
|
||||
elementId: string
|
||||
): { blockId: string | null; blockIndex: number; elementIndex: number; block: TSurveyBlock | null } => {
|
||||
const blocks = survey.blocks;
|
||||
|
||||
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
|
||||
const block = blocks[blockIndex];
|
||||
const elementIndex = block.elements.findIndex((e) => e.id === elementId);
|
||||
if (elementIndex !== -1) {
|
||||
return { blockId: block.id, blockIndex, elementIndex, block };
|
||||
}
|
||||
}
|
||||
|
||||
return { blockId: null, blockIndex: -1, elementIndex: -1, block: null };
|
||||
};
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
|
||||
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 {
|
||||
TConditionGroup,
|
||||
TSingleCondition,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicAction,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
addConditionBelow,
|
||||
createGroupFromResource,
|
||||
@@ -33,6 +36,9 @@ describe("surveyLogic", () => {
|
||||
type: "link",
|
||||
status: "inProgress",
|
||||
welcomeCard: {
|
||||
html: {
|
||||
default: "Thanks for providing your feedback - let's go!",
|
||||
},
|
||||
enabled: false,
|
||||
headline: {
|
||||
default: "Welcome!",
|
||||
@@ -43,28 +49,25 @@ describe("surveyLogic", () => {
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
},
|
||||
blocks: [
|
||||
questions: [
|
||||
{
|
||||
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",
|
||||
},
|
||||
},
|
||||
],
|
||||
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",
|
||||
},
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
endings: [
|
||||
{
|
||||
id: "gt1yoaeb5a3istszxqbl08mk",
|
||||
@@ -129,7 +132,7 @@ describe("surveyLogic", () => {
|
||||
});
|
||||
|
||||
test("duplicateLogicItem duplicates IDs recursively", () => {
|
||||
const logic: TSurveyBlockLogic = {
|
||||
const logic: TSurveyLogic = {
|
||||
id: "L1",
|
||||
conditions: simpleGroup(),
|
||||
actions: [{ id: "A1", objective: "requireAnswer", target: "q1" }],
|
||||
@@ -208,13 +211,13 @@ describe("surveyLogic", () => {
|
||||
});
|
||||
|
||||
test("getUpdatedActionBody returns new action bodies correctly", () => {
|
||||
const base: TSurveyBlockLogicAction = { id: "A", objective: "requireAnswer", target: "q" };
|
||||
const base: TSurveyLogicAction = { 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, "jumpToBlock");
|
||||
expect(jump.objective).toBe("jumpToBlock");
|
||||
const jump = getUpdatedActionBody(req, "jumpToQuestion");
|
||||
expect(jump.objective).toBe("jumpToQuestion");
|
||||
});
|
||||
|
||||
test("evaluateLogic handles AND/OR groups and single conditions", () => {
|
||||
@@ -246,7 +249,7 @@ describe("surveyLogic", () => {
|
||||
test("performActions calculates, requires, and jumps correctly", () => {
|
||||
const data: TResponseData = { q: "5" };
|
||||
const initialVars: TResponseVariables = {};
|
||||
const actions: TSurveyBlockLogicAction[] = [
|
||||
const actions: TSurveyLogicAction[] = [
|
||||
{
|
||||
id: "a1",
|
||||
objective: "calculate",
|
||||
@@ -255,7 +258,7 @@ describe("surveyLogic", () => {
|
||||
value: { type: "static", value: 3 },
|
||||
},
|
||||
{ id: "a2", objective: "requireAnswer", target: "q2" },
|
||||
{ id: "a3", objective: "jumpToBlock", target: "q3" },
|
||||
{ id: "a3", objective: "jumpToQuestion", target: "q3" },
|
||||
];
|
||||
const result = performActions(mockSurvey, actions, data, initialVars);
|
||||
expect(result.calculations.v).toBe(3);
|
||||
@@ -460,7 +463,7 @@ describe("surveyLogic", () => {
|
||||
variables: [{ id: "v", name: "num", type: "number", value: 0 }],
|
||||
};
|
||||
const data: TResponseData = { q: 2 };
|
||||
const actions: TSurveyBlockLogicAction[] = [
|
||||
const actions: TSurveyLogicAction[] = [
|
||||
{
|
||||
id: "a1",
|
||||
objective: "calculate",
|
||||
@@ -747,87 +750,84 @@ describe("surveyLogic", () => {
|
||||
test("getLeftOperandValue handles different question types", () => {
|
||||
const surveyWithQuestions: TJsEnvironmentStateSurvey = {
|
||||
...mockSurvey,
|
||||
blocks: [
|
||||
questions: [
|
||||
...mockSurvey.questions,
|
||||
{
|
||||
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,
|
||||
},
|
||||
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" } },
|
||||
],
|
||||
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,24 +1008,17 @@ describe("surveyLogic", () => {
|
||||
test("getRightOperandValue handles different data types and sources", () => {
|
||||
const surveyWithVars: TJsEnvironmentStateSurvey = {
|
||||
...mockSurvey,
|
||||
blocks: [
|
||||
questions: [
|
||||
...mockSurvey.questions,
|
||||
{
|
||||
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 },
|
||||
},
|
||||
],
|
||||
id: "question1",
|
||||
type: TSurveyQuestionTypeEnum.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" },
|
||||
@@ -1326,24 +1319,19 @@ describe("surveyLogic", () => {
|
||||
test("getLeftOperandValue handles number input type with non-number value", () => {
|
||||
const surveyWithNumberInput: TJsEnvironmentStateSurvey = {
|
||||
...mockSurvey,
|
||||
blocks: [
|
||||
questions: [
|
||||
{
|
||||
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 },
|
||||
},
|
||||
],
|
||||
id: "numQuestion",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Number question" },
|
||||
required: true,
|
||||
inputType: "number",
|
||||
placeholder: { default: "Enter a number" },
|
||||
buttonLabel: { default: "Next" },
|
||||
longAnswer: false,
|
||||
charLimit: {},
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
};
|
||||
|
||||
const condition: TSingleCondition = {
|
||||
|
||||
@@ -2,15 +2,17 @@ import { createId } from "@paralleldrive/cuid2";
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurveyBlockLogic,
|
||||
TSurveyBlockLogicAction,
|
||||
TSurveyBlockLogicActionObjective,
|
||||
} from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TConditionGroup, TSingleCondition } from "@formbricks/types/surveys/logic";
|
||||
import { TActionCalculate, TSurveyLogicAction, TSurveyVariable } from "@formbricks/types/surveys/types";
|
||||
TActionCalculate,
|
||||
TActionObjective,
|
||||
TConditionGroup,
|
||||
TSingleCondition,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicAction,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyVariable,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
|
||||
type TCondition = TSingleCondition | TConditionGroup;
|
||||
|
||||
@@ -18,7 +20,7 @@ export const isConditionGroup = (condition: TCondition): condition is TCondition
|
||||
return (condition as TConditionGroup).connector !== undefined;
|
||||
};
|
||||
|
||||
export const duplicateLogicItem = (logicItem: TSurveyBlockLogic): TSurveyBlockLogic => {
|
||||
export const duplicateLogicItem = (logicItem: TSurveyLogic): TSurveyLogic => {
|
||||
const duplicateConditionGroup = (group: TConditionGroup): TConditionGroup => {
|
||||
return {
|
||||
...group,
|
||||
@@ -40,7 +42,7 @@ export const duplicateLogicItem = (logicItem: TSurveyBlockLogic): TSurveyBlockLo
|
||||
};
|
||||
};
|
||||
|
||||
const duplicateAction = (action: TSurveyBlockLogicAction): TSurveyBlockLogicAction => {
|
||||
const duplicateAction = (action: TSurveyLogicAction): TSurveyLogicAction => {
|
||||
return {
|
||||
...action,
|
||||
id: createId(),
|
||||
@@ -196,9 +198,9 @@ export const updateCondition = (
|
||||
};
|
||||
|
||||
export const getUpdatedActionBody = (
|
||||
action: TSurveyBlockLogicAction,
|
||||
objective: TSurveyBlockLogicActionObjective
|
||||
): TSurveyBlockLogicAction => {
|
||||
action: TSurveyLogicAction,
|
||||
objective: TActionObjective
|
||||
): TSurveyLogicAction => {
|
||||
if (objective === action.objective) return action;
|
||||
switch (objective) {
|
||||
case "calculate":
|
||||
@@ -215,14 +217,12 @@ export const getUpdatedActionBody = (
|
||||
objective: "requireAnswer",
|
||||
target: "",
|
||||
};
|
||||
case "jumpToBlock":
|
||||
case "jumpToQuestion":
|
||||
return {
|
||||
id: action.id,
|
||||
objective: "jumpToBlock",
|
||||
objective: "jumpToQuestion",
|
||||
target: "",
|
||||
};
|
||||
default:
|
||||
return action;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -263,17 +263,14 @@ const evaluateSingleCondition = (
|
||||
condition.leftOperand,
|
||||
selectedLanguage
|
||||
);
|
||||
|
||||
let rightValue = condition.rightOperand
|
||||
? getRightOperandValue(localSurvey, data, variablesData, condition.rightOperand)
|
||||
: undefined;
|
||||
|
||||
const questions = getElementsFromBlocks(localSurvey.blocks);
|
||||
|
||||
let leftField: TSurveyElement | TSurveyVariable | string;
|
||||
let leftField: TSurveyQuestion | TSurveyVariable | string;
|
||||
|
||||
if (condition.leftOperand?.type === "question") {
|
||||
leftField = questions.find((q) => q.id === condition.leftOperand?.value) ?? "";
|
||||
leftField = localSurvey.questions.find((q) => q.id === condition.leftOperand?.value) as TSurveyQuestion;
|
||||
} else if (condition.leftOperand?.type === "variable") {
|
||||
leftField = localSurvey.variables.find((v) => v.id === condition.leftOperand?.value) as TSurveyVariable;
|
||||
} else if (condition.leftOperand?.type === "hiddenField") {
|
||||
@@ -282,10 +279,12 @@ const evaluateSingleCondition = (
|
||||
leftField = "";
|
||||
}
|
||||
|
||||
let rightField: TSurveyElement | TSurveyVariable | string;
|
||||
let rightField: TSurveyQuestion | TSurveyVariable | string;
|
||||
|
||||
if (condition.rightOperand?.type === "question") {
|
||||
rightField = questions.find((q) => q.id === condition.rightOperand?.value) ?? "";
|
||||
rightField = localSurvey.questions.find(
|
||||
(q) => q.id === condition.rightOperand?.value
|
||||
) as TSurveyQuestion;
|
||||
} else if (condition.rightOperand?.type === "variable") {
|
||||
rightField = localSurvey.variables.find(
|
||||
(v) => v.id === condition.rightOperand?.value
|
||||
@@ -308,7 +307,7 @@ const evaluateSingleCondition = (
|
||||
case "equals":
|
||||
if (condition.leftOperand.type === "question") {
|
||||
if (
|
||||
(leftField as TSurveyElement).type === TSurveyElementTypeEnum.Date &&
|
||||
(leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date &&
|
||||
typeof leftValue === "string" &&
|
||||
typeof rightValue === "string"
|
||||
) {
|
||||
@@ -319,12 +318,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 TSurveyElement).type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
|
||||
if ((rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
|
||||
if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) {
|
||||
return rightValue.includes(leftValue as string);
|
||||
} else return false;
|
||||
} else if (
|
||||
(rightField as TSurveyElement).type === TSurveyElementTypeEnum.Date &&
|
||||
(rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date &&
|
||||
typeof leftValue === "string" &&
|
||||
typeof rightValue === "string"
|
||||
) {
|
||||
@@ -343,7 +342,7 @@ const evaluateSingleCondition = (
|
||||
// when left value is of picture selection question and right value is its option
|
||||
if (
|
||||
condition.leftOperand.type === "question" &&
|
||||
(leftField as TSurveyElement).type === TSurveyElementTypeEnum.PictureSelection &&
|
||||
(leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.PictureSelection &&
|
||||
Array.isArray(leftValue) &&
|
||||
leftValue.length > 0 &&
|
||||
typeof rightValue === "string"
|
||||
@@ -354,7 +353,7 @@ const evaluateSingleCondition = (
|
||||
// when left value is of date question and right value is string
|
||||
if (
|
||||
condition.leftOperand.type === "question" &&
|
||||
(leftField as TSurveyElement).type === TSurveyElementTypeEnum.Date &&
|
||||
(leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date &&
|
||||
typeof leftValue === "string" &&
|
||||
typeof rightValue === "string"
|
||||
) {
|
||||
@@ -363,12 +362,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 TSurveyElement).type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
|
||||
if ((rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
|
||||
if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) {
|
||||
return !rightValue.includes(leftValue as string);
|
||||
} else return false;
|
||||
} else if (
|
||||
(rightField as TSurveyElement).type === TSurveyElementTypeEnum.Date &&
|
||||
(rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date &&
|
||||
typeof leftValue === "string" &&
|
||||
typeof rightValue === "string"
|
||||
) {
|
||||
@@ -399,7 +398,7 @@ const evaluateSingleCondition = (
|
||||
if (typeof leftValue === "string") {
|
||||
if (
|
||||
condition.leftOperand.type === "question" &&
|
||||
(leftField as TSurveyElement).type === TSurveyElementTypeEnum.FileUpload &&
|
||||
(leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.FileUpload &&
|
||||
leftValue
|
||||
) {
|
||||
return leftValue !== "skipped";
|
||||
@@ -512,8 +511,7 @@ const getLeftOperandValue = (
|
||||
) => {
|
||||
switch (leftOperand.type) {
|
||||
case "question":
|
||||
const questions = getElementsFromBlocks(localSurvey.blocks);
|
||||
const currentQuestion = questions.find((q) => q.id === leftOperand.value);
|
||||
const currentQuestion = localSurvey.questions.find((q) => q.id === leftOperand.value);
|
||||
if (!currentQuestion) return undefined;
|
||||
|
||||
const responseValue = data[leftOperand.value];
|
||||
@@ -625,7 +623,7 @@ const getRightOperandValue = (
|
||||
|
||||
export const performActions = (
|
||||
survey: TJsEnvironmentStateSurvey,
|
||||
actions: TSurveyBlockLogicAction[] | TSurveyLogicAction[],
|
||||
actions: TSurveyLogicAction[],
|
||||
data: TResponseData,
|
||||
calculationResults: TResponseVariables
|
||||
): {
|
||||
@@ -646,7 +644,7 @@ export const performActions = (
|
||||
case "requireAnswer":
|
||||
requiredQuestionIds.push(action.target);
|
||||
break;
|
||||
case "jumpToBlock":
|
||||
case "jumpToQuestion":
|
||||
if (!jumpTarget) {
|
||||
jumpTarget = action.target;
|
||||
}
|
||||
|
||||
525
apps/web/lib/testing/README.md
Normal file
525
apps/web/lib/testing/README.md
Normal file
@@ -0,0 +1,525 @@
|
||||
# Testing Utilities — Tutorial
|
||||
|
||||
Practical utilities to write cleaner, faster, more consistent unit tests.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
// NOW import modules that depend on mocks
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { FIXTURES, TEST_IDS } from "@/lib/testing/constants";
|
||||
// ⚠️ CRITICAL: Setup ALL mocks BEFORE importing modules that use them
|
||||
import { COMMON_ERRORS, createContactsMocks } from "@/lib/testing/mocks";
|
||||
import { setupTestEnvironment } from "@/lib/testing/setup";
|
||||
import { getContact } from "./contacts";
|
||||
|
||||
vi.mock("@formbricks/database", () => createContactsMocks());
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
setupTestEnvironment();
|
||||
|
||||
describe("ContactService", () => {
|
||||
test("should find a contact", async () => {
|
||||
vi.mocked(prisma.contact.findUnique).mockResolvedValue(FIXTURES.contact);
|
||||
|
||||
const result = await getContact(TEST_IDS.contact);
|
||||
|
||||
expect(result).toEqual(FIXTURES.contact);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Setup Rules ⚠️
|
||||
|
||||
### Rule 1: Mock Order is Everything
|
||||
|
||||
**Vitest requires all `vi.mock()` calls to happen BEFORE any imports that use the mocked modules.**
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - will fail with "prisma is not defined"
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
vi.mock("@formbricks/database", () => createContactsMocks());
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - setup mocks first
|
||||
// THEN import modules that depend on the mock
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { createContactsMocks } from "@/lib/testing/mocks";
|
||||
|
||||
vi.mock("@formbricks/database", () => createContactsMocks());
|
||||
```
|
||||
|
||||
### Rule 2: Mock All External Dependencies
|
||||
|
||||
Don't forget to mock functions that are called by your tested code:
|
||||
|
||||
```typescript
|
||||
// ✅ Mock validateInputs if it's called by the function you're testing
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
// Set up a default behavior
|
||||
vi.mocked(validateInputs).mockImplementation(() => []);
|
||||
```
|
||||
|
||||
### Rule 3: Fixtures Must Match Real Data Structures
|
||||
|
||||
Test fixtures should match the exact structure expected by your code:
|
||||
|
||||
```typescript
|
||||
// ❌ INCOMPLETE - will fail when code tries to access attributes
|
||||
const contact = {
|
||||
id: TEST_IDS.contact,
|
||||
email: "test@example.com",
|
||||
userId: TEST_IDS.user,
|
||||
};
|
||||
|
||||
// ✅ COMPLETE - matches what transformPrismaContact expects
|
||||
const contact = {
|
||||
id: TEST_IDS.contact,
|
||||
environmentId: TEST_IDS.environment,
|
||||
userId: TEST_IDS.user,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
attributes: [
|
||||
{ value: "test@example.com", attributeKey: { key: "email", name: "Email" } },
|
||||
{ value: TEST_IDS.user, attributeKey: { key: "userId", name: "User ID" } },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Concept 1: TEST_IDs — Use Constants, Not Magic Strings
|
||||
|
||||
### The Problem
|
||||
|
||||
Scattered magic strings make tests hard to maintain:
|
||||
|
||||
```typescript
|
||||
// ❌ Don't do this
|
||||
describe("getContact", () => {
|
||||
test("should find contact", async () => {
|
||||
const contactId = "contact-123";
|
||||
const userId = "user-456";
|
||||
const environmentId = "env-789";
|
||||
|
||||
const result = await getContact(contactId);
|
||||
expect(result.userId).toBe(userId);
|
||||
});
|
||||
|
||||
test("should handle missing contact", async () => {
|
||||
const contactId = "contact-123"; // Same ID, defined again
|
||||
await expect(getContact(contactId)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### The Solution
|
||||
|
||||
Use TEST_IDs for consistent, reusable identifiers:
|
||||
|
||||
```typescript
|
||||
// ✅ Do this
|
||||
import { TEST_IDS } from "@/lib/testing/constants";
|
||||
|
||||
describe("getContact", () => {
|
||||
test("should find contact", async () => {
|
||||
const result = await getContact(TEST_IDS.contact);
|
||||
expect(result.userId).toBe(TEST_IDS.user);
|
||||
});
|
||||
|
||||
test("should handle missing contact", async () => {
|
||||
await expect(getContact(TEST_IDS.contact)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Available IDs:**
|
||||
|
||||
```
|
||||
TEST_IDS.contact, contactAlt, user, environment, survey, organization, quota,
|
||||
attribute, response, team, project, segment, webhook, apiKey, membership
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Concept 2: FIXTURES — Use Pre-built Test Data
|
||||
|
||||
### The Problem
|
||||
|
||||
Duplicated mock data across tests:
|
||||
|
||||
```typescript
|
||||
// ❌ Don't do this
|
||||
describe("ContactService", () => {
|
||||
test("should validate contact email", async () => {
|
||||
const contact = {
|
||||
id: "contact-1",
|
||||
email: "test@example.com",
|
||||
userId: "user-1",
|
||||
environmentId: "env-1",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
};
|
||||
expect(isValidEmail(contact.email)).toBe(true);
|
||||
});
|
||||
|
||||
test("should create contact from data", async () => {
|
||||
const contact = {
|
||||
id: "contact-1",
|
||||
email: "test@example.com",
|
||||
userId: "user-1",
|
||||
environmentId: "env-1",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
};
|
||||
const result = await createContact(contact);
|
||||
expect(result).toEqual(contact);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### The Solution
|
||||
|
||||
Use FIXTURES for consistent test data:
|
||||
|
||||
```typescript
|
||||
// ✅ Do this
|
||||
import { FIXTURES } from "@/lib/testing/constants";
|
||||
|
||||
describe("ContactService", () => {
|
||||
test("should validate contact email", async () => {
|
||||
expect(isValidEmail(FIXTURES.contact.email)).toBe(true);
|
||||
});
|
||||
|
||||
test("should create contact from data", async () => {
|
||||
const result = await createContact(FIXTURES.contact);
|
||||
expect(result).toEqual(FIXTURES.contact);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Available fixtures:** contact, survey, attributeKey, environment, organization, project, team, user, response
|
||||
|
||||
---
|
||||
|
||||
## Concept 3: setupTestEnvironment — Standard Cleanup
|
||||
|
||||
### The Problem
|
||||
|
||||
Inconsistent beforeEach/afterEach patterns across tests:
|
||||
|
||||
```typescript
|
||||
// ❌ Don't do this
|
||||
describe("module A", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
// tests...
|
||||
});
|
||||
|
||||
describe("module B", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
// tests...
|
||||
});
|
||||
```
|
||||
|
||||
### The Solution
|
||||
|
||||
Use setupTestEnvironment() for consistent cleanup:
|
||||
|
||||
```typescript
|
||||
// ✅ Do this
|
||||
import { setupTestEnvironment } from "@/lib/testing/setup";
|
||||
|
||||
setupTestEnvironment();
|
||||
|
||||
describe("module", () => {
|
||||
test("should work", () => {
|
||||
// Cleanup is automatic
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
|
||||
- Clears all mocks before and after each test
|
||||
- Provides consistent test isolation
|
||||
- One line replaces repetitive setup code
|
||||
|
||||
---
|
||||
|
||||
## Concept 4: Mock Factories — Reduce Mock Setup from 40+ Lines to 1
|
||||
|
||||
### The Problem
|
||||
|
||||
Massive repetitive mock setup:
|
||||
|
||||
```typescript
|
||||
// ❌ Don't do this (40+ lines)
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
contactAttribute: {
|
||||
findMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
contactAttributeKey: {
|
||||
findMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
### The Solution
|
||||
|
||||
Use mock factories:
|
||||
|
||||
```typescript
|
||||
// ✅ Do this (1 line)
|
||||
import { createContactsMocks } from "@/lib/testing/mocks";
|
||||
|
||||
vi.mock("@formbricks/database", () => createContactsMocks());
|
||||
```
|
||||
|
||||
**Available factories:**
|
||||
|
||||
- `createContactsMocks()` — Contact operations (contact, contactAttribute, contactAttributeKey)
|
||||
- `createQuotasMocks()` — Quota operations
|
||||
- `createSurveysMocks()` — Survey and response operations
|
||||
|
||||
### Error Testing with Mock Factories
|
||||
|
||||
**Use COMMON_ERRORS for standardized error tests:**
|
||||
|
||||
```typescript
|
||||
// ❌ Don't do this (10+ lines per error)
|
||||
const error = new Prisma.PrismaClientKnownRequestError("Not found", {
|
||||
code: "P2025",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.contact.findUnique).mockRejectedValue(error);
|
||||
|
||||
await expect(getContact("invalid")).rejects.toThrow();
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ✅ Do this (1 line)
|
||||
import { COMMON_ERRORS } from "@/lib/testing/mocks";
|
||||
|
||||
vi.mocked(prisma.contact.findUnique).mockRejectedValue(COMMON_ERRORS.RECORD_NOT_FOUND);
|
||||
|
||||
await expect(getContact("invalid")).rejects.toThrow();
|
||||
```
|
||||
|
||||
**Available errors:**
|
||||
|
||||
```
|
||||
COMMON_ERRORS.UNIQUE_CONSTRAINT // P2002
|
||||
COMMON_ERRORS.RECORD_NOT_FOUND // P2025
|
||||
COMMON_ERRORS.FOREIGN_KEY // P2003
|
||||
COMMON_ERRORS.REQUIRED_RELATION // P2014
|
||||
COMMON_ERRORS.DATABASE_ERROR // P5000
|
||||
```
|
||||
|
||||
### Transaction Testing with Mock Factories
|
||||
|
||||
**Use createMockTransaction() for complex database transactions:**
|
||||
|
||||
```typescript
|
||||
// ❌ Don't do this (25+ lines)
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
$transaction: vi.fn(async (cb) => {
|
||||
return cb({
|
||||
responseQuotaLink: {
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
});
|
||||
}),
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ✅ Do this (3 lines)
|
||||
import { createMockTransaction, mockPrismaTransaction } from "@/lib/testing/mocks";
|
||||
|
||||
const mockTx = createMockTransaction({
|
||||
responseQuotaLink: ["deleteMany", "createMany", "updateMany"],
|
||||
});
|
||||
vi.mocked(prisma.$transaction) = mockPrismaTransaction(mockTx);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Real-World Example: Efficient Test Suite
|
||||
|
||||
Here's how the utilities work together to write clean, efficient tests:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { vi } from "vitest";
|
||||
import { FIXTURES, TEST_IDS } from "@/lib/testing/constants";
|
||||
import { COMMON_ERRORS, createContactsMocks } from "@/lib/testing/mocks";
|
||||
import { setupTestEnvironment } from "@/lib/testing/setup";
|
||||
|
||||
setupTestEnvironment();
|
||||
vi.mock("@formbricks/database", () => createContactsMocks());
|
||||
|
||||
describe("ContactService", () => {
|
||||
describe("getContact", () => {
|
||||
test("should fetch contact successfully", async () => {
|
||||
vi.mocked(prisma.contact.findUnique).mockResolvedValue(FIXTURES.contact);
|
||||
|
||||
const result = await getContact(TEST_IDS.contact);
|
||||
|
||||
expect(result).toEqual(FIXTURES.contact);
|
||||
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: TEST_IDS.contact },
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle contact not found", async () => {
|
||||
vi.mocked(prisma.contact.findUnique).mockRejectedValue(COMMON_ERRORS.RECORD_NOT_FOUND);
|
||||
|
||||
await expect(getContact(TEST_IDS.contact)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createContact", () => {
|
||||
test("should create contact with valid data", async () => {
|
||||
vi.mocked(prisma.contact.create).mockResolvedValue(FIXTURES.contact);
|
||||
|
||||
const result = await createContact({
|
||||
email: FIXTURES.contact.email,
|
||||
environmentId: TEST_IDS.environment,
|
||||
});
|
||||
|
||||
expect(result).toEqual(FIXTURES.contact);
|
||||
});
|
||||
|
||||
test("should reject duplicate email", async () => {
|
||||
vi.mocked(prisma.contact.create).mockRejectedValue(COMMON_ERRORS.UNIQUE_CONSTRAINT);
|
||||
|
||||
await expect(
|
||||
createContact({ email: "duplicate@test.com", environmentId: TEST_IDS.environment })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteContact", () => {
|
||||
test("should delete contact and return void", async () => {
|
||||
vi.mocked(prisma.contact.delete).mockResolvedValue(undefined);
|
||||
|
||||
await deleteContact(TEST_IDS.contact);
|
||||
|
||||
expect(prisma.contact.delete).toHaveBeenCalledWith({
|
||||
where: { id: TEST_IDS.contact },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to Use — Import Options
|
||||
|
||||
### Option 1: From vitestSetup (Recommended)
|
||||
|
||||
```typescript
|
||||
import { COMMON_ERRORS, FIXTURES, TEST_IDS, createContactsMocks, setupTestEnvironment } from "@/vitestSetup";
|
||||
```
|
||||
|
||||
### Option 2: Direct Imports
|
||||
|
||||
```typescript
|
||||
import { FIXTURES, TEST_IDS } from "@/lib/testing/constants";
|
||||
import { COMMON_ERRORS, createContactsMocks } from "@/lib/testing/mocks";
|
||||
import { setupTestEnvironment } from "@/lib/testing/setup";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
apps/web/lib/testing/
|
||||
├── constants.ts — TEST_IDS & FIXTURES
|
||||
├── setup.ts — setupTestEnvironment()
|
||||
└── mocks/ — Mock factories & error utilities
|
||||
├── database.ts — createContactsMocks(), etc.
|
||||
├── errors.ts — COMMON_ERRORS, error factories
|
||||
├── transactions.ts — Transaction helpers
|
||||
└── index.ts — Exports everything
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary: What Each Concept Solves
|
||||
|
||||
| Concept | Problem | Solution |
|
||||
| -------------------------- | ---------------------------------------- | --------------------------- |
|
||||
| **TEST_IDs** | Magic strings scattered everywhere | One constant per concept |
|
||||
| **FIXTURES** | Duplicate test data in every test | Pre-built, reusable objects |
|
||||
| **setupTestEnvironment()** | Inconsistent cleanup patterns | One standard setup |
|
||||
| **Mock Factories** | 20-40 lines of boilerplate per test file | 1 line mock setup |
|
||||
|
||||
---
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
### ✅ Do's
|
||||
|
||||
- Use `TEST_IDS.*` instead of hardcoded strings
|
||||
- Use `FIXTURES.*` for standard test objects
|
||||
- Call `setupTestEnvironment()` at the top of your test file
|
||||
- Use `createContactsMocks()` instead of manually mocking prisma
|
||||
- Use `COMMON_ERRORS.*` for standard error scenarios
|
||||
- Import utilities from `@/vitestSetup` for convenience
|
||||
|
||||
### ❌ Don'ts
|
||||
|
||||
- Don't create magic string IDs in tests
|
||||
- Don't duplicate fixture objects across tests
|
||||
- Don't manually write beforeEach/afterEach cleanup
|
||||
- Don't manually construct Prisma error objects
|
||||
- Don't duplicate long mock setup code
|
||||
- Don't create custom mock structures when factories exist
|
||||
|
||||
---
|
||||
|
||||
## Need More Help?
|
||||
|
||||
- **Mock Factories** → See `mocks/database.ts`, `mocks/errors.ts`, `mocks/transactions.ts`
|
||||
- **All Available Fixtures** → See `constants.ts`
|
||||
- **Error Codes** → See `mocks/errors.ts` for all COMMON_ERRORS
|
||||
- **Mock Setup Pattern** → Review `apps/web/modules/ee/contacts/lib/contacts.test.ts` for a complete example
|
||||
126
apps/web/lib/testing/constants.ts
Normal file
126
apps/web/lib/testing/constants.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
|
||||
/**
|
||||
* Standard test IDs to eliminate magic strings across test files.
|
||||
* Use these constants instead of hardcoded IDs like "contact-1", "env-123", etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { TEST_IDS } from "@/lib/testing/constants";
|
||||
*
|
||||
* test("should fetch contact", async () => {
|
||||
* const result = await getContact(TEST_IDS.contact);
|
||||
* expect(result).toBeDefined();
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export const TEST_IDS = {
|
||||
contact: "contact-123",
|
||||
contactAlt: "contact-456",
|
||||
user: "user-123",
|
||||
environment: "env-123",
|
||||
survey: "survey-123",
|
||||
organization: "org-123",
|
||||
quota: "quota-123",
|
||||
attribute: "attr-123",
|
||||
response: "response-123",
|
||||
team: "team-123",
|
||||
project: "project-123",
|
||||
segment: "segment-123",
|
||||
webhook: "webhook-123",
|
||||
apiKey: "api-key-123",
|
||||
membership: "membership-123",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Common test fixtures to reduce duplicate test data definitions.
|
||||
* Extend these as needed for your specific test cases.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { FIXTURES } from "@/lib/testing/constants";
|
||||
*
|
||||
* test("should create contact", async () => {
|
||||
* vi.mocked(getContactAttributeKeys).mockResolvedValue(FIXTURES.attributeKeys);
|
||||
* const result = await createContact(FIXTURES.contact);
|
||||
* expect(result.email).toBe(FIXTURES.contact.email);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export const FIXTURES = {
|
||||
contact: {
|
||||
id: TEST_IDS.contact,
|
||||
environmentId: TEST_IDS.environment,
|
||||
userId: TEST_IDS.user,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
attributes: [
|
||||
{ value: "test@example.com", attributeKey: { key: "email", name: "Email" } },
|
||||
{ value: TEST_IDS.user, attributeKey: { key: "userId", name: "User ID" } },
|
||||
],
|
||||
},
|
||||
|
||||
survey: {
|
||||
id: TEST_IDS.survey,
|
||||
name: "Test Survey",
|
||||
environmentId: TEST_IDS.environment,
|
||||
},
|
||||
|
||||
attributeKey: {
|
||||
id: TEST_IDS.attribute,
|
||||
key: "email",
|
||||
name: "Email",
|
||||
environmentId: TEST_IDS.environment,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
isUnique: false,
|
||||
description: null,
|
||||
type: "default" as const,
|
||||
},
|
||||
|
||||
attributeKeys: [
|
||||
{
|
||||
id: "key-1",
|
||||
key: "email",
|
||||
name: "Email",
|
||||
environmentId: TEST_IDS.environment,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
isUnique: false,
|
||||
description: null,
|
||||
type: "default",
|
||||
},
|
||||
{
|
||||
id: "key-2",
|
||||
key: "name",
|
||||
name: "Name",
|
||||
environmentId: TEST_IDS.environment,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
isUnique: false,
|
||||
description: null,
|
||||
type: "default",
|
||||
},
|
||||
] as TContactAttributeKey[],
|
||||
|
||||
responseData: {
|
||||
q1: "Open text answer",
|
||||
q2: "Option 1",
|
||||
},
|
||||
|
||||
environment: {
|
||||
id: TEST_IDS.environment,
|
||||
name: "Test Environment",
|
||||
type: "development" as const,
|
||||
},
|
||||
|
||||
organization: {
|
||||
id: TEST_IDS.organization,
|
||||
name: "Test Organization",
|
||||
},
|
||||
|
||||
project: {
|
||||
id: TEST_IDS.project,
|
||||
name: "Test Project",
|
||||
},
|
||||
} as const;
|
||||
299
apps/web/lib/testing/mocks/README.md
Normal file
299
apps/web/lib/testing/mocks/README.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# Mock Factories & Error Utilities
|
||||
|
||||
Centralized mock factories and error utilities to eliminate 150+ redundant mock setups and standardize error testing across test files.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Database Mocks
|
||||
|
||||
```typescript
|
||||
import { createContactsMocks, COMMON_ERRORS } from "@/lib/testing/mocks";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Setup contacts mocks (replaces 30+ lines)
|
||||
vi.mock("@formbricks/database", () => createContactsMocks());
|
||||
|
||||
describe("ContactService", () => {
|
||||
test("handles not found error", async () => {
|
||||
vi.mocked(prisma.contact.findUnique).mockRejectedValue(COMMON_ERRORS.RECORD_NOT_FOUND);
|
||||
|
||||
await expect(getContact("id")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Transaction Mocks
|
||||
|
||||
```typescript
|
||||
import { createMockTransaction, mockPrismaTransaction } from "@/lib/testing/mocks";
|
||||
|
||||
const mockTx = createMockTransaction({
|
||||
responseQuotaLink: ["deleteMany", "createMany", "updateMany", "count", "groupBy"],
|
||||
});
|
||||
|
||||
vi.mocked(prisma.$transaction) = mockPrismaTransaction(mockTx);
|
||||
```
|
||||
|
||||
### Error Testing
|
||||
|
||||
```typescript
|
||||
import { createPrismaError, COMMON_ERRORS, MockValidationError } from "@/lib/testing/mocks";
|
||||
|
||||
// Use pre-built errors
|
||||
vi.mocked(fn).mockRejectedValue(COMMON_ERRORS.UNIQUE_CONSTRAINT);
|
||||
|
||||
// Or create custom errors
|
||||
vi.mocked(fn).mockRejectedValue(createPrismaError("P2002", "Email already exists"));
|
||||
|
||||
// Or use Formbricks domain errors
|
||||
vi.mocked(fn).mockRejectedValue(new MockNotFoundError("Contact"));
|
||||
```
|
||||
|
||||
## Available Utilities
|
||||
|
||||
### Database Mocks
|
||||
|
||||
#### `createContactsMocks()`
|
||||
Complete mock setup for contact operations.
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
contactAttribute: {
|
||||
findMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
// ... 10+ more methods
|
||||
},
|
||||
contactAttributeKey: {
|
||||
// ... 6+ methods
|
||||
},
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
import { createContactsMocks } from "@/lib/testing/mocks";
|
||||
|
||||
vi.mock("@formbricks/database", () => createContactsMocks());
|
||||
```
|
||||
|
||||
#### `createQuotasMocks()`
|
||||
Complete mock setup for quota operations with transactions.
|
||||
|
||||
#### `createSurveysMocks()`
|
||||
Complete mock setup for survey and response operations.
|
||||
|
||||
#### Individual Mock Methods
|
||||
If you need more control, use individual mock method factories:
|
||||
- `mockContactMethods()`
|
||||
- `mockContactAttributeMethods()`
|
||||
- `mockContactAttributeKeyMethods()`
|
||||
- `mockResponseQuotaLinkMethods()`
|
||||
- `mockSurveyMethods()`
|
||||
- `mockResponseMethods()`
|
||||
|
||||
### Error Utilities
|
||||
|
||||
#### `createPrismaError(code, message?)`
|
||||
Factory to create Prisma errors with specific codes.
|
||||
|
||||
```typescript
|
||||
import { createPrismaError } from "@/lib/testing/mocks";
|
||||
|
||||
vi.mocked(prisma.contact.create).mockRejectedValue(
|
||||
createPrismaError("P2002", "Email already exists")
|
||||
);
|
||||
```
|
||||
|
||||
**Common Prisma Error Codes:**
|
||||
- `P2002` - Unique constraint violation
|
||||
- `P2025` - Record not found
|
||||
- `P2003` - Foreign key constraint
|
||||
- `P2014` - Required relation violation
|
||||
|
||||
#### `COMMON_ERRORS`
|
||||
Pre-built common error instances for convenience.
|
||||
|
||||
```typescript
|
||||
import { COMMON_ERRORS } from "@/lib/testing/mocks";
|
||||
|
||||
// Available:
|
||||
// COMMON_ERRORS.UNIQUE_CONSTRAINT
|
||||
// COMMON_ERRORS.RECORD_NOT_FOUND
|
||||
// COMMON_ERRORS.FOREIGN_KEY
|
||||
// COMMON_ERRORS.REQUIRED_RELATION
|
||||
// COMMON_ERRORS.DATABASE_ERROR
|
||||
```
|
||||
|
||||
#### Domain Error Classes
|
||||
Mock implementations of Formbricks domain errors:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
MockValidationError,
|
||||
MockDatabaseError,
|
||||
MockNotFoundError,
|
||||
MockAuthorizationError,
|
||||
} from "@/lib/testing/mocks";
|
||||
|
||||
vi.mocked(validateInputs).mockRejectedValue(new MockValidationError("Invalid email"));
|
||||
vi.mocked(getContact).mockRejectedValue(new MockNotFoundError("Contact"));
|
||||
vi.mocked(updateContact).mockRejectedValue(new MockAuthorizationError());
|
||||
```
|
||||
|
||||
### Transaction Mocks
|
||||
|
||||
#### `createMockTransaction(structure)`
|
||||
Dynamically create transaction mock objects.
|
||||
|
||||
```typescript
|
||||
import { createMockTransaction } from "@/lib/testing/mocks";
|
||||
|
||||
const mockTx = createMockTransaction({
|
||||
responseQuotaLink: ["deleteMany", "createMany", "updateMany"],
|
||||
contact: ["findMany", "create"],
|
||||
response: ["count"],
|
||||
});
|
||||
|
||||
// Now you have:
|
||||
// mockTx.responseQuotaLink.deleteMany, mockTx.responseQuotaLink.createMany, etc.
|
||||
// mockTx.contact.findMany, mockTx.contact.create, etc.
|
||||
// mockTx.response.count, etc.
|
||||
```
|
||||
|
||||
#### `mockPrismaTransaction(mockTx)`
|
||||
Wrap transaction mock for use with `prisma.$transaction`.
|
||||
|
||||
```typescript
|
||||
import { createMockTransaction, mockPrismaTransaction } from "@/lib/testing/mocks";
|
||||
|
||||
const mockTx = createMockTransaction({
|
||||
responseQuotaLink: ["deleteMany", "createMany"],
|
||||
});
|
||||
|
||||
vi.mocked(prisma.$transaction) = mockPrismaTransaction(mockTx);
|
||||
```
|
||||
|
||||
#### Pre-configured Mocks
|
||||
Ready-to-use transaction mocks:
|
||||
- `quotaTransactionMock` - For quota operations
|
||||
- `contactTransactionMock` - For contact operations
|
||||
- `responseTransactionMock` - For response operations
|
||||
|
||||
```typescript
|
||||
import { quotaTransactionMock, mockPrismaTransaction } from "@/lib/testing/mocks";
|
||||
|
||||
vi.mocked(prisma.$transaction) = mockPrismaTransaction(quotaTransactionMock);
|
||||
```
|
||||
|
||||
#### `sequenceTransactionMocks(txMocks[])`
|
||||
Handle multiple sequential transaction calls with different structures.
|
||||
|
||||
```typescript
|
||||
import { createMockTransaction, sequenceTransactionMocks } from "@/lib/testing/mocks";
|
||||
|
||||
const tx1 = createMockTransaction({ contact: ["findMany"] });
|
||||
const tx2 = createMockTransaction({ response: ["count"] });
|
||||
|
||||
vi.mocked(prisma.$transaction) = sequenceTransactionMocks([tx1, tx2]);
|
||||
|
||||
// First $transaction call gets tx1, second call gets tx2
|
||||
```
|
||||
|
||||
## Impact Summary
|
||||
|
||||
- **Duplicate Mock Setups:** 150+ reduced to 1 line
|
||||
- **Error Testing:** 100+ test cases standardized
|
||||
- **Transaction Mocks:** 15+ complex setups simplified
|
||||
- **Test Readability:** 40-50% cleaner test code
|
||||
- **Setup Time:** 90% reduction for database tests
|
||||
|
||||
## Migration Example
|
||||
|
||||
### Before (40+ lines)
|
||||
|
||||
```typescript
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
$transaction: vi.fn(),
|
||||
responseQuotaLink: {
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
groupBy: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("QuotaService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("handles quota not found", async () => {
|
||||
const error = new Prisma.PrismaClientKnownRequestError("Not found", {
|
||||
code: "P2025",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.responseQuotaLink.count).mockRejectedValue(error);
|
||||
|
||||
await expect(getQuota("id")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### After (20 lines)
|
||||
|
||||
```typescript
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { setupTestEnvironment } from "@/lib/testing/setup";
|
||||
import { createQuotasMocks, COMMON_ERRORS } from "@/lib/testing/mocks";
|
||||
import { vi } from "vitest";
|
||||
|
||||
setupTestEnvironment();
|
||||
vi.mock("@formbricks/database", () => createQuotasMocks());
|
||||
|
||||
describe("QuotaService", () => {
|
||||
test("handles quota not found", async () => {
|
||||
vi.mocked(prisma.responseQuotaLink.count).mockRejectedValue(COMMON_ERRORS.RECORD_NOT_FOUND);
|
||||
|
||||
await expect(getQuota("id")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
✅ 50% reduction in mock setup code
|
||||
✅ Standardized error testing across files
|
||||
✅ Easier test maintenance
|
||||
✅ Better test readability
|
||||
✅ Consistent patterns across the codebase
|
||||
✅ Less boilerplate per test file
|
||||
|
||||
## What's Next?
|
||||
|
||||
Phase 3 will introduce:
|
||||
- Custom Vitest matchers for consistent assertions
|
||||
- Comprehensive testing standards documentation
|
||||
- Team training materials
|
||||
|
||||
See the main testing analysis documents in the repository root for the full roadmap.
|
||||
|
||||
134
apps/web/lib/testing/mocks/database.ts
Normal file
134
apps/web/lib/testing/mocks/database.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Mock methods for contact operations.
|
||||
* Used to mock prisma.contact in database operations.
|
||||
*/
|
||||
export const mockContactMethods = () => ({
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Mock methods for contact attribute operations.
|
||||
* Used to mock prisma.contactAttribute in database operations.
|
||||
*/
|
||||
export const mockContactAttributeMethods = () => ({
|
||||
findMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Mock methods for contact attribute key operations.
|
||||
* Used to mock prisma.contactAttributeKey in database operations.
|
||||
*/
|
||||
export const mockContactAttributeKeyMethods = () => ({
|
||||
findMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Mock methods for response quota link operations.
|
||||
* Used to mock prisma.responseQuotaLink in database operations.
|
||||
*/
|
||||
export const mockResponseQuotaLinkMethods = () => ({
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
groupBy: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Complete mock setup for contacts module.
|
||||
* Reduces 20-30 lines of mock setup per test file to 1 line.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createContactsMocks } from "@/lib/testing/mocks";
|
||||
* import { vi } from "vitest";
|
||||
*
|
||||
* vi.mock("@formbricks/database", () => createContactsMocks());
|
||||
* ```
|
||||
*/
|
||||
export function createContactsMocks() {
|
||||
return {
|
||||
prisma: {
|
||||
contact: mockContactMethods(),
|
||||
contactAttribute: mockContactAttributeMethods(),
|
||||
contactAttributeKey: mockContactAttributeKeyMethods(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete mock setup for quotas module.
|
||||
* Reduces 30-40 lines of mock setup per test file to 1 line.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createQuotasMocks } from "@/lib/testing/mocks";
|
||||
* import { vi } from "vitest";
|
||||
*
|
||||
* vi.mock("@formbricks/database", () => createQuotasMocks());
|
||||
* ```
|
||||
*/
|
||||
export function createQuotasMocks() {
|
||||
return {
|
||||
prisma: {
|
||||
$transaction: vi.fn(),
|
||||
responseQuotaLink: mockResponseQuotaLinkMethods(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock methods for survey operations.
|
||||
*/
|
||||
export const mockSurveyMethods = () => ({
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Mock methods for response operations.
|
||||
*/
|
||||
export const mockResponseMethods = () => ({
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Complete mock setup for surveys module.
|
||||
*/
|
||||
export function createSurveysMocks() {
|
||||
return {
|
||||
prisma: {
|
||||
survey: mockSurveyMethods(),
|
||||
response: mockResponseMethods(),
|
||||
},
|
||||
};
|
||||
}
|
||||
102
apps/web/lib/testing/mocks/errors.ts
Normal file
102
apps/web/lib/testing/mocks/errors.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Factory function to create Prisma errors with a specific error code and message.
|
||||
* Eliminates 100+ lines of repetitive Prisma error setup across test files.
|
||||
*
|
||||
* @param code - The Prisma error code (e.g., "P2002", "P2025")
|
||||
* @param message - Optional error message (defaults to "Database error")
|
||||
* @returns A PrismaClientKnownRequestError instance
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createPrismaError } from "@/lib/testing/mocks";
|
||||
*
|
||||
* vi.mocked(prisma.contact.findMany).mockRejectedValue(
|
||||
* createPrismaError("P2002", "Unique constraint failed")
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function createPrismaError(code: string, message = "Database error") {
|
||||
return new Prisma.PrismaClientKnownRequestError(message, {
|
||||
code,
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-built common Prisma errors for convenience.
|
||||
* Use these instead of creating errors manually every time.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { COMMON_ERRORS } from "@/lib/testing/mocks";
|
||||
*
|
||||
* vi.mocked(prisma.contact.findUnique).mockRejectedValue(
|
||||
* COMMON_ERRORS.RECORD_NOT_FOUND
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export const COMMON_ERRORS = {
|
||||
// P2002: Unique constraint failed
|
||||
UNIQUE_CONSTRAINT: createPrismaError("P2002", "Unique constraint violation"),
|
||||
|
||||
// P2025: Record not found
|
||||
RECORD_NOT_FOUND: createPrismaError("P2025", "Record not found"),
|
||||
|
||||
// P2003: Foreign key constraint failed
|
||||
FOREIGN_KEY: createPrismaError("P2003", "Foreign key constraint failed"),
|
||||
|
||||
// P2014: Required relation violation
|
||||
REQUIRED_RELATION: createPrismaError("P2014", "Required relation violation"),
|
||||
|
||||
// Generic database error
|
||||
DATABASE_ERROR: createPrismaError("P5000", "Database connection error"),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Validation error mock for non-database validation failures.
|
||||
* Use this for validation errors in service layers.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { ValidationError } from "@formbricks/types/errors";
|
||||
*
|
||||
* vi.mocked(validateInputs).mockImplementation(() => {
|
||||
* throw new ValidationError("Invalid input");
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class MockValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error types that match Formbricks domain errors.
|
||||
*/
|
||||
export class MockDatabaseError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DatabaseError";
|
||||
}
|
||||
}
|
||||
|
||||
export class MockNotFoundError extends Error {
|
||||
constructor(entity: string) {
|
||||
super(`${entity} not found`);
|
||||
this.name = "NotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class MockAuthorizationError extends Error {
|
||||
constructor(message = "Unauthorized") {
|
||||
super(message);
|
||||
this.name = "AuthorizationError";
|
||||
}
|
||||
}
|
||||
49
apps/web/lib/testing/mocks/index.ts
Normal file
49
apps/web/lib/testing/mocks/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Centralized mock exports for all testing utilities.
|
||||
*
|
||||
* Import only what you need:
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createContactsMocks } from "@/lib/testing/mocks";
|
||||
* import { COMMON_ERRORS, createPrismaError } from "@/lib/testing/mocks";
|
||||
* import { createMockTransaction, mockPrismaTransaction } from "@/lib/testing/mocks";
|
||||
* ```
|
||||
*
|
||||
* Or import everything:
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import * as mocks from "@/lib/testing/mocks";
|
||||
* ```
|
||||
*/
|
||||
|
||||
export {
|
||||
createContactsMocks,
|
||||
createQuotasMocks,
|
||||
createSurveysMocks,
|
||||
mockContactMethods,
|
||||
mockContactAttributeMethods,
|
||||
mockContactAttributeKeyMethods,
|
||||
mockResponseQuotaLinkMethods,
|
||||
mockSurveyMethods,
|
||||
mockResponseMethods,
|
||||
} from "./database";
|
||||
|
||||
export {
|
||||
createPrismaError,
|
||||
COMMON_ERRORS,
|
||||
MockValidationError,
|
||||
MockDatabaseError,
|
||||
MockNotFoundError,
|
||||
MockAuthorizationError,
|
||||
} from "./errors";
|
||||
|
||||
export {
|
||||
createMockTransaction,
|
||||
mockPrismaTransaction,
|
||||
quotaTransactionMock,
|
||||
contactTransactionMock,
|
||||
responseTransactionMock,
|
||||
sequenceTransactionMocks,
|
||||
} from "./transactions";
|
||||
123
apps/web/lib/testing/mocks/transactions.ts
Normal file
123
apps/web/lib/testing/mocks/transactions.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Factory to dynamically create mock transaction objects with specified methods.
|
||||
* Eliminates complex, repetitive transaction mock setup across test files.
|
||||
*
|
||||
* @param structure - Object mapping namespaces to arrays of method names
|
||||
* @returns Mock transaction object with all specified methods as vi.fn()
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createMockTransaction } from "@/lib/testing/mocks";
|
||||
*
|
||||
* const mockTx = createMockTransaction({
|
||||
* responseQuotaLink: ["deleteMany", "createMany", "updateMany", "count", "groupBy"],
|
||||
* contact: ["findMany", "create"],
|
||||
* });
|
||||
*
|
||||
* // Now you have:
|
||||
* // mockTx.responseQuotaLink.deleteMany, mockTx.responseQuotaLink.createMany, etc.
|
||||
* // mockTx.contact.findMany, mockTx.contact.create, etc.
|
||||
* ```
|
||||
*/
|
||||
export function createMockTransaction(structure: Record<string, string[]>) {
|
||||
return Object.entries(structure).reduce(
|
||||
(acc, [namespace, methods]) => {
|
||||
acc[namespace] = methods.reduce(
|
||||
(methodAcc, method) => {
|
||||
methodAcc[method] = vi.fn();
|
||||
return methodAcc;
|
||||
},
|
||||
{} as Record<string, ReturnType<typeof vi.fn>>
|
||||
);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Record<string, ReturnType<typeof vi.fn>>>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock Prisma $transaction wrapper.
|
||||
* Passes the transaction object to the callback function.
|
||||
*
|
||||
* @param mockTx - The mock transaction object
|
||||
* @returns A vi.fn() that mocks prisma.$transaction
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createMockTransaction, mockPrismaTransaction } from "@/lib/testing/mocks";
|
||||
*
|
||||
* const mockTx = createMockTransaction({
|
||||
* responseQuotaLink: ["deleteMany", "createMany"],
|
||||
* });
|
||||
*
|
||||
* vi.mocked(prisma.$transaction) = mockPrismaTransaction(mockTx);
|
||||
*
|
||||
* // Now when code calls prisma.$transaction(async (tx) => { ... })
|
||||
* // the tx parameter will be mockTx
|
||||
* ```
|
||||
*/
|
||||
export function mockPrismaTransaction(mockTx: any) {
|
||||
return vi.fn(async (cb: any) => cb(mockTx));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-configured transaction mock for quota operations.
|
||||
* Use this when testing quota-related database transactions.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { quotaTransactionMock } from "@/lib/testing/mocks";
|
||||
*
|
||||
* vi.mocked(prisma.$transaction) = quotaTransactionMock;
|
||||
* ```
|
||||
*/
|
||||
export const quotaTransactionMock = createMockTransaction({
|
||||
responseQuotaLink: ["deleteMany", "createMany", "updateMany", "count", "groupBy"],
|
||||
});
|
||||
|
||||
/**
|
||||
* Pre-configured transaction mock for contact operations.
|
||||
*/
|
||||
export const contactTransactionMock = createMockTransaction({
|
||||
contact: ["findMany", "create", "update", "delete"],
|
||||
contactAttribute: ["findMany", "create", "update", "deleteMany"],
|
||||
contactAttributeKey: ["findMany", "create"],
|
||||
});
|
||||
|
||||
/**
|
||||
* Pre-configured transaction mock for response operations.
|
||||
*/
|
||||
export const responseTransactionMock = createMockTransaction({
|
||||
response: ["findMany", "create", "update", "delete", "count"],
|
||||
responseQuotaLink: ["create", "deleteMany", "updateMany"],
|
||||
});
|
||||
|
||||
/**
|
||||
* Utility to configure multiple transaction return values in sequence.
|
||||
* Useful when code makes multiple calls to $transaction with different structures.
|
||||
*
|
||||
* @param txMocks - Array of transaction mock objects
|
||||
* @returns A vi.fn() that returns each mock in sequence
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createMockTransaction, sequenceTransactionMocks } from "@/lib/testing/mocks";
|
||||
*
|
||||
* const tx1 = createMockTransaction({ contact: ["findMany"] });
|
||||
* const tx2 = createMockTransaction({ response: ["count"] });
|
||||
*
|
||||
* vi.mocked(prisma.$transaction) = sequenceTransactionMocks([tx1, tx2]);
|
||||
*
|
||||
* // First call gets tx1, second call gets tx2
|
||||
* ```
|
||||
*/
|
||||
export function sequenceTransactionMocks(txMocks: any[]) {
|
||||
let callCount = 0;
|
||||
return vi.fn(async (cb: any) => {
|
||||
const currentMock = txMocks[callCount];
|
||||
callCount++;
|
||||
return cb(currentMock);
|
||||
});
|
||||
}
|
||||
31
apps/web/lib/testing/setup.ts
Normal file
31
apps/web/lib/testing/setup.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Standard test environment setup with consistent cleanup patterns.
|
||||
* Call this function once at the top of your test file to ensure
|
||||
* mocks are properly cleaned up between tests.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { setupTestEnvironment } from "@/lib/testing/setup";
|
||||
*
|
||||
* setupTestEnvironment();
|
||||
*
|
||||
* describe("MyModule", () => {
|
||||
* test("should work correctly", () => {
|
||||
* // Your test code here
|
||||
* });
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Note: This replaces manual beforeEach/afterEach blocks in individual test files.
|
||||
*/
|
||||
export function setupTestEnvironment() {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatDistance, intlFormat } from "date-fns";
|
||||
import { de, enUS, fr, ja, pt, ptBR, ro, zhCN, zhTW } from "date-fns/locale";
|
||||
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, zhCN, zhTW } from "date-fns/locale";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
export const convertDateString = (dateString: string | null) => {
|
||||
@@ -91,6 +91,8 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
|
||||
return ptBR;
|
||||
case "fr-FR":
|
||||
return fr;
|
||||
case "nl-NL":
|
||||
return nl;
|
||||
case "zh-Hant-TW":
|
||||
return zhTW;
|
||||
case "pt-PT":
|
||||
@@ -101,6 +103,8 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
|
||||
return ja;
|
||||
case "zh-Hans-CN":
|
||||
return zhCN;
|
||||
case "es-ES":
|
||||
return es;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ describe("recall utility functions", () => {
|
||||
const headline = { en: "How do you like #recall:product/fallback:ournbspproduct#?" };
|
||||
const survey = {
|
||||
id: "test-survey",
|
||||
blocks: [{ id: "b1", elements: [{ id: "product", headline: { en: "Product Question" } }] }],
|
||||
questions: [{ 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",
|
||||
blocks: [{ id: "b1", elements: [{ id: "product", headline: { en: "Product Question" } }] }],
|
||||
questions: [{ 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",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
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",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
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",
|
||||
blocks: [{ id: "b1", elements: [{ id: "inner", headline: { en: "Inner with @outer" } }] }],
|
||||
questions: [{ id: "inner", headline: { en: "Inner with @outer" } }],
|
||||
hiddenFields: { fieldIds: [] },
|
||||
variables: [],
|
||||
} as any;
|
||||
@@ -241,56 +241,41 @@ describe("recall utility functions", () => {
|
||||
test("identifies question with empty fallback value", () => {
|
||||
const questionHeadline = { en: "Question with #recall:id1/fallback:# empty fallback" };
|
||||
const survey = {
|
||||
blocks: [
|
||||
questions: [
|
||||
{
|
||||
id: "b1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
headline: questionHeadline,
|
||||
},
|
||||
],
|
||||
id: "q1",
|
||||
headline: questionHeadline,
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = checkForEmptyFallBackValue(survey, "en");
|
||||
expect(result).toBe(survey.blocks[0].elements[0]);
|
||||
expect(result).toBe(survey.questions[0]);
|
||||
});
|
||||
|
||||
test("identifies question with empty fallback in subheader", () => {
|
||||
const questionSubheader = { en: "Subheader with #recall:id1/fallback:# empty fallback" };
|
||||
const survey = {
|
||||
blocks: [
|
||||
questions: [
|
||||
{
|
||||
id: "b1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
headline: { en: "Normal question" },
|
||||
subheader: questionSubheader,
|
||||
},
|
||||
],
|
||||
id: "q1",
|
||||
headline: { en: "Normal question" },
|
||||
subheader: questionSubheader,
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = checkForEmptyFallBackValue(survey, "en");
|
||||
expect(result).toBe(survey.blocks[0].elements[0]);
|
||||
expect(result).toBe(survey.questions[0]);
|
||||
});
|
||||
|
||||
test("returns null when no empty fallback values are found", () => {
|
||||
const questionHeadline = { en: "Question with #recall:id1/fallback:default# valid fallback" };
|
||||
const survey = {
|
||||
blocks: [
|
||||
questions: [
|
||||
{
|
||||
id: "b1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
headline: questionHeadline,
|
||||
},
|
||||
],
|
||||
id: "q1",
|
||||
headline: questionHeadline,
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
@@ -303,21 +288,16 @@ describe("recall utility functions", () => {
|
||||
describe("replaceHeadlineRecall", () => {
|
||||
test("processes all questions in a survey", () => {
|
||||
const survey: TSurvey = {
|
||||
blocks: [
|
||||
questions: [
|
||||
{
|
||||
id: "b1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
headline: { en: "Question with #recall:id1/fallback:default#" },
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
headline: { en: "Another with #recall:id2/fallback:other#" },
|
||||
},
|
||||
],
|
||||
id: "q1",
|
||||
headline: { en: "Question with #recall:id1/fallback:default#" },
|
||||
},
|
||||
],
|
||||
{
|
||||
id: "q2",
|
||||
headline: { en: "Another with #recall:id2/fallback:other#" },
|
||||
},
|
||||
] as unknown as TSurveyQuestion[],
|
||||
hiddenFields: { fieldIds: [] },
|
||||
variables: [],
|
||||
} as unknown as TSurvey;
|
||||
@@ -328,8 +308,8 @@ describe("recall utility functions", () => {
|
||||
|
||||
// Verify recallToHeadline was called for each question
|
||||
expect(result).not.toBe(survey); // Should be a clone
|
||||
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);
|
||||
expect(result.questions[0].headline).not.toEqual(survey.questions[0].headline);
|
||||
expect(result.questions[1].headline).not.toEqual(survey.questions[1].headline);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -337,15 +317,10 @@ 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 = {
|
||||
blocks: [
|
||||
{
|
||||
id: "b1",
|
||||
elements: [
|
||||
{ id: "id1", headline: { en: "Question One" } },
|
||||
{ id: "id2", headline: { en: "Question Two" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
questions: [
|
||||
{ id: "id1", headline: { en: "Question One" } },
|
||||
{ id: "id2", headline: { en: "Question Two" } },
|
||||
] as unknown as TSurveyQuestion[],
|
||||
hiddenFields: { fieldIds: [] },
|
||||
variables: [],
|
||||
} as unknown as TSurvey;
|
||||
@@ -364,7 +339,7 @@ describe("recall utility functions", () => {
|
||||
test("handles hidden fields in recall items", () => {
|
||||
const text = "Text with #recall:hidden1/fallback:val1#";
|
||||
const survey: TSurvey = {
|
||||
blocks: [],
|
||||
questions: [],
|
||||
hiddenFields: { fieldIds: ["hidden1"] },
|
||||
variables: [],
|
||||
} as unknown as TSurvey;
|
||||
@@ -379,7 +354,7 @@ describe("recall utility functions", () => {
|
||||
test("handles variables in recall items", () => {
|
||||
const text = "Text with #recall:var1/fallback:val1#";
|
||||
const survey: TSurvey = {
|
||||
blocks: [],
|
||||
questions: [],
|
||||
hiddenFields: { fieldIds: [] },
|
||||
variables: [{ id: "var1", name: "Variable One" }],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TResponseData, TResponseDataValue, TResponseVariables } from "@formbricks/types/responses";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { TI18nString, TSurvey, TSurveyQuestion, 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 {
|
||||
@@ -62,8 +59,7 @@ const getRecallItemLabel = <T extends TSurvey>(
|
||||
const isHiddenField = survey.hiddenFields.fieldIds?.includes(recallItemId);
|
||||
if (isHiddenField) return recallItemId;
|
||||
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
const surveyQuestion = questions.find((question) => question.id === recallItemId);
|
||||
const surveyQuestion = survey.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
|
||||
@@ -126,14 +122,13 @@ 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): TSurveyElement | null => {
|
||||
export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): TSurveyQuestion | null => {
|
||||
const doesTextHaveRecall = (text: string) => {
|
||||
const recalls = text.match(/#recall:[^ ]+/g);
|
||||
return recalls?.some((recall) => !extractFallbackValue(recall));
|
||||
};
|
||||
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
for (const question of questions) {
|
||||
for (const question of survey.questions) {
|
||||
if (
|
||||
doesTextHaveRecall(getLocalizedValue(question.headline, language)) ||
|
||||
(question.subheader && doesTextHaveRecall(getLocalizedValue(question.subheader, language)))
|
||||
@@ -147,8 +142,7 @@ 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);
|
||||
const questions = getElementsFromBlocks(modifiedSurvey.blocks);
|
||||
questions.forEach((question) => {
|
||||
modifiedSurvey.questions.forEach((question) => {
|
||||
question.headline = recallToHeadline(question.headline, modifiedSurvey, false, language);
|
||||
});
|
||||
return modifiedSurvey;
|
||||
@@ -162,8 +156,7 @@ export const getRecallItems = (text: string, survey: TSurvey, languageCode: stri
|
||||
let recallItems: TSurveyRecallItem[] = [];
|
||||
ids.forEach((recallItemId) => {
|
||||
const isHiddenField = survey.hiddenFields.fieldIds?.includes(recallItemId);
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
const isSurveyQuestion = questions.find((question) => question.id === recallItemId);
|
||||
const isSurveyQuestion = survey.questions.find((question) => question.id === recallItemId);
|
||||
const isVariable = survey.variables.find((variable) => variable.id === recallItemId);
|
||||
|
||||
const recallItemLabel = getRecallItemLabel(recallItemId, survey, languageCode);
|
||||
|
||||
@@ -1,119 +1,164 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
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 { 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 { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { replaceElementPresetPlaceholders, replacePresetPlaceholders } from "./templates";
|
||||
import { replacePresetPlaceholders, replaceQuestionPresetPlaceholders } from "./templates";
|
||||
|
||||
vi.mock("@/lib/i18n/utils");
|
||||
vi.mock("@/lib/pollyfills/structuredClone");
|
||||
// Mock the imported functions
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
getLocalizedValue: vi.fn(),
|
||||
}));
|
||||
|
||||
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] || "");
|
||||
});
|
||||
vi.mock("@/lib/pollyfills/structuredClone", () => ({
|
||||
structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
|
||||
}));
|
||||
|
||||
describe("Template Utilities", () => {
|
||||
describe("replaceElementPresetPlaceholders", () => {
|
||||
test("returns original element when project is not provided", () => {
|
||||
const element = {
|
||||
type: "openText",
|
||||
headline: { default: "Question about $[projectName]?" },
|
||||
} as unknown as TSurveyElement;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const result = replaceElementPresetPlaceholders(element, undefined as any);
|
||||
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;
|
||||
|
||||
expect(result).toEqual(element);
|
||||
});
|
||||
const result = replaceQuestionPresetPlaceholders(question, undefined as unknown as TProject);
|
||||
|
||||
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?");
|
||||
expect(result).toEqual(question);
|
||||
expect(structuredClone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("replaces projectName placeholder in subheader", () => {
|
||||
const element = {
|
||||
type: "openText",
|
||||
headline: { default: "Question" },
|
||||
subheader: { default: "Subheader for $[projectName]" },
|
||||
} as unknown as TSurveyElement;
|
||||
const question: TSurveyQuestion = {
|
||||
id: "test-id",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: {
|
||||
default: "Test Question",
|
||||
},
|
||||
subheader: {
|
||||
default: "Subheader for $[projectName]",
|
||||
},
|
||||
} as unknown as TSurveyQuestion;
|
||||
|
||||
const project = {
|
||||
name: "TestProject",
|
||||
const project: TProject = {
|
||||
id: "project-id",
|
||||
name: "Test Project",
|
||||
organizationId: "org-id",
|
||||
} as unknown as TProject;
|
||||
|
||||
const result = replaceElementPresetPlaceholders(element, project);
|
||||
// Mock for headline and subheader with correct return values
|
||||
vi.mocked(getLocalizedValue).mockReturnValueOnce("Test Question");
|
||||
vi.mocked(getLocalizedValue).mockReturnValueOnce("Subheader for $[projectName]");
|
||||
|
||||
expect(result.headline?.default).toBe("Question");
|
||||
expect(result.subheader?.default).toBe("Subheader for TestProject");
|
||||
const result = replaceQuestionPresetPlaceholders(question, project);
|
||||
|
||||
expect(vi.mocked(getLocalizedValue)).toHaveBeenCalledTimes(2);
|
||||
expect(result.subheader?.default).toBe("Subheader for Test Project");
|
||||
});
|
||||
|
||||
test("handles missing headline and subheader", () => {
|
||||
const element = {
|
||||
type: "openText",
|
||||
} as unknown as TSurveyElement;
|
||||
const question: TSurveyQuestion = {
|
||||
id: "test-id",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
} as unknown as TSurveyQuestion;
|
||||
|
||||
const project = {
|
||||
name: "TestProject",
|
||||
const project: TProject = {
|
||||
id: "project-id",
|
||||
name: "Test Project",
|
||||
organizationId: "org-id",
|
||||
} as unknown as TProject;
|
||||
|
||||
const result = replaceElementPresetPlaceholders(element, project);
|
||||
const result = replaceQuestionPresetPlaceholders(question, project);
|
||||
|
||||
expect(structuredClone).toHaveBeenCalledWith(element);
|
||||
expect(result).toEqual(element);
|
||||
expect(structuredClone).toHaveBeenCalledWith(question);
|
||||
expect(result).toEqual(question);
|
||||
expect(getLocalizedValue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("replacePresetPlaceholders", () => {
|
||||
test("replaces projectName placeholder in template name and blocks", () => {
|
||||
const mockTemplate = {
|
||||
name: "Template 1",
|
||||
test("replaces projectName placeholder in template name and questions", () => {
|
||||
const template: TTemplate = {
|
||||
id: "template-1",
|
||||
name: "Test Template",
|
||||
description: "Template Description",
|
||||
preset: {
|
||||
name: "$[projectName] Feedback",
|
||||
welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false },
|
||||
blocks: [
|
||||
questions: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "elem1",
|
||||
type: "openText",
|
||||
headline: { default: "How would you rate $[projectName]?" },
|
||||
required: true,
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
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]",
|
||||
},
|
||||
},
|
||||
],
|
||||
endings: [],
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
},
|
||||
category: "product",
|
||||
} as unknown as TTemplate;
|
||||
|
||||
const project = {
|
||||
name: "TestProject",
|
||||
} as TProject;
|
||||
name: "Awesome App",
|
||||
};
|
||||
|
||||
const result = replacePresetPlaceholders(mockTemplate, project);
|
||||
// Mock getLocalizedValue to return the original strings with placeholders
|
||||
vi.mocked(getLocalizedValue)
|
||||
.mockReturnValueOnce("How do you like $[projectName]?")
|
||||
.mockReturnValueOnce("Another question")
|
||||
.mockReturnValueOnce("About $[projectName]");
|
||||
|
||||
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?");
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,46 +1,37 @@
|
||||
import type { TProject } from "@formbricks/types/project";
|
||||
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import type { TTemplate } from "@formbricks/types/templates";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TTemplate } from "@formbricks/types/templates";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
|
||||
export const replaceElementPresetPlaceholders = (
|
||||
element: TSurveyElement,
|
||||
export const replaceQuestionPresetPlaceholders = (
|
||||
question: TSurveyQuestion,
|
||||
project: TProject
|
||||
): TSurveyElement => {
|
||||
if (!project) return element;
|
||||
const newElement = structuredClone(element);
|
||||
): TSurveyQuestion => {
|
||||
if (!project) return question;
|
||||
const newQuestion = structuredClone(question);
|
||||
const defaultLanguageCode = "default";
|
||||
|
||||
if (newElement.headline) {
|
||||
newElement.headline[defaultLanguageCode] = getLocalizedValue(
|
||||
newElement.headline,
|
||||
if (newQuestion.headline) {
|
||||
newQuestion.headline[defaultLanguageCode] = getLocalizedValue(
|
||||
newQuestion.headline,
|
||||
defaultLanguageCode
|
||||
).replace("$[projectName]", project.name);
|
||||
}
|
||||
|
||||
if (newElement.subheader) {
|
||||
newElement.subheader[defaultLanguageCode] = getLocalizedValue(
|
||||
newElement.subheader,
|
||||
if (newQuestion.subheader) {
|
||||
newQuestion.subheader[defaultLanguageCode] = getLocalizedValue(
|
||||
newQuestion.subheader,
|
||||
defaultLanguageCode
|
||||
)?.replace("$[projectName]", project.name);
|
||||
}
|
||||
|
||||
return newElement;
|
||||
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);
|
||||
|
||||
// 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)),
|
||||
}));
|
||||
}
|
||||
|
||||
preset.questions = preset.questions.map((question) => {
|
||||
return replaceQuestionPresetPlaceholders(question, project);
|
||||
});
|
||||
return { ...template, preset };
|
||||
};
|
||||
|
||||
@@ -124,12 +124,3 @@ 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);
|
||||
};
|
||||
|
||||
@@ -53,9 +53,9 @@ export const I18nProvider = ({ children, language, defaultLanguage }: I18nProvid
|
||||
initializeI18n();
|
||||
}, [locale, defaultLanguage]);
|
||||
|
||||
// Don't render children until i18n is ready to prevent hydration issues
|
||||
// Don't render children until i18n is ready to prevent race conditions
|
||||
if (!isReady) {
|
||||
return <div style={{ visibility: "hidden" }}>{children}</div>;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user