mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-29 09:50:10 -06:00
Compare commits
174 Commits
release/4.
...
chore/merg
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1e5ce6270 | ||
|
|
86cc8fb8ff | ||
|
|
ee56cc10e7 | ||
|
|
7c47299775 | ||
|
|
92f4f04f7c | ||
|
|
e072a0e889 | ||
|
|
4723a428e7 | ||
|
|
d7692a1b76 | ||
|
|
a08f2db40c | ||
|
|
48eb4fe705 | ||
|
|
26a2d50d45 | ||
|
|
da5d9e27e1 | ||
|
|
8e2934d7bb | ||
|
|
799b86801d | ||
|
|
9d77b808d0 | ||
|
|
f57497d8b3 | ||
|
|
007d996870 | ||
|
|
9f59d7a967 | ||
|
|
aab6798b29 | ||
|
|
f07092595f | ||
|
|
c03c7ec1ed | ||
|
|
628de8e6ae | ||
|
|
b9c647ef62 | ||
|
|
7770d43f9a | ||
|
|
615aa6aaad | ||
|
|
f57ca755f4 | ||
|
|
fd37b978c0 | ||
|
|
d40ce9ce84 | ||
|
|
be4b54a827 | ||
|
|
21c63bc400 | ||
|
|
77722aa638 | ||
|
|
2a9897370e | ||
|
|
5e85347bf5 | ||
|
|
e03df83e88 | ||
|
|
e4a9d28b4b | ||
|
|
ed26427302 | ||
|
|
b79703f87e | ||
|
|
567cc4b893 | ||
|
|
d9b37496fc | ||
|
|
87a06c846a | ||
|
|
c5e02a597d | ||
|
|
1fec0ca7a6 | ||
|
|
ae165eac87 | ||
|
|
7de5fdc383 | ||
|
|
3e61d31041 | ||
|
|
d7e537f699 | ||
|
|
554809742b | ||
|
|
1e6c7609b6 | ||
|
|
59438d9afe | ||
|
|
e234ed78cf | ||
|
|
74b168d727 | ||
|
|
2ed2da61cd | ||
|
|
729f269d4e | ||
|
|
28adfb905c | ||
|
|
05c455ed62 | ||
|
|
41776d0001 | ||
|
|
f7687bc0ea | ||
|
|
a2a6870a21 | ||
|
|
3ab62968e5 | ||
|
|
d4f7f0f35d | ||
|
|
af34391309 | ||
|
|
70978fbbdf | ||
|
|
a10cd0cb47 | ||
|
|
45100673f1 | ||
|
|
35f53769a5 | ||
|
|
22ad78a187 | ||
|
|
67076c4b4c | ||
|
|
74bfeb132e | ||
|
|
562b4047ae | ||
|
|
4791018546 | ||
|
|
be1e546729 | ||
|
|
f6683d1165 | ||
|
|
13be7a8970 | ||
|
|
5bad0da477 | ||
|
|
9c776c5e4e | ||
|
|
0472d5e8f0 | ||
|
|
00a61f7abe | ||
|
|
c50b46f715 | ||
|
|
6999abba3b | ||
|
|
9ae66f44ae | ||
|
|
7933d0077a | ||
|
|
ce0a0573be | ||
|
|
cc8289fa33 | ||
|
|
c458051839 | ||
|
|
718a199d5b | ||
|
|
5ab9fdf1e3 | ||
|
|
5741209aa9 | ||
|
|
3e27143ab1 | ||
|
|
018e2883ff | ||
|
|
35d0d8ed54 | ||
|
|
85fb7ca956 | ||
|
|
5bce5c0a3b | ||
|
|
c61212964c | ||
|
|
b8d41a6e9b | ||
|
|
eedd5200a4 | ||
|
|
71a85c7126 | ||
|
|
2258699156 | ||
|
|
b1a7b929bd | ||
|
|
fded9a3bad | ||
|
|
4d84468269 | ||
|
|
341e2639e1 | ||
|
|
e6e010e801 | ||
|
|
8ced882406 | ||
|
|
f7d462cc7f | ||
|
|
056470e6f0 | ||
|
|
e965ad4b97 | ||
|
|
f3d679d087 | ||
|
|
c79a600efc | ||
|
|
7a8da3b84b | ||
|
|
4b2d48397d | ||
|
|
3ea81dc7c1 | ||
|
|
d9b6b550a9 | ||
|
|
56a6ba08ba | ||
|
|
1ba55ff66c | ||
|
|
0cf621d76c | ||
|
|
3dc615fdc0 | ||
|
|
7157b17901 | ||
|
|
82c26941e4 | ||
|
|
12e703c02b | ||
|
|
591d5fa3d4 | ||
|
|
07065f2675 | ||
|
|
211bca1bd8 | ||
|
|
7ca45cefeb | ||
|
|
5a20839c5b | ||
|
|
85743bd3d0 | ||
|
|
335ec02361 | ||
|
|
7918523957 | ||
|
|
3b5fe4cb94 | ||
|
|
6bbd5ec7ef | ||
|
|
c9542dcf79 | ||
|
|
4277a9dc34 | ||
|
|
b1da63e47d | ||
|
|
8c05154a86 | ||
|
|
4df28878db | ||
|
|
b355d05b25 | ||
|
|
45122de652 | ||
|
|
2180bf98ba | ||
|
|
2d4a94721b | ||
|
|
b2b97c8bed | ||
|
|
e757e9aec9 | ||
|
|
cf4119baf6 | ||
|
|
f349f7199d | ||
|
|
6be2ae3071 | ||
|
|
e7d8803a13 | ||
|
|
53a9b218bc | ||
|
|
c618e7d473 | ||
|
|
600b793641 | ||
|
|
cde03b6997 | ||
|
|
00371bfb01 | ||
|
|
3d0f703ae1 | ||
|
|
33eadaaa7b | ||
|
|
452617529c | ||
|
|
5951eea618 | ||
|
|
e314feb416 | ||
|
|
0910b0f1a7 | ||
|
|
10ba42eb31 | ||
|
|
04f1e17e23 | ||
|
|
4642cc60c9 | ||
|
|
49fa5c587c | ||
|
|
6be6782531 | ||
|
|
3ae4f8aa68 | ||
|
|
3d3c69a92b | ||
|
|
4f9b48b5e5 | ||
|
|
80789327d0 | ||
|
|
38108a32d1 | ||
|
|
b1b94eaa66 | ||
|
|
67cc96449d | ||
|
|
ce4b64da0e | ||
|
|
9790b071d7 | ||
|
|
1f5ba0e60e | ||
|
|
b502bbc91e | ||
|
|
bf41a53b86 | ||
|
|
6772ac7c20 | ||
|
|
26292ecf39 |
@@ -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)
|
||||
```
|
||||
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
---
|
||||
description: >
|
||||
This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
|
||||
and data patterns. It should be used **only when the agent explicitly requests database schema-level
|
||||
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
|
||||
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
|
||||
globs: []
|
||||
alwaysApply: agent-requested
|
||||
globs: schema.prisma
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Formbricks Database Schema Reference
|
||||
|
||||
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
1
.github/workflows/e2e.yml
vendored
1
.github/workflows/e2e.yml
vendored
@@ -17,7 +17,6 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
TELEMETRY_DISABLED: 1
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
|
||||
|
||||
@@ -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,14 +32,22 @@ const mockProject: TProject = {
|
||||
};
|
||||
const mockTemplate: TXMTemplate = {
|
||||
name: "$[projectName] Survey",
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "q1",
|
||||
inputType: "text",
|
||||
type: "email" as any,
|
||||
headline: { default: "$[projectName] Question" },
|
||||
required: false,
|
||||
charLimit: { enabled: true, min: 400, max: 1000 },
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
type: "openText" as const,
|
||||
inputType: "text" as const,
|
||||
headline: { default: "$[projectName] Question" },
|
||||
subheader: { default: "" },
|
||||
required: false,
|
||||
placeholder: { default: "" },
|
||||
charLimit: 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
endings: [
|
||||
@@ -66,9 +74,9 @@ describe("replacePresetPlaceholders", () => {
|
||||
expect(result.name).toBe("Test Project Survey");
|
||||
});
|
||||
|
||||
test("replaces projectName placeholder in question headline", () => {
|
||||
test("replaces projectName placeholder in element headline", () => {
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||
expect(result.questions[0].headline.default).toBe("Test Project Question");
|
||||
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
|
||||
});
|
||||
|
||||
test("returns a new object without mutating the original template", () => {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
import { replaceQuestionPresetPlaceholders } from "@/lib/utils/templates";
|
||||
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
|
||||
|
||||
// replace all occurences of projectName with the actual project name in the current template
|
||||
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject) => {
|
||||
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => {
|
||||
const survey = structuredClone(template);
|
||||
survey.name = survey.name.replace("$[projectName]", project.name);
|
||||
survey.questions = survey.questions.map((question) => {
|
||||
return replaceQuestionPresetPlaceholders(question, project);
|
||||
});
|
||||
return { ...template, ...survey };
|
||||
|
||||
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
|
||||
...block,
|
||||
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
|
||||
}));
|
||||
|
||||
return { ...survey, name: survey.name.replace("$[projectName]", project.name), blocks: modifiedBlocks };
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("xm-templates", () => {
|
||||
expect(result).toEqual({
|
||||
name: "",
|
||||
endings: expect.any(Array),
|
||||
questions: [],
|
||||
blocks: [],
|
||||
styling: {
|
||||
overwriteThemeStyling: true,
|
||||
},
|
||||
|
||||
@@ -3,19 +3,21 @@ import { TFunction } from "i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
import {
|
||||
buildCTAQuestion,
|
||||
buildNPSQuestion,
|
||||
buildOpenTextQuestion,
|
||||
buildRatingQuestion,
|
||||
getDefaultEndingCard,
|
||||
} from "@/app/lib/survey-builder";
|
||||
buildBlock,
|
||||
buildCTAElement,
|
||||
buildNPSElement,
|
||||
buildOpenTextElement,
|
||||
buildRatingElement,
|
||||
createBlockJumpLogic,
|
||||
} from "@/app/lib/survey-block-builder";
|
||||
import { getDefaultEndingCard } from "@/app/lib/survey-builder";
|
||||
|
||||
export const getXMSurveyDefault = (t: TFunction): TXMTemplate => {
|
||||
try {
|
||||
return {
|
||||
name: "",
|
||||
endings: [getDefaultEndingCard([], t)],
|
||||
questions: [],
|
||||
blocks: [],
|
||||
styling: {
|
||||
overwriteThemeStyling: true,
|
||||
},
|
||||
@@ -30,25 +32,40 @@ const npsSurvey = (t: TFunction): TXMTemplate => {
|
||||
return {
|
||||
...getXMSurveyDefault(t),
|
||||
name: t("templates.nps_survey_name"),
|
||||
questions: [
|
||||
buildNPSQuestion({
|
||||
headline: t("templates.nps_survey_question_1_headline"),
|
||||
required: true,
|
||||
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.nps_survey_question_1_upper_label"),
|
||||
isColorCodingEnabled: true,
|
||||
blocks: [
|
||||
buildBlock({
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
buildNPSElement({
|
||||
headline: t("templates.nps_survey_question_1_headline"),
|
||||
required: true,
|
||||
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.nps_survey_question_1_upper_label"),
|
||||
isColorCodingEnabled: true,
|
||||
}),
|
||||
],
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
headline: t("templates.nps_survey_question_2_headline"),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
buildBlock({
|
||||
name: "Block 2",
|
||||
elements: [
|
||||
buildOpenTextElement({
|
||||
headline: t("templates.nps_survey_question_2_headline"),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
}),
|
||||
],
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
headline: t("templates.nps_survey_question_3_headline"),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
buildBlock({
|
||||
name: "Block 3",
|
||||
elements: [
|
||||
buildOpenTextElement({
|
||||
headline: t("templates.nps_survey_question_3_headline"),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
}),
|
||||
],
|
||||
t,
|
||||
}),
|
||||
],
|
||||
@@ -56,15 +73,27 @@ const npsSurvey = (t: TFunction): TXMTemplate => {
|
||||
};
|
||||
|
||||
const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
||||
const reusableElementIds = [createId(), createId(), createId()];
|
||||
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
|
||||
const defaultSurvey = getXMSurveyDefault(t);
|
||||
|
||||
return {
|
||||
...defaultSurvey,
|
||||
name: t("templates.star_rating_survey_name"),
|
||||
questions: [
|
||||
buildRatingQuestion({
|
||||
id: reusableQuestionIds[0],
|
||||
blocks: [
|
||||
buildBlock({
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
buildRatingElement({
|
||||
id: reusableElementIds[0],
|
||||
range: 5,
|
||||
scale: "number",
|
||||
headline: t("templates.star_rating_survey_question_1_headline"),
|
||||
required: true,
|
||||
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
|
||||
}),
|
||||
],
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -75,8 +104,8 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[0],
|
||||
type: "question",
|
||||
value: reusableElementIds[0],
|
||||
type: "element",
|
||||
},
|
||||
operator: "isLessThanOrEqual",
|
||||
rightOperand: {
|
||||
@@ -89,64 +118,44 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: reusableQuestionIds[2],
|
||||
objective: "jumpToBlock",
|
||||
target: block3Id,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
range: 5,
|
||||
scale: "number",
|
||||
headline: t("templates.star_rating_survey_question_1_headline"),
|
||||
required: true,
|
||||
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
|
||||
t,
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[1],
|
||||
subheader: t("templates.star_rating_survey_question_2_html"),
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[1],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isClicked",
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: defaultSurvey.endings[0].id,
|
||||
},
|
||||
],
|
||||
},
|
||||
buildBlock({
|
||||
name: "Block 2",
|
||||
elements: [
|
||||
buildCTAElement({
|
||||
id: reusableElementIds[1],
|
||||
subheader: t("templates.star_rating_survey_question_2_html"),
|
||||
headline: t("templates.star_rating_survey_question_2_headline"),
|
||||
required: false,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonExternal: true,
|
||||
ctaButtonLabel: t("templates.star_rating_survey_question_2_button_label"),
|
||||
}),
|
||||
],
|
||||
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,
|
||||
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
id: reusableQuestionIds[2],
|
||||
headline: t("templates.star_rating_survey_question_3_headline"),
|
||||
required: true,
|
||||
subheader: t("templates.star_rating_survey_question_3_subheader"),
|
||||
buildBlock({
|
||||
id: block3Id,
|
||||
name: "Block 3",
|
||||
elements: [
|
||||
buildOpenTextElement({
|
||||
id: reusableElementIds[2],
|
||||
headline: t("templates.star_rating_survey_question_3_headline"),
|
||||
required: true,
|
||||
subheader: t("templates.star_rating_survey_question_3_subheader"),
|
||||
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
|
||||
inputType: "text",
|
||||
}),
|
||||
],
|
||||
buttonLabel: t("templates.star_rating_survey_question_3_button_label"),
|
||||
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
|
||||
inputType: "text",
|
||||
t,
|
||||
}),
|
||||
],
|
||||
@@ -154,15 +163,27 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||
};
|
||||
|
||||
const csatSurvey = (t: TFunction): TXMTemplate => {
|
||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
||||
const reusableElementIds = [createId(), createId(), createId()];
|
||||
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
|
||||
const defaultSurvey = getXMSurveyDefault(t);
|
||||
|
||||
return {
|
||||
...defaultSurvey,
|
||||
name: t("templates.csat_survey_name"),
|
||||
questions: [
|
||||
buildRatingQuestion({
|
||||
id: reusableQuestionIds[0],
|
||||
blocks: [
|
||||
buildBlock({
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
buildRatingElement({
|
||||
id: reusableElementIds[0],
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
headline: t("templates.csat_survey_question_1_headline"),
|
||||
required: true,
|
||||
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.csat_survey_question_1_upper_label"),
|
||||
}),
|
||||
],
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -173,8 +194,8 @@ const csatSurvey = (t: TFunction): TXMTemplate => {
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[0],
|
||||
type: "question",
|
||||
value: reusableElementIds[0],
|
||||
type: "element",
|
||||
},
|
||||
operator: "isLessThanOrEqual",
|
||||
rightOperand: {
|
||||
@@ -187,60 +208,40 @@ const csatSurvey = (t: TFunction): TXMTemplate => {
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: reusableQuestionIds[2],
|
||||
objective: "jumpToBlock",
|
||||
target: block3Id,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
headline: t("templates.csat_survey_question_1_headline"),
|
||||
required: true,
|
||||
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.csat_survey_question_1_upper_label"),
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
id: reusableQuestionIds[1],
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[1],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isSubmitted",
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: defaultSurvey.endings[0].id,
|
||||
},
|
||||
],
|
||||
},
|
||||
buildBlock({
|
||||
name: "Block 2",
|
||||
elements: [
|
||||
buildOpenTextElement({
|
||||
id: reusableElementIds[1],
|
||||
headline: t("templates.csat_survey_question_2_headline"),
|
||||
required: false,
|
||||
placeholder: t("templates.csat_survey_question_2_placeholder"),
|
||||
inputType: "text",
|
||||
}),
|
||||
],
|
||||
headline: t("templates.csat_survey_question_2_headline"),
|
||||
required: false,
|
||||
placeholder: t("templates.csat_survey_question_2_placeholder"),
|
||||
inputType: "text",
|
||||
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isSubmitted")],
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
id: reusableQuestionIds[2],
|
||||
headline: t("templates.csat_survey_question_3_headline"),
|
||||
required: false,
|
||||
placeholder: t("templates.csat_survey_question_3_placeholder"),
|
||||
inputType: "text",
|
||||
buildBlock({
|
||||
id: block3Id,
|
||||
name: "Block 3",
|
||||
elements: [
|
||||
buildOpenTextElement({
|
||||
id: reusableElementIds[2],
|
||||
headline: t("templates.csat_survey_question_3_headline"),
|
||||
required: false,
|
||||
placeholder: t("templates.csat_survey_question_3_placeholder"),
|
||||
inputType: "text",
|
||||
}),
|
||||
],
|
||||
t,
|
||||
}),
|
||||
],
|
||||
@@ -251,21 +252,31 @@ const cessSurvey = (t: TFunction): TXMTemplate => {
|
||||
return {
|
||||
...getXMSurveyDefault(t),
|
||||
name: t("templates.cess_survey_name"),
|
||||
questions: [
|
||||
buildRatingQuestion({
|
||||
range: 5,
|
||||
scale: "number",
|
||||
headline: t("templates.cess_survey_question_1_headline"),
|
||||
required: true,
|
||||
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.cess_survey_question_1_upper_label"),
|
||||
blocks: [
|
||||
buildBlock({
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
buildRatingElement({
|
||||
range: 5,
|
||||
scale: "number",
|
||||
headline: t("templates.cess_survey_question_1_headline"),
|
||||
required: true,
|
||||
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.cess_survey_question_1_upper_label"),
|
||||
}),
|
||||
],
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
headline: t("templates.cess_survey_question_2_headline"),
|
||||
required: true,
|
||||
placeholder: t("templates.cess_survey_question_2_placeholder"),
|
||||
inputType: "text",
|
||||
buildBlock({
|
||||
name: "Block 2",
|
||||
elements: [
|
||||
buildOpenTextElement({
|
||||
headline: t("templates.cess_survey_question_2_headline"),
|
||||
required: true,
|
||||
placeholder: t("templates.cess_survey_question_2_placeholder"),
|
||||
inputType: "text",
|
||||
}),
|
||||
],
|
||||
t,
|
||||
}),
|
||||
],
|
||||
@@ -273,15 +284,27 @@ const cessSurvey = (t: TFunction): TXMTemplate => {
|
||||
};
|
||||
|
||||
const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
||||
const reusableElementIds = [createId(), createId(), createId()];
|
||||
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
|
||||
const defaultSurvey = getXMSurveyDefault(t);
|
||||
|
||||
return {
|
||||
...defaultSurvey,
|
||||
name: t("templates.smileys_survey_name"),
|
||||
questions: [
|
||||
buildRatingQuestion({
|
||||
id: reusableQuestionIds[0],
|
||||
blocks: [
|
||||
buildBlock({
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
buildRatingElement({
|
||||
id: reusableElementIds[0],
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
headline: t("templates.smileys_survey_question_1_headline"),
|
||||
required: true,
|
||||
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
|
||||
}),
|
||||
],
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -292,8 +315,8 @@ const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[0],
|
||||
type: "question",
|
||||
value: reusableElementIds[0],
|
||||
type: "element",
|
||||
},
|
||||
operator: "isLessThanOrEqual",
|
||||
rightOperand: {
|
||||
@@ -306,64 +329,44 @@ const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: reusableQuestionIds[2],
|
||||
objective: "jumpToBlock",
|
||||
target: block3Id,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
headline: t("templates.smileys_survey_question_1_headline"),
|
||||
required: true,
|
||||
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
|
||||
t,
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[1],
|
||||
subheader: t("templates.smileys_survey_question_2_html"),
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[1],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isClicked",
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: defaultSurvey.endings[0].id,
|
||||
},
|
||||
],
|
||||
},
|
||||
buildBlock({
|
||||
name: "Block 2",
|
||||
elements: [
|
||||
buildCTAElement({
|
||||
id: reusableElementIds[1],
|
||||
subheader: t("templates.smileys_survey_question_2_html"),
|
||||
headline: t("templates.smileys_survey_question_2_headline"),
|
||||
required: false,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonExternal: true,
|
||||
ctaButtonLabel: t("templates.smileys_survey_question_2_button_label"),
|
||||
}),
|
||||
],
|
||||
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,
|
||||
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
id: reusableQuestionIds[2],
|
||||
headline: t("templates.smileys_survey_question_3_headline"),
|
||||
required: true,
|
||||
subheader: t("templates.smileys_survey_question_3_subheader"),
|
||||
buildBlock({
|
||||
id: block3Id,
|
||||
name: "Block 3",
|
||||
elements: [
|
||||
buildOpenTextElement({
|
||||
id: reusableElementIds[2],
|
||||
headline: t("templates.smileys_survey_question_3_headline"),
|
||||
required: true,
|
||||
subheader: t("templates.smileys_survey_question_3_subheader"),
|
||||
placeholder: t("templates.smileys_survey_question_3_placeholder"),
|
||||
inputType: "text",
|
||||
}),
|
||||
],
|
||||
buttonLabel: t("templates.smileys_survey_question_3_button_label"),
|
||||
placeholder: t("templates.smileys_survey_question_3_placeholder"),
|
||||
inputType: "text",
|
||||
t,
|
||||
}),
|
||||
],
|
||||
@@ -374,25 +377,40 @@ const enpsSurvey = (t: TFunction): TXMTemplate => {
|
||||
return {
|
||||
...getXMSurveyDefault(t),
|
||||
name: t("templates.enps_survey_name"),
|
||||
questions: [
|
||||
buildNPSQuestion({
|
||||
headline: t("templates.enps_survey_question_1_headline"),
|
||||
required: false,
|
||||
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.enps_survey_question_1_upper_label"),
|
||||
isColorCodingEnabled: true,
|
||||
blocks: [
|
||||
buildBlock({
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
buildNPSElement({
|
||||
headline: t("templates.enps_survey_question_1_headline"),
|
||||
required: false,
|
||||
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.enps_survey_question_1_upper_label"),
|
||||
isColorCodingEnabled: true,
|
||||
}),
|
||||
],
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
headline: t("templates.enps_survey_question_2_headline"),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
buildBlock({
|
||||
name: "Block 2",
|
||||
elements: [
|
||||
buildOpenTextElement({
|
||||
headline: t("templates.enps_survey_question_2_headline"),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
}),
|
||||
],
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
headline: t("templates.enps_survey_question_3_headline"),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
buildBlock({
|
||||
name: "Block 3",
|
||||
elements: [
|
||||
buildOpenTextElement({
|
||||
headline: t("templates.enps_survey_question_3_headline"),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
}),
|
||||
],
|
||||
t,
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
|
||||
import { IS_POSTHOG_CONFIGURED } from "@/lib/constants";
|
||||
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
@@ -40,14 +38,6 @@ const ProjectOnboardingLayout = async (props) => {
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-slate-50">
|
||||
<PosthogIdentify
|
||||
session={session}
|
||||
user={user}
|
||||
organizationId={organization.id}
|
||||
organizationName={organization.name}
|
||||
organizationBilling={organization.billing}
|
||||
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
|
||||
/>
|
||||
<ToasterClient />
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
||||
|
||||
const SurveyEditorEnvironmentLayout = async (props) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
|
||||
const { t, session, user } = await environmentIdLayoutChecks(params.environmentId);
|
||||
|
||||
if (!session) {
|
||||
return redirect(`/auth/login`);
|
||||
@@ -25,15 +24,9 @@ const SurveyEditorEnvironmentLayout = async (props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<EnvironmentIdBaseLayout
|
||||
environmentId={params.environmentId}
|
||||
session={session}
|
||||
user={user}
|
||||
organization={organization}>
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
||||
</div>
|
||||
</EnvironmentIdBaseLayout>
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { Session } from "next-auth";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { useEffect } from "react";
|
||||
import { TOrganizationBilling } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
interface PosthogIdentifyProps {
|
||||
session: Session;
|
||||
user: TUser;
|
||||
environmentId?: string;
|
||||
organizationId?: string;
|
||||
organizationName?: string;
|
||||
organizationBilling?: TOrganizationBilling;
|
||||
isPosthogEnabled: boolean;
|
||||
}
|
||||
|
||||
export const PosthogIdentify = ({
|
||||
session,
|
||||
user,
|
||||
environmentId,
|
||||
organizationId,
|
||||
organizationName,
|
||||
organizationBilling,
|
||||
isPosthogEnabled,
|
||||
}: PosthogIdentifyProps) => {
|
||||
const posthog = usePostHog();
|
||||
|
||||
useEffect(() => {
|
||||
if (isPosthogEnabled && session.user && posthog) {
|
||||
posthog.identify(session.user.id, {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
});
|
||||
if (environmentId) {
|
||||
posthog.group("environment", environmentId, { name: environmentId });
|
||||
}
|
||||
if (organizationId) {
|
||||
posthog.group("organization", organizationId, {
|
||||
name: organizationName,
|
||||
plan: organizationBilling?.plan,
|
||||
responseLimit: organizationBilling?.limits.monthly.responses,
|
||||
miuLimit: organizationBilling?.limits.monthly.miu,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
posthog,
|
||||
session.user,
|
||||
environmentId,
|
||||
organizationId,
|
||||
organizationName,
|
||||
organizationBilling,
|
||||
user.name,
|
||||
user.email,
|
||||
isPosthogEnabled,
|
||||
]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -4,7 +4,6 @@ import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/comp
|
||||
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
|
||||
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
||||
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
|
||||
|
||||
const EnvLayout = async (props: {
|
||||
@@ -24,11 +23,7 @@ const EnvLayout = async (props: {
|
||||
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
||||
|
||||
return (
|
||||
<EnvironmentIdBaseLayout
|
||||
environmentId={params.environmentId}
|
||||
session={layoutData.session}
|
||||
user={layoutData.user}
|
||||
organization={layoutData.organization}>
|
||||
<>
|
||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||
<EnvironmentContextWrapper
|
||||
environment={layoutData.environment}
|
||||
@@ -36,7 +31,7 @@ const EnvLayout = async (props: {
|
||||
organization={layoutData.organization}>
|
||||
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
|
||||
</EnvironmentContextWrapper>
|
||||
</EnvironmentIdBaseLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { TFunction } from "i18next";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Control, Controller, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -14,14 +14,15 @@ import {
|
||||
TIntegrationAirtableInput,
|
||||
TIntegrationAirtableTables,
|
||||
} from "@formbricks/types/integration/airtable";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown";
|
||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
|
||||
import AirtableLogo from "@/images/airtableLogo.svg";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -45,6 +46,45 @@ import {
|
||||
} from "@/modules/ui/components/select";
|
||||
import { IntegrationModalInputs } from "../lib/types";
|
||||
|
||||
const ElementCheckbox = ({
|
||||
element,
|
||||
selectedSurvey,
|
||||
field,
|
||||
}: {
|
||||
element: TSurveyElement;
|
||||
selectedSurvey: TSurvey;
|
||||
field: {
|
||||
value: string[] | undefined;
|
||||
onChange: (value: string[]) => void;
|
||||
};
|
||||
}) => {
|
||||
const handleCheckedChange = (checked: boolean) => {
|
||||
if (checked) {
|
||||
field.onChange([...(field.value || []), element.id]);
|
||||
} else {
|
||||
field.onChange(field.value?.filter((value) => value !== element.id) || []);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="my-1 flex items-center space-x-2">
|
||||
<label htmlFor={element.id} className="flex cursor-pointer items-center">
|
||||
<Checkbox
|
||||
type="button"
|
||||
id={element.id}
|
||||
value={element.id}
|
||||
className="bg-white"
|
||||
checked={field.value?.includes(element.id)}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
/>
|
||||
<span className="ml-2">
|
||||
{getTextContent(recallToHeadline(element.headline, selectedSurvey, false, "default")["default"])}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type EditModeProps =
|
||||
| { isEditMode: false; defaultData?: never }
|
||||
| { isEditMode: true; defaultData: IntegrationModalInputs & { index: number } };
|
||||
@@ -68,9 +108,10 @@ const NoBaseFoundError = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderQuestionSelection = ({
|
||||
const renderElementSelection = ({
|
||||
t,
|
||||
selectedSurvey,
|
||||
elements,
|
||||
control,
|
||||
includeVariables,
|
||||
setIncludeVariables,
|
||||
@@ -83,6 +124,7 @@ const renderQuestionSelection = ({
|
||||
}: {
|
||||
t: TFunction;
|
||||
selectedSurvey: TSurvey;
|
||||
elements: TSurveyElement[];
|
||||
control: Control<IntegrationModalInputs>;
|
||||
includeVariables: boolean;
|
||||
setIncludeVariables: (value: boolean) => void;
|
||||
@@ -99,31 +141,13 @@ const renderQuestionSelection = ({
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
|
||||
{elements.map((element) => (
|
||||
<Controller
|
||||
key={question.id}
|
||||
key={element.id}
|
||||
control={control}
|
||||
name={"questions"}
|
||||
name={"elements"}
|
||||
render={({ field }) => (
|
||||
<div className="my-1 flex items-center space-x-2">
|
||||
<label htmlFor={question.id} className="flex cursor-pointer items-center">
|
||||
<Checkbox
|
||||
type="button"
|
||||
id={question.id}
|
||||
value={question.id}
|
||||
className="bg-white"
|
||||
checked={field.value?.includes(question.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
return checked
|
||||
? field.onChange([...field.value, question.id])
|
||||
: field.onChange(field.value?.filter((value) => value !== question.id));
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">
|
||||
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<ElementCheckbox element={element} selectedSurvey={selectedSurvey} field={field} />
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
@@ -194,6 +218,11 @@ export const AddIntegrationModal = ({
|
||||
};
|
||||
|
||||
const selectedSurvey = surveys.find((item) => item.id === survey);
|
||||
const elements = useMemo(
|
||||
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
||||
[selectedSurvey]
|
||||
);
|
||||
|
||||
const submitHandler = async (data: IntegrationModalInputs) => {
|
||||
try {
|
||||
if (!data.base || data.base === "") {
|
||||
@@ -208,7 +237,7 @@ export const AddIntegrationModal = ({
|
||||
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
||||
}
|
||||
|
||||
if (data.questions.length === 0) {
|
||||
if (data.elements.length === 0) {
|
||||
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
||||
}
|
||||
|
||||
@@ -216,9 +245,9 @@ export const AddIntegrationModal = ({
|
||||
const integrationData: TIntegrationAirtableConfigData = {
|
||||
surveyId: selectedSurvey.id,
|
||||
surveyName: selectedSurvey.name,
|
||||
questionIds: data.questions,
|
||||
questions:
|
||||
data.questions.length === selectedSurvey.questions.length
|
||||
elementIds: data.elements,
|
||||
elements:
|
||||
data.elements.length === elements.length
|
||||
? t("common.all_questions")
|
||||
: t("common.selected_questions"),
|
||||
createdAt: new Date(),
|
||||
@@ -366,7 +395,7 @@ export const AddIntegrationModal = ({
|
||||
required
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
setValue("questions", []);
|
||||
setValue("elements", []);
|
||||
}}
|
||||
defaultValue={defaultData?.survey}>
|
||||
<SelectTrigger>
|
||||
@@ -392,9 +421,10 @@ export const AddIntegrationModal = ({
|
||||
|
||||
{survey &&
|
||||
selectedSurvey &&
|
||||
renderQuestionSelection({
|
||||
renderElementSelection({
|
||||
t,
|
||||
selectedSurvey,
|
||||
elements: elements,
|
||||
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 = [
|
||||
@@ -110,7 +108,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
onClick={() => {
|
||||
setDefaultValues({
|
||||
base: data.baseId,
|
||||
questions: data.questionIds,
|
||||
elements: data.elementIds,
|
||||
survey: data.surveyId,
|
||||
table: data.tableId,
|
||||
includeVariables: !!data.includeVariables,
|
||||
@@ -123,7 +121,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
}}>
|
||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||
<div className="col-span-2 text-center">{data.tableName}</div>
|
||||
<div className="col-span-2 text-center">{data.questions}</div>
|
||||
<div className="col-span-2 text-center">{data.elements}</div>
|
||||
<div className="col-span-2 text-center">
|
||||
{timeSince(data.createdAt.toString(), props.locale)}
|
||||
</div>
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ export type IntegrationModalInputs = {
|
||||
base: string;
|
||||
table: string;
|
||||
survey: string;
|
||||
questions: string[];
|
||||
elements: string[];
|
||||
includeVariables: boolean;
|
||||
includeHiddenFields: boolean;
|
||||
includeMetadata: boolean;
|
||||
|
||||
@@ -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, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -20,9 +20,9 @@ import {
|
||||
isValidGoogleSheetsUrl,
|
||||
} from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/util";
|
||||
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
@@ -62,12 +62,12 @@ export const AddIntegrationModal = ({
|
||||
spreadsheetName: "",
|
||||
surveyId: "",
|
||||
surveyName: "",
|
||||
questionIds: [""],
|
||||
questions: "",
|
||||
elementIds: [""],
|
||||
elements: "",
|
||||
createdAt: new Date(),
|
||||
};
|
||||
const { handleSubmit } = useForm();
|
||||
const [selectedQuestions, setSelectedQuestions] = useState<string[]>([]);
|
||||
const [selectedElements, setSelectedElements] = useState<string[]>([]);
|
||||
const [isLinkingSheet, setIsLinkingSheet] = useState(false);
|
||||
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
|
||||
const [spreadsheetUrl, setSpreadsheetUrl] = useState("");
|
||||
@@ -86,12 +86,17 @@ export const AddIntegrationModal = ({
|
||||
},
|
||||
};
|
||||
|
||||
const surveyElements = useMemo(
|
||||
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
||||
[selectedSurvey]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSurvey && !selectedIntegration) {
|
||||
const questionIds = selectedSurvey.questions.map((question) => question.id);
|
||||
setSelectedQuestions(questionIds);
|
||||
const elementIds = surveyElements.map((element) => element.id);
|
||||
setSelectedElements(elementIds);
|
||||
}
|
||||
}, [selectedIntegration, selectedSurvey]);
|
||||
}, [surveyElements, selectedIntegration, selectedSurvey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIntegration) {
|
||||
@@ -101,7 +106,7 @@ export const AddIntegrationModal = ({
|
||||
return survey.id === selectedIntegration.surveyId;
|
||||
})!
|
||||
);
|
||||
setSelectedQuestions(selectedIntegration.questionIds);
|
||||
setSelectedElements(selectedIntegration.elementIds);
|
||||
setIncludeVariables(!!selectedIntegration.includeVariables);
|
||||
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
|
||||
setIncludeMetadata(!!selectedIntegration.includeMetadata);
|
||||
@@ -121,7 +126,7 @@ export const AddIntegrationModal = ({
|
||||
if (!selectedSurvey) {
|
||||
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
||||
}
|
||||
if (selectedQuestions.length === 0) {
|
||||
if (selectedElements.length === 0) {
|
||||
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
||||
}
|
||||
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
|
||||
@@ -143,9 +148,9 @@ export const AddIntegrationModal = ({
|
||||
integrationData.spreadsheetName = spreadsheetName;
|
||||
integrationData.surveyId = selectedSurvey.id;
|
||||
integrationData.surveyName = selectedSurvey.name;
|
||||
integrationData.questionIds = selectedQuestions;
|
||||
integrationData.questions =
|
||||
selectedQuestions.length === selectedSurvey?.questions.length
|
||||
integrationData.elementIds = selectedElements;
|
||||
integrationData.elements =
|
||||
selectedElements.length === surveyElements.length
|
||||
? t("common.all_questions")
|
||||
: t("common.selected_questions");
|
||||
integrationData.createdAt = new Date();
|
||||
@@ -176,7 +181,7 @@ export const AddIntegrationModal = ({
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (questionId: TSurveyQuestionId) => {
|
||||
setSelectedQuestions((prevValues) =>
|
||||
setSelectedElements((prevValues) =>
|
||||
prevValues.includes(questionId)
|
||||
? prevValues.filter((value) => value !== questionId)
|
||||
: [...prevValues, questionId]
|
||||
@@ -263,7 +268,7 @@ export const AddIntegrationModal = ({
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
|
||||
{surveyElements.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
|
||||
@@ -271,13 +276,17 @@ export const AddIntegrationModal = ({
|
||||
id={question.id}
|
||||
value={question.id}
|
||||
className="bg-white"
|
||||
checked={selectedQuestions.includes(question.id)}
|
||||
checked={selectedElements.includes(question.id)}
|
||||
onCheckedChange={() => {
|
||||
handleCheckboxChange(question.id);
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2 w-[30rem] truncate">
|
||||
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
||||
{getTextContent(
|
||||
recallToHeadline(question.headline, selectedSurvey, false, "default")[
|
||||
"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">
|
||||
@@ -118,7 +110,7 @@ export const ManageIntegration = ({
|
||||
}}>
|
||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||
<div className="col-span-2 text-center">{data.spreadsheetName}</div>
|
||||
<div className="col-span-2 text-center">{data.questions}</div>
|
||||
<div className="col-span-2 text-center">{data.elements}</div>
|
||||
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
TIntegrationNotionConfigData,
|
||||
TIntegrationNotionDatabase,
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import {
|
||||
@@ -21,10 +22,10 @@ import {
|
||||
UNSUPPORTED_TYPES_BY_NOTION,
|
||||
} from "@/app/(app)/environments/[environmentId]/project/integrations/notion/constants";
|
||||
import NotionLogo from "@/images/notion.png";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { getElementTypes } from "@/modules/survey/lib/elements";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -38,6 +39,59 @@ import {
|
||||
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
const MappingErrorMessage = ({
|
||||
error,
|
||||
col,
|
||||
elem,
|
||||
t,
|
||||
}: {
|
||||
error: { type: string; msg?: React.ReactNode | string } | null | undefined;
|
||||
col: { id: string; name: string; type: string };
|
||||
elem: { id: string; name: string; type: string };
|
||||
t: ReturnType<typeof useTranslation>["t"];
|
||||
}) => {
|
||||
const showErrorMsg = useMemo(() => {
|
||||
switch (error?.type) {
|
||||
case ERRORS.UNSUPPORTED_TYPE:
|
||||
return (
|
||||
<>
|
||||
-{" "}
|
||||
{t("environments.integrations.notion.col_name_of_type_is_not_supported", {
|
||||
col_name: col.name,
|
||||
type: col.type,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
case ERRORS.MAPPING:
|
||||
const element = getElementTypes(t).find((et) => et.id === elem.type);
|
||||
if (!element) return null;
|
||||
return (
|
||||
<>
|
||||
{t("environments.integrations.notion.que_name_of_type_cant_be_mapped_to", {
|
||||
que_name: elem.name,
|
||||
question_label: element.label,
|
||||
col_name: col.name,
|
||||
col_type: col.type,
|
||||
mapped_type: TYPE_MAPPING[element.id].join(" ,"),
|
||||
})}
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [error, col, elem, t]);
|
||||
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<div className="my-4 w-full rounded-lg bg-red-100 p-4 text-sm text-red-800">
|
||||
<span className="mb-2 block">{error.type}</span>
|
||||
{showErrorMsg}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface AddIntegrationModalProps {
|
||||
environmentId: string;
|
||||
surveys: TSurvey[];
|
||||
@@ -64,7 +118,7 @@ export const AddIntegrationModal = ({
|
||||
const [mapping, setMapping] = useState<
|
||||
{
|
||||
column: { id: string; name: string; type: string };
|
||||
question: { id: string; name: string; type: string };
|
||||
element: { id: string; name: string; type: string };
|
||||
error?: {
|
||||
type: string;
|
||||
msg: React.ReactNode | string;
|
||||
@@ -73,7 +127,7 @@ export const AddIntegrationModal = ({
|
||||
>([
|
||||
{
|
||||
column: { id: "", name: "", type: "" },
|
||||
question: { id: "", name: "", type: "" },
|
||||
element: { id: "", name: "", type: "" },
|
||||
},
|
||||
]);
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
@@ -86,12 +140,17 @@ export const AddIntegrationModal = ({
|
||||
mapping: [
|
||||
{
|
||||
column: { id: "", name: "", type: "" },
|
||||
question: { id: "", name: "", type: "" },
|
||||
element: { id: "", name: "", type: "" },
|
||||
},
|
||||
],
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const elements = useMemo(
|
||||
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
||||
[selectedSurvey]
|
||||
);
|
||||
|
||||
const notionIntegrationData: TIntegrationInput = {
|
||||
type: "notion",
|
||||
config: {
|
||||
@@ -119,12 +178,12 @@ export const AddIntegrationModal = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDatabase?.id]);
|
||||
|
||||
const questionItems = useMemo(() => {
|
||||
const questions = selectedSurvey
|
||||
? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
|
||||
id: q.id,
|
||||
name: getTextContent(getLocalizedValue(q.headline, "default")),
|
||||
type: q.type,
|
||||
const elementItems = useMemo(() => {
|
||||
const mappedElements = selectedSurvey
|
||||
? elements.map((el) => ({
|
||||
id: el.id,
|
||||
name: getTextContent(recallToHeadline(el.headline, selectedSurvey, false, "default")["default"]),
|
||||
type: el.type,
|
||||
}))
|
||||
: [];
|
||||
|
||||
@@ -132,31 +191,31 @@ export const AddIntegrationModal = ({
|
||||
selectedSurvey?.variables.map((variable) => ({
|
||||
id: variable.id,
|
||||
name: variable.name,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
})) || [];
|
||||
|
||||
const hiddenFields =
|
||||
selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
|
||||
id: fId,
|
||||
name: `${t("common.hidden_field")} : ${fId}`,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
})) || [];
|
||||
const Metadata = [
|
||||
{
|
||||
id: "metadata",
|
||||
name: t("common.metadata"),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
},
|
||||
];
|
||||
const createdAt = [
|
||||
{
|
||||
id: "createdAt",
|
||||
name: t("common.created_at"),
|
||||
type: TSurveyQuestionTypeEnum.Date,
|
||||
type: TSurveyElementTypeEnum.Date,
|
||||
},
|
||||
];
|
||||
|
||||
return [...questions, ...variables, ...hiddenFields, ...Metadata, ...createdAt];
|
||||
return [...mappedElements, ...variables, ...hiddenFields, ...Metadata, ...createdAt];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedSurvey?.id]);
|
||||
|
||||
@@ -190,7 +249,7 @@ export const AddIntegrationModal = ({
|
||||
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
||||
}
|
||||
|
||||
if (mapping.length === 1 && (!mapping[0].question.id || !mapping[0].column.id)) {
|
||||
if (mapping.length === 1 && (!mapping[0].element.id || !mapping[0].column.id)) {
|
||||
throw new Error(t("environments.integrations.notion.please_select_at_least_one_mapping"));
|
||||
}
|
||||
|
||||
@@ -199,8 +258,8 @@ export const AddIntegrationModal = ({
|
||||
}
|
||||
|
||||
if (
|
||||
mapping.filter((m) => m.column.id && !m.question.id).length >= 1 ||
|
||||
mapping.filter((m) => m.question.id && !m.column.id).length >= 1
|
||||
mapping.filter((m) => m.column.id && !m.element.id).length >= 1 ||
|
||||
mapping.filter((m) => m.element.id && !m.column.id).length >= 1
|
||||
) {
|
||||
throw new Error(
|
||||
t("environments.integrations.notion.please_complete_mapping_fields_with_notion_property")
|
||||
@@ -261,23 +320,23 @@ export const AddIntegrationModal = ({
|
||||
setSelectedDatabase(null);
|
||||
setSelectedSurvey(null);
|
||||
};
|
||||
const getFilteredQuestionItems = (selectedIdx) => {
|
||||
const selectedQuestionIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.question.id);
|
||||
const getFilteredElementItems = (selectedIdx) => {
|
||||
const selectedElementIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.element.id);
|
||||
|
||||
return questionItems.filter((q) => !selectedQuestionIds.includes(q.id));
|
||||
return elementItems.filter((el) => !selectedElementIds.includes(el.id));
|
||||
};
|
||||
|
||||
const createCopy = (item) => structuredClone(item);
|
||||
|
||||
const MappingRow = ({ idx }: { idx: number }) => {
|
||||
const filteredQuestionItems = getFilteredQuestionItems(idx);
|
||||
const filteredElementItems = getFilteredElementItems(idx);
|
||||
|
||||
const addRow = () => {
|
||||
setMapping((prev) => [
|
||||
...prev,
|
||||
{
|
||||
column: { id: "", name: "", type: "" },
|
||||
question: { id: "", name: "", type: "" },
|
||||
element: { id: "", name: "", type: "" },
|
||||
},
|
||||
]);
|
||||
};
|
||||
@@ -288,49 +347,6 @@ export const AddIntegrationModal = ({
|
||||
});
|
||||
};
|
||||
|
||||
const ErrorMsg = ({ error, col, ques }) => {
|
||||
const showErrorMsg = useMemo(() => {
|
||||
switch (error?.type) {
|
||||
case ERRORS.UNSUPPORTED_TYPE:
|
||||
return (
|
||||
<>
|
||||
-{" "}
|
||||
{t("environments.integrations.notion.col_name_of_type_is_not_supported", {
|
||||
col_name: col.name,
|
||||
type: col.type,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
case ERRORS.MAPPING:
|
||||
const question = getQuestionTypes(t).find((qt) => qt.id === ques.type);
|
||||
if (!question) return null;
|
||||
return (
|
||||
<>
|
||||
{t("environments.integrations.notion.que_name_of_type_cant_be_mapped_to", {
|
||||
que_name: ques.name,
|
||||
question_label: question.label,
|
||||
col_name: col.name,
|
||||
col_type: col.type,
|
||||
mapped_type: TYPE_MAPPING[question.id].join(" ,"),
|
||||
})}
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [error]);
|
||||
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<div className="my-4 w-full rounded-lg bg-red-100 p-4 text-sm text-red-800">
|
||||
<span className="mb-2 block">{error.type}</span>
|
||||
{showErrorMsg}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getFilteredDbItems = () => {
|
||||
const colMapping = mapping.map((m) => m.column.id);
|
||||
return dbItems.filter((item) => !colMapping.includes(item.id));
|
||||
@@ -338,19 +354,20 @@ export const AddIntegrationModal = ({
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ErrorMsg
|
||||
<MappingErrorMessage
|
||||
key={idx}
|
||||
error={mapping[idx]?.error}
|
||||
col={mapping[idx].column}
|
||||
ques={mapping[idx].question}
|
||||
elem={mapping[idx].element}
|
||||
t={t}
|
||||
/>
|
||||
<div className="flex w-full items-center space-x-2">
|
||||
<div className="flex w-full items-center">
|
||||
<div className="max-w-full flex-1">
|
||||
<DropdownSelector
|
||||
placeholder={t("environments.integrations.notion.select_a_survey_question")}
|
||||
items={filteredQuestionItems}
|
||||
selectedItem={mapping?.[idx]?.question}
|
||||
items={filteredElementItems}
|
||||
selectedItem={mapping?.[idx]?.element}
|
||||
setSelectedItem={(item) => {
|
||||
setMapping((prev) => {
|
||||
const copy = createCopy(prev);
|
||||
@@ -362,7 +379,7 @@ export const AddIntegrationModal = ({
|
||||
error: {
|
||||
type: ERRORS.UNSUPPORTED_TYPE,
|
||||
},
|
||||
question: item,
|
||||
element: item,
|
||||
};
|
||||
return copy;
|
||||
}
|
||||
@@ -374,7 +391,7 @@ export const AddIntegrationModal = ({
|
||||
error: {
|
||||
type: ERRORS.MAPPING,
|
||||
},
|
||||
question: item,
|
||||
element: item,
|
||||
};
|
||||
return copy;
|
||||
}
|
||||
@@ -382,13 +399,13 @@ export const AddIntegrationModal = ({
|
||||
|
||||
copy[idx] = {
|
||||
...copy[idx],
|
||||
question: item,
|
||||
element: item,
|
||||
error: null,
|
||||
};
|
||||
return copy;
|
||||
});
|
||||
}}
|
||||
disabled={questionItems.length === 0}
|
||||
disabled={elementItems.length === 0}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-px w-4 border-t border-t-slate-300" />
|
||||
@@ -400,9 +417,9 @@ export const AddIntegrationModal = ({
|
||||
setSelectedItem={(item) => {
|
||||
setMapping((prev) => {
|
||||
const copy = createCopy(prev);
|
||||
const ques = copy[idx].question;
|
||||
if (ques.id) {
|
||||
const isValidQuesType = TYPE_MAPPING[ques.type].includes(item.type);
|
||||
const elem = copy[idx].element;
|
||||
if (elem.id) {
|
||||
const isValidElemType = TYPE_MAPPING[elem.type].includes(item.type);
|
||||
|
||||
if (UNSUPPORTED_TYPES_BY_NOTION.includes(item.type)) {
|
||||
copy[idx] = {
|
||||
@@ -415,7 +432,7 @@ export const AddIntegrationModal = ({
|
||||
return copy;
|
||||
}
|
||||
|
||||
if (!isValidQuesType) {
|
||||
if (!isValidElemType) {
|
||||
copy[idx] = {
|
||||
...copy[idx],
|
||||
error: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -13,12 +13,12 @@ import {
|
||||
TIntegrationSlackConfigData,
|
||||
TIntegrationSlackInput,
|
||||
} from "@formbricks/types/integration/slack";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
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 SlackLogo from "@/images/slacklogo.png";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
@@ -55,7 +55,7 @@ export const AddChannelMappingModal = ({
|
||||
}: AddChannelMappingModalProps) => {
|
||||
const { handleSubmit } = useForm();
|
||||
const { t } = useTranslation();
|
||||
const [selectedQuestions, setSelectedQuestions] = useState<string[]>([]);
|
||||
const [selectedElements, setSelectedElements] = useState<string[]>([]);
|
||||
const [isLinkingChannel, setIsLinkingChannel] = useState(false);
|
||||
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
|
||||
const [selectedChannel, setSelectedChannel] = useState<TIntegrationItem | null>(null);
|
||||
@@ -73,14 +73,19 @@ export const AddChannelMappingModal = ({
|
||||
},
|
||||
};
|
||||
|
||||
const surveyElements = useMemo(
|
||||
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
||||
[selectedSurvey]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSurvey) {
|
||||
const questionIds = selectedSurvey.questions.map((question) => question.id);
|
||||
const elementIds = surveyElements.map((element) => element.id);
|
||||
if (!selectedIntegration) {
|
||||
setSelectedQuestions(questionIds);
|
||||
setSelectedElements(elementIds);
|
||||
}
|
||||
}
|
||||
}, [selectedIntegration, selectedSurvey]);
|
||||
}, [surveyElements, selectedIntegration, selectedSurvey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIntegration) {
|
||||
@@ -93,7 +98,7 @@ export const AddChannelMappingModal = ({
|
||||
return survey.id === selectedIntegration.surveyId;
|
||||
})!
|
||||
);
|
||||
setSelectedQuestions(selectedIntegration.questionIds);
|
||||
setSelectedElements(selectedIntegration.elementIds);
|
||||
setIncludeVariables(!!selectedIntegration.includeVariables);
|
||||
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
|
||||
setIncludeMetadata(!!selectedIntegration.includeMetadata);
|
||||
@@ -112,7 +117,7 @@ export const AddChannelMappingModal = ({
|
||||
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
||||
}
|
||||
|
||||
if (selectedQuestions.length === 0) {
|
||||
if (selectedElements.length === 0) {
|
||||
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
||||
}
|
||||
setIsLinkingChannel(true);
|
||||
@@ -121,9 +126,9 @@ export const AddChannelMappingModal = ({
|
||||
channelName: selectedChannel.name,
|
||||
surveyId: selectedSurvey.id,
|
||||
surveyName: selectedSurvey.name,
|
||||
questionIds: selectedQuestions,
|
||||
questions:
|
||||
selectedQuestions.length === selectedSurvey?.questions.length
|
||||
elementIds: selectedElements,
|
||||
elements:
|
||||
selectedElements.length === surveyElements.length
|
||||
? t("common.all_questions")
|
||||
: t("common.selected_questions"),
|
||||
createdAt: new Date(),
|
||||
@@ -154,11 +159,11 @@ export const AddChannelMappingModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (questionId: TSurveyQuestionId) => {
|
||||
setSelectedQuestions((prevValues) =>
|
||||
prevValues.includes(questionId)
|
||||
? prevValues.filter((value) => value !== questionId)
|
||||
: [...prevValues, questionId]
|
||||
const handleCheckboxChange = (elementId: string) => {
|
||||
setSelectedElements((prevValues) =>
|
||||
prevValues.includes(elementId)
|
||||
? prevValues.filter((value) => value !== elementId)
|
||||
: [...prevValues, elementId]
|
||||
);
|
||||
};
|
||||
|
||||
@@ -269,21 +274,25 @@ export const AddChannelMappingModal = ({
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default")?.questions?.map((question) => (
|
||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||
<label htmlFor={question.id} className="flex cursor-pointer items-center">
|
||||
{surveyElements.map((element) => (
|
||||
<div key={element.id} className="my-1 flex items-center space-x-2">
|
||||
<label htmlFor={element.id} className="flex cursor-pointer items-center">
|
||||
<Checkbox
|
||||
type="button"
|
||||
id={question.id}
|
||||
value={question.id}
|
||||
id={element.id}
|
||||
value={element.id}
|
||||
className="bg-white"
|
||||
checked={selectedQuestions.includes(question.id)}
|
||||
checked={selectedElements.includes(element.id)}
|
||||
onCheckedChange={() => {
|
||||
handleCheckboxChange(question.id);
|
||||
handleCheckboxChange(element.id);
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">
|
||||
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
||||
{getTextContent(
|
||||
recallToHeadline(element.headline, selectedSurvey, false, "default")[
|
||||
"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">
|
||||
@@ -134,7 +126,7 @@ export const ManageIntegration = ({
|
||||
}}>
|
||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||
<div className="col-span-2 text-center">{data.channelName}</div>
|
||||
<div className="col-span-2 text-center">{data.questions}</div>
|
||||
<div className="col-span-2 text-center">{data.elements}</div>
|
||||
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import React, { createContext, useCallback, useContext, useState } from "react";
|
||||
import {
|
||||
QuestionOption,
|
||||
QuestionOptions,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
|
||||
ElementOption,
|
||||
ElementOptions,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementsComboBox";
|
||||
import { ElementFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
|
||||
import { getTodayDate } from "@/app/lib/surveys/surveys";
|
||||
|
||||
export interface FilterValue {
|
||||
questionType: Partial<QuestionOption>;
|
||||
elementType: Partial<ElementOption>;
|
||||
filterType: {
|
||||
filterValue: string | undefined;
|
||||
filterComboBoxValue: string | string[] | undefined;
|
||||
@@ -24,8 +24,8 @@ export interface SelectedFilterValue {
|
||||
}
|
||||
|
||||
interface SelectedFilterOptions {
|
||||
questionOptions: QuestionOptions[];
|
||||
questionFilterOptions: QuestionFilterOptions[];
|
||||
elementOptions: ElementOptions[];
|
||||
elementFilterOptions: ElementFilterOptions[];
|
||||
}
|
||||
|
||||
export interface DateRange {
|
||||
@@ -53,8 +53,8 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
});
|
||||
// state holds all the options of the responses fetched
|
||||
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
|
||||
questionFilterOptions: [],
|
||||
questionOptions: [],
|
||||
elementFilterOptions: [],
|
||||
elementOptions: [],
|
||||
});
|
||||
|
||||
const [dateRange, setDateRange] = useState<DateRange>({
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Metadata } from "next";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
@@ -25,7 +26,7 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
|
||||
};
|
||||
|
||||
const SurveyLayout = async ({ children }) => {
|
||||
return <>{children}</>;
|
||||
return <ResponseFilterProvider>{children}</ResponseFilterProvider>;
|
||||
};
|
||||
|
||||
export default SurveyLayout;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
|
||||
interface ResponseDataViewProps {
|
||||
survey: TSurvey;
|
||||
@@ -55,9 +56,11 @@ export const formatContactInfoData = (responseValue: TResponseDataValue): Record
|
||||
export const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => {
|
||||
const responseData: Record<string, any> = {};
|
||||
|
||||
for (const question of survey.questions) {
|
||||
const responseValue = response.data[question.id];
|
||||
switch (question.type) {
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
for (const element of elements) {
|
||||
const responseValue = response.data[element.id];
|
||||
switch (element.type) {
|
||||
case "matrix":
|
||||
if (typeof responseValue === "object") {
|
||||
Object.assign(responseData, responseValue);
|
||||
@@ -70,7 +73,7 @@ export const extractResponseData = (response: TResponseWithQuotas, survey: TSurv
|
||||
Object.assign(responseData, formatContactInfoData(responseValue));
|
||||
break;
|
||||
default:
|
||||
responseData[question.id] = responseValue;
|
||||
responseData[element.id] = responseValue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
|
||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
@@ -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,7 +5,8 @@ import { TFunction } from "i18next";
|
||||
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
||||
@@ -13,7 +14,8 @@ 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 { VARIABLES_ICON_MAP, getQuestionIconMap } from "@/modules/survey/lib/questions";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { VARIABLES_ICON_MAP, getElementIconMap } from "@/modules/survey/lib/elements";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { ResponseBadges } from "@/modules/ui/components/response-badges";
|
||||
@@ -28,35 +30,33 @@ import {
|
||||
getMetadataValue,
|
||||
} from "../lib/utils";
|
||||
|
||||
const getQuestionColumnsData = (
|
||||
question: TSurveyQuestion,
|
||||
const getElementColumnsData = (
|
||||
element: TSurveyElement,
|
||||
survey: TSurvey,
|
||||
isExpanded: boolean,
|
||||
t: TFunction
|
||||
): ColumnDef<TResponseTableData>[] => {
|
||||
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
|
||||
const ELEMENTS_ICON_MAP = getElementIconMap(t);
|
||||
const addressFields = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
|
||||
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
|
||||
|
||||
// Helper function to create consistent column headers
|
||||
const createQuestionHeader = (questionType: string, headline: string, suffix?: string) => {
|
||||
const createElementHeader = (elementType: string, headline: string, suffix?: string) => {
|
||||
const title = suffix ? `${headline} - ${suffix}` : headline;
|
||||
const QuestionHeader = () => (
|
||||
const ElementHeader = () => (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">{QUESTIONS_ICON_MAP[questionType]}</span>
|
||||
<span className="h-4 w-4">{ELEMENTS_ICON_MAP[elementType]}</span>
|
||||
<span className="truncate">{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
QuestionHeader.displayName = "QuestionHeader";
|
||||
return QuestionHeader;
|
||||
return ElementHeader;
|
||||
};
|
||||
|
||||
// Helper function to get localized question headline
|
||||
const getQuestionHeadline = (question: TSurveyQuestion, survey: TSurvey) => {
|
||||
const getElementHeadline = (element: TSurveyElement, survey: TSurvey) => {
|
||||
return getTextContent(
|
||||
getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default")
|
||||
getLocalizedValue(recallToHeadline(element.headline, survey, false, "default"), "default")
|
||||
);
|
||||
};
|
||||
|
||||
@@ -75,18 +75,18 @@ const getQuestionColumnsData = (
|
||||
);
|
||||
};
|
||||
|
||||
switch (question.type) {
|
||||
switch (element.type) {
|
||||
case "matrix":
|
||||
return question.rows.map((matrixRow) => {
|
||||
return element.rows.map((matrixRow) => {
|
||||
return {
|
||||
accessorKey: "QUESTION_" + question.id + "_" + matrixRow.label.default,
|
||||
accessorKey: "ELEMENT_" + element.id + "_" + matrixRow.label.default,
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["matrix"]}</span>
|
||||
<span className="h-4 w-4">{ELEMENTS_ICON_MAP["matrix"]}</span>
|
||||
<span className="truncate">
|
||||
{getTextContent(getLocalizedValue(question.headline, "default")) +
|
||||
{getTextContent(getLocalizedValue(element.headline, "default")) +
|
||||
" - " +
|
||||
getLocalizedValue(matrixRow.label, "default")}
|
||||
</span>
|
||||
@@ -106,12 +106,12 @@ const getQuestionColumnsData = (
|
||||
case "address":
|
||||
return addressFields.map((addressField) => {
|
||||
return {
|
||||
accessorKey: "QUESTION_" + question.id + "_" + addressField,
|
||||
accessorKey: "ELEMENT_" + element.id + "_" + addressField,
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["address"]}</span>
|
||||
<span className="h-4 w-4">{ELEMENTS_ICON_MAP["address"]}</span>
|
||||
<span className="truncate">{getAddressFieldLabel(addressField, t)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,12 +129,12 @@ const getQuestionColumnsData = (
|
||||
case "contactInfo":
|
||||
return contactInfoFields.map((contactInfoField) => {
|
||||
return {
|
||||
accessorKey: "QUESTION_" + question.id + "_" + contactInfoField,
|
||||
accessorKey: "ELEMENT_" + element.id + "_" + contactInfoField,
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["contactInfo"]}</span>
|
||||
<span className="h-4 w-4">{ELEMENTS_ICON_MAP["contactInfo"]}</span>
|
||||
<span className="truncate">{getContactInfoFieldLabel(contactInfoField, t)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,17 +153,17 @@ const getQuestionColumnsData = (
|
||||
case "multipleChoiceSingle":
|
||||
case "ranking":
|
||||
case "pictureSelection": {
|
||||
const questionHeadline = getQuestionHeadline(question, survey);
|
||||
const elementHeadline = getElementHeadline(element, survey);
|
||||
return [
|
||||
{
|
||||
accessorKey: "QUESTION_" + question.id,
|
||||
header: createQuestionHeader(question.type, questionHeadline),
|
||||
accessorKey: "ELEMENT_" + element.id,
|
||||
header: createElementHeader(element.type, elementHeadline),
|
||||
cell: ({ row }) => {
|
||||
const responseValue = row.original.responseData[question.id];
|
||||
const responseValue = row.original.responseData[element.id];
|
||||
const language = row.original.language;
|
||||
return (
|
||||
<RenderResponse
|
||||
question={question}
|
||||
element={element}
|
||||
survey={survey}
|
||||
responseData={responseValue}
|
||||
language={language}
|
||||
@@ -174,15 +174,15 @@ const getQuestionColumnsData = (
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "QUESTION_" + question.id + "optionIds",
|
||||
header: createQuestionHeader(question.type, questionHeadline, t("common.option_id")),
|
||||
accessorKey: "ELEMENT_" + element.id + "optionIds",
|
||||
header: createElementHeader(element.type, elementHeadline, t("common.option_id")),
|
||||
cell: ({ row }) => {
|
||||
const responseValue = row.original.responseData[question.id];
|
||||
const responseValue = row.original.responseData[element.id];
|
||||
// Type guard to ensure responseValue is the correct type
|
||||
if (typeof responseValue === "string" || Array.isArray(responseValue)) {
|
||||
const choiceIds = extractChoiceIdsFromResponse(
|
||||
responseValue,
|
||||
question,
|
||||
element,
|
||||
row.original.language || undefined
|
||||
);
|
||||
return renderChoiceIdBadges(choiceIds, isExpanded);
|
||||
@@ -196,28 +196,25 @@ const getQuestionColumnsData = (
|
||||
default:
|
||||
return [
|
||||
{
|
||||
accessorKey: "QUESTION_" + question.id,
|
||||
accessorKey: "ELEMENT_" + element.id,
|
||||
header: () => (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">{QUESTIONS_ICON_MAP[question.type]}</span>
|
||||
<span className="h-4 w-4">{ELEMENTS_ICON_MAP[element.type]}</span>
|
||||
<span className="truncate">
|
||||
{getTextContent(
|
||||
getLocalizedValue(
|
||||
recallToHeadline(question.headline, survey, false, "default"),
|
||||
"default"
|
||||
)
|
||||
getLocalizedValue(recallToHeadline(element.headline, survey, false, "default"), "default")
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const responseValue = row.original.responseData[question.id];
|
||||
const responseValue = row.original.responseData[element.id];
|
||||
const language = row.original.language;
|
||||
return (
|
||||
<RenderResponse
|
||||
question={question}
|
||||
element={element}
|
||||
survey={survey}
|
||||
responseData={responseValue}
|
||||
language={language}
|
||||
@@ -265,9 +262,8 @@ export const generateResponseTableColumns = (
|
||||
t: TFunction,
|
||||
showQuotasColumn: boolean
|
||||
): ColumnDef<TResponseTableData>[] => {
|
||||
const questionColumns = survey.questions.flatMap((question) =>
|
||||
getQuestionColumnsData(question, survey, isExpanded, t)
|
||||
);
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t));
|
||||
|
||||
const dateColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "createdAt",
|
||||
@@ -414,7 +410,7 @@ export const generateResponseTableColumns = (
|
||||
),
|
||||
};
|
||||
|
||||
// Combine the selection column with the dynamic question columns
|
||||
// Combine the selection column with the dynamic element columns
|
||||
const baseColumns = [
|
||||
personColumn,
|
||||
singleUseIdColumn,
|
||||
@@ -422,7 +418,7 @@ export const generateResponseTableColumns = (
|
||||
...(showQuotasColumn ? [quotasColumn] : []),
|
||||
statusColumn,
|
||||
...(survey.isVerifyEmailEnabled ? [verifiedEmailColumn] : []),
|
||||
...questionColumns,
|
||||
...elementColumns,
|
||||
...variableColumns,
|
||||
...hiddenFieldColumns,
|
||||
...metadataColumns,
|
||||
|
||||
@@ -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,26 +2,27 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionSummaryAddress } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyElementSummaryAddress } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { ArrayResponse } from "@/modules/ui/components/array-response";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||
|
||||
interface AddressSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryAddress;
|
||||
elementSummary: TSurveyElementSummaryAddress;
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const AddressSummary = ({ questionSummary, environmentId, survey, locale }: AddressSummaryProps) => {
|
||||
export const AddressSummary = ({ elementSummary, environmentId, survey, locale }: AddressSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
||||
<div>
|
||||
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
||||
@@ -29,42 +30,48 @@ export const AddressSummary = ({ questionSummary, environmentId, survey, locale
|
||||
<div className="px-4 md:px-6">{t("common.time")}</div>
|
||||
</div>
|
||||
<div className="max-h-[62vh] w-full overflow-y-auto">
|
||||
{questionSummary.samples.map((response) => {
|
||||
return (
|
||||
<div
|
||||
key={response.id}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
{elementSummary.samples.length === 0 ? (
|
||||
<div className="p-8">
|
||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
||||
</div>
|
||||
) : (
|
||||
elementSummary.samples.map((response) => {
|
||||
return (
|
||||
<div
|
||||
key={response.id}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
|
||||
<ArrayResponse value={response.value} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
|
||||
<ArrayResponse value={response.value} />
|
||||
</div>
|
||||
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,39 +2,39 @@
|
||||
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionSummaryCta } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyElementSummaryCta } from "@formbricks/types/surveys/types";
|
||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { convertFloatToNDecimal } from "../lib/utils";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||
|
||||
interface CTASummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryCta;
|
||||
elementSummary: TSurveyElementSummaryCta;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
export const CTASummary = ({ questionSummary, survey }: CTASummaryProps) => {
|
||||
export const CTASummary = ({ elementSummary, survey }: CTASummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader
|
||||
<ElementSummaryHeader
|
||||
survey={survey}
|
||||
questionSummary={questionSummary}
|
||||
elementSummary={elementSummary}
|
||||
showResponses={false}
|
||||
additionalInfo={
|
||||
<>
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
{`${questionSummary.impressionCount} ${t("common.impressions")}`}
|
||||
{`${elementSummary.impressionCount} ${t("common.impressions")}`}
|
||||
</div>
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
{`${questionSummary.clickCount} ${t("common.clicks")}`}
|
||||
{`${elementSummary.clickCount} ${t("common.clicks")}`}
|
||||
</div>
|
||||
{!questionSummary.question.required && (
|
||||
{!elementSummary.element.required && (
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
{`${questionSummary.skipCount} ${t("common.skips")}`}
|
||||
{`${elementSummary.skipCount} ${t("common.skips")}`}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -46,16 +46,16 @@ export const CTASummary = ({ questionSummary, survey }: CTASummaryProps) => {
|
||||
<p className="font-semibold text-slate-700">CTR</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(questionSummary.ctr.percentage, 2)}%
|
||||
{convertFloatToNDecimal(elementSummary.ctr.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{questionSummary.ctr.count}{" "}
|
||||
{questionSummary.ctr.count === 1 ? t("common.click") : t("common.clicks")}
|
||||
{elementSummary.ctr.count}{" "}
|
||||
{elementSummary.ctr.count === 1 ? t("common.click") : t("common.clicks")}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={questionSummary.ctr.percentage / 100} />
|
||||
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.ctr.percentage / 100} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionSummaryCal } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyElementSummaryCal } from "@formbricks/types/surveys/types";
|
||||
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||
|
||||
interface CalSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryCal;
|
||||
elementSummary: TSurveyElementSummaryCal;
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
export const CalSummary = ({ questionSummary, survey }: CalSummaryProps) => {
|
||||
export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
<div>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
@@ -25,16 +25,16 @@ export const CalSummary = ({ questionSummary, survey }: CalSummaryProps) => {
|
||||
<p className="font-semibold text-slate-700">{t("common.booked")}</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(questionSummary.booked.percentage, 2)}%
|
||||
{convertFloatToNDecimal(elementSummary.booked.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{questionSummary.booked.count}{" "}
|
||||
{questionSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
|
||||
{elementSummary.booked.count}{" "}
|
||||
{elementSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={questionSummary.booked.percentage / 100} />
|
||||
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
@@ -42,16 +42,16 @@ export const CalSummary = ({ questionSummary, survey }: CalSummaryProps) => {
|
||||
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(questionSummary.skipped.percentage, 2)}%
|
||||
{convertFloatToNDecimal(elementSummary.skipped.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{questionSummary.skipped.count}{" "}
|
||||
{questionSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
|
||||
{elementSummary.skipped.count}{" "}
|
||||
{elementSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={questionSummary.skipped.percentage / 100} />
|
||||
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,46 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryConsent,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyElementSummaryConsent } from "@formbricks/types/surveys/types";
|
||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { convertFloatToNDecimal } from "../lib/utils";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||
|
||||
interface ConsentSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryConsent;
|
||||
elementSummary: TSurveyElementSummaryConsent;
|
||||
survey: TSurvey;
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
elementId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
elementType: TSurveyElementTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSummaryProps) => {
|
||||
export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const summaryItems = [
|
||||
{
|
||||
title: t("common.accepted"),
|
||||
percentage: questionSummary.accepted.percentage,
|
||||
count: questionSummary.accepted.count,
|
||||
percentage: elementSummary.accepted.percentage,
|
||||
count: elementSummary.accepted.count,
|
||||
},
|
||||
{
|
||||
title: t("common.dismissed"),
|
||||
percentage: questionSummary.dismissed.percentage,
|
||||
count: questionSummary.dismissed.count,
|
||||
percentage: elementSummary.dismissed.percentage,
|
||||
count: elementSummary.dismissed.count,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{summaryItems.map((summaryItem) => {
|
||||
return (
|
||||
@@ -49,9 +45,9 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
|
||||
key={summaryItem.title}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
elementSummary.element.id,
|
||||
elementSummary.element.headline,
|
||||
elementSummary.element.type,
|
||||
"is",
|
||||
summaryItem.title
|
||||
)
|
||||
|
||||
@@ -2,23 +2,24 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionSummaryContactInfo } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyElementSummaryContactInfo } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { ArrayResponse } from "@/modules/ui/components/array-response";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||
|
||||
interface ContactInfoSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryContactInfo;
|
||||
elementSummary: TSurveyElementSummaryContactInfo;
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const ContactInfoSummary = ({
|
||||
questionSummary,
|
||||
elementSummary,
|
||||
environmentId,
|
||||
survey,
|
||||
locale,
|
||||
@@ -26,7 +27,7 @@ export const ContactInfoSummary = ({
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
||||
<div>
|
||||
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
||||
@@ -34,42 +35,48 @@ export const ContactInfoSummary = ({
|
||||
<div className="px-4 md:px-6">{t("common.time")}</div>
|
||||
</div>
|
||||
<div className="max-h-[62vh] w-full overflow-y-auto">
|
||||
{questionSummary.samples.map((response) => {
|
||||
return (
|
||||
<div
|
||||
key={response.id}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
{elementSummary.samples.length === 0 ? (
|
||||
<div className="p-8">
|
||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
||||
</div>
|
||||
) : (
|
||||
elementSummary.samples.map((response) => {
|
||||
return (
|
||||
<div
|
||||
key={response.id}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
|
||||
<ArrayResponse value={response.value} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
|
||||
<ArrayResponse value={response.value} />
|
||||
</div>
|
||||
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||
|
||||
interface DateElementSummary {
|
||||
elementSummary: TSurveyElementSummaryDate;
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const DateElementSummary = ({ elementSummary, environmentId, survey, locale }: DateElementSummary) => {
|
||||
const { t } = useTranslation();
|
||||
const [visibleResponses, setVisibleResponses] = useState(10);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||
setVisibleResponses((prevVisibleResponses) =>
|
||||
Math.min(prevVisibleResponses + 10, elementSummary.samples.length)
|
||||
);
|
||||
};
|
||||
|
||||
const renderResponseValue = (value: string) => {
|
||||
const parsedDate = new Date(value);
|
||||
|
||||
const formattedDate = isNaN(parsedDate.getTime())
|
||||
? `${t("common.invalid_date")}(${value})`
|
||||
: formatDateWithOrdinal(parsedDate);
|
||||
|
||||
return formattedDate;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
||||
<div className="">
|
||||
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
||||
<div className="col-span-2 pl-4 md:pl-6">{t("common.response")}</div>
|
||||
<div className="px-4 md:px-6">{t("common.time")}</div>
|
||||
</div>
|
||||
<div className="max-h-[62vh] w-full overflow-y-auto">
|
||||
{elementSummary.samples.length === 0 ? (
|
||||
<div className="p-8">
|
||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
||||
</div>
|
||||
) : (
|
||||
elementSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<div
|
||||
key={response.id}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||
{renderResponseValue(response.value)}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{elementSummary.samples.length > 0 && visibleResponses < elementSummary.samples.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,102 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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";
|
||||
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface DateQuestionSummary {
|
||||
questionSummary: TSurveyQuestionSummaryDate;
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const DateQuestionSummary = ({
|
||||
questionSummary,
|
||||
environmentId,
|
||||
survey,
|
||||
locale,
|
||||
}: DateQuestionSummary) => {
|
||||
const { t } = useTranslation();
|
||||
const [visibleResponses, setVisibleResponses] = useState(10);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||
setVisibleResponses((prevVisibleResponses) =>
|
||||
Math.min(prevVisibleResponses + 10, questionSummary.samples.length)
|
||||
);
|
||||
};
|
||||
|
||||
const renderResponseValue = (value: string) => {
|
||||
const parsedDate = new Date(value);
|
||||
|
||||
const formattedDate = isNaN(parsedDate.getTime())
|
||||
? `${t("common.invalid_date")}(${value})`
|
||||
: formatDateWithOrdinal(parsedDate);
|
||||
|
||||
return formattedDate;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<div className="">
|
||||
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
||||
<div className="col-span-2 pl-4 md:pl-6">{t("common.response")}</div>
|
||||
<div className="px-4 md:px-6">{t("common.time")}</div>
|
||||
</div>
|
||||
<div className="max-h-[62vh] w-full overflow-y-auto">
|
||||
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<div
|
||||
key={response.id}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||
{renderResponseValue(response.value)}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{visibleResponses < questionSummary.samples.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,28 +3,28 @@
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import type { JSX } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionSummary } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyElementSummary } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||
import { getElementTypes } from "@/modules/survey/lib/elements";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
|
||||
interface HeadProps {
|
||||
questionSummary: TSurveyQuestionSummary;
|
||||
elementSummary: TSurveyElementSummary;
|
||||
showResponses?: boolean;
|
||||
additionalInfo?: JSX.Element;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
export const QuestionSummaryHeader = ({
|
||||
questionSummary,
|
||||
export const ElementSummaryHeader = ({
|
||||
elementSummary,
|
||||
additionalInfo,
|
||||
showResponses = true,
|
||||
survey,
|
||||
}: HeadProps) => {
|
||||
const { t } = useTranslation();
|
||||
const questionType = getQuestionTypes(t).find((type) => type.id === questionSummary.question.type);
|
||||
const elementType = getElementTypes(t).find((type) => type.id === elementSummary.element.type);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
||||
@@ -32,7 +32,7 @@ export const QuestionSummaryHeader = ({
|
||||
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
|
||||
{formatTextWithSlashes(
|
||||
getTextContent(
|
||||
recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"]
|
||||
recallToHeadline(elementSummary.element.headline, survey, true, "default")["default"]
|
||||
),
|
||||
"@",
|
||||
["text-lg"]
|
||||
@@ -41,24 +41,24 @@ export const QuestionSummaryHeader = ({
|
||||
</div>
|
||||
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
{questionType && <questionType.icon className="mr-2 h-4 w-4" />}
|
||||
{questionType ? questionType.label : t("environments.surveys.summary.unknown_question_type")}{" "}
|
||||
{elementType && <elementType.icon className="mr-2 h-4 w-4" />}
|
||||
{elementType ? elementType.label : t("environments.surveys.summary.unknown_question_type")}{" "}
|
||||
{t("common.question")}
|
||||
</div>
|
||||
{showResponses && (
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
{`${questionSummary.responseCount} ${t("common.responses")}`}
|
||||
{`${elementSummary.responseCount} ${t("common.responses")}`}
|
||||
</div>
|
||||
)}
|
||||
{additionalInfo}
|
||||
{!questionSummary.question.required && (
|
||||
{!elementSummary.element.required && (
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
{t("environments.surveys.edit.optional")}
|
||||
</div>
|
||||
)}
|
||||
<IdBadge id={elementSummary.element.id} />
|
||||
</div>
|
||||
<IdBadge id={questionSummary.question.id} label={t("common.question_id")} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,24 +4,25 @@ import { DownloadIcon, FileIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionSummaryFileUpload } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyElementSummaryFileUpload } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||
|
||||
interface FileUploadSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryFileUpload;
|
||||
elementSummary: TSurveyElementSummaryFileUpload;
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const FileUploadSummary = ({
|
||||
questionSummary,
|
||||
elementSummary,
|
||||
environmentId,
|
||||
survey,
|
||||
locale,
|
||||
@@ -31,13 +32,13 @@ export const FileUploadSummary = ({
|
||||
const handleLoadMore = () => {
|
||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||
setVisibleResponses((prevVisibleResponses) =>
|
||||
Math.min(prevVisibleResponses + 10, questionSummary.files.length)
|
||||
Math.min(prevVisibleResponses + 10, elementSummary.files.length)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
||||
<div className="">
|
||||
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
||||
@@ -45,71 +46,77 @@ export const FileUploadSummary = ({
|
||||
<div className="px-4 md:px-6">{t("common.time")}</div>
|
||||
</div>
|
||||
<div className="max-h-[62vh] w-full overflow-y-auto">
|
||||
{questionSummary.files.slice(0, visibleResponses).map((response) => (
|
||||
<div
|
||||
key={response.id}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 grid">
|
||||
{Array.isArray(response.value) &&
|
||||
(response.value.length > 0 ? (
|
||||
response.value.map((fileUrl) => {
|
||||
const fileName = getOriginalFileNameFromUrl(fileUrl);
|
||||
|
||||
return (
|
||||
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
|
||||
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
|
||||
<div className="absolute right-0 top-0 m-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
|
||||
<DownloadIcon className="h-6 text-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div className="flex flex-col items-center justify-center p-2">
|
||||
<FileIcon className="h-6 text-slate-500" />
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{fileName}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex w-full flex-col items-center justify-center p-2">
|
||||
<p className="mt-2 text-sm font-semibold text-slate-500 dark:text-slate-400">
|
||||
{t("common.skipped")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</div>
|
||||
{elementSummary.files.length === 0 ? (
|
||||
<div className="p-8">
|
||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
elementSummary.files.slice(0, visibleResponses).map((response) => (
|
||||
<div
|
||||
key={response.id}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 grid">
|
||||
{Array.isArray(response.value) &&
|
||||
(response.value.length > 0 ? (
|
||||
response.value.map((fileUrl) => {
|
||||
const fileName = getOriginalFileNameFromUrl(fileUrl);
|
||||
|
||||
return (
|
||||
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
|
||||
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
|
||||
<div className="absolute right-0 top-0 m-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
|
||||
<DownloadIcon className="h-6 text-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div className="flex flex-col items-center justify-center p-2">
|
||||
<FileIcon className="h-6 text-slate-500" />
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{fileName}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex w-full flex-col items-center justify-center p-2">
|
||||
<p className="mt-2 text-sm font-semibold text-slate-500 dark:text-slate-400">
|
||||
{t("common.skipped")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{visibleResponses < questionSummary.files.length && (
|
||||
{elementSummary.files.length > 0 && visibleResponses < elementSummary.files.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
|
||||
@@ -4,33 +4,34 @@ import { InboxIcon, Link, MessageSquareTextIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurveyQuestionSummaryHiddenFields } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementSummaryHiddenFields } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
|
||||
interface HiddenFieldsSummaryProps {
|
||||
environment: TEnvironment;
|
||||
questionSummary: TSurveyQuestionSummaryHiddenFields;
|
||||
elementSummary: TSurveyElementSummaryHiddenFields;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: HiddenFieldsSummaryProps) => {
|
||||
export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: HiddenFieldsSummaryProps) => {
|
||||
const [visibleResponses, setVisibleResponses] = useState(10);
|
||||
const { t } = useTranslation();
|
||||
const handleLoadMore = () => {
|
||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||
setVisibleResponses((prevVisibleResponses) =>
|
||||
Math.min(prevVisibleResponses + 10, questionSummary.samples.length)
|
||||
Math.min(prevVisibleResponses + 10, elementSummary.samples.length)
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
||||
<div className={"align-center flex justify-between gap-4"}>
|
||||
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{questionSummary.id}</h3>
|
||||
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{elementSummary.id}</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
|
||||
@@ -40,8 +41,8 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
|
||||
</div>
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
{questionSummary.responseCount}{" "}
|
||||
{questionSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
|
||||
{elementSummary.responseCount}{" "}
|
||||
{elementSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,40 +52,46 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
|
||||
<div className="col-span-2 pl-4 md:pl-6">{t("common.response")}</div>
|
||||
<div className="px-4 md:px-6">{t("common.time")}</div>
|
||||
</div>
|
||||
{questionSummary.samples.slice(0, visibleResponses).map((response, idx) => (
|
||||
<div
|
||||
key={`${response.value}-${idx}`}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environment.id}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||
{response.value}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</div>
|
||||
{elementSummary.samples.length === 0 ? (
|
||||
<div className="p-8">
|
||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
||||
</div>
|
||||
))}
|
||||
{visibleResponses < questionSummary.samples.length && (
|
||||
) : (
|
||||
elementSummary.samples.slice(0, visibleResponses).map((response, idx) => (
|
||||
<div
|
||||
key={`${response.value}-${idx}`}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environment.id}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||
{response.value}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{elementSummary.samples.length > 0 && visibleResponses < elementSummary.samples.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
|
||||
@@ -1,29 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryMatrix,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyElementSummaryMatrix } from "@formbricks/types/surveys/types";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||
|
||||
interface MatrixQuestionSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryMatrix;
|
||||
interface MatrixElementSummaryProps {
|
||||
elementSummary: TSurveyElementSummaryMatrix;
|
||||
survey: TSurvey;
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
elementId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
elementType: TSurveyElementTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: MatrixQuestionSummaryProps) => {
|
||||
export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: MatrixElementSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const getOpacityLevel = (percentage: number): string => {
|
||||
const parsedPercentage = percentage;
|
||||
@@ -40,13 +36,11 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
return "";
|
||||
};
|
||||
|
||||
const columns = questionSummary.data[0]
|
||||
? questionSummary.data[0].columnPercentages.map((c) => c.column)
|
||||
: [];
|
||||
const columns = elementSummary.data[0] ? elementSummary.data[0].columnPercentages.map((c) => c.column) : [];
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
||||
<div className="overflow-x-auto p-6">
|
||||
{/* Summary Table */}
|
||||
<table className="mx-auto border-collapse cursor-default text-left">
|
||||
@@ -63,7 +57,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
|
||||
{elementSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
|
||||
<tr key={rowLabel}>
|
||||
<td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4">
|
||||
<TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
|
||||
@@ -79,16 +73,16 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
tooltipContent={getTooltipContent(
|
||||
undefined,
|
||||
percentage,
|
||||
questionSummary.data[rowIndex].totalResponsesForRow
|
||||
elementSummary.data[rowIndex].totalResponsesForRow
|
||||
)}>
|
||||
<button
|
||||
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
|
||||
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
elementSummary.element.id,
|
||||
elementSummary.element.headline,
|
||||
elementSummary.element.type,
|
||||
rowLabel,
|
||||
column
|
||||
)
|
||||
@@ -4,14 +4,9 @@ import { InboxIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Fragment, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryMultipleChoice,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyType,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyElementSummaryMultipleChoice, TSurveyType } from "@formbricks/types/surveys/types";
|
||||
import { getChoiceIdByValue } from "@/lib/response/utils";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
@@ -19,24 +14,24 @@ import { Button } from "@/modules/ui/components/button";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { convertFloatToNDecimal } from "../lib/utils";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||
|
||||
interface MultipleChoiceSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryMultipleChoice;
|
||||
elementSummary: TSurveyElementSummaryMultipleChoice;
|
||||
environmentId: string;
|
||||
surveyType: TSurveyType;
|
||||
survey: TSurvey;
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
elementId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
elementType: TSurveyElementTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const MultipleChoiceSummary = ({
|
||||
questionSummary,
|
||||
elementSummary,
|
||||
environmentId,
|
||||
surveyType,
|
||||
survey,
|
||||
@@ -44,9 +39,9 @@ export const MultipleChoiceSummary = ({
|
||||
}: MultipleChoiceSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [visibleOtherResponses, setVisibleOtherResponses] = useState(10);
|
||||
const otherValue = questionSummary.question.choices.find((choice) => choice.id === "other")?.label.default;
|
||||
const otherValue = elementSummary.element.choices.find((choice) => choice.id === "other")?.label.default;
|
||||
// sort by count and transform to array
|
||||
const results = Object.values(questionSummary.choices).sort((a, b) => {
|
||||
const results = Object.values(elementSummary.choices).sort((a, b) => {
|
||||
const aHasOthers = (a.others?.length ?? 0) > 0;
|
||||
const bHasOthers = (b.others?.length ?? 0) > 0;
|
||||
|
||||
@@ -73,108 +68,111 @@ export const MultipleChoiceSummary = ({
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader
|
||||
questionSummary={questionSummary}
|
||||
<ElementSummaryHeader
|
||||
elementSummary={elementSummary}
|
||||
survey={survey}
|
||||
additionalInfo={
|
||||
questionSummary.type === "multipleChoiceMulti" ? (
|
||||
elementSummary.type === "multipleChoiceMulti" ? (
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
{`${questionSummary.selectionCount} ${t("common.selections")}`}
|
||||
{`${elementSummary.selectionCount} ${t("common.selections")}`}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{results.map((result) => {
|
||||
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
|
||||
return (
|
||||
<Fragment key={result.value}>
|
||||
<button
|
||||
type="button"
|
||||
className="group w-full cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
|
||||
? t("environments.surveys.summary.includes_either")
|
||||
: t("environments.surveys.summary.includes_all"),
|
||||
[result.value]
|
||||
)
|
||||
}>
|
||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
|
||||
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
|
||||
{result.value}
|
||||
</p>
|
||||
{choiceId && <IdBadge id={choiceId} />}
|
||||
</div>
|
||||
<div className="flex w-full space-x-2">
|
||||
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
||||
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
||||
</p>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group-hover:opacity-80">
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</div>
|
||||
</button>
|
||||
{result.others && result.others.length > 0 && (
|
||||
<div className="mt-4 rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-1 pl-6">
|
||||
{t("environments.surveys.summary.other_values_found")}
|
||||
<div className="px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5">
|
||||
{results.map((result) => {
|
||||
const choiceId = getChoiceIdByValue(result.value, elementSummary.element);
|
||||
return (
|
||||
<Fragment key={result.value}>
|
||||
<button
|
||||
type="button"
|
||||
className="group w-full cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
elementSummary.element.id,
|
||||
elementSummary.element.headline,
|
||||
elementSummary.element.type,
|
||||
elementSummary.type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
|
||||
otherValue === result.value
|
||||
? t("environments.surveys.summary.includes_either")
|
||||
: t("environments.surveys.summary.includes_all"),
|
||||
[result.value]
|
||||
)
|
||||
}>
|
||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
|
||||
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
|
||||
{result.value}
|
||||
</p>
|
||||
{choiceId && <IdBadge id={choiceId} />}
|
||||
</div>
|
||||
<div className="flex w-full space-x-2">
|
||||
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
||||
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
||||
</p>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
|
||||
</div>
|
||||
{result.others
|
||||
.filter((otherValue) => otherValue.value !== "")
|
||||
.slice(0, visibleOtherResponses)
|
||||
.map((otherValue, idx) => (
|
||||
<div key={`${idx}-${otherValue}`} dir="auto">
|
||||
{surveyType === "link" && (
|
||||
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
|
||||
<span>{otherValue.value}</span>
|
||||
</div>
|
||||
)}
|
||||
{surveyType === "app" && otherValue.contact && (
|
||||
<Link
|
||||
href={
|
||||
otherValue.contact.id
|
||||
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
|
||||
: { pathname: null }
|
||||
}
|
||||
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
|
||||
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
|
||||
<div className="group-hover:opacity-80">
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</div>
|
||||
</button>
|
||||
{result.others && result.others.length > 0 && (
|
||||
<div className="mt-4 rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-1 pl-6">
|
||||
{t("environments.surveys.summary.other_values_found")}
|
||||
</div>
|
||||
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
|
||||
</div>
|
||||
{result.others
|
||||
.filter((otherValue) => otherValue.value !== "")
|
||||
.slice(0, visibleOtherResponses)
|
||||
.map((otherValue, idx) => (
|
||||
<div key={`${idx}-${otherValue}`} dir="auto">
|
||||
{surveyType === "link" && (
|
||||
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
|
||||
<span>{otherValue.value}</span>
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
|
||||
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
|
||||
<span>
|
||||
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
)}
|
||||
{surveyType === "app" && otherValue.contact && (
|
||||
<Link
|
||||
href={
|
||||
otherValue.contact.id
|
||||
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
|
||||
: { pathname: null }
|
||||
}
|
||||
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
|
||||
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
|
||||
<span>{otherValue.value}</span>
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
|
||||
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
|
||||
<span>
|
||||
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{visibleOtherResponses < result.others.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{visibleOtherResponses < result.others.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,31 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart, BarChartHorizontal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryNps,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyElementSummaryNps } 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 { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
import { ClickableBarSegment } from "./ClickableBarSegment";
|
||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||
import { SatisfactionIndicator } from "./SatisfactionIndicator";
|
||||
|
||||
interface NPSSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryNps;
|
||||
elementSummary: TSurveyElementSummaryNps;
|
||||
survey: TSurvey;
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
elementId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
elementType: TSurveyElementTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryProps) => {
|
||||
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 = ({ elementSummary, survey, setFilter }: NPSSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
|
||||
|
||||
const applyFilter = (group: string) => {
|
||||
const filters = {
|
||||
promoters: {
|
||||
@@ -50,9 +64,9 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
|
||||
if (filter) {
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
elementSummary.element.id,
|
||||
elementSummary.element.headline,
|
||||
elementSummary.element.type,
|
||||
filter.comparison,
|
||||
filter.values
|
||||
);
|
||||
@@ -61,41 +75,115 @@ 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)}%
|
||||
</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>
|
||||
<ElementSummaryHeader
|
||||
elementSummary={elementSummary}
|
||||
survey={survey}
|
||||
additionalInfo={
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
<SatisfactionIndicator percentage={elementSummary.promoters.percentage} />
|
||||
<div>
|
||||
{t("environments.surveys.summary.promoters")}:{" "}
|
||||
{convertFloatToNDecimal(elementSummary.promoters.percentage, 2)}%
|
||||
</div>
|
||||
<ProgressBar
|
||||
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
|
||||
progress={questionSummary[group]?.percentage / 100}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</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="px-4 pb-6 pt-4 md:px-6">
|
||||
<div className="space-y-5 text-sm 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(elementSummary[group]?.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{elementSummary[group]?.count}{" "}
|
||||
{elementSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar
|
||||
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
|
||||
progress={elementSummary[group]?.percentage / 100}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</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">
|
||||
{elementSummary.choices.map((choice) => {
|
||||
const opacity = calculateNPSOpacity(choice.rating);
|
||||
|
||||
return (
|
||||
<ClickableBarSegment
|
||||
key={choice.rating}
|
||||
className="group flex cursor-pointer flex-col items-center"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
elementSummary.element.id,
|
||||
elementSummary.element.headline,
|
||||
elementSummary.element.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>
|
||||
</TooltipProvider>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex justify-center pb-4 pt-4">
|
||||
<HalfCircle value={questionSummary.score} />
|
||||
<HalfCircle value={elementSummary.score} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,91 +3,98 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyElementSummaryOpenText } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||
|
||||
interface OpenTextSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryOpenText;
|
||||
elementSummary: TSurveyElementSummaryOpenText;
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const OpenTextSummary = ({ questionSummary, environmentId, survey, locale }: OpenTextSummaryProps) => {
|
||||
export const OpenTextSummary = ({ elementSummary, environmentId, survey, locale }: OpenTextSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [visibleResponses, setVisibleResponses] = useState(10);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||
setVisibleResponses((prevVisibleResponses) =>
|
||||
Math.min(prevVisibleResponses + 10, questionSummary.samples.length)
|
||||
Math.min(prevVisibleResponses + 10, elementSummary.samples.length)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
||||
<div className="border-t border-slate-200"></div>
|
||||
<div className="max-h-[40vh] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader className="bg-slate-100">
|
||||
<TableRow>
|
||||
<TableHead>{t("common.user")}</TableHead>
|
||||
<TableHead>{t("common.response")}</TableHead>
|
||||
<TableHead>{t("common.time")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<TableRow key={response.id}>
|
||||
<TableCell>
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-normal text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{typeof response.value === "string"
|
||||
? renderHyperlinkedContent(response.value)
|
||||
: response.value}
|
||||
</TableCell>
|
||||
<TableCell width={120}>
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</TableCell>
|
||||
{elementSummary.samples.length === 0 ? (
|
||||
<div className="p-8">
|
||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[40vh] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader className="bg-slate-100">
|
||||
<TableRow>
|
||||
<TableHead className="w-1/4">{t("common.user")}</TableHead>
|
||||
<TableHead className="w-2/4">{t("common.response")}</TableHead>
|
||||
<TableHead className="w-1/4">{t("common.time")}</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{visibleResponses < questionSummary.samples.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{elementSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<TableRow key={response.id}>
|
||||
<TableCell className="w-1/4">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-normal text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="w-2/4 font-medium">
|
||||
{typeof response.value === "string"
|
||||
? renderHyperlinkedContent(response.value)
|
||||
: response.value}
|
||||
</TableCell>
|
||||
<TableCell className="w-1/4">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{visibleResponses < elementSummary.samples.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,52 +3,48 @@
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryPictureSelection,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyElementSummaryPictureSelection } from "@formbricks/types/surveys/types";
|
||||
import { getChoiceIdByValue } from "@/lib/response/utils";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { convertFloatToNDecimal } from "../lib/utils";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||
|
||||
interface PictureChoiceSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryPictureSelection;
|
||||
elementSummary: TSurveyElementSummaryPictureSelection;
|
||||
survey: TSurvey;
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
elementId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
elementType: TSurveyElementTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: PictureChoiceSummaryProps) => {
|
||||
const results = questionSummary.choices;
|
||||
export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: PictureChoiceSummaryProps) => {
|
||||
const results = elementSummary.choices;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader
|
||||
questionSummary={questionSummary}
|
||||
<ElementSummaryHeader
|
||||
elementSummary={elementSummary}
|
||||
survey={survey}
|
||||
additionalInfo={
|
||||
questionSummary.question.allowMulti ? (
|
||||
elementSummary.element.allowMulti ? (
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
{`${questionSummary.selectionCount} ${t("common.selections")}`}
|
||||
{`${elementSummary.selectionCount} ${t("common.selections")}`}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, index) => {
|
||||
const choiceId = getChoiceIdByValue(result.imageUrl, questionSummary.question);
|
||||
const choiceId = getChoiceIdByValue(result.imageUrl, elementSummary.element);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -56,9 +52,9 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
|
||||
key={result.id}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
elementSummary.element.id,
|
||||
elementSummary.element.headline,
|
||||
elementSummary.element.type,
|
||||
t("environments.surveys.summary.includes_all"),
|
||||
[`${t("environments.surveys.edit.picture_idx", { idx: index + 1 })}`]
|
||||
)
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionSummaryRanking } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyElementSummaryRanking } from "@formbricks/types/surveys/types";
|
||||
import { getChoiceIdByValue } from "@/lib/response/utils";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { convertFloatToNDecimal } from "../lib/utils";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||
|
||||
interface RankingSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryRanking;
|
||||
elementSummary: TSurveyElementSummaryRanking;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
export const RankingSummary = ({ questionSummary, survey }: RankingSummaryProps) => {
|
||||
export const RankingSummary = ({ elementSummary, survey }: RankingSummaryProps) => {
|
||||
// sort by count and transform to array
|
||||
const { t } = useTranslation();
|
||||
const results = Object.values(questionSummary.choices).sort((a, b) => {
|
||||
const results = Object.values(elementSummary.choices).sort((a, b) => {
|
||||
return a.avgRanking - b.avgRanking; // Sort by count
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, resultsIdx) => {
|
||||
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
|
||||
const choiceId = getChoiceIdByValue(result.value, elementSummary.element);
|
||||
return (
|
||||
<div key={result.value} className="group cursor-pointer">
|
||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||
|
||||
@@ -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,101 +1,222 @@
|
||||
"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 {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryRating,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyElementSummaryRating } from "@formbricks/types/surveys/types";
|
||||
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { RatingResponse } from "@/modules/ui/components/rating-response";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||
import { TooltipProvider } from "@/modules/ui/components/tooltip";
|
||||
import { ClickableBarSegment } from "./ClickableBarSegment";
|
||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||
import { RatingScaleLegend } from "./RatingScaleLegend";
|
||||
import { SatisfactionIndicator } from "./SatisfactionIndicator";
|
||||
|
||||
interface RatingSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryRating;
|
||||
elementSummary: TSurveyElementSummaryRating;
|
||||
survey: TSurvey;
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
elementId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
elementType: TSurveyElementTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSummaryProps) => {
|
||||
export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
|
||||
|
||||
const getIconBasedOnScale = useMemo(() => {
|
||||
const scale = questionSummary.question.scale;
|
||||
const scale = elementSummary.element.scale;
|
||||
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
|
||||
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
|
||||
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
|
||||
}, [questionSummary]);
|
||||
}, [elementSummary]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader
|
||||
questionSummary={questionSummary}
|
||||
<ElementSummaryHeader
|
||||
elementSummary={elementSummary}
|
||||
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")}: {elementSummary.average.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
<SatisfactionIndicator percentage={elementSummary.csat.satisfiedPercentage} />
|
||||
<div>
|
||||
CSAT: {elementSummary.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}
|
||||
/>
|
||||
|
||||
<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">
|
||||
{elementSummary.responseCount === 0 ? (
|
||||
<>
|
||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
||||
<RatingScaleLegend
|
||||
scale={elementSummary.element.scale}
|
||||
range={elementSummary.element.range}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
|
||||
{elementSummary.choices.map((result, index) => {
|
||||
if (result.percentage === 0) return null;
|
||||
|
||||
const range = elementSummary.element.range;
|
||||
const opacity = 0.3 + (result.rating / range) * 0.8;
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === elementSummary.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(
|
||||
elementSummary.element.id,
|
||||
elementSummary.element.headline,
|
||||
elementSummary.element.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">
|
||||
{elementSummary.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 < elementSummary.choices.length - 1
|
||||
? "1px solid rgb(226, 232, 240)"
|
||||
: "none",
|
||||
}}>
|
||||
<div className="mb-1 flex items-center justify-center">
|
||||
<RatingResponse
|
||||
scale={elementSummary.element.scale}
|
||||
answer={result.rating}
|
||||
range={elementSummary.element.range}
|
||||
addColors={false}
|
||||
variant="aggregated"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs font-medium text-slate-600">
|
||||
{convertFloatToNDecimal(result.percentage, 1)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
</p>
|
||||
<RatingScaleLegend
|
||||
scale={elementSummary.element.scale}
|
||||
range={elementSummary.element.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">
|
||||
{elementSummary.choices.map((result) => (
|
||||
<div key={result.rating}>
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
elementSummary.element.id,
|
||||
elementSummary.element.headline,
|
||||
elementSummary.element.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={elementSummary.element.scale}
|
||||
answer={result.rating}
|
||||
range={elementSummary.element.range}
|
||||
addColors={elementSummary.element.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>
|
||||
<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>
|
||||
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{elementSummary.dismissed && elementSummary.dismissed.count > 0 && (
|
||||
<div className="rounded-b-lg border-t bg-white px-6 py-4">
|
||||
<div key="dismissed">
|
||||
<div className="text flex justify-between px-2">
|
||||
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{questionSummary.dismissed.count}{" "}
|
||||
{questionSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
|
||||
{elementSummary.dismissed.count}{" "}
|
||||
{elementSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,10 +2,11 @@
|
||||
|
||||
import { TimerIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionType, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||
import { getQuestionIcon } from "@/modules/survey/lib/questions";
|
||||
import { getElementIcon } from "@/modules/survey/lib/elements";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface SummaryDropOffsProps {
|
||||
@@ -15,8 +16,8 @@ interface SummaryDropOffsProps {
|
||||
|
||||
export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const getIcon = (questionType: TSurveyQuestionType) => {
|
||||
const Icon = getQuestionIcon(questionType, t);
|
||||
const getIcon = (elementType: TSurveyElementTypeEnum) => {
|
||||
const Icon = getElementIcon(elementType, t);
|
||||
return <Icon className="mt-[3px] h-5 w-5 shrink-0 text-slate-600" />;
|
||||
};
|
||||
|
||||
@@ -44,10 +45,10 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||
</div>
|
||||
{dropOff.map((quesDropOff) => (
|
||||
<div
|
||||
key={quesDropOff.questionId}
|
||||
key={quesDropOff.elementId}
|
||||
className="grid grid-cols-6 items-start border-b border-slate-100 text-xs text-slate-800 md:text-sm">
|
||||
<div className="col-span-3 flex gap-3 px-4 py-2 md:px-6">
|
||||
{getIcon(quesDropOff.questionType)}
|
||||
{getIcon(quesDropOff.elementType)}
|
||||
<p>
|
||||
{formatTextWithSlashes(
|
||||
recallToHeadline(
|
||||
|
||||
@@ -3,23 +3,25 @@
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TI18nString, TSurveyQuestionId, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { EmptyAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
|
||||
import {
|
||||
SelectedFilterValue,
|
||||
useResponseFilter,
|
||||
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { EmptyAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import { CTASummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary";
|
||||
import { CalSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary";
|
||||
import { ConsentSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary";
|
||||
import { ContactInfoSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ContactInfoSummary";
|
||||
import { DateQuestionSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary";
|
||||
import { DateElementSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateElementSummary";
|
||||
import { FileUploadSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary";
|
||||
import { HiddenFieldsSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary";
|
||||
import { MatrixQuestionSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary";
|
||||
import { MatrixElementSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixElementSummary";
|
||||
import { MultipleChoiceSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary";
|
||||
import { NPSSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary";
|
||||
import { OpenTextSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary";
|
||||
@@ -27,9 +29,9 @@ import { PictureChoiceSummary } from "@/app/(app)/environments/[environmentId]/s
|
||||
import { RankingSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary";
|
||||
import { RatingSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary";
|
||||
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 { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementsComboBox";
|
||||
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";
|
||||
|
||||
@@ -45,29 +47,29 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
const { setSelectedFilter, selectedFilter } = useResponseFilter();
|
||||
const { t } = useTranslation();
|
||||
const setFilter = (
|
||||
questionId: TSurveyQuestionId,
|
||||
elementId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
elementType: TSurveyElementTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => {
|
||||
const filterObject: SelectedFilterValue = { ...selectedFilter };
|
||||
const value = {
|
||||
id: questionId,
|
||||
label: getLocalizedValue(label, "default"),
|
||||
questionType: questionType,
|
||||
type: OptionsType.QUESTIONS,
|
||||
id: elementId,
|
||||
label: getTextContent(getLocalizedValue(label, "default")),
|
||||
elementType,
|
||||
type: OptionsType.ELEMENTS,
|
||||
};
|
||||
|
||||
// Find the index of the existing filter with the same questionId
|
||||
// Find the index of the existing filter with the same elementId
|
||||
const existingFilterIndex = filterObject.filter.findIndex(
|
||||
(filter) => filter.questionType.id === questionId
|
||||
(filter) => filter.elementType.id === elementId
|
||||
);
|
||||
|
||||
if (existingFilterIndex !== -1) {
|
||||
// Replace the existing filter
|
||||
filterObject.filter[existingFilterIndex] = {
|
||||
questionType: value,
|
||||
elementType: value,
|
||||
filterType: {
|
||||
filterComboBoxValue: filterComboBoxValue,
|
||||
filterValue: filterValue,
|
||||
@@ -77,14 +79,14 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
} else {
|
||||
// Add new filter
|
||||
filterObject.filter.push({
|
||||
questionType: value,
|
||||
elementType: value,
|
||||
filterType: {
|
||||
filterComboBoxValue: filterComboBoxValue,
|
||||
filterValue: filterValue,
|
||||
},
|
||||
});
|
||||
toast.success(
|
||||
constructToastMessage(questionType, filterValue, survey, questionId, t, filterComboBoxValue) ??
|
||||
constructToastMessage(elementType, filterValue, survey, elementId, t, filterComboBoxValue) ??
|
||||
t("environments.surveys.summary.filter_added_successfully"),
|
||||
{ duration: 5000 }
|
||||
);
|
||||
@@ -103,19 +105,14 @@ 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 === TSurveyQuestionTypeEnum.OpenText) {
|
||||
summary.map((elementSummary) => {
|
||||
if (elementSummary.type === TSurveyElementTypeEnum.OpenText) {
|
||||
return (
|
||||
<OpenTextSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary}
|
||||
key={elementSummary.element.id}
|
||||
elementSummary={elementSummary}
|
||||
environmentId={environment.id}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
@@ -123,13 +120,13 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
);
|
||||
}
|
||||
if (
|
||||
questionSummary.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||
questionSummary.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
|
||||
elementSummary.type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
|
||||
elementSummary.type === TSurveyElementTypeEnum.MultipleChoiceMulti
|
||||
) {
|
||||
return (
|
||||
<MultipleChoiceSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary}
|
||||
key={elementSummary.element.id}
|
||||
elementSummary={elementSummary}
|
||||
environmentId={environment.id}
|
||||
surveyType={survey.type}
|
||||
survey={survey}
|
||||
@@ -137,132 +134,128 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.NPS) {
|
||||
if (elementSummary.type === TSurveyElementTypeEnum.NPS) {
|
||||
return (
|
||||
<NPSSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary}
|
||||
key={elementSummary.element.id}
|
||||
elementSummary={elementSummary}
|
||||
survey={survey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.CTA) {
|
||||
if (elementSummary.type === TSurveyElementTypeEnum.CTA) {
|
||||
return (
|
||||
<CTASummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
/>
|
||||
<CTASummary key={elementSummary.element.id} elementSummary={elementSummary} survey={survey} />
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Rating) {
|
||||
if (elementSummary.type === TSurveyElementTypeEnum.Rating) {
|
||||
return (
|
||||
<RatingSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary}
|
||||
key={elementSummary.element.id}
|
||||
elementSummary={elementSummary}
|
||||
survey={survey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Consent) {
|
||||
if (elementSummary.type === TSurveyElementTypeEnum.Consent) {
|
||||
return (
|
||||
<ConsentSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary}
|
||||
key={elementSummary.element.id}
|
||||
elementSummary={elementSummary}
|
||||
survey={survey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.PictureSelection) {
|
||||
if (elementSummary.type === TSurveyElementTypeEnum.PictureSelection) {
|
||||
return (
|
||||
<PictureChoiceSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary}
|
||||
key={elementSummary.element.id}
|
||||
elementSummary={elementSummary}
|
||||
survey={survey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Date) {
|
||||
if (elementSummary.type === TSurveyElementTypeEnum.Date) {
|
||||
return (
|
||||
<DateQuestionSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary}
|
||||
<DateElementSummary
|
||||
key={elementSummary.element.id}
|
||||
elementSummary={elementSummary}
|
||||
environmentId={environment.id}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.FileUpload) {
|
||||
if (elementSummary.type === TSurveyElementTypeEnum.FileUpload) {
|
||||
return (
|
||||
<FileUploadSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary}
|
||||
key={elementSummary.element.id}
|
||||
elementSummary={elementSummary}
|
||||
environmentId={environment.id}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Cal) {
|
||||
if (elementSummary.type === TSurveyElementTypeEnum.Cal) {
|
||||
return (
|
||||
<CalSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary}
|
||||
key={elementSummary.element.id}
|
||||
elementSummary={elementSummary}
|
||||
environmentId={environment.id}
|
||||
survey={survey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
if (elementSummary.type === TSurveyElementTypeEnum.Matrix) {
|
||||
return (
|
||||
<MatrixQuestionSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary}
|
||||
<MatrixElementSummary
|
||||
key={elementSummary.element.id}
|
||||
elementSummary={elementSummary}
|
||||
survey={survey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Address) {
|
||||
if (elementSummary.type === TSurveyElementTypeEnum.Address) {
|
||||
return (
|
||||
<AddressSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary}
|
||||
key={elementSummary.element.id}
|
||||
elementSummary={elementSummary}
|
||||
environmentId={environment.id}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.Ranking) {
|
||||
if (elementSummary.type === TSurveyElementTypeEnum.Ranking) {
|
||||
return (
|
||||
<RankingSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary}
|
||||
key={elementSummary.element.id}
|
||||
elementSummary={elementSummary}
|
||||
survey={survey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === "hiddenField") {
|
||||
if (elementSummary.type === "hiddenField") {
|
||||
return (
|
||||
<HiddenFieldsSummary
|
||||
key={questionSummary.id}
|
||||
questionSummary={questionSummary}
|
||||
key={elementSummary.id}
|
||||
elementSummary={elementSummary}
|
||||
environment={environment}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.ContactInfo) {
|
||||
if (elementSummary.type === TSurveyElementTypeEnum.ContactInfo) {
|
||||
return (
|
||||
<ContactInfoSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary}
|
||||
key={elementSummary.element.id}
|
||||
elementSummary={elementSummary}
|
||||
environmentId={environment.id}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from "@/modules/ui/lib/utils";
|
||||
|
||||
interface SummaryMetadataProps {
|
||||
surveySummary: TSurveySummary["meta"];
|
||||
quotasCount: number;
|
||||
isLoading: boolean;
|
||||
tab: "dropOffs" | "quotas" | undefined;
|
||||
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
|
||||
@@ -31,6 +32,7 @@ const formatTime = (ttc) => {
|
||||
|
||||
export const SummaryMetadata = ({
|
||||
surveySummary,
|
||||
quotasCount,
|
||||
isLoading,
|
||||
tab,
|
||||
setTab,
|
||||
@@ -61,7 +63,7 @@ export const SummaryMetadata = ({
|
||||
<div
|
||||
className={cn(
|
||||
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
|
||||
isQuotasAllowed && "2xl:grid-cols-6"
|
||||
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6"
|
||||
)}>
|
||||
<StatCard
|
||||
label={t("environments.surveys.summary.impressions")}
|
||||
@@ -105,7 +107,7 @@ export const SummaryMetadata = ({
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{isQuotasAllowed && (
|
||||
{isQuotasAllowed && quotasCount > 0 && (
|
||||
<InteractiveCard
|
||||
key="quotas"
|
||||
tab="quotas"
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
|
||||
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
|
||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
@@ -115,6 +115,7 @@ export const SummaryPage = ({
|
||||
<>
|
||||
<SummaryMetadata
|
||||
surveySummary={surveySummary.meta}
|
||||
quotasCount={surveySummary.quotas?.length ?? 0}
|
||||
isLoading={isLoading}
|
||||
tab={tab}
|
||||
setTab={setTab}
|
||||
|
||||
@@ -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,7 +5,8 @@ import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||
import { TI18nString, TSurvey, TSurveyMetadata } from "@formbricks/types/surveys/types";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurvey, TSurveyMetadata } from "@formbricks/types/surveys/types";
|
||||
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
||||
import { createI18nString, extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
|
||||
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,23 +14,24 @@ import {
|
||||
TResponseVariables,
|
||||
ZResponseFilterCriteria,
|
||||
} from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurveyElement,
|
||||
TSurveyElementChoice,
|
||||
TSurveyElementTypeEnum,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyContactInfoQuestion,
|
||||
TSurveyElementSummaryAddress,
|
||||
TSurveyElementSummaryContactInfo,
|
||||
TSurveyElementSummaryDate,
|
||||
TSurveyElementSummaryFileUpload,
|
||||
TSurveyElementSummaryHiddenFields,
|
||||
TSurveyElementSummaryMultipleChoice,
|
||||
TSurveyElementSummaryOpenText,
|
||||
TSurveyElementSummaryPictureSelection,
|
||||
TSurveyElementSummaryRanking,
|
||||
TSurveyElementSummaryRating,
|
||||
TSurveyLanguage,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryAddress,
|
||||
TSurveyQuestionSummaryDate,
|
||||
TSurveyQuestionSummaryFileUpload,
|
||||
TSurveyQuestionSummaryHiddenFields,
|
||||
TSurveyQuestionSummaryMultipleChoice,
|
||||
TSurveyQuestionSummaryOpenText,
|
||||
TSurveyQuestionSummaryPictureSelection,
|
||||
TSurveyQuestionSummaryRanking,
|
||||
TSurveyQuestionSummaryRating,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveySummary,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
@@ -40,6 +41,7 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { buildWhereClause } from "@/lib/response/utils";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { findElementLocation, getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { convertFloatTo2Decimal } from "./utils";
|
||||
@@ -95,39 +97,44 @@ export const getSurveySummaryMeta = (
|
||||
};
|
||||
};
|
||||
|
||||
const evaluateLogicAndGetNextQuestionId = (
|
||||
const evaluateLogicAndGetNextElementId = (
|
||||
localSurvey: TSurvey,
|
||||
elements: TSurveyElement[],
|
||||
data: TResponseData,
|
||||
localVariables: TResponseVariables,
|
||||
currentQuestionIndex: number,
|
||||
currQuesTemp: TSurveyQuestion,
|
||||
currentElementIndex: number,
|
||||
currElementTemp: TSurveyElement,
|
||||
selectedLanguage: string | null
|
||||
): {
|
||||
nextQuestionId: TSurveyQuestionId | undefined;
|
||||
nextElementId: string | undefined;
|
||||
updatedSurvey: TSurvey;
|
||||
updatedVariables: TResponseVariables;
|
||||
} => {
|
||||
const questions = localSurvey.questions;
|
||||
|
||||
let updatedSurvey = { ...localSurvey };
|
||||
let updatedVariables = { ...localVariables };
|
||||
|
||||
let firstJumpTarget: string | undefined;
|
||||
|
||||
if (currQuesTemp.logic && currQuesTemp.logic.length > 0) {
|
||||
for (const logic of currQuesTemp.logic) {
|
||||
const { block: currentBlock } = findElementLocation(localSurvey, currElementTemp.id);
|
||||
|
||||
if (currentBlock?.logic && currentBlock.logic.length > 0) {
|
||||
for (const logic of currentBlock.logic) {
|
||||
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
|
||||
const { jumpTarget, requiredQuestionIds, calculations } = performActions(
|
||||
const { jumpTarget, requiredElementIds, calculations } = performActions(
|
||||
updatedSurvey,
|
||||
logic.actions,
|
||||
data,
|
||||
updatedVariables
|
||||
);
|
||||
|
||||
if (requiredQuestionIds.length > 0) {
|
||||
updatedSurvey.questions = updatedSurvey.questions.map((q) =>
|
||||
requiredQuestionIds.includes(q.id) ? { ...q, required: true } : q
|
||||
);
|
||||
if (requiredElementIds.length > 0) {
|
||||
// Update blocks to mark elements as required
|
||||
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
|
||||
...block,
|
||||
elements: block.elements.map((e) =>
|
||||
requiredElementIds.includes(e.id) ? { ...e, required: true } : e
|
||||
),
|
||||
}));
|
||||
}
|
||||
updatedVariables = { ...updatedVariables, ...calculations };
|
||||
|
||||
@@ -139,32 +146,33 @@ const evaluateLogicAndGetNextQuestionId = (
|
||||
}
|
||||
|
||||
// If no jump target was set, check for a fallback logic
|
||||
if (!firstJumpTarget && currQuesTemp.logicFallback) {
|
||||
firstJumpTarget = currQuesTemp.logicFallback;
|
||||
if (!firstJumpTarget && currentBlock?.logicFallback) {
|
||||
firstJumpTarget = currentBlock.logicFallback;
|
||||
}
|
||||
|
||||
// Return the first jump target if found, otherwise go to the next question
|
||||
const nextQuestionId = firstJumpTarget || questions[currentQuestionIndex + 1]?.id || undefined;
|
||||
// Return the first jump target if found, otherwise go to the next element
|
||||
const nextElementId = firstJumpTarget || elements[currentElementIndex + 1]?.id || undefined;
|
||||
|
||||
return { nextQuestionId, updatedSurvey, updatedVariables };
|
||||
return { nextElementId, updatedSurvey, updatedVariables };
|
||||
};
|
||||
|
||||
export const getSurveySummaryDropOff = (
|
||||
survey: TSurvey,
|
||||
elements: TSurveyElement[],
|
||||
responses: TSurveySummaryResponse[],
|
||||
displayCount: number
|
||||
): TSurveySummary["dropOff"] => {
|
||||
const initialTtc = survey.questions.reduce((acc: Record<string, number>, question) => {
|
||||
acc[question.id] = 0;
|
||||
const initialTtc = elements.reduce((acc: Record<string, number>, element) => {
|
||||
acc[element.id] = 0;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
let totalTtc = { ...initialTtc };
|
||||
let responseCounts = { ...initialTtc };
|
||||
|
||||
let dropOffArr = new Array(survey.questions.length).fill(0) as number[];
|
||||
let impressionsArr = new Array(survey.questions.length).fill(0) as number[];
|
||||
let dropOffPercentageArr = new Array(survey.questions.length).fill(0) as number[];
|
||||
let dropOffArr = new Array(elements.length).fill(0) as number[];
|
||||
let impressionsArr = new Array(elements.length).fill(0) as number[];
|
||||
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
|
||||
|
||||
const surveyVariablesData = survey.variables?.reduce(
|
||||
(acc, variable) => {
|
||||
@@ -176,10 +184,10 @@ export const getSurveySummaryDropOff = (
|
||||
|
||||
responses.forEach((response) => {
|
||||
// Calculate total time-to-completion
|
||||
Object.keys(totalTtc).forEach((questionId) => {
|
||||
if (response.ttc && response.ttc[questionId]) {
|
||||
totalTtc[questionId] += response.ttc[questionId];
|
||||
responseCounts[questionId]++;
|
||||
Object.keys(totalTtc).forEach((elementId) => {
|
||||
if (response.ttc && response.ttc[elementId]) {
|
||||
totalTtc[elementId] += response.ttc[elementId];
|
||||
responseCounts[elementId]++;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -191,11 +199,11 @@ export const getSurveySummaryDropOff = (
|
||||
|
||||
let currQuesIdx = 0;
|
||||
|
||||
while (currQuesIdx < localSurvey.questions.length) {
|
||||
const currQues = localSurvey.questions[currQuesIdx];
|
||||
while (currQuesIdx < elements.length) {
|
||||
const currQues = elements[currQuesIdx];
|
||||
if (!currQues) break;
|
||||
|
||||
// question is not answered and required
|
||||
// element is not answered and required
|
||||
if (response.data[currQues.id] === undefined && currQues.required) {
|
||||
dropOffArr[currQuesIdx]++;
|
||||
impressionsArr[currQuesIdx]++;
|
||||
@@ -204,8 +212,9 @@ export const getSurveySummaryDropOff = (
|
||||
|
||||
impressionsArr[currQuesIdx]++;
|
||||
|
||||
const { nextQuestionId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextQuestionId(
|
||||
const { nextElementId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextElementId(
|
||||
localSurvey,
|
||||
elements,
|
||||
localResponseData,
|
||||
localVariables,
|
||||
currQuesIdx,
|
||||
@@ -216,9 +225,9 @@ export const getSurveySummaryDropOff = (
|
||||
localSurvey = updatedSurvey;
|
||||
localVariables = updatedVariables;
|
||||
|
||||
if (nextQuestionId) {
|
||||
const nextQuesIdx = survey.questions.findIndex((q) => q.id === nextQuestionId);
|
||||
if (!response.data[nextQuestionId] && !response.finished) {
|
||||
if (nextElementId) {
|
||||
const nextQuesIdx = elements.findIndex((q) => q.id === nextElementId);
|
||||
if (!response.data[nextElementId] && !response.finished) {
|
||||
dropOffArr[nextQuesIdx]++;
|
||||
impressionsArr[nextQuesIdx]++;
|
||||
break;
|
||||
@@ -230,10 +239,9 @@ export const getSurveySummaryDropOff = (
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate the average time for each question
|
||||
Object.keys(totalTtc).forEach((questionId) => {
|
||||
totalTtc[questionId] =
|
||||
responseCounts[questionId] > 0 ? totalTtc[questionId] / responseCounts[questionId] : 0;
|
||||
// Calculate the average time for each element
|
||||
Object.keys(totalTtc).forEach((elementId) => {
|
||||
totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0;
|
||||
});
|
||||
|
||||
if (!survey.welcomeCard.enabled) {
|
||||
@@ -250,18 +258,18 @@ export const getSurveySummaryDropOff = (
|
||||
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
|
||||
}
|
||||
|
||||
for (let i = 1; i < survey.questions.length; i++) {
|
||||
for (let i = 1; i < elements.length; i++) {
|
||||
if (impressionsArr[i] !== 0) {
|
||||
dropOffPercentageArr[i] = (dropOffArr[i] / impressionsArr[i]) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
const dropOff = survey.questions.map((question, index) => {
|
||||
const dropOff = elements.map((element, index) => {
|
||||
return {
|
||||
questionId: question.id,
|
||||
questionType: question.type,
|
||||
headline: getTextContent(getLocalizedValue(question.headline, "default")),
|
||||
ttc: convertFloatTo2Decimal(totalTtc[question.id]) || 0,
|
||||
elementId: element.id,
|
||||
elementType: element.type,
|
||||
headline: getTextContent(getLocalizedValue(element.headline, "default")),
|
||||
ttc: convertFloatTo2Decimal(totalTtc[element.id]) || 0,
|
||||
impressions: impressionsArr[index] || 0,
|
||||
dropOffCount: dropOffArr[index] || 0,
|
||||
dropOffPercentage: convertFloatTo2Decimal(dropOffPercentageArr[index]) || 0,
|
||||
@@ -277,51 +285,66 @@ const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: strin
|
||||
return language?.default ? "default" : language?.language.code || "default";
|
||||
};
|
||||
|
||||
const checkForI18n = (responseData: TResponseData, id: string, survey: TSurvey, languageCode: string) => {
|
||||
const question = survey.questions.find((question) => question.id === id);
|
||||
const checkForI18n = (
|
||||
responseData: TResponseData,
|
||||
id: string,
|
||||
elements: TSurveyElement[],
|
||||
languageCode: string
|
||||
) => {
|
||||
const element = elements.find((element) => element.id === id);
|
||||
|
||||
if (question?.type === "multipleChoiceMulti" || question?.type === "ranking") {
|
||||
if (element?.type === "multipleChoiceMulti" || element?.type === "ranking") {
|
||||
// Initialize an array to hold the choice values
|
||||
let choiceValues = [] as string[];
|
||||
|
||||
// Type guard: both element types have choices property
|
||||
const hasChoices = "choices" in element;
|
||||
if (!hasChoices) return [];
|
||||
|
||||
(typeof responseData[id] === "string"
|
||||
? ([responseData[id]] as string[])
|
||||
: (responseData[id] as string[])
|
||||
)?.forEach((data) => {
|
||||
choiceValues.push(
|
||||
getLocalizedValue(
|
||||
question.choices.find((choice) => choice.label[languageCode] === data)?.label,
|
||||
element.choices.find((choice) => choice.label[languageCode] === data)?.label,
|
||||
"default"
|
||||
) || data
|
||||
);
|
||||
});
|
||||
|
||||
// Return the array of localized choice values of multiSelect multi questions
|
||||
// Return the array of localized choice values of multiSelect multi elements
|
||||
return choiceValues;
|
||||
}
|
||||
|
||||
// Return the localized value of the choice fo multiSelect single question
|
||||
const choice = (question as TSurveyMultipleChoiceQuestion)?.choices.find(
|
||||
(choice) => choice.label[languageCode] === responseData[id]
|
||||
);
|
||||
// Return the localized value of the choice fo multiSelect single element
|
||||
if (element && "choices" in element) {
|
||||
const choice = element.choices?.find(
|
||||
(choice: TSurveyElementChoice) => choice.label?.[languageCode] === responseData[id]
|
||||
);
|
||||
return choice && "label" in choice
|
||||
? getLocalizedValue(choice.label, "default") || responseData[id]
|
||||
: responseData[id];
|
||||
}
|
||||
|
||||
return getLocalizedValue(choice?.label, "default") || responseData[id];
|
||||
return responseData[id];
|
||||
};
|
||||
|
||||
export const getQuestionSummary = async (
|
||||
export const getElementSummary = async (
|
||||
survey: TSurvey,
|
||||
elements: TSurveyElement[],
|
||||
responses: TSurveySummaryResponse[],
|
||||
dropOff: TSurveySummary["dropOff"]
|
||||
): Promise<TSurveySummary["summary"]> => {
|
||||
const VALUES_LIMIT = 50;
|
||||
let summary: TSurveySummary["summary"] = [];
|
||||
|
||||
for (const question of survey.questions) {
|
||||
switch (question.type) {
|
||||
case TSurveyQuestionTypeEnum.OpenText: {
|
||||
let values: TSurveyQuestionSummaryOpenText["samples"] = [];
|
||||
for (const element of elements) {
|
||||
switch (element.type) {
|
||||
case TSurveyElementTypeEnum.OpenText: {
|
||||
let values: TSurveyElementSummaryOpenText["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
const answer = response.data[element.id];
|
||||
if (answer && typeof answer === "string") {
|
||||
values.push({
|
||||
id: response.id,
|
||||
@@ -334,8 +357,8 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
type: element.type,
|
||||
element: element,
|
||||
responseCount: values.length,
|
||||
samples: values.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
@@ -343,18 +366,18 @@ export const getQuestionSummary = async (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti: {
|
||||
let values: TSurveyQuestionSummaryMultipleChoice["choices"] = [];
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti: {
|
||||
let values: TSurveyElementSummaryMultipleChoice["choices"] = [];
|
||||
|
||||
const otherOption = question.choices.find((choice) => choice.id === "other");
|
||||
const noneOption = question.choices.find((choice) => choice.id === "none");
|
||||
const otherOption = element.choices.find((choice) => choice.id === "other");
|
||||
const noneOption = element.choices.find((choice) => choice.id === "none");
|
||||
|
||||
const questionChoices = question.choices
|
||||
const elementChoices = element.choices
|
||||
.filter((choice) => choice.id !== "other" && choice.id !== "none")
|
||||
.map((choice) => getLocalizedValue(choice.label, "default"));
|
||||
|
||||
const choiceCountMap = questionChoices.reduce((acc: Record<string, number>, choice) => {
|
||||
const choiceCountMap = elementChoices.reduce((acc: Record<string, number>, choice) => {
|
||||
acc[choice] = 0;
|
||||
return acc;
|
||||
}, {});
|
||||
@@ -363,7 +386,7 @@ export const getQuestionSummary = async (
|
||||
const noneLabel = noneOption ? getLocalizedValue(noneOption.label, "default") : null;
|
||||
let noneCount = 0;
|
||||
|
||||
const otherValues: TSurveyQuestionSummaryMultipleChoice["choices"][number]["others"] = [];
|
||||
const otherValues: TSurveyElementSummaryMultipleChoice["choices"][number]["others"] = [];
|
||||
let totalSelectionCount = 0;
|
||||
let totalResponseCount = 0;
|
||||
responses.forEach((response) => {
|
||||
@@ -371,16 +394,16 @@ export const getQuestionSummary = async (
|
||||
|
||||
const answer =
|
||||
responseLanguageCode === "default"
|
||||
? response.data[question.id]
|
||||
: checkForI18n(response.data, question.id, survey, responseLanguageCode);
|
||||
? response.data[element.id]
|
||||
: checkForI18n(response.data, element.id, elements, responseLanguageCode);
|
||||
|
||||
let hasValidAnswer = false;
|
||||
|
||||
if (Array.isArray(answer) && question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
|
||||
if (Array.isArray(answer) && element.type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
|
||||
answer.forEach((value) => {
|
||||
if (value) {
|
||||
totalSelectionCount++;
|
||||
if (questionChoices.includes(value)) {
|
||||
if (elementChoices.includes(value)) {
|
||||
choiceCountMap[value]++;
|
||||
} else if (noneLabel && value === noneLabel) {
|
||||
noneCount++;
|
||||
@@ -396,11 +419,11 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
} else if (
|
||||
typeof answer === "string" &&
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle
|
||||
element.type === TSurveyElementTypeEnum.MultipleChoiceSingle
|
||||
) {
|
||||
if (answer) {
|
||||
totalSelectionCount++;
|
||||
if (questionChoices.includes(answer)) {
|
||||
if (elementChoices.includes(answer)) {
|
||||
choiceCountMap[answer]++;
|
||||
} else if (noneLabel && answer === noneLabel) {
|
||||
noneCount++;
|
||||
@@ -452,8 +475,8 @@ export const getQuestionSummary = async (
|
||||
}
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
type: element.type,
|
||||
element,
|
||||
responseCount: totalResponseCount,
|
||||
selectionCount: totalSelectionCount,
|
||||
choices: values,
|
||||
@@ -462,18 +485,18 @@ export const getQuestionSummary = async (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.PictureSelection: {
|
||||
let values: TSurveyQuestionSummaryPictureSelection["choices"] = [];
|
||||
case TSurveyElementTypeEnum.PictureSelection: {
|
||||
let values: TSurveyElementSummaryPictureSelection["choices"] = [];
|
||||
const choiceCountMap: Record<string, number> = {};
|
||||
|
||||
question.choices.forEach((choice) => {
|
||||
element.choices.forEach((choice) => {
|
||||
choiceCountMap[choice.id] = 0;
|
||||
});
|
||||
let totalResponseCount = 0;
|
||||
let totalSelectionCount = 0;
|
||||
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
const answer = response.data[element.id];
|
||||
if (Array.isArray(answer)) {
|
||||
totalResponseCount++;
|
||||
answer.forEach((value) => {
|
||||
@@ -483,7 +506,7 @@ export const getQuestionSummary = async (
|
||||
}
|
||||
});
|
||||
|
||||
question.choices.forEach((choice) => {
|
||||
element.choices.forEach((choice) => {
|
||||
values.push({
|
||||
id: choice.id,
|
||||
imageUrl: choice.imageUrl,
|
||||
@@ -496,8 +519,8 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
type: element.type,
|
||||
element,
|
||||
responseCount: totalResponseCount,
|
||||
selectionCount: totalSelectionCount,
|
||||
choices: values,
|
||||
@@ -506,10 +529,10 @@ export const getQuestionSummary = async (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Rating: {
|
||||
let values: TSurveyQuestionSummaryRating["choices"] = [];
|
||||
case TSurveyElementTypeEnum.Rating: {
|
||||
let values: TSurveyElementSummaryRating["choices"] = [];
|
||||
const choiceCountMap: Record<number, number> = {};
|
||||
const range = question.range;
|
||||
const range = element.range;
|
||||
|
||||
for (let i = 1; i <= range; i++) {
|
||||
choiceCountMap[i] = 0;
|
||||
@@ -520,40 +543,62 @@ export const getQuestionSummary = async (
|
||||
let dismissed = 0;
|
||||
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
const answer = response.data[element.id];
|
||||
if (typeof answer === "number") {
|
||||
totalResponseCount++;
|
||||
choiceCountMap[answer]++;
|
||||
totalRating += answer;
|
||||
} else if (response.ttc && response.ttc[question.id] > 0) {
|
||||
} else if (response.ttc && response.ttc[element.id] > 0) {
|
||||
dismissed++;
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
type: element.type,
|
||||
element,
|
||||
average: convertFloatTo2Decimal(totalRating / totalResponseCount) || 0,
|
||||
responseCount: totalResponseCount,
|
||||
choices: values,
|
||||
dismissed: {
|
||||
count: dismissed,
|
||||
},
|
||||
csat: {
|
||||
satisfiedCount,
|
||||
satisfiedPercentage,
|
||||
},
|
||||
});
|
||||
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.NPS: {
|
||||
case TSurveyElementTypeEnum.NPS: {
|
||||
const data = {
|
||||
promoters: 0,
|
||||
passives: 0,
|
||||
@@ -563,10 +608,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];
|
||||
const value = response.data[element.id];
|
||||
if (typeof value === "number") {
|
||||
data.total++;
|
||||
scoreCountMap[value]++;
|
||||
if (value >= 9) {
|
||||
data.promoters++;
|
||||
} else if (value >= 7) {
|
||||
@@ -574,7 +626,7 @@ export const getQuestionSummary = async (
|
||||
} else {
|
||||
data.detractors++;
|
||||
}
|
||||
} else if (response.ttc && response.ttc[question.id] > 0) {
|
||||
} else if (response.ttc && response.ttc[element.id] > 0) {
|
||||
data.total++;
|
||||
data.dismissed++;
|
||||
}
|
||||
@@ -585,9 +637,16 @@ 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,
|
||||
type: element.type,
|
||||
element,
|
||||
responseCount: data.total,
|
||||
total: data.total,
|
||||
score: data.score,
|
||||
@@ -607,17 +666,23 @@ export const getQuestionSummary = async (
|
||||
count: data.dismissed,
|
||||
percentage: data.total > 0 ? convertFloatTo2Decimal((data.dismissed / data.total) * 100) : 0,
|
||||
},
|
||||
choices,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.CTA: {
|
||||
case TSurveyElementTypeEnum.CTA: {
|
||||
// Only calculate summary for CTA elements with external buttons (CTR tracking is only meaningful for external links)
|
||||
if (!element.buttonExternal) {
|
||||
break;
|
||||
}
|
||||
|
||||
const data = {
|
||||
clicked: 0,
|
||||
dismissed: 0,
|
||||
};
|
||||
|
||||
responses.forEach((response) => {
|
||||
const value = response.data[question.id];
|
||||
const value = response.data[element.id];
|
||||
if (value === "clicked") {
|
||||
data.clicked++;
|
||||
} else if (value === "dismissed") {
|
||||
@@ -626,12 +691,12 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
|
||||
const totalResponses = data.clicked + data.dismissed;
|
||||
const idx = survey.questions.findIndex((q) => q.id === question.id);
|
||||
const idx = elements.findIndex((q) => q.id === element.id);
|
||||
const impressions = dropOff[idx].impressions;
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
type: element.type,
|
||||
element,
|
||||
impressionCount: impressions,
|
||||
clickCount: data.clicked,
|
||||
skipCount: data.dismissed,
|
||||
@@ -643,17 +708,17 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Consent: {
|
||||
case TSurveyElementTypeEnum.Consent: {
|
||||
const data = {
|
||||
accepted: 0,
|
||||
dismissed: 0,
|
||||
};
|
||||
|
||||
responses.forEach((response) => {
|
||||
const value = response.data[question.id];
|
||||
const value = response.data[element.id];
|
||||
if (value === "accepted") {
|
||||
data.accepted++;
|
||||
} else if (response.ttc && response.ttc[question.id] > 0) {
|
||||
} else if (response.ttc && response.ttc[element.id] > 0) {
|
||||
data.dismissed++;
|
||||
}
|
||||
});
|
||||
@@ -661,8 +726,8 @@ export const getQuestionSummary = async (
|
||||
const totalResponses = data.accepted + data.dismissed;
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
type: element.type,
|
||||
element,
|
||||
responseCount: totalResponses,
|
||||
accepted: {
|
||||
count: data.accepted,
|
||||
@@ -678,10 +743,10 @@ export const getQuestionSummary = async (
|
||||
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Date: {
|
||||
let values: TSurveyQuestionSummaryDate["samples"] = [];
|
||||
case TSurveyElementTypeEnum.Date: {
|
||||
let values: TSurveyElementSummaryDate["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
const answer = response.data[element.id];
|
||||
if (answer && typeof answer === "string") {
|
||||
values.push({
|
||||
id: response.id,
|
||||
@@ -694,8 +759,8 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
type: element.type,
|
||||
element,
|
||||
responseCount: values.length,
|
||||
samples: values.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
@@ -703,10 +768,10 @@ export const getQuestionSummary = async (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.FileUpload: {
|
||||
let values: TSurveyQuestionSummaryFileUpload["files"] = [];
|
||||
case TSurveyElementTypeEnum.FileUpload: {
|
||||
let values: TSurveyElementSummaryFileUpload["files"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
const answer = response.data[element.id];
|
||||
if (Array.isArray(answer)) {
|
||||
values.push({
|
||||
id: response.id,
|
||||
@@ -719,8 +784,8 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
type: element.type,
|
||||
element,
|
||||
responseCount: values.length,
|
||||
files: values.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
@@ -728,25 +793,25 @@ export const getQuestionSummary = async (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Cal: {
|
||||
case TSurveyElementTypeEnum.Cal: {
|
||||
const data = {
|
||||
booked: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
|
||||
responses.forEach((response) => {
|
||||
const value = response.data[question.id];
|
||||
const value = response.data[element.id];
|
||||
if (value === "booked") {
|
||||
data.booked++;
|
||||
} else if (response.ttc && response.ttc[question.id] > 0) {
|
||||
} else if (response.ttc && response.ttc[element.id] > 0) {
|
||||
data.skipped++;
|
||||
}
|
||||
});
|
||||
const totalResponses = data.booked + data.skipped;
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
type: element.type,
|
||||
element,
|
||||
responseCount: totalResponses,
|
||||
booked: {
|
||||
count: data.booked,
|
||||
@@ -761,9 +826,9 @@ export const getQuestionSummary = async (
|
||||
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Matrix: {
|
||||
const rows = question.rows.map((row) => getLocalizedValue(row.label, "default"));
|
||||
const columns = question.columns.map((column) => getLocalizedValue(column.label, "default"));
|
||||
case TSurveyElementTypeEnum.Matrix: {
|
||||
const rows = element.rows.map((row) => getLocalizedValue(row.label, "default"));
|
||||
const columns = element.columns.map((column) => getLocalizedValue(column.label, "default"));
|
||||
let totalResponseCount = 0;
|
||||
|
||||
// Initialize count object
|
||||
@@ -776,13 +841,13 @@ export const getQuestionSummary = async (
|
||||
}, {});
|
||||
|
||||
responses.forEach((response) => {
|
||||
const selectedResponses = response.data[question.id] as Record<string, string>;
|
||||
const selectedResponses = response.data[element.id] as Record<string, string>;
|
||||
const responseLanguageCode = getLanguageCode(survey.languages, response.language);
|
||||
if (selectedResponses) {
|
||||
totalResponseCount++;
|
||||
question.rows.forEach((row) => {
|
||||
element.rows.forEach((row) => {
|
||||
const localizedRow = getLocalizedValue(row.label, responseLanguageCode);
|
||||
const colValue = question.columns.find((column) => {
|
||||
const colValue = element.columns.find((column) => {
|
||||
return (
|
||||
getLocalizedValue(column.label, responseLanguageCode) === selectedResponses[localizedRow]
|
||||
);
|
||||
@@ -815,18 +880,17 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
type: element.type,
|
||||
element,
|
||||
responseCount: totalResponseCount,
|
||||
data: matrixSummary,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Address:
|
||||
case TSurveyQuestionTypeEnum.ContactInfo: {
|
||||
let values: TSurveyQuestionSummaryAddress["samples"] = [];
|
||||
case TSurveyElementTypeEnum.Address: {
|
||||
let values: TSurveyElementSummaryAddress["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
const answer = response.data[element.id];
|
||||
if (Array.isArray(answer) && answer.length > 0) {
|
||||
values.push({
|
||||
id: response.id,
|
||||
@@ -839,8 +903,8 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: question.type as TSurveyQuestionTypeEnum.ContactInfo,
|
||||
question: question as TSurveyContactInfoQuestion,
|
||||
type: TSurveyElementTypeEnum.Address,
|
||||
element,
|
||||
responseCount: values.length,
|
||||
samples: values.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
@@ -848,13 +912,39 @@ export const getQuestionSummary = async (
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Ranking: {
|
||||
let values: TSurveyQuestionSummaryRanking["choices"] = [];
|
||||
const questionChoices = question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
|
||||
case TSurveyElementTypeEnum.ContactInfo: {
|
||||
let values: TSurveyElementSummaryContactInfo["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[element.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,
|
||||
element,
|
||||
responseCount: values.length,
|
||||
samples: values.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.Ranking: {
|
||||
let values: TSurveyElementSummaryRanking["choices"] = [];
|
||||
const elementChoices = element.choices.map((choice) => getLocalizedValue(choice.label, "default"));
|
||||
let totalResponseCount = 0;
|
||||
const choiceRankSums: Record<string, number> = {};
|
||||
const choiceCountMap: Record<string, number> = {};
|
||||
questionChoices.forEach((choice) => {
|
||||
|
||||
elementChoices.forEach((choice: string) => {
|
||||
choiceRankSums[choice] = 0;
|
||||
choiceCountMap[choice] = 0;
|
||||
});
|
||||
@@ -864,14 +954,14 @@ export const getQuestionSummary = async (
|
||||
|
||||
const answer =
|
||||
responseLanguageCode === "default"
|
||||
? response.data[question.id]
|
||||
: checkForI18n(response.data, question.id, survey, responseLanguageCode);
|
||||
? response.data[element.id]
|
||||
: checkForI18n(response.data, element.id, elements, responseLanguageCode);
|
||||
|
||||
if (Array.isArray(answer)) {
|
||||
totalResponseCount++;
|
||||
answer.forEach((value, index) => {
|
||||
const ranking = index + 1; // Calculate ranking based on index
|
||||
if (questionChoices.includes(value)) {
|
||||
if (elementChoices.includes(value)) {
|
||||
choiceRankSums[value] += ranking;
|
||||
choiceCountMap[value]++;
|
||||
}
|
||||
@@ -879,7 +969,7 @@ export const getQuestionSummary = async (
|
||||
}
|
||||
});
|
||||
|
||||
questionChoices.forEach((choice) => {
|
||||
elementChoices.forEach((choice: string) => {
|
||||
const count = choiceCountMap[choice];
|
||||
const avgRanking = count > 0 ? choiceRankSums[choice] / count : 0;
|
||||
values.push({
|
||||
@@ -890,8 +980,8 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
type: element.type,
|
||||
element,
|
||||
responseCount: totalResponseCount,
|
||||
choices: values,
|
||||
});
|
||||
@@ -902,7 +992,7 @@ export const getQuestionSummary = async (
|
||||
}
|
||||
|
||||
survey.hiddenFields?.fieldIds?.forEach((hiddenFieldId) => {
|
||||
let values: TSurveyQuestionSummaryHiddenFields["samples"] = [];
|
||||
let values: TSurveyElementSummaryHiddenFields["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[hiddenFieldId];
|
||||
if (answer && typeof answer === "string") {
|
||||
@@ -938,6 +1028,8 @@ export const getSurveySummary = reactCache(
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
const batchSize = 5000;
|
||||
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
|
||||
|
||||
@@ -968,16 +1060,16 @@ export const getSurveySummary = reactCache(
|
||||
getQuotasSummary(surveyId),
|
||||
]);
|
||||
|
||||
const dropOff = getSurveySummaryDropOff(survey, responses, displayCount);
|
||||
const [meta, questionWiseSummary] = await Promise.all([
|
||||
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
|
||||
const [meta, elementSummary] = await Promise.all([
|
||||
getSurveySummaryMeta(responses, displayCount, quotas),
|
||||
getQuestionSummary(survey, responses, dropOff),
|
||||
getElementSummary(survey, elements, responses, dropOff),
|
||||
]);
|
||||
|
||||
return {
|
||||
meta,
|
||||
dropOff,
|
||||
summary: questionWiseSummary,
|
||||
summary: elementSummary,
|
||||
quotas,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { constructToastMessage, convertFloatTo2Decimal, convertFloatToNDecimal } from "./utils";
|
||||
|
||||
describe("Utils Tests", () => {
|
||||
@@ -34,29 +35,40 @@ describe("Utils Tests", () => {
|
||||
type: "app",
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Q1" },
|
||||
required: false,
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Q2" },
|
||||
required: false,
|
||||
choices: [{ id: "c1", label: { default: "Choice 1" } }],
|
||||
},
|
||||
{
|
||||
id: "q3",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Q3" },
|
||||
required: false,
|
||||
rows: [{ id: "r1", label: { default: "Row 1" } }],
|
||||
columns: [{ id: "col1", label: { default: "Col 1" } }],
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q1" },
|
||||
required: false,
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Q2" },
|
||||
required: false,
|
||||
choices: [{ id: "c1", label: { default: "Choice 1" } }],
|
||||
buttonLabel: { default: "Next" },
|
||||
shuffleOption: "none",
|
||||
},
|
||||
{
|
||||
id: "q3",
|
||||
type: TSurveyElementTypeEnum.Matrix,
|
||||
headline: { default: "Q3" },
|
||||
required: false,
|
||||
rows: [{ id: "r1", label: { default: "Row 1" } }],
|
||||
columns: [{ id: "col1", label: { default: "Col 1" } }],
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
@@ -74,7 +86,7 @@ describe("Utils Tests", () => {
|
||||
|
||||
test("should construct message for matrix question type", () => {
|
||||
const message = constructToastMessage(
|
||||
TSurveyQuestionTypeEnum.Matrix,
|
||||
TSurveyElementTypeEnum.Matrix,
|
||||
"is",
|
||||
mockSurvey,
|
||||
"q3",
|
||||
@@ -95,7 +107,7 @@ describe("Utils Tests", () => {
|
||||
});
|
||||
|
||||
test("should construct message for matrix question type with array filterComboBoxValue", () => {
|
||||
const message = constructToastMessage(TSurveyQuestionTypeEnum.Matrix, "is", mockSurvey, "q3", mockT, [
|
||||
const message = constructToastMessage(TSurveyElementTypeEnum.Matrix, "is", mockSurvey, "q3", mockT, [
|
||||
"MatrixValue1",
|
||||
"MatrixValue2",
|
||||
]);
|
||||
@@ -114,7 +126,7 @@ describe("Utils Tests", () => {
|
||||
|
||||
test("should construct message when filterComboBoxValue is undefined (skipped)", () => {
|
||||
const message = constructToastMessage(
|
||||
TSurveyQuestionTypeEnum.OpenText,
|
||||
TSurveyElementTypeEnum.OpenText,
|
||||
"is skipped",
|
||||
mockSurvey,
|
||||
"q1",
|
||||
@@ -134,7 +146,7 @@ describe("Utils Tests", () => {
|
||||
|
||||
test("should construct message for non-matrix question with string filterComboBoxValue", () => {
|
||||
const message = constructToastMessage(
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
"is",
|
||||
mockSurvey,
|
||||
"q2",
|
||||
@@ -156,7 +168,7 @@ describe("Utils Tests", () => {
|
||||
|
||||
test("should construct message for non-matrix question with array filterComboBoxValue", () => {
|
||||
const message = constructToastMessage(
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
"includes all of",
|
||||
mockSurvey,
|
||||
"q2", // Assuming q2 can be multi for this test case logic
|
||||
@@ -178,7 +190,7 @@ describe("Utils Tests", () => {
|
||||
|
||||
test("should handle questionId not found in survey", () => {
|
||||
const message = constructToastMessage(
|
||||
TSurveyQuestionTypeEnum.OpenText,
|
||||
TSurveyElementTypeEnum.OpenText,
|
||||
"is",
|
||||
mockSurvey,
|
||||
"qNonExistent",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { TSurvey, TSurveyQuestionId, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
|
||||
export const convertFloatToNDecimal = (num: number, N: number = 2) => {
|
||||
return Math.round(num * Math.pow(10, N)) / Math.pow(10, N);
|
||||
@@ -10,27 +12,28 @@ export const convertFloatTo2Decimal = (num: number) => {
|
||||
};
|
||||
|
||||
export const constructToastMessage = (
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
elementType: TSurveyElementTypeEnum,
|
||||
filterValue: string,
|
||||
survey: TSurvey,
|
||||
questionId: TSurveyQuestionId,
|
||||
elementId: string,
|
||||
t: TFunction,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => {
|
||||
const questionIdx = survey.questions.findIndex((question) => question.id === questionId);
|
||||
if (questionType === "matrix") {
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
const elementIdx = elements.findIndex((element) => element.id === elementId);
|
||||
if (elementType === "matrix") {
|
||||
return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question", {
|
||||
questionIdx: questionIdx + 1,
|
||||
questionIdx: elementIdx + 1,
|
||||
filterComboBoxValue: filterComboBoxValue?.toString() ?? "",
|
||||
filterValue,
|
||||
});
|
||||
} else if (filterComboBoxValue === undefined) {
|
||||
return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question_is_skipped", {
|
||||
questionIdx: questionIdx + 1,
|
||||
questionIdx: elementIdx + 1,
|
||||
});
|
||||
} else {
|
||||
return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question", {
|
||||
questionIdx: questionIdx + 1,
|
||||
questionIdx: elementIdx + 1,
|
||||
filterComboBoxValue: Array.isArray(filterComboBoxValue)
|
||||
? filterComboBoxValue.join(",")
|
||||
: filterComboBoxValue,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -25,7 +25,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
DateRange,
|
||||
useResponseFilter,
|
||||
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
|
||||
import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys";
|
||||
@@ -164,12 +164,12 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
|
||||
const datePickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const extracMetadataKeys = useCallback((obj, parentKey = "") => {
|
||||
const extractMetadataKeys = useCallback((obj, parentKey = "") => {
|
||||
let keys: string[] = [];
|
||||
|
||||
for (let key in obj) {
|
||||
if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||
keys = keys.concat(extracMetadataKeys(obj[key], parentKey + key + " - "));
|
||||
keys = keys.concat(extractMetadataKeys(obj[key], parentKey + key + " - "));
|
||||
} else {
|
||||
keys.push(parentKey + key);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ 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 { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementsComboBox";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -25,20 +26,29 @@ import {
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
type QuestionFilterComboBoxProps = {
|
||||
filterOptions: string[] | undefined;
|
||||
filterComboBoxOptions: string[] | undefined;
|
||||
const DEFAULT_LANGUAGE_CODE = "default";
|
||||
|
||||
// Helper to get localized option value
|
||||
const getOptionValue = (option: string | TI18nString): string => {
|
||||
return typeof option === "object" && option !== null
|
||||
? getLocalizedValue(option, DEFAULT_LANGUAGE_CODE)
|
||||
: option;
|
||||
};
|
||||
|
||||
type ElementFilterComboBoxProps = {
|
||||
filterOptions: (string | TI18nString)[] | undefined;
|
||||
filterComboBoxOptions: (string | TI18nString)[] | undefined;
|
||||
filterValue: string | undefined;
|
||||
filterComboBoxValue: string | string[] | undefined;
|
||||
onChangeFilterValue: (o: string) => void;
|
||||
onChangeFilterComboBoxValue: (o: string | string[]) => void;
|
||||
type?: TSurveyQuestionTypeEnum | Omit<OptionsType, OptionsType.QUESTIONS>;
|
||||
type?: TSurveyElementTypeEnum | Omit<OptionsType, OptionsType.ELEMENTS>;
|
||||
handleRemoveMultiSelect: (value: string[]) => void;
|
||||
disabled?: boolean;
|
||||
fieldId?: string;
|
||||
};
|
||||
|
||||
export const QuestionFilterComboBox = ({
|
||||
export const ElementFilterComboBox = ({
|
||||
filterComboBoxOptions,
|
||||
filterComboBoxValue,
|
||||
filterOptions,
|
||||
@@ -49,7 +59,7 @@ export const QuestionFilterComboBox = ({
|
||||
handleRemoveMultiSelect,
|
||||
disabled = false,
|
||||
fieldId,
|
||||
}: QuestionFilterComboBoxProps) => {
|
||||
}: ElementFilterComboBoxProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const commandRef = useRef(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -57,32 +67,28 @@ export const QuestionFilterComboBox = ({
|
||||
|
||||
useClickOutside(commandRef, () => setOpen(false));
|
||||
|
||||
const defaultLanguageCode = "default";
|
||||
|
||||
// Check if multiple selection is allowed
|
||||
const isMultiple = useMemo(
|
||||
() =>
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||
type === TSurveyQuestionTypeEnum.PictureSelection ||
|
||||
(type === TSurveyQuestionTypeEnum.NPS && filterValue === "Includes either"),
|
||||
[type, filterValue]
|
||||
);
|
||||
const isMultiSelectType =
|
||||
type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
|
||||
type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
|
||||
type === TSurveyElementTypeEnum.PictureSelection;
|
||||
const isNPSIncludesEither = type === TSurveyElementTypeEnum.NPS && filterValue === "Includes either";
|
||||
const isMultiple = isMultiSelectType || isNPSIncludesEither;
|
||||
|
||||
// Filter out already selected options for multi-select
|
||||
const options = useMemo(() => {
|
||||
if (!isMultiple) return filterComboBoxOptions;
|
||||
|
||||
return filterComboBoxOptions?.filter((o) => {
|
||||
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
const optionValue = getOptionValue(o);
|
||||
return !filterComboBoxValue?.includes(optionValue);
|
||||
});
|
||||
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue, defaultLanguageCode]);
|
||||
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue]);
|
||||
|
||||
// Disable combo box for NPS/Rating when Submitted/Skipped
|
||||
const isDisabledComboBox =
|
||||
(type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) &&
|
||||
(filterValue === "Submitted" || filterValue === "Skipped");
|
||||
const isNPSOrRating = type === TSurveyElementTypeEnum.NPS || type === TSurveyElementTypeEnum.Rating;
|
||||
const isSubmittedOrSkipped = filterValue === "Submitted" || filterValue === "Skipped";
|
||||
const isDisabledComboBox = isNPSOrRating && isSubmittedOrSkipped;
|
||||
|
||||
// Check if this is a text input field (URL meta field)
|
||||
const isTextInputField = type === OptionsType.META && fieldId === "url";
|
||||
@@ -91,14 +97,14 @@ export const QuestionFilterComboBox = ({
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
options?.filter((o) => {
|
||||
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
const optionValue = getOptionValue(o);
|
||||
return optionValue.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
}),
|
||||
[options, searchQuery, defaultLanguageCode]
|
||||
[options, searchQuery]
|
||||
);
|
||||
|
||||
const handleCommandItemSelect = (o: string) => {
|
||||
const value = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
const handleCommandItemSelect = (o: string | TI18nString) => {
|
||||
const value = getOptionValue(o);
|
||||
|
||||
if (isMultiple) {
|
||||
const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value];
|
||||
@@ -200,14 +206,17 @@ 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 = getOptionValue(o);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={`${optionValue}-${index}`}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onChangeFilterValue(optionValue)}>
|
||||
{optionValue}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
@@ -269,7 +278,7 @@ 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 = getOptionValue(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 { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
import { NetPromoterScoreIcon } from "@/modules/ui/components/icons";
|
||||
|
||||
export enum OptionsType {
|
||||
QUESTIONS = "Questions",
|
||||
ELEMENTS = "Elements",
|
||||
TAGS = "Tags",
|
||||
ATTRIBUTES = "Attributes",
|
||||
OTHERS = "Other Filters",
|
||||
@@ -53,37 +53,37 @@ export enum OptionsType {
|
||||
QUOTAS = "Quotas",
|
||||
}
|
||||
|
||||
export type QuestionOption = {
|
||||
export type ElementOption = {
|
||||
label: string;
|
||||
questionType?: TSurveyQuestionTypeEnum;
|
||||
elementType?: TSurveyElementTypeEnum;
|
||||
type: OptionsType;
|
||||
id: string;
|
||||
};
|
||||
export type QuestionOptions = {
|
||||
export type ElementOptions = {
|
||||
header: OptionsType;
|
||||
option: QuestionOption[];
|
||||
option: ElementOption[];
|
||||
};
|
||||
|
||||
interface QuestionComboBoxProps {
|
||||
options: QuestionOptions[];
|
||||
selected: Partial<QuestionOption>;
|
||||
onChangeValue: (option: QuestionOption) => void;
|
||||
interface ElementComboBoxProps {
|
||||
options: ElementOptions[];
|
||||
selected: Partial<ElementOption>;
|
||||
onChangeValue: (option: ElementOption) => void;
|
||||
}
|
||||
|
||||
const questionIcons = {
|
||||
// questions
|
||||
[TSurveyQuestionTypeEnum.OpenText]: MessageSquareTextIcon,
|
||||
[TSurveyQuestionTypeEnum.Rating]: StarIcon,
|
||||
[TSurveyQuestionTypeEnum.CTA]: MousePointerClickIcon,
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: ListIcon,
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: Rows3Icon,
|
||||
[TSurveyQuestionTypeEnum.NPS]: NetPromoterScoreIcon,
|
||||
[TSurveyQuestionTypeEnum.Consent]: CheckIcon,
|
||||
[TSurveyQuestionTypeEnum.PictureSelection]: ImageIcon,
|
||||
[TSurveyQuestionTypeEnum.Matrix]: GridIcon,
|
||||
[TSurveyQuestionTypeEnum.Ranking]: ListOrderedIcon,
|
||||
[TSurveyQuestionTypeEnum.Address]: HomeIcon,
|
||||
[TSurveyQuestionTypeEnum.ContactInfo]: ContactIcon,
|
||||
const elementIcons = {
|
||||
// elements
|
||||
[TSurveyElementTypeEnum.OpenText]: MessageSquareTextIcon,
|
||||
[TSurveyElementTypeEnum.Rating]: StarIcon,
|
||||
[TSurveyElementTypeEnum.CTA]: MousePointerClickIcon,
|
||||
[TSurveyElementTypeEnum.MultipleChoiceMulti]: ListIcon,
|
||||
[TSurveyElementTypeEnum.MultipleChoiceSingle]: Rows3Icon,
|
||||
[TSurveyElementTypeEnum.NPS]: NetPromoterScoreIcon,
|
||||
[TSurveyElementTypeEnum.Consent]: CheckIcon,
|
||||
[TSurveyElementTypeEnum.PictureSelection]: ImageIcon,
|
||||
[TSurveyElementTypeEnum.Matrix]: GridIcon,
|
||||
[TSurveyElementTypeEnum.Ranking]: ListOrderedIcon,
|
||||
[TSurveyElementTypeEnum.Address]: HomeIcon,
|
||||
[TSurveyElementTypeEnum.ContactInfo]: ContactIcon,
|
||||
|
||||
// attributes
|
||||
[OptionsType.ATTRIBUTES]: User,
|
||||
@@ -111,14 +111,14 @@ const questionIcons = {
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
const IconComponent = questionIcons[type];
|
||||
const IconComponent = elementIcons[type];
|
||||
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
|
||||
};
|
||||
|
||||
const getIconBackground = (type: OptionsType | string): string => {
|
||||
const backgroundMap: Record<string, string> = {
|
||||
[OptionsType.ATTRIBUTES]: "bg-indigo-500",
|
||||
[OptionsType.QUESTIONS]: "bg-brand-dark",
|
||||
[OptionsType.ELEMENTS]: "bg-brand-dark",
|
||||
[OptionsType.TAGS]: "bg-indigo-500",
|
||||
[OptionsType.QUOTAS]: "bg-slate-500",
|
||||
};
|
||||
@@ -130,10 +130,10 @@ const getLabelClassName = (type: OptionsType | string, label?: string): string =
|
||||
return label === "os" || label === "url" ? "uppercase" : "capitalize";
|
||||
};
|
||||
|
||||
export const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
|
||||
export const SelectedCommandItem = ({ label, elementType, type }: Partial<ElementOption>) => {
|
||||
const getDisplayIcon = () => {
|
||||
if (!type) return null;
|
||||
if (type === OptionsType.QUESTIONS && questionType) return getIcon(questionType);
|
||||
if (type === OptionsType.ELEMENTS && elementType) return getIcon(elementType);
|
||||
if (type === OptionsType.ATTRIBUTES) return getIcon(OptionsType.ATTRIBUTES);
|
||||
if (type === OptionsType.HIDDEN_FIELDS) return getIcon(OptionsType.HIDDEN_FIELDS);
|
||||
if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) return getIcon(label);
|
||||
@@ -158,7 +158,7 @@ export const SelectedCommandItem = ({ label, questionType, type }: Partial<Quest
|
||||
);
|
||||
};
|
||||
|
||||
export const QuestionsComboBox = ({ options, selected, onChangeValue }: QuestionComboBoxProps) => {
|
||||
export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementComboBoxProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const commandRef = useRef(null);
|
||||
@@ -209,7 +209,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
|
||||
|
||||
{open && (
|
||||
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md shadow-md outline-none">
|
||||
<CommandList>
|
||||
<CommandList className="max-h-[600px]">
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
{options?.map((data) => (
|
||||
<Fragment key={data.header}>
|
||||
@@ -4,15 +4,18 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
SelectedFilterValue,
|
||||
TResponseStatus,
|
||||
useResponseFilter,
|
||||
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
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 { ElementFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementFilterComboBox";
|
||||
import { generateElementAndFilterOptions } 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 {
|
||||
@@ -22,12 +25,20 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
|
||||
import { ElementOption, ElementsComboBox, OptionsType } from "./ElementsComboBox";
|
||||
|
||||
export type QuestionFilterOptions = {
|
||||
type: TSurveyQuestionTypeEnum | "Attributes" | "Tags" | "Languages" | "Quotas";
|
||||
filterOptions: string[];
|
||||
filterComboBoxOptions: string[];
|
||||
export type ElementFilterOptions = {
|
||||
type:
|
||||
| TSurveyElementTypeEnum
|
||||
| "Attributes"
|
||||
| "Tags"
|
||||
| "Languages"
|
||||
| "Quotas"
|
||||
| "Hidden Fields"
|
||||
| "Meta"
|
||||
| OptionsType.OTHERS;
|
||||
filterOptions: (string | TI18nString)[];
|
||||
filterComboBoxOptions: (string | TI18nString)[];
|
||||
id: string;
|
||||
};
|
||||
|
||||
@@ -69,6 +80,12 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [filterValue, setFilterValue] = useState<SelectedFilterValue>(selectedFilter);
|
||||
|
||||
const getDefaultFilterValue = (option?: ElementFilterOptions): 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 () => {
|
||||
@@ -78,7 +95,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
if (!surveyFilterData?.data) return;
|
||||
|
||||
const { attributes, meta, environmentTags, hiddenFields, quotas } = surveyFilterData.data;
|
||||
const { questionFilterOptions, questionOptions } = generateQuestionAndFilterOptions(
|
||||
const { elementFilterOptions, elementOptions } = generateElementAndFilterOptions(
|
||||
survey,
|
||||
environmentTags,
|
||||
attributes,
|
||||
@@ -86,34 +103,35 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
hiddenFields,
|
||||
quotas
|
||||
);
|
||||
setSelectedOptions({ questionFilterOptions, questionOptions });
|
||||
setSelectedOptions({ elementFilterOptions: elementFilterOptions, elementOptions: elementOptions });
|
||||
}
|
||||
};
|
||||
|
||||
handleInitialData();
|
||||
}, [isOpen, setSelectedOptions, survey]);
|
||||
|
||||
const handleOnChangeQuestionComboBoxValue = (value: QuestionOption, index: number) => {
|
||||
if (filterValue.filter[index].questionType) {
|
||||
const handleOnChangeElementComboBoxValue = (value: ElementOption, index: number) => {
|
||||
const matchingFilterOption = selectedOptions.elementFilterOptions.find(
|
||||
(q) => q.type === value.type || q.type === value.elementType
|
||||
);
|
||||
const defaultFilterValue = getDefaultFilterValue(matchingFilterOption);
|
||||
|
||||
if (filterValue.filter[index].elementType) {
|
||||
// Create a new array and copy existing values from SelectedFilter
|
||||
filterValue.filter[index] = {
|
||||
questionType: value,
|
||||
elementType: 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 });
|
||||
} else {
|
||||
// Update the existing value at the specified index
|
||||
filterValue.filter[index].questionType = value;
|
||||
filterValue.filter[index].elementType = 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 });
|
||||
}
|
||||
@@ -123,8 +141,8 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
const clearItem = () => {
|
||||
setFilterValue({
|
||||
filter: filterValue.filter.filter((s) => {
|
||||
// keep the filter if questionType is selected and filterComboBoxValue is selected
|
||||
return s.questionType.hasOwnProperty("label") && s.filterType.filterComboBoxValue?.length;
|
||||
// keep the filter if elementType is selected and filterComboBoxValue is selected
|
||||
return s.elementType.hasOwnProperty("label") && s.filterType.filterComboBoxValue?.length;
|
||||
}),
|
||||
responseStatus: filterValue.responseStatus,
|
||||
});
|
||||
@@ -144,7 +162,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
filter: [
|
||||
...filterValue.filter,
|
||||
{
|
||||
questionType: {},
|
||||
elementType: {},
|
||||
filterType: { filterComboBoxValue: undefined, filterValue: undefined },
|
||||
},
|
||||
],
|
||||
@@ -196,10 +214,10 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
};
|
||||
|
||||
// remove the filter which has already been selected
|
||||
const questionComboBoxOptions = selectedOptions.questionOptions.map((q) => {
|
||||
const elementComboBoxOptions = selectedOptions.elementOptions.map((q) => {
|
||||
return {
|
||||
...q,
|
||||
option: q.option.filter((o) => !filterValue.filter.some((f) => f?.questionType?.id === o?.id)),
|
||||
option: q.option.filter((o) => !filterValue.filter.some((f) => f?.elementType?.id === o?.id)),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -217,11 +235,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
|
||||
@@ -260,41 +280,41 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
<div className="flex w-full flex-wrap gap-3 md:flex-nowrap">
|
||||
<div
|
||||
className="grid w-full grid-cols-1 items-center gap-3 md:grid-cols-2"
|
||||
key={`${s.questionType.id}-${i}-${s.questionType.label}`}>
|
||||
<QuestionsComboBox
|
||||
key={`${s.questionType.label}-${i}-${s.questionType.id}`}
|
||||
options={questionComboBoxOptions}
|
||||
selected={s.questionType}
|
||||
onChangeValue={(value) => handleOnChangeQuestionComboBoxValue(value, i)}
|
||||
key={`${s.elementType.id}-${i}-${s.elementType.label}`}>
|
||||
<ElementsComboBox
|
||||
key={`${s.elementType.label}-${i}-${s.elementType.id}`}
|
||||
options={elementComboBoxOptions}
|
||||
selected={s.elementType}
|
||||
onChangeValue={(value) => handleOnChangeElementComboBoxValue(value, i)}
|
||||
/>
|
||||
<QuestionFilterComboBox
|
||||
key={`${s.questionType.id}-${i}`}
|
||||
<ElementFilterComboBox
|
||||
key={`${s.elementType.id}-${i}`}
|
||||
filterOptions={
|
||||
selectedOptions.questionFilterOptions.find(
|
||||
selectedOptions.elementFilterOptions.find(
|
||||
(q) =>
|
||||
(q.type === s.questionType.questionType || q.type === s.questionType.type) &&
|
||||
q.id === s.questionType.id
|
||||
(q.type === s.elementType.elementType || q.type === s.elementType.type) &&
|
||||
q.id === s.elementType.id
|
||||
)?.filterOptions
|
||||
}
|
||||
filterComboBoxOptions={
|
||||
selectedOptions.questionFilterOptions.find(
|
||||
selectedOptions.elementFilterOptions.find(
|
||||
(q) =>
|
||||
(q.type === s.questionType.questionType || q.type === s.questionType.type) &&
|
||||
q.id === s.questionType.id
|
||||
(q.type === s.elementType.elementType || q.type === s.elementType.type) &&
|
||||
q.id === s.elementType.id
|
||||
)?.filterComboBoxOptions
|
||||
}
|
||||
filterValue={filterValue.filter[i].filterType.filterValue}
|
||||
filterComboBoxValue={filterValue.filter[i].filterType.filterComboBoxValue}
|
||||
type={
|
||||
s?.questionType?.type === OptionsType.QUESTIONS
|
||||
? s?.questionType?.questionType
|
||||
: s?.questionType?.type
|
||||
s?.elementType?.type === OptionsType.ELEMENTS
|
||||
? s?.elementType?.elementType
|
||||
: s?.elementType?.type
|
||||
}
|
||||
fieldId={s?.questionType?.id}
|
||||
fieldId={s?.elementType?.id}
|
||||
handleRemoveMultiSelect={(value) => handleRemoveMultiSelect(value, i)}
|
||||
onChangeFilterComboBoxValue={(value) => handleOnChangeFilterComboBoxValue(value, i)}
|
||||
onChangeFilterValue={(value) => handleOnChangeFilterValue(value, i)}
|
||||
disabled={!s?.questionType?.label}
|
||||
disabled={!s?.elementType?.label}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-end gap-1 md:w-auto">
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { Suspense } from "react";
|
||||
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
|
||||
import { IS_POSTHOG_CONFIGURED, POSTHOG_API_HOST, POSTHOG_API_KEY } from "@/lib/constants";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
||||
import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client";
|
||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||
|
||||
const AppLayout = async ({ children }) => {
|
||||
@@ -21,20 +18,9 @@ const AppLayout = async ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
<NoMobileOverlay />
|
||||
<Suspense>
|
||||
<PostHogPageview
|
||||
posthogEnabled={IS_POSTHOG_CONFIGURED}
|
||||
postHogApiHost={POSTHOG_API_HOST}
|
||||
postHogApiKey={POSTHOG_API_KEY}
|
||||
/>
|
||||
</Suspense>
|
||||
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
|
||||
<>
|
||||
<IntercomClientWrapper user={user} />
|
||||
<ToasterClient />
|
||||
{children}
|
||||
</>
|
||||
</PHProvider>
|
||||
<IntercomClientWrapper user={user} />
|
||||
<ToasterClient />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,12 +23,8 @@ import {
|
||||
TIntegrationSlackCredential,
|
||||
} from "@formbricks/types/integration/slack";
|
||||
import { TResponse, TResponseMeta } from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyPictureSelectionQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { writeData as airtableWriteData } from "@/lib/airtable/service";
|
||||
import { writeData as googleSheetWriteData } from "@/lib/googleSheet/service";
|
||||
@@ -101,33 +97,47 @@ const mockPipelineInput = {
|
||||
const mockSurvey = {
|
||||
id: surveyId,
|
||||
name: "Test Survey",
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: questionId1,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Question 1 {{recall:q2}}" },
|
||||
required: true,
|
||||
} as unknown as TSurveyOpenTextQuestion,
|
||||
{
|
||||
id: questionId2,
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Question 2" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "choice1", label: { default: "Choice 1" } },
|
||||
{ id: "choice2", label: { default: "Choice 2" } },
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: questionId1,
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Question 1 {{recall:q2}}" },
|
||||
required: true,
|
||||
inputType: "text",
|
||||
charLimit: 1000,
|
||||
subheader: { default: "" },
|
||||
placeholder: { default: "" },
|
||||
},
|
||||
{
|
||||
id: questionId2,
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Question 2" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "choice1", label: { default: "Choice 1" } },
|
||||
{ id: "choice2", label: { default: "Choice 2" } },
|
||||
],
|
||||
shuffleOption: "none",
|
||||
subheader: { default: "" },
|
||||
},
|
||||
{
|
||||
id: questionId3,
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
headline: { default: "Question 3" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "picChoice1", imageUrl: "http://image.com/1" },
|
||||
{ id: "picChoice2", imageUrl: "http://image.com/2" },
|
||||
],
|
||||
allowMultiple: false,
|
||||
subheader: { default: "" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: questionId3,
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
headline: { default: "Question 3" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "picChoice1", imageUrl: "http://image.com/1" },
|
||||
{ id: "picChoice2", imageUrl: "http://image.com/2" },
|
||||
],
|
||||
} as unknown as TSurveyPictureSelectionQuestion,
|
||||
],
|
||||
hiddenFields: {
|
||||
enabled: true,
|
||||
@@ -162,7 +172,7 @@ const mockAirtableIntegration: TIntegrationAirtable = {
|
||||
data: [
|
||||
{
|
||||
surveyId: surveyId,
|
||||
questionIds: [questionId1, questionId2],
|
||||
elementIds: [questionId1, questionId2],
|
||||
baseId: "base1",
|
||||
tableId: "table1",
|
||||
createdAt: new Date(),
|
||||
@@ -186,8 +196,8 @@ const mockGoogleSheetsIntegration: TIntegrationGoogleSheets = {
|
||||
surveyId: surveyId,
|
||||
spreadsheetId: "sheet1",
|
||||
spreadsheetName: "Sheet Name",
|
||||
questionIds: [questionId1],
|
||||
questions: "What is Q1?",
|
||||
elementIds: [questionId1],
|
||||
elements: "What is Q1?",
|
||||
createdAt: new Date("2024-01-01T00:00:00.000Z"),
|
||||
includeHiddenFields: false,
|
||||
includeMetadata: false,
|
||||
@@ -209,8 +219,8 @@ const mockSlackIntegration: TIntegrationSlack = {
|
||||
surveyId: surveyId,
|
||||
channelId: "channel1",
|
||||
channelName: "Channel 1",
|
||||
questionIds: [questionId1, questionId2, questionId3],
|
||||
questions: "Q1, Q2, Q3",
|
||||
elementIds: [questionId1, questionId2, questionId3],
|
||||
elements: "Q1, Q2, Q3",
|
||||
createdAt: new Date(),
|
||||
includeHiddenFields: true,
|
||||
includeMetadata: true,
|
||||
@@ -239,19 +249,19 @@ const mockNotionIntegration: TIntegrationNotion = {
|
||||
databaseName: "DB 1",
|
||||
mapping: [
|
||||
{
|
||||
question: { id: questionId1, name: "Question 1", type: TSurveyQuestionTypeEnum.OpenText },
|
||||
element: { id: questionId1, name: "Question 1", type: TSurveyQuestionTypeEnum.OpenText },
|
||||
column: { id: "col1", name: "Column 1", type: "rich_text" },
|
||||
},
|
||||
{
|
||||
question: { id: questionId3, name: "Question 3", type: TSurveyQuestionTypeEnum.PictureSelection },
|
||||
element: { id: questionId3, name: "Question 3", type: TSurveyQuestionTypeEnum.PictureSelection },
|
||||
column: { id: "col3", name: "Column 3", type: "url" },
|
||||
},
|
||||
{
|
||||
question: { id: "metadata", name: "Metadata", type: "metadata" },
|
||||
element: { id: "metadata", name: "Metadata", type: "metadata" },
|
||||
column: { id: "col_meta", name: "Metadata Col", type: "rich_text" },
|
||||
},
|
||||
{
|
||||
question: { id: "createdAt", name: "Created At", type: "createdAt" },
|
||||
element: { id: "createdAt", name: "Created At", type: "createdAt" },
|
||||
column: { id: "col_created", name: "Created Col", type: "date" },
|
||||
},
|
||||
],
|
||||
@@ -341,16 +351,14 @@ describe("handleIntegrations", () => {
|
||||
mockAirtableIntegration.config.key,
|
||||
mockAirtableIntegration.config.data[0],
|
||||
[
|
||||
[
|
||||
"Answer 1",
|
||||
"Choice 1, Choice 2",
|
||||
"Hidden Value",
|
||||
expectedMetadataString,
|
||||
"Variable Value",
|
||||
"2024-01-01 12:00",
|
||||
], // responses + hidden + meta + var + created
|
||||
["Question 1 {{recall:q2}}", "Question 2", hiddenFieldId, "Metadata", "Variable 1", "Created At"], // questions (raw headline for Airtable) + hidden + meta + var + created
|
||||
]
|
||||
"Answer 1",
|
||||
"Choice 1, Choice 2",
|
||||
"Hidden Value",
|
||||
expectedMetadataString,
|
||||
"Variable Value",
|
||||
"2024-01-01 12:00",
|
||||
], // responses + hidden + meta + var + created
|
||||
["Question 1 {{recall:q2}}", "Question 2", hiddenFieldId, "Metadata", "Variable 1", "Created At"] // elements (raw headline for Airtable) + hidden + meta + var + created
|
||||
);
|
||||
});
|
||||
|
||||
@@ -385,10 +393,8 @@ describe("handleIntegrations", () => {
|
||||
expect(googleSheetWriteData).toHaveBeenCalledWith(
|
||||
expectedIntegrationData,
|
||||
mockGoogleSheetsIntegration.config.data[0].spreadsheetId,
|
||||
[
|
||||
["Answer 1"], // responses
|
||||
["Question 1 {{recall:q2}}"], // questions (raw headline for Google Sheets)
|
||||
]
|
||||
["Answer 1"], // responses
|
||||
["Question 1 {{recall:q2}}"] // elements (raw headline for Google Sheets)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
||||
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
||||
import { TResponseMeta } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TResponseDataValue, TResponseMeta } from "@formbricks/types/responses";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { writeData as airtableWriteData } from "@/lib/airtable/service";
|
||||
@@ -16,6 +17,7 @@ import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { writeData as writeNotionData } from "@/lib/notion/service";
|
||||
import { processResponseData } from "@/lib/responses";
|
||||
import { writeDataToSlack } from "@/lib/slack/service";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { truncateText } from "@/lib/utils/strings";
|
||||
@@ -42,33 +44,40 @@ const processDataForIntegration = async (
|
||||
includeMetadata: boolean,
|
||||
includeHiddenFields: boolean,
|
||||
includeCreatedAt: boolean,
|
||||
questionIds: string[]
|
||||
): Promise<string[][]> => {
|
||||
elementIds: string[]
|
||||
): Promise<{
|
||||
responses: string[];
|
||||
elements: string[];
|
||||
}> => {
|
||||
const ids =
|
||||
includeHiddenFields && survey.hiddenFields.fieldIds
|
||||
? [...questionIds, ...survey.hiddenFields.fieldIds]
|
||||
: questionIds;
|
||||
const values = await extractResponses(integrationType, data, ids, survey);
|
||||
? [...elementIds, ...survey.hiddenFields.fieldIds]
|
||||
: elementIds;
|
||||
const { responses, elements } = await extractResponses(integrationType, data, ids, survey);
|
||||
|
||||
if (includeMetadata) {
|
||||
values[0].push(convertMetaObjectToString(data.response.meta));
|
||||
values[1].push("Metadata");
|
||||
responses.push(convertMetaObjectToString(data.response.meta));
|
||||
elements.push("Metadata");
|
||||
}
|
||||
if (includeVariables) {
|
||||
survey.variables.forEach((variable) => {
|
||||
survey.variables?.forEach((variable) => {
|
||||
const value = data.response.variables[variable.id];
|
||||
if (value !== undefined) {
|
||||
values[0].push(String(data.response.variables[variable.id]));
|
||||
values[1].push(variable.name);
|
||||
responses.push(String(data.response.variables[variable.id]));
|
||||
elements.push(variable.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (includeCreatedAt) {
|
||||
const date = new Date(data.response.createdAt);
|
||||
values[0].push(`${getFormattedDateTimeString(date)}`);
|
||||
values[1].push("Created At");
|
||||
responses.push(`${getFormattedDateTimeString(date)}`);
|
||||
elements.push("Created At");
|
||||
}
|
||||
|
||||
return values;
|
||||
return {
|
||||
responses,
|
||||
elements,
|
||||
};
|
||||
};
|
||||
|
||||
export const handleIntegrations = async (
|
||||
@@ -131,9 +140,9 @@ const handleAirtableIntegration = async (
|
||||
!!element.includeMetadata,
|
||||
!!element.includeHiddenFields,
|
||||
!!element.includeCreatedAt,
|
||||
element.questionIds
|
||||
element.elementIds
|
||||
);
|
||||
await airtableWriteData(integration.config.key, element, values);
|
||||
await airtableWriteData(integration.config.key, element, values.responses, values.elements);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,14 +176,14 @@ const handleGoogleSheetsIntegration = async (
|
||||
!!element.includeMetadata,
|
||||
!!element.includeHiddenFields,
|
||||
!!element.includeCreatedAt,
|
||||
element.questionIds
|
||||
element.elementIds
|
||||
);
|
||||
const integrationData = structuredClone(integration);
|
||||
integrationData.config.data.forEach((data) => {
|
||||
data.createdAt = new Date(data.createdAt);
|
||||
});
|
||||
|
||||
await writeData(integrationData, element.spreadsheetId, values);
|
||||
await writeData(integrationData, element.spreadsheetId, values.responses, values.elements);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,9 +217,15 @@ const handleSlackIntegration = async (
|
||||
!!element.includeMetadata,
|
||||
!!element.includeHiddenFields,
|
||||
!!element.includeCreatedAt,
|
||||
element.questionIds
|
||||
element.elementIds
|
||||
);
|
||||
await writeDataToSlack(
|
||||
integration.config.key,
|
||||
element.channelId,
|
||||
values.responses,
|
||||
values.elements,
|
||||
survey?.name
|
||||
);
|
||||
await writeDataToSlack(integration.config.key, element.channelId, values, survey?.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,63 +242,81 @@ const handleSlackIntegration = async (
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to process a single element's response for integrations
|
||||
const processElementResponse = (
|
||||
element: ReturnType<typeof getElementsFromBlocks>[number],
|
||||
responseValue: TResponseDataValue
|
||||
): string => {
|
||||
if (responseValue === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (element.type === TSurveyElementTypeEnum.PictureSelection) {
|
||||
const selectedChoiceIds = responseValue as string[];
|
||||
return element.choices
|
||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||
.map((choice) => choice.imageUrl)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
return processResponseData(responseValue);
|
||||
};
|
||||
|
||||
// Helper to create empty response object for non-slack integrations
|
||||
const createEmptyResponseObject = (responseData: Record<string, unknown>): Record<string, string> => {
|
||||
return Object.keys(responseData).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = "";
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
};
|
||||
|
||||
const extractResponses = async (
|
||||
integrationType: TIntegrationType,
|
||||
pipelineData: TPipelineInput,
|
||||
questionIds: string[],
|
||||
elementIds: string[],
|
||||
survey: TSurvey
|
||||
): Promise<string[][]> => {
|
||||
): Promise<{
|
||||
responses: string[];
|
||||
elements: string[];
|
||||
}> => {
|
||||
const responses: string[] = [];
|
||||
const questions: string[] = [];
|
||||
const elements: string[] = [];
|
||||
const surveyElements = getElementsFromBlocks(survey.blocks);
|
||||
const emptyResponseObject = createEmptyResponseObject(pipelineData.response.data);
|
||||
|
||||
for (const questionId of questionIds) {
|
||||
//check for hidden field Ids
|
||||
if (survey.hiddenFields.fieldIds?.includes(questionId)) {
|
||||
responses.push(processResponseData(pipelineData.response.data[questionId]));
|
||||
questions.push(questionId);
|
||||
continue;
|
||||
}
|
||||
const question = survey?.questions.find((q) => q.id === questionId);
|
||||
if (!question) {
|
||||
for (const elementId of elementIds) {
|
||||
// Check for hidden field Ids
|
||||
if (survey.hiddenFields.fieldIds?.includes(elementId)) {
|
||||
responses.push(processResponseData(pipelineData.response.data[elementId]));
|
||||
elements.push(elementId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const responseValue = pipelineData.response.data[questionId];
|
||||
|
||||
if (responseValue !== undefined) {
|
||||
let answer: typeof responseValue;
|
||||
if (question.type === TSurveyQuestionTypeEnum.PictureSelection) {
|
||||
const selectedChoiceIds = responseValue as string[];
|
||||
answer = question?.choices
|
||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||
.map((choice) => choice.imageUrl)
|
||||
.join("\n");
|
||||
} else {
|
||||
answer = responseValue;
|
||||
}
|
||||
|
||||
responses.push(processResponseData(answer));
|
||||
} else {
|
||||
responses.push("");
|
||||
const element = surveyElements.find((q) => q.id === elementId);
|
||||
if (!element) {
|
||||
continue;
|
||||
}
|
||||
// Create emptyResponseObject with same keys but empty string values
|
||||
const emptyResponseObject = Object.keys(pipelineData.response.data).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = "";
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
questions.push(
|
||||
|
||||
const responseValue = pipelineData.response.data[elementId];
|
||||
responses.push(processElementResponse(element, responseValue));
|
||||
|
||||
const responseDataForRecall =
|
||||
integrationType === "slack" ? pipelineData.response.data : emptyResponseObject;
|
||||
const variablesForRecall = integrationType === "slack" ? pipelineData.response.variables : {};
|
||||
|
||||
elements.push(
|
||||
parseRecallInfo(
|
||||
getTextContent(getLocalizedValue(question?.headline, "default")),
|
||||
integrationType === "slack" ? pipelineData.response.data : emptyResponseObject,
|
||||
integrationType === "slack" ? pipelineData.response.variables : {}
|
||||
getTextContent(getLocalizedValue(element.headline, "default")),
|
||||
responseDataForRecall,
|
||||
variablesForRecall
|
||||
) || ""
|
||||
);
|
||||
}
|
||||
|
||||
return [responses, questions];
|
||||
return { responses, elements };
|
||||
};
|
||||
|
||||
const handleNotionIntegration = async (
|
||||
@@ -321,32 +354,34 @@ const buildNotionPayloadProperties = (
|
||||
const properties: any = {};
|
||||
const responses = data.response.data;
|
||||
|
||||
const mappingQIds = mapping
|
||||
.filter((m) => m.question.type === TSurveyQuestionTypeEnum.PictureSelection)
|
||||
.map((m) => m.question.id);
|
||||
const surveyElements = getElementsFromBlocks(surveyData.blocks);
|
||||
|
||||
const mappingElementIds = mapping
|
||||
.filter((m) => m.element.type === TSurveyElementTypeEnum.PictureSelection)
|
||||
.map((m) => m.element.id);
|
||||
|
||||
Object.keys(responses).forEach((resp) => {
|
||||
if (mappingQIds.find((qId) => qId === resp)) {
|
||||
if (mappingElementIds.find((elementId) => elementId === resp)) {
|
||||
const selectedChoiceIds = responses[resp] as string[];
|
||||
const pictureQuestion = surveyData.questions.find((q) => q.id === resp);
|
||||
const pictureElement = surveyElements.find((el) => el.id === resp);
|
||||
|
||||
responses[resp] = (pictureQuestion as any)?.choices
|
||||
responses[resp] = (pictureElement as any)?.choices
|
||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||
.map((choice) => choice.imageUrl);
|
||||
}
|
||||
});
|
||||
|
||||
mapping.forEach((map) => {
|
||||
if (map.question.id === "metadata") {
|
||||
if (map.element.id === "metadata") {
|
||||
properties[map.column.name] = {
|
||||
[map.column.type]: getValue(map.column.type, convertMetaObjectToString(data.response.meta)) || null,
|
||||
};
|
||||
} else if (map.question.id === "createdAt") {
|
||||
} else if (map.element.id === "createdAt") {
|
||||
properties[map.column.name] = {
|
||||
[map.column.type]: getValue(map.column.type, data.response.createdAt) || null,
|
||||
};
|
||||
} else {
|
||||
const value = responses[map.question.id];
|
||||
const value = responses[map.element.id];
|
||||
properties[map.column.name] = {
|
||||
[map.column.type]: getValue(map.column.type, value) || null,
|
||||
};
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Organization } from "@prisma/client";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
|
||||
export const handleBillingLimitsCheck = async (
|
||||
environmentId: string,
|
||||
organizationId: string,
|
||||
organizationBilling: Organization["billing"]
|
||||
): Promise<void> => {
|
||||
if (!IS_FORMBRICKS_CLOUD) return;
|
||||
|
||||
const responsesCount = await getMonthlyOrganizationResponseCount(organizationId);
|
||||
const responsesLimit = organizationBilling.limits.monthly.responses;
|
||||
|
||||
if (responsesLimit && responsesCount >= responsesLimit) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organizationBilling.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
responses: responsesLimit,
|
||||
miu: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Log error but do not throw
|
||||
logger.error(err, "Error sending plan limits reached event to Posthog");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -18,10 +18,6 @@ import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import {
|
||||
capturePosthogEnvironmentEvent,
|
||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||
} from "@/lib/posthogServer";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
|
||||
|
||||
@@ -58,20 +54,6 @@ const checkResponseLimit = async (environmentId: string): Promise<boolean> => {
|
||||
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
|
||||
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||
|
||||
if (isLimitReached) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: { responses: monthlyResponseLimit, miu: null },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, `Error sending plan limits reached event to Posthog`);
|
||||
}
|
||||
}
|
||||
|
||||
return isLimitReached;
|
||||
};
|
||||
|
||||
@@ -111,10 +93,7 @@ export const GET = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
if (!environment.appSetupCompleted) {
|
||||
await Promise.all([
|
||||
updateEnvironment(environment.id, { appSetupCompleted: true }),
|
||||
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
|
||||
]);
|
||||
await updateEnvironment(environment.id, { appSetupCompleted: true });
|
||||
}
|
||||
|
||||
// check organization subscriptions and response limits
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createDisplay } from "./lib/display";
|
||||
|
||||
@@ -59,7 +58,6 @@ export const POST = withV1ApiWrapper({
|
||||
try {
|
||||
const response = await createDisplay(inputValidation.data);
|
||||
|
||||
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
||||
return {
|
||||
response: responses.successResponse(response, true),
|
||||
};
|
||||
|
||||
@@ -92,6 +92,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
||||
welcomeCard: true,
|
||||
name: true,
|
||||
questions: true,
|
||||
blocks: true,
|
||||
variables: true,
|
||||
type: true,
|
||||
showLanguageSwitch: true,
|
||||
|
||||
@@ -8,16 +8,11 @@ import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||
import {
|
||||
capturePosthogEnvironmentEvent,
|
||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||
} from "@/lib/posthogServer";
|
||||
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
|
||||
import { getEnvironmentState } from "./environmentState";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/lib/posthogServer");
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cache: {
|
||||
withCache: vi.fn(),
|
||||
@@ -43,7 +38,6 @@ vi.mock("@/lib/constants", () => ({
|
||||
RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key",
|
||||
IS_RECAPTCHA_CONFIGURED: true,
|
||||
IS_PRODUCTION: true,
|
||||
IS_POSTHOG_CONFIGURED: false,
|
||||
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
|
||||
}));
|
||||
|
||||
@@ -188,9 +182,7 @@ describe("getEnvironmentState", () => {
|
||||
expect(result.data).toEqual(expectedData);
|
||||
expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId);
|
||||
expect(prisma.environment.update).not.toHaveBeenCalled();
|
||||
expect(capturePosthogEnvironmentEvent).not.toHaveBeenCalled();
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if environment not found", async () => {
|
||||
@@ -226,7 +218,6 @@ describe("getEnvironmentState", () => {
|
||||
where: { id: environmentId },
|
||||
data: { appSetupCompleted: true },
|
||||
});
|
||||
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(environmentId, "app setup completed");
|
||||
expect(result.data).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -237,16 +228,6 @@ describe("getEnvironmentState", () => {
|
||||
|
||||
expect(result.data.surveys).toEqual([]);
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
|
||||
plan: mockOrganization.billing.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
miu: null,
|
||||
responses: mockOrganization.billing.limits.monthly.responses,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should return surveys if monthly response limit not reached (Cloud)", async () => {
|
||||
@@ -256,21 +237,6 @@ describe("getEnvironmentState", () => {
|
||||
|
||||
expect(result.data.surveys).toEqual(mockSurveys);
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle error when sending Posthog limit reached event", async () => {
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
||||
const posthogError = new Error("Posthog failed");
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
|
||||
|
||||
const result = await getEnvironmentState(environmentId);
|
||||
|
||||
expect(result.data.surveys).toEqual([]);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
posthogError,
|
||||
"Error sending plan limits reached event to Posthog"
|
||||
);
|
||||
});
|
||||
|
||||
test("should include recaptchaSiteKey if recaptcha variables are set", async () => {
|
||||
@@ -313,7 +279,6 @@ describe("getEnvironmentState", () => {
|
||||
|
||||
// Should return surveys even with high count since limit is null (unlimited)
|
||||
expect(result.data.surveys).toEqual(mockSurveys);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should propagate database update errors", async () => {
|
||||
@@ -331,21 +296,6 @@ describe("getEnvironmentState", () => {
|
||||
await expect(getEnvironmentState(environmentId)).rejects.toThrow("Database error");
|
||||
});
|
||||
|
||||
test("should propagate PostHog event capture errors", async () => {
|
||||
const incompleteEnvironmentData = {
|
||||
...mockEnvironmentStateData,
|
||||
environment: {
|
||||
...mockEnvironmentStateData.environment,
|
||||
appSetupCompleted: false,
|
||||
},
|
||||
};
|
||||
vi.mocked(getEnvironmentStateData).mockResolvedValue(incompleteEnvironmentData);
|
||||
vi.mocked(capturePosthogEnvironmentEvent).mockRejectedValue(new Error("PostHog error"));
|
||||
|
||||
// Should throw error since Promise.all will fail if PostHog event capture fails
|
||||
await expect(getEnvironmentState(environmentId)).rejects.toThrow("PostHog error");
|
||||
});
|
||||
|
||||
test("should include recaptchaSiteKey when IS_RECAPTCHA_CONFIGURED is true", async () => {
|
||||
const result = await getEnvironmentState(environmentId);
|
||||
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import "server-only";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TJsEnvironmentState } from "@formbricks/types/js";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
|
||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||
import {
|
||||
capturePosthogEnvironmentEvent,
|
||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||
} from "@/lib/posthogServer";
|
||||
import { getEnvironmentStateData } from "./data";
|
||||
|
||||
/**
|
||||
@@ -33,13 +28,10 @@ export const getEnvironmentState = async (
|
||||
// Handle app setup completion update if needed
|
||||
// This is a one-time setup flag that can tolerate TTL-based cache expiration
|
||||
if (!environment.appSetupCompleted) {
|
||||
await Promise.all([
|
||||
prisma.environment.update({
|
||||
where: { id: environmentId },
|
||||
data: { appSetupCompleted: true },
|
||||
}),
|
||||
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
|
||||
]);
|
||||
await prisma.environment.update({
|
||||
where: { id: environmentId },
|
||||
data: { appSetupCompleted: true },
|
||||
});
|
||||
}
|
||||
|
||||
// Check monthly response limits for Formbricks Cloud
|
||||
@@ -49,24 +41,6 @@ export const getEnvironmentState = async (
|
||||
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
isMonthlyResponsesLimitReached =
|
||||
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||
|
||||
// Send plan limits event if needed
|
||||
if (isMonthlyResponsesLimitReached) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
miu: null,
|
||||
responses: organization.billing.limits.monthly.responses,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err, "Error sending plan limits reached event to Posthog");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the response data
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getResponse } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponseInput } from "@formbricks/types/responses";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
|
||||
@@ -24,22 +19,13 @@ vi.mock("@/lib/constants", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getMonthlyOrganizationResponseCount: vi.fn(),
|
||||
getOrganizationByEnvironmentId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/posthogServer", () => ({
|
||||
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/response/utils", () => ({
|
||||
calculateTtcTotal: vi.fn((ttc) => ttc),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/telemetry", () => ({
|
||||
captureTelemetry: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
@@ -138,35 +124,6 @@ describe("createResponse", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should check response limits if IS_FORMBRICKS_CLOUD is true", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
||||
|
||||
await createResponse(mockResponseInput, prisma);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
||||
|
||||
await createResponse(mockResponseInput, prisma);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
|
||||
plan: "free",
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
responses: 100,
|
||||
miu: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if organization not found", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(ResourceNotFoundError);
|
||||
@@ -186,20 +143,6 @@ describe("createResponse", () => {
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
|
||||
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
|
||||
});
|
||||
|
||||
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
||||
const posthogError = new Error("PostHog error");
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
|
||||
|
||||
await createResponse(mockResponseInput);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
posthogError,
|
||||
"Error sending plan limits reached event to Posthog"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createResponseWithQuotaEvaluation", () => {
|
||||
|
||||
@@ -6,11 +6,9 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
|
||||
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { getContactByUserId } from "./contact";
|
||||
@@ -83,7 +81,6 @@ export const createResponse = async (
|
||||
tx: Prisma.TransactionClient
|
||||
): Promise<TResponse> => {
|
||||
validateInputs([responseInput, ZResponseInput]);
|
||||
captureTelemetry("response created");
|
||||
|
||||
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
|
||||
|
||||
@@ -121,8 +118,6 @@ export const createResponse = async (
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -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";
|
||||
@@ -10,7 +10,6 @@ import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
@@ -51,7 +50,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) {
|
||||
@@ -172,11 +171,6 @@ export const POST = withV1ApiWrapper({
|
||||
});
|
||||
}
|
||||
|
||||
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
|
||||
surveyId: responseData.surveyId,
|
||||
surveyType: survey.type,
|
||||
});
|
||||
|
||||
const quotaObj = createQuotaFullObject(quotaFull);
|
||||
|
||||
const responseDataWithQuota = {
|
||||
|
||||
@@ -4,11 +4,7 @@ import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponse, TResponseInput } from "@formbricks/types/responses";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getResponseContact } from "@/lib/response/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
@@ -96,9 +92,6 @@ const mockTransformedResponses = [mockResponse, { ...mockResponse, id: "response
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: true,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
@@ -118,10 +111,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
}));
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/lib/posthogServer");
|
||||
vi.mock("@/lib/response/service");
|
||||
vi.mock("@/lib/response/utils");
|
||||
vi.mock("@/lib/telemetry");
|
||||
vi.mock("@/lib/utils/validate");
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -162,7 +153,6 @@ describe("Response Lib Tests", () => {
|
||||
vi.mocked(mockTx.response.create).mockResolvedValue({
|
||||
...mockResponsePrisma,
|
||||
});
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
||||
|
||||
const response = await createResponse(mockResponseInputWithUserId, mockTx);
|
||||
|
||||
@@ -217,68 +207,6 @@ describe("Response Lib Tests", () => {
|
||||
|
||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
|
||||
});
|
||||
|
||||
describe("Cloud specific tests", () => {
|
||||
test("should check response limit and send event if limit reached", async () => {
|
||||
// IS_FORMBRICKS_CLOUD is true by default from the top-level mock
|
||||
const limit = 100;
|
||||
const mockOrgWithBilling = {
|
||||
...mockOrganization,
|
||||
billing: { limits: { monthly: { responses: limit } } },
|
||||
} as any;
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
|
||||
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
|
||||
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
|
||||
|
||||
await createResponse(mockResponseInput, mockTx);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should check response limit and not send event if limit not reached", async () => {
|
||||
const limit = 100;
|
||||
const mockOrgWithBilling = {
|
||||
...mockOrganization,
|
||||
billing: { limits: { monthly: { responses: limit } } },
|
||||
} as any;
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
|
||||
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
|
||||
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit - 1); // Limit not reached
|
||||
|
||||
await createResponse(mockResponseInput, mockTx);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should log error if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
|
||||
const limit = 100;
|
||||
const mockOrgWithBilling = {
|
||||
...mockOrganization,
|
||||
billing: { limits: { monthly: { responses: limit } } },
|
||||
} as any;
|
||||
const posthogError = new Error("Posthog error");
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
|
||||
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
|
||||
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
|
||||
|
||||
// Expecting successful response creation despite PostHog error
|
||||
const response = await createResponse(mockResponseInput, mockTx);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
posthogError,
|
||||
"Error sending plan limits reached event to Posthog"
|
||||
);
|
||||
expect(response).toEqual(mockResponse); // Should still return the created response
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResponsesByEnvironmentIds", () => {
|
||||
|
||||
@@ -8,14 +8,12 @@ import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
|
||||
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getResponseContact } from "@/lib/response/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { getContactByUserId } from "./contact";
|
||||
@@ -93,7 +91,6 @@ export const createResponse = async (
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<TResponse> => {
|
||||
validateInputs([responseInput, ZResponseInput]);
|
||||
captureTelemetry("response created");
|
||||
|
||||
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
|
||||
|
||||
@@ -131,8 +128,6 @@ export const createResponse = async (
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -6,6 +6,11 @@ import { handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys";
|
||||
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import {
|
||||
transformBlocksToQuestions,
|
||||
transformQuestionsToBlocks,
|
||||
validateSurveyInput,
|
||||
} from "@/app/lib/api/survey-transformation";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
@@ -45,6 +50,22 @@ export const GET = withV1ApiWrapper({
|
||||
response: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
const shouldTransformToQuestions =
|
||||
result.survey.blocks &&
|
||||
result.survey.blocks.length > 0 &&
|
||||
result.survey.blocks.every((block) => block.elements.length === 1);
|
||||
|
||||
if (shouldTransformToQuestions) {
|
||||
return {
|
||||
response: responses.successResponse({
|
||||
...result.survey,
|
||||
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
|
||||
blocks: [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse(result.survey),
|
||||
};
|
||||
@@ -131,6 +152,23 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const validateResult = validateSurveyInput({ ...surveyUpdate, updateOnly: true });
|
||||
if (!validateResult.ok) {
|
||||
return {
|
||||
response: responses.badRequestResponse(validateResult.error.message),
|
||||
};
|
||||
}
|
||||
|
||||
const { hasQuestions } = validateResult.data;
|
||||
|
||||
if (hasQuestions) {
|
||||
surveyUpdate.blocks = transformQuestionsToBlocks(
|
||||
surveyUpdate.questions,
|
||||
surveyUpdate.endings || result.survey.endings
|
||||
);
|
||||
surveyUpdate.questions = [];
|
||||
}
|
||||
|
||||
const inputValidation = ZSurveyUpdateInput.safeParse({
|
||||
...result.survey,
|
||||
...surveyUpdate,
|
||||
@@ -155,6 +193,19 @@ export const PUT = withV1ApiWrapper({
|
||||
try {
|
||||
const updatedSurvey = await updateSurvey({ ...inputValidation.data, id: params.surveyId });
|
||||
auditLog.newObject = updatedSurvey;
|
||||
|
||||
if (hasQuestions) {
|
||||
const surveyWithQuestions = {
|
||||
...updatedSurvey,
|
||||
questions: transformBlocksToQuestions(updatedSurvey.blocks, updatedSurvey.endings),
|
||||
blocks: [],
|
||||
};
|
||||
|
||||
return {
|
||||
response: responses.successResponse(surveyWithQuestions),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse(updatedSurvey),
|
||||
};
|
||||
|
||||
@@ -4,6 +4,11 @@ import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
|
||||
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import {
|
||||
transformBlocksToQuestions,
|
||||
transformQuestionsToBlocks,
|
||||
validateSurveyInput,
|
||||
} from "@/app/lib/api/survey-transformation";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
@@ -27,10 +32,30 @@ export const GET = withV1ApiWrapper({
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
|
||||
const surveys = await getSurveys(environmentIds, limit, offset);
|
||||
|
||||
const surveysWithQuestions = surveys.map((survey) => {
|
||||
// If the survey has blocks and each block has ONLY ONE element, we can transform the blocks to questions
|
||||
// This is only for backwards compatibility with the older surveys
|
||||
const shouldTransformToQuestions =
|
||||
survey.blocks &&
|
||||
survey.blocks.length > 0 &&
|
||||
survey.blocks.every((block) => block.elements.length === 1);
|
||||
|
||||
if (shouldTransformToQuestions) {
|
||||
return {
|
||||
...survey,
|
||||
questions: transformBlocksToQuestions(survey.blocks, survey.endings),
|
||||
blocks: [],
|
||||
};
|
||||
}
|
||||
|
||||
return survey;
|
||||
});
|
||||
|
||||
return {
|
||||
response: responses.successResponse(surveys),
|
||||
response: responses.successResponse(surveysWithQuestions),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
@@ -63,6 +88,7 @@ export const POST = withV1ApiWrapper({
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
};
|
||||
}
|
||||
|
||||
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
@@ -92,6 +118,20 @@ export const POST = withV1ApiWrapper({
|
||||
|
||||
const surveyData = { ...inputValidation.data, environmentId };
|
||||
|
||||
const validateResult = validateSurveyInput(surveyData);
|
||||
if (!validateResult.ok) {
|
||||
return {
|
||||
response: responses.badRequestResponse(validateResult.error.message),
|
||||
};
|
||||
}
|
||||
|
||||
const { hasQuestions } = validateResult.data;
|
||||
|
||||
if (hasQuestions) {
|
||||
surveyData.blocks = transformQuestionsToBlocks(surveyData.questions, surveyData.endings || []);
|
||||
surveyData.questions = [];
|
||||
}
|
||||
|
||||
const featureCheckResult = await checkFeaturePermissions(surveyData, organization);
|
||||
if (featureCheckResult) {
|
||||
return {
|
||||
@@ -103,6 +143,18 @@ export const POST = withV1ApiWrapper({
|
||||
auditLog.targetId = survey.id;
|
||||
auditLog.newObject = survey;
|
||||
|
||||
if (hasQuestions) {
|
||||
const surveyWithQuestions = {
|
||||
...survey,
|
||||
questions: transformBlocksToQuestions(survey.blocks, survey.endings),
|
||||
blocks: [],
|
||||
};
|
||||
|
||||
return {
|
||||
response: responses.successResponse(surveyWithQuestions),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse(survey),
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createDisplay } from "./lib/display";
|
||||
|
||||
@@ -49,7 +48,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
try {
|
||||
const response = await createDisplay(inputValidation.data);
|
||||
|
||||
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
||||
return responses.successResponse(response, true);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
|
||||
@@ -8,13 +8,8 @@ import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { getContact } from "./contact";
|
||||
@@ -49,9 +44,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/lib/posthogServer");
|
||||
vi.mock("@/lib/response/utils");
|
||||
vi.mock("@/lib/telemetry");
|
||||
vi.mock("@/lib/utils/validate");
|
||||
vi.mock("@/modules/ee/quotas/lib/evaluation-service");
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -166,9 +159,6 @@ describe("createResponse V2", () => {
|
||||
...ttc,
|
||||
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
|
||||
}));
|
||||
vi.mocked(captureTelemetry).mockResolvedValue(undefined);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockResolvedValue(undefined);
|
||||
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
||||
shouldEndSurvey: false,
|
||||
quotaFull: null,
|
||||
@@ -179,32 +169,6 @@ describe("createResponse V2", () => {
|
||||
mockIsFormbricksCloud = false;
|
||||
});
|
||||
|
||||
test("should check response limits if IS_FORMBRICKS_CLOUD is true", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
await createResponse(mockResponseInput, mockTx);
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
||||
|
||||
await createResponse(mockResponseInput, mockTx);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
|
||||
plan: "free",
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
responses: 100,
|
||||
miu: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if organization not found", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(ResourceNotFoundError);
|
||||
@@ -225,20 +189,6 @@ describe("createResponse V2", () => {
|
||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
|
||||
});
|
||||
|
||||
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
||||
const posthogError = new Error("PostHog error");
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
|
||||
|
||||
await createResponse(mockResponseInput, mockTx); // Should not throw
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
posthogError,
|
||||
"Error sending plan limits reached event to Posthog"
|
||||
);
|
||||
});
|
||||
|
||||
test("should correctly map prisma tags to response tags", async () => {
|
||||
const mockTag: TTag = { id: "tag1", name: "Tag 1", environmentId };
|
||||
const prismaResponseWithTags = {
|
||||
@@ -269,7 +219,6 @@ describe("createResponseWithQuotaEvaluation V2", () => {
|
||||
...ttc,
|
||||
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
|
||||
}));
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
||||
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
||||
shouldEndSurvey: false,
|
||||
quotaFull: null,
|
||||
|
||||
@@ -6,12 +6,10 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
|
||||
import { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
|
||||
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { getContact } from "./contact";
|
||||
@@ -91,7 +89,6 @@ export const createResponse = async (
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<TResponse> => {
|
||||
validateInputs([responseInput, ZResponseInput]);
|
||||
captureTelemetry("response created");
|
||||
|
||||
const { environmentId, contactId, finished, ttc: initialTtc } = responseInput;
|
||||
|
||||
@@ -129,8 +126,6 @@ export const createResponse = async (
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
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";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
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 { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
import { createResponseWithQuotaEvaluation } from "./lib/response";
|
||||
@@ -43,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) {
|
||||
@@ -91,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: survey.questions,
|
||||
surveyQuestions: getElementsFromBlocks(survey.blocks),
|
||||
responseLanguage: responseInputData.language,
|
||||
});
|
||||
|
||||
@@ -148,11 +148,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
});
|
||||
}
|
||||
|
||||
await capturePosthogEnvironmentEvent(environmentId, "response created", {
|
||||
surveyId: responseData.surveyId,
|
||||
surveyType: survey.type,
|
||||
});
|
||||
|
||||
const quotaObj = createQuotaFullObject(quotaFull);
|
||||
|
||||
const responseDataWithQuota = {
|
||||
|
||||
1336
apps/web/app/lib/api/survey-transformation.test.ts
Normal file
1336
apps/web/app/lib/api/survey-transformation.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
501
apps/web/app/lib/api/survey-transformation.ts
Normal file
501
apps/web/app/lib/api/survey-transformation.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { type TSurveyBlock, TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks";
|
||||
import { type TConditionGroup, type TSingleCondition } from "@formbricks/types/surveys/logic";
|
||||
import {
|
||||
type TSurveyEnding,
|
||||
TSurveyLogicAction,
|
||||
type TSurveyQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { isConditionGroup, isSingleCondition } from "@formbricks/types/surveys/validation";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
|
||||
type Condition = TSingleCondition | TConditionGroup;
|
||||
|
||||
const conditionReferencesCTA = (
|
||||
condition: Condition | null | undefined,
|
||||
ctaElementId: string,
|
||||
operator?: string
|
||||
): boolean => {
|
||||
if (!condition) return false;
|
||||
|
||||
if (isSingleCondition(condition)) {
|
||||
if (condition.leftOperand.value === ctaElementId) {
|
||||
if (operator) {
|
||||
return condition.operator === operator;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isConditionGroup(condition)) {
|
||||
return condition.conditions.some((c) => conditionReferencesCTA(c, ctaElementId, operator));
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const removeCtaConditions = (
|
||||
conditionGroup: TConditionGroup,
|
||||
ctaElementId: string,
|
||||
operatorsToRemove: string[]
|
||||
): TConditionGroup | null => {
|
||||
const filteredConditions = conditionGroup.conditions.filter((condition) => {
|
||||
if (isSingleCondition(condition)) {
|
||||
if (condition.leftOperand.value === ctaElementId) {
|
||||
return !operatorsToRemove.includes(condition.operator);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isConditionGroup(condition)) {
|
||||
const cleaned = removeCtaConditions(condition, ctaElementId, operatorsToRemove);
|
||||
if (!cleaned || cleaned.conditions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
Object.assign(condition, cleaned);
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (filteredConditions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...conditionGroup,
|
||||
conditions: filteredConditions,
|
||||
};
|
||||
};
|
||||
|
||||
const migrateCTAQuestion = (question: Record<string, unknown>): void => {
|
||||
if (question.type !== "cta") return;
|
||||
|
||||
const hasExternalButton = question.buttonExternal === true && Boolean(question.buttonUrl);
|
||||
|
||||
if (hasExternalButton) {
|
||||
if (question.buttonLabel) {
|
||||
question.ctaButtonLabel = question.buttonLabel;
|
||||
}
|
||||
question.buttonExternal = true;
|
||||
} else {
|
||||
delete question.buttonExternal;
|
||||
delete question.buttonUrl;
|
||||
}
|
||||
|
||||
delete question.buttonLabel;
|
||||
delete question.dismissButtonLabel;
|
||||
};
|
||||
|
||||
const cleanCTALogicFromQuestion = (
|
||||
question: Record<string, unknown>,
|
||||
ctaQuestions: Map<string, boolean>
|
||||
): void => {
|
||||
if (!question.logic || !Array.isArray(question.logic) || question.logic.length === 0) return;
|
||||
|
||||
const cleanedLogic: unknown[] = [];
|
||||
|
||||
question.logic.forEach((logicRule: { conditions: TConditionGroup; [key: string]: unknown }) => {
|
||||
let shouldKeepRule = true;
|
||||
let modifiedConditions = logicRule.conditions;
|
||||
|
||||
ctaQuestions.forEach((hasExternalButton, ctaId) => {
|
||||
if (!hasExternalButton) {
|
||||
if (conditionReferencesCTA(modifiedConditions, ctaId)) {
|
||||
const cleanedConditions = removeCtaConditions(modifiedConditions, ctaId, [
|
||||
"isClicked",
|
||||
"isSkipped",
|
||||
]);
|
||||
if (!cleanedConditions?.conditions || cleanedConditions.conditions.length === 0) {
|
||||
shouldKeepRule = false;
|
||||
} else {
|
||||
modifiedConditions = cleanedConditions;
|
||||
}
|
||||
}
|
||||
} else if (conditionReferencesCTA(modifiedConditions, ctaId, "isSkipped")) {
|
||||
const cleanedConditions = removeCtaConditions(modifiedConditions, ctaId, ["isSkipped"]);
|
||||
if (!cleanedConditions?.conditions || cleanedConditions.conditions.length === 0) {
|
||||
shouldKeepRule = false;
|
||||
} else {
|
||||
modifiedConditions = cleanedConditions;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldKeepRule) {
|
||||
cleanedLogic.push({
|
||||
...logicRule,
|
||||
conditions: modifiedConditions,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (cleanedLogic.length === 0) {
|
||||
delete question.logic;
|
||||
} else {
|
||||
question.logic = cleanedLogic;
|
||||
}
|
||||
};
|
||||
|
||||
const processCTAQuestions = (questions: Record<string, unknown>[]): void => {
|
||||
const ctaQuestions = new Map<string, boolean>();
|
||||
|
||||
questions.forEach((question) => {
|
||||
if (question.type === "cta") {
|
||||
const hasExternalButton = question.buttonExternal === true && Boolean(question.buttonUrl);
|
||||
ctaQuestions.set(question.id as string, hasExternalButton);
|
||||
}
|
||||
});
|
||||
|
||||
if (ctaQuestions.size === 0) return;
|
||||
|
||||
questions.forEach((question) => {
|
||||
migrateCTAQuestion(question);
|
||||
});
|
||||
|
||||
questions.forEach((question) => {
|
||||
cleanCTALogicFromQuestion(question, ctaQuestions);
|
||||
});
|
||||
};
|
||||
|
||||
const getBlockName = (questionIdx: number): string => {
|
||||
return `Block ${String(questionIdx + 1)}`;
|
||||
};
|
||||
|
||||
const updateLogicActions = (
|
||||
actions: TSurveyLogicAction[],
|
||||
questionIdToBlockId: Map<string, string>,
|
||||
endingIds: Set<string>
|
||||
): TSurveyBlockLogicAction[] => {
|
||||
return actions.map((action) => {
|
||||
if (action.objective === "jumpToQuestion") {
|
||||
const target = action.target;
|
||||
const blockId = questionIdToBlockId.get(target);
|
||||
|
||||
if (blockId) {
|
||||
return {
|
||||
...action,
|
||||
objective: "jumpToBlock",
|
||||
target: blockId,
|
||||
};
|
||||
}
|
||||
|
||||
if (endingIds.has(target)) {
|
||||
return {
|
||||
...action,
|
||||
objective: "jumpToBlock",
|
||||
target,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...action,
|
||||
objective: "jumpToBlock",
|
||||
target,
|
||||
};
|
||||
}
|
||||
|
||||
return action as TSurveyBlockLogicAction;
|
||||
});
|
||||
};
|
||||
|
||||
const updateLogicFallback = (
|
||||
fallback: string,
|
||||
questionIdToBlockId: Map<string, string>,
|
||||
endingIds: Set<string>
|
||||
): string | undefined => {
|
||||
const blockId = questionIdToBlockId.get(fallback);
|
||||
|
||||
if (blockId) {
|
||||
return blockId;
|
||||
}
|
||||
|
||||
if (endingIds.has(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const convertQuestionToElementType = (condition: Condition | null | undefined): Condition | null => {
|
||||
if (!condition) return null;
|
||||
|
||||
if (isSingleCondition(condition)) {
|
||||
const newCondition = { ...condition } as Record<string, unknown>;
|
||||
const leftOperand = { ...condition.leftOperand } as Record<string, unknown>;
|
||||
|
||||
if ((leftOperand.type as string) === "question") {
|
||||
leftOperand.type = "element";
|
||||
}
|
||||
newCondition.leftOperand = leftOperand;
|
||||
|
||||
if (condition.rightOperand) {
|
||||
const rightOperand = { ...condition.rightOperand } as Record<string, unknown>;
|
||||
if ((rightOperand.type as string) === "question") {
|
||||
rightOperand.type = "element";
|
||||
}
|
||||
newCondition.rightOperand = rightOperand;
|
||||
}
|
||||
|
||||
return newCondition as TSingleCondition;
|
||||
}
|
||||
|
||||
if (isConditionGroup(condition)) {
|
||||
const newConditionGroup: TConditionGroup = {
|
||||
...condition,
|
||||
conditions: condition.conditions.map((nestedCondition) => {
|
||||
const converted = convertQuestionToElementType(nestedCondition);
|
||||
return converted ?? nestedCondition;
|
||||
}),
|
||||
};
|
||||
|
||||
return newConditionGroup;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const convertElementToQuestionType = (condition: Condition | null | undefined): Condition | null => {
|
||||
if (!condition) return null;
|
||||
|
||||
if (isSingleCondition(condition)) {
|
||||
const newCondition = { ...condition } as Record<string, unknown>;
|
||||
const leftOperand = { ...condition.leftOperand } as Record<string, unknown>;
|
||||
|
||||
newCondition.leftOperand = {
|
||||
...leftOperand,
|
||||
type: leftOperand.type === "element" ? "question" : leftOperand.type,
|
||||
};
|
||||
|
||||
if (condition.rightOperand) {
|
||||
const rightOperand = { ...condition.rightOperand } as Record<string, unknown>;
|
||||
newCondition.rightOperand = {
|
||||
...rightOperand,
|
||||
type: rightOperand.type === "element" ? "question" : rightOperand.type,
|
||||
};
|
||||
}
|
||||
|
||||
return newCondition as TSingleCondition;
|
||||
}
|
||||
|
||||
if (isConditionGroup(condition)) {
|
||||
const newConditionGroup: TConditionGroup = {
|
||||
...condition,
|
||||
conditions: condition.conditions.map((nestedCondition) => {
|
||||
const converted = convertElementToQuestionType(nestedCondition);
|
||||
return converted ?? nestedCondition;
|
||||
}),
|
||||
};
|
||||
|
||||
return newConditionGroup;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const reverseLogicActions = (
|
||||
actions: TSurveyBlockLogicAction[],
|
||||
blockIdToQuestionId: Map<string, string>,
|
||||
endingIds: Set<string>
|
||||
): TSurveyLogicAction[] => {
|
||||
return actions.map((action) => {
|
||||
if (action.objective === "jumpToBlock") {
|
||||
const target = action.target;
|
||||
const questionId = blockIdToQuestionId.get(target);
|
||||
|
||||
if (questionId) {
|
||||
return {
|
||||
...action,
|
||||
objective: "jumpToQuestion",
|
||||
target: questionId,
|
||||
};
|
||||
}
|
||||
|
||||
if (endingIds.has(target)) {
|
||||
return {
|
||||
...action,
|
||||
objective: "jumpToQuestion",
|
||||
target,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...action,
|
||||
objective: "jumpToQuestion",
|
||||
target,
|
||||
};
|
||||
}
|
||||
|
||||
return action;
|
||||
});
|
||||
};
|
||||
|
||||
const reverseLogicFallback = (
|
||||
fallback: string,
|
||||
blockIdToQuestionId: Map<string, string>,
|
||||
endingIds: Set<string>
|
||||
): string | undefined => {
|
||||
const questionId = blockIdToQuestionId.get(fallback);
|
||||
|
||||
if (questionId) {
|
||||
return questionId;
|
||||
}
|
||||
|
||||
if (endingIds.has(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const transformQuestionsToBlocks = (
|
||||
questions: TSurveyQuestion[],
|
||||
endings: TSurveyEnding[] = []
|
||||
): TSurveyBlock[] => {
|
||||
if (questions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const questionsCopy = structuredClone(questions);
|
||||
|
||||
processCTAQuestions(questionsCopy);
|
||||
|
||||
const endingIds = new Set<string>(endings.map((ending) => ending.id));
|
||||
|
||||
const questionIdToBlockId = new Map<string, string>();
|
||||
const blocks: Record<string, unknown>[] = [];
|
||||
|
||||
for (let i = 0; i < questionsCopy.length; i++) {
|
||||
const question = questionsCopy[i];
|
||||
|
||||
const blockId = createId();
|
||||
questionIdToBlockId.set(question.id as string, blockId);
|
||||
|
||||
const { logic, logicFallback, buttonLabel, backButtonLabel, ...baseElement } = question;
|
||||
|
||||
blocks.push({
|
||||
id: blockId,
|
||||
name: getBlockName(i),
|
||||
elements: [baseElement],
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
logic,
|
||||
logicFallback,
|
||||
});
|
||||
}
|
||||
|
||||
for (const block of blocks) {
|
||||
if (Array.isArray(block.logic) && block.logic.length > 0) {
|
||||
block.logic = block.logic.map(
|
||||
(item: { conditions: TConditionGroup; actions: TSurveyLogicAction[] }) => {
|
||||
const updatedConditions = convertQuestionToElementType(item.conditions);
|
||||
|
||||
if (!updatedConditions || !isConditionGroup(updatedConditions)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
conditions: updatedConditions,
|
||||
actions: updateLogicActions(item.actions, questionIdToBlockId, endingIds),
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof block.logicFallback === "string") {
|
||||
block.logicFallback = updateLogicFallback(block.logicFallback, questionIdToBlockId, endingIds);
|
||||
}
|
||||
}
|
||||
|
||||
return blocks as TSurveyBlock[];
|
||||
};
|
||||
|
||||
export const transformBlocksToQuestions = (
|
||||
blocks: TSurveyBlock[],
|
||||
endings: TSurveyEnding[] = []
|
||||
): TSurveyQuestion[] => {
|
||||
if (blocks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const endingIds = new Set<string>(endings.map((ending) => ending.id));
|
||||
const questions: Record<string, unknown>[] = [];
|
||||
|
||||
const blockIdToQuestionId = blocks.reduce((acc, block) => {
|
||||
if (block.elements.length === 0) return acc;
|
||||
acc.set(block.id, block.elements[0].id);
|
||||
return acc;
|
||||
}, new Map<string, string>());
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.elements.length === 0) continue;
|
||||
|
||||
const element = { ...block.elements[0] } as Record<string, unknown>;
|
||||
|
||||
if (element.type === "cta" && element.ctaButtonLabel) {
|
||||
element.buttonLabel = element.ctaButtonLabel;
|
||||
}
|
||||
|
||||
if (Array.isArray(block.logic) && block.logic.length > 0) {
|
||||
element.logic = block.logic.map(
|
||||
(item: { id: string; conditions: TConditionGroup; actions: TSurveyBlockLogicAction[] }) => {
|
||||
const updatedConditions = convertElementToQuestionType(item.conditions);
|
||||
|
||||
if (!updatedConditions || !isConditionGroup(updatedConditions)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
conditions: updatedConditions,
|
||||
actions: reverseLogicActions(item.actions, blockIdToQuestionId, endingIds),
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (block.logicFallback) {
|
||||
element.logicFallback = reverseLogicFallback(block.logicFallback, blockIdToQuestionId, endingIds);
|
||||
}
|
||||
|
||||
if (block.buttonLabel) {
|
||||
element.buttonLabel = block.buttonLabel;
|
||||
}
|
||||
|
||||
if (block.backButtonLabel) {
|
||||
element.backButtonLabel = block.backButtonLabel;
|
||||
}
|
||||
|
||||
questions.push(element);
|
||||
}
|
||||
|
||||
return questions as TSurveyQuestion[];
|
||||
};
|
||||
|
||||
export const validateSurveyInput = (input: {
|
||||
questions?: TSurveyQuestion[];
|
||||
blocks?: TSurveyBlock[];
|
||||
updateOnly?: boolean;
|
||||
}): Result<{ hasQuestions: boolean; hasBlocks: boolean }, InvalidInputError> => {
|
||||
const hasQuestions = Boolean(input.questions && input.questions.length > 0);
|
||||
const hasBlocks = Boolean(input.blocks && input.blocks.length > 0);
|
||||
|
||||
if (hasQuestions && hasBlocks) {
|
||||
return err(
|
||||
new InvalidInputError(
|
||||
"Cannot provide both questions and blocks. Please provide only one of these fields."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasQuestions && !hasBlocks && !input.updateOnly) {
|
||||
return err(new InvalidInputError("Must provide either questions or blocks. Both cannot be empty."));
|
||||
}
|
||||
|
||||
return ok({ hasQuestions, hasBlocks });
|
||||
};
|
||||
308
apps/web/app/lib/survey-block-builder.ts
Normal file
308
apps/web/app/lib/survey-block-builder.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
|
||||
import type {
|
||||
TSurveyCTAElement,
|
||||
TSurveyConsentElement,
|
||||
TSurveyElement,
|
||||
TSurveyMultipleChoiceElement,
|
||||
TSurveyNPSElement,
|
||||
TSurveyOpenTextElement,
|
||||
TSurveyOpenTextElementInputType,
|
||||
TSurveyRatingElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import type { TShuffleOption } from "@formbricks/types/surveys/types";
|
||||
import { createI18nString } from "@/lib/i18n/utils";
|
||||
|
||||
const getDefaultButtonLabel = (label: string | undefined, t: TFunction) =>
|
||||
createI18nString(label || t("common.next"), []);
|
||||
|
||||
const getDefaultBackButtonLabel = (label: string | undefined, t: TFunction) =>
|
||||
createI18nString(label || t("common.back"), []);
|
||||
|
||||
export const buildMultipleChoiceElement = ({
|
||||
id,
|
||||
headline,
|
||||
type,
|
||||
subheader,
|
||||
choices,
|
||||
choiceIds,
|
||||
shuffleOption,
|
||||
required,
|
||||
containsOther = false,
|
||||
}: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceMulti | TSurveyElementTypeEnum.MultipleChoiceSingle;
|
||||
subheader?: string;
|
||||
choices: string[];
|
||||
choiceIds?: string[];
|
||||
shuffleOption?: TShuffleOption;
|
||||
required?: boolean;
|
||||
containsOther?: boolean;
|
||||
}): TSurveyMultipleChoiceElement => {
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type,
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
choices: choices.map((choice, index) => {
|
||||
const isLastIndex = index === choices.length - 1;
|
||||
let choiceId: string;
|
||||
if (containsOther && isLastIndex) {
|
||||
choiceId = "other";
|
||||
} else if (choiceIds) {
|
||||
choiceId = choiceIds[index];
|
||||
} else {
|
||||
choiceId = createId();
|
||||
}
|
||||
return { id: choiceId, label: createI18nString(choice, []) };
|
||||
}),
|
||||
shuffleOption: shuffleOption || "none",
|
||||
required: required ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildOpenTextElement = ({
|
||||
id,
|
||||
headline,
|
||||
subheader,
|
||||
placeholder,
|
||||
inputType,
|
||||
required,
|
||||
longAnswer,
|
||||
}: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
subheader?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
inputType: TSurveyOpenTextElementInputType;
|
||||
longAnswer?: boolean;
|
||||
}): TSurveyOpenTextElement => {
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
inputType,
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
placeholder: placeholder ? createI18nString(placeholder, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
required: required ?? false,
|
||||
longAnswer,
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const buildRatingElement = ({
|
||||
id,
|
||||
headline,
|
||||
subheader,
|
||||
scale,
|
||||
range,
|
||||
lowerLabel,
|
||||
upperLabel,
|
||||
required,
|
||||
isColorCodingEnabled = false,
|
||||
}: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
scale: TSurveyRatingElement["scale"];
|
||||
range: TSurveyRatingElement["range"];
|
||||
lowerLabel?: string;
|
||||
upperLabel?: string;
|
||||
subheader?: string;
|
||||
required?: boolean;
|
||||
isColorCodingEnabled?: boolean;
|
||||
}): TSurveyRatingElement => {
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type: TSurveyElementTypeEnum.Rating,
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
scale,
|
||||
range,
|
||||
required: required ?? false,
|
||||
isColorCodingEnabled,
|
||||
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
|
||||
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildConsentElement = ({
|
||||
id,
|
||||
headline,
|
||||
subheader,
|
||||
label,
|
||||
required,
|
||||
}: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
subheader: string;
|
||||
required?: boolean;
|
||||
label: string;
|
||||
}): TSurveyConsentElement => {
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type: TSurveyElementTypeEnum.Consent,
|
||||
subheader: createI18nString(subheader, []),
|
||||
headline: createI18nString(headline, []),
|
||||
required: required ?? false,
|
||||
label: createI18nString(label, []),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildCTAElement = ({
|
||||
id,
|
||||
headline,
|
||||
subheader,
|
||||
buttonExternal,
|
||||
required,
|
||||
ctaButtonLabel,
|
||||
buttonUrl,
|
||||
}: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
buttonExternal?: boolean;
|
||||
subheader: string;
|
||||
required?: boolean;
|
||||
ctaButtonLabel?: string;
|
||||
buttonUrl?: string;
|
||||
}): TSurveyCTAElement => {
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
subheader: createI18nString(subheader, []),
|
||||
headline: createI18nString(headline, []),
|
||||
ctaButtonLabel: ctaButtonLabel ? createI18nString(ctaButtonLabel, []) : undefined,
|
||||
required: required ?? false,
|
||||
buttonExternal: buttonExternal ?? false,
|
||||
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: "element",
|
||||
},
|
||||
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: "element",
|
||||
},
|
||||
operator: "equals",
|
||||
rightOperand: {
|
||||
type: "static",
|
||||
value: choiceId,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToBlock",
|
||||
target: targetBlockId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Block builder function
|
||||
export const buildBlock = ({
|
||||
id,
|
||||
name,
|
||||
elements,
|
||||
logic,
|
||||
logicFallback,
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
t,
|
||||
}: {
|
||||
id?: string;
|
||||
name: string;
|
||||
elements: TSurveyElement[];
|
||||
logic?: TSurveyBlockLogic[];
|
||||
logicFallback?: string;
|
||||
buttonLabel?: string;
|
||||
backButtonLabel?: string;
|
||||
t: TFunction;
|
||||
}): TSurveyBlock => {
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
name,
|
||||
elements,
|
||||
logic,
|
||||
logicFallback,
|
||||
buttonLabel: buttonLabel ? getDefaultButtonLabel(buttonLabel, t) : undefined,
|
||||
backButtonLabel: backButtonLabel ? getDefaultBackButtonLabel(backButtonLabel, t) : undefined,
|
||||
};
|
||||
};
|
||||
@@ -1,15 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { TShuffleOption, TSurveyLogic, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
buildCTAQuestion,
|
||||
buildConsentQuestion,
|
||||
buildMultipleChoiceQuestion,
|
||||
buildNPSQuestion,
|
||||
buildOpenTextQuestion,
|
||||
buildRatingQuestion,
|
||||
buildSurvey,
|
||||
createChoiceJumpLogic,
|
||||
createJumpLogic,
|
||||
getDefaultEndingCard,
|
||||
getDefaultSurveyPreset,
|
||||
getDefaultWelcomeCard,
|
||||
@@ -19,595 +10,81 @@ import {
|
||||
const mockT = (props: any): string => (typeof props === "string" ? props : props.key);
|
||||
|
||||
describe("Survey Builder", () => {
|
||||
describe("buildMultipleChoiceQuestion", () => {
|
||||
test("creates a single choice question with required fields", () => {
|
||||
const question = buildMultipleChoiceQuestion({
|
||||
headline: "Test Question",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
choices: ["Option 1", "Option 2", "Option 3"],
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(question).toMatchObject({
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Test Question" },
|
||||
choices: expect.arrayContaining([
|
||||
expect.objectContaining({ label: { default: "Option 1" } }),
|
||||
expect.objectContaining({ label: { default: "Option 2" } }),
|
||||
expect.objectContaining({ label: { default: "Option 3" } }),
|
||||
]),
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
shuffleOption: "none",
|
||||
required: false,
|
||||
});
|
||||
expect(question.choices.length).toBe(3);
|
||||
expect(question.id).toBeDefined();
|
||||
describe("Helper Functions", () => {
|
||||
test("getDefaultSurveyPreset returns expected default survey preset", () => {
|
||||
const preset = getDefaultSurveyPreset(mockT);
|
||||
expect(preset.name).toBe("New Survey");
|
||||
// test welcomeCard and endings
|
||||
expect(preset.welcomeCard).toHaveProperty("headline");
|
||||
expect(preset.endings).toHaveLength(1);
|
||||
expect(preset.endings[0]).toHaveProperty("headline");
|
||||
expect(preset.hiddenFields).toEqual(hiddenFieldsDefault);
|
||||
expect(preset.blocks).toEqual([]);
|
||||
});
|
||||
|
||||
test("creates a multiple choice question with provided ID", () => {
|
||||
const customId = "custom-id-123";
|
||||
const question = buildMultipleChoiceQuestion({
|
||||
id: customId,
|
||||
headline: "Test Question",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
choices: ["Option 1", "Option 2"],
|
||||
t: mockT,
|
||||
test("getDefaultWelcomeCard returns expected welcome card", () => {
|
||||
const welcomeCard = getDefaultWelcomeCard(mockT);
|
||||
expect(welcomeCard).toMatchObject({
|
||||
enabled: false,
|
||||
headline: { default: "templates.default_welcome_card_headline" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
});
|
||||
|
||||
expect(question.id).toBe(customId);
|
||||
expect(question.type).toBe(TSurveyQuestionTypeEnum.MultipleChoiceMulti);
|
||||
// Check that the welcome card is properly structured
|
||||
expect(welcomeCard).toHaveProperty("enabled");
|
||||
expect(welcomeCard).toHaveProperty("headline");
|
||||
expect(welcomeCard).toHaveProperty("showResponseCount");
|
||||
expect(welcomeCard).toHaveProperty("timeToFinish");
|
||||
});
|
||||
|
||||
test("handles 'other' option correctly", () => {
|
||||
const choices = ["Option 1", "Option 2", "Other"];
|
||||
const question = buildMultipleChoiceQuestion({
|
||||
headline: "Test Question",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
choices,
|
||||
containsOther: true,
|
||||
t: mockT,
|
||||
test("getDefaultEndingCard returns expected ending card", () => {
|
||||
const languages: string[] = [];
|
||||
const endingCard = getDefaultEndingCard(languages, mockT);
|
||||
expect(endingCard).toMatchObject({
|
||||
type: "endScreen",
|
||||
headline: { default: "templates.default_ending_card_headline" },
|
||||
subheader: { default: "templates.default_ending_card_subheader" },
|
||||
});
|
||||
|
||||
expect(question.choices.length).toBe(3);
|
||||
expect(question.choices[2].id).toBe("other");
|
||||
expect(endingCard.id).toBeDefined();
|
||||
expect(endingCard).toHaveProperty("buttonLabel");
|
||||
expect(endingCard).toHaveProperty("buttonLink");
|
||||
});
|
||||
|
||||
test("uses provided choice IDs when available", () => {
|
||||
const choiceIds = ["id1", "id2", "id3"];
|
||||
const question = buildMultipleChoiceQuestion({
|
||||
headline: "Test Question",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
choices: ["Option 1", "Option 2", "Option 3"],
|
||||
choiceIds,
|
||||
t: mockT,
|
||||
test("hiddenFieldsDefault has expected structure", () => {
|
||||
expect(hiddenFieldsDefault).toMatchObject({
|
||||
enabled: true,
|
||||
fieldIds: [],
|
||||
});
|
||||
|
||||
expect(question.choices[0].id).toBe(choiceIds[0]);
|
||||
expect(question.choices[1].id).toBe(choiceIds[1]);
|
||||
expect(question.choices[2].id).toBe(choiceIds[2]);
|
||||
});
|
||||
|
||||
test("applies all optional parameters correctly", () => {
|
||||
const logic: TSurveyLogic[] = [
|
||||
{
|
||||
id: "logic-1",
|
||||
conditions: {
|
||||
id: "cond-1",
|
||||
connector: "and",
|
||||
conditions: [],
|
||||
},
|
||||
actions: [],
|
||||
},
|
||||
];
|
||||
test("buildSurvey returns built survey with overridden preset properties", () => {
|
||||
const config = {
|
||||
name: "Custom Survey",
|
||||
role: "productManager" as const,
|
||||
industries: ["saas" as const],
|
||||
channels: ["link" as const],
|
||||
description: "A custom survey description",
|
||||
blocks: [],
|
||||
endings: [getDefaultEndingCard([], mockT)],
|
||||
hiddenFields: hiddenFieldsDefault,
|
||||
};
|
||||
|
||||
const shuffleOption: TShuffleOption = "all";
|
||||
const survey = buildSurvey(config, mockT);
|
||||
|
||||
const question = buildMultipleChoiceQuestion({
|
||||
headline: "Test Question",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
subheader: "This is a subheader",
|
||||
choices: ["Option 1", "Option 2"],
|
||||
buttonLabel: "Custom Next",
|
||||
backButtonLabel: "Custom Back",
|
||||
shuffleOption,
|
||||
required: false,
|
||||
logic,
|
||||
t: mockT,
|
||||
});
|
||||
// role, industries, channels, description
|
||||
expect(survey.role).toBe(config.role);
|
||||
expect(survey.industries).toEqual(config.industries);
|
||||
expect(survey.channels).toEqual(config.channels);
|
||||
expect(survey.description).toBe(config.description);
|
||||
|
||||
expect(question.subheader).toEqual({ default: "This is a subheader" });
|
||||
expect(question.buttonLabel).toEqual({ default: "Custom Next" });
|
||||
expect(question.backButtonLabel).toEqual({ default: "Custom Back" });
|
||||
expect(question.shuffleOption).toBe("all");
|
||||
expect(question.required).toBe(false);
|
||||
expect(question.logic).toBe(logic);
|
||||
});
|
||||
});
|
||||
// preset overrides
|
||||
expect(survey.preset.name).toBe(config.name);
|
||||
expect(survey.preset.endings).toEqual(config.endings);
|
||||
expect(survey.preset.hiddenFields).toEqual(config.hiddenFields);
|
||||
expect(survey.preset.blocks).toEqual(config.blocks);
|
||||
|
||||
describe("buildOpenTextQuestion", () => {
|
||||
test("creates an open text question with required fields", () => {
|
||||
const question = buildOpenTextQuestion({
|
||||
headline: "Open Question",
|
||||
inputType: "text",
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(question).toMatchObject({
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Open Question" },
|
||||
inputType: "text",
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: false,
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
expect(question.id).toBeDefined();
|
||||
});
|
||||
|
||||
test("applies all optional parameters correctly", () => {
|
||||
const logic: TSurveyLogic[] = [
|
||||
{
|
||||
id: "logic-1",
|
||||
conditions: {
|
||||
id: "cond-1",
|
||||
connector: "and",
|
||||
conditions: [],
|
||||
},
|
||||
actions: [],
|
||||
},
|
||||
];
|
||||
|
||||
const question = buildOpenTextQuestion({
|
||||
id: "custom-id",
|
||||
headline: "Open Question",
|
||||
subheader: "Answer this question",
|
||||
placeholder: "Type here",
|
||||
buttonLabel: "Submit",
|
||||
backButtonLabel: "Previous",
|
||||
required: false,
|
||||
longAnswer: true,
|
||||
inputType: "email",
|
||||
logic,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(question.id).toBe("custom-id");
|
||||
expect(question.subheader).toEqual({ default: "Answer this question" });
|
||||
expect(question.placeholder).toEqual({ default: "Type here" });
|
||||
expect(question.buttonLabel).toEqual({ default: "Submit" });
|
||||
expect(question.backButtonLabel).toEqual({ default: "Previous" });
|
||||
expect(question.required).toBe(false);
|
||||
expect(question.longAnswer).toBe(true);
|
||||
expect(question.inputType).toBe("email");
|
||||
expect(question.logic).toBe(logic);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRatingQuestion", () => {
|
||||
test("creates a rating question with required fields", () => {
|
||||
const question = buildRatingQuestion({
|
||||
headline: "Rating Question",
|
||||
scale: "number",
|
||||
range: 5,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(question).toMatchObject({
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rating Question" },
|
||||
scale: "number",
|
||||
range: 5,
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: false,
|
||||
isColorCodingEnabled: false,
|
||||
});
|
||||
expect(question.id).toBeDefined();
|
||||
});
|
||||
|
||||
test("applies all optional parameters correctly", () => {
|
||||
const logic: TSurveyLogic[] = [
|
||||
{
|
||||
id: "logic-1",
|
||||
conditions: {
|
||||
id: "cond-1",
|
||||
connector: "and",
|
||||
conditions: [],
|
||||
},
|
||||
actions: [],
|
||||
},
|
||||
];
|
||||
|
||||
const question = buildRatingQuestion({
|
||||
id: "custom-id",
|
||||
headline: "Rating Question",
|
||||
subheader: "Rate us",
|
||||
scale: "star",
|
||||
range: 10,
|
||||
lowerLabel: "Poor",
|
||||
upperLabel: "Excellent",
|
||||
buttonLabel: "Submit",
|
||||
backButtonLabel: "Previous",
|
||||
required: false,
|
||||
isColorCodingEnabled: true,
|
||||
logic,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(question.id).toBe("custom-id");
|
||||
expect(question.subheader).toEqual({ default: "Rate us" });
|
||||
expect(question.scale).toBe("star");
|
||||
expect(question.range).toBe(10);
|
||||
expect(question.lowerLabel).toEqual({ default: "Poor" });
|
||||
expect(question.upperLabel).toEqual({ default: "Excellent" });
|
||||
expect(question.buttonLabel).toEqual({ default: "Submit" });
|
||||
expect(question.backButtonLabel).toEqual({ default: "Previous" });
|
||||
expect(question.required).toBe(false);
|
||||
expect(question.isColorCodingEnabled).toBe(true);
|
||||
expect(question.logic).toBe(logic);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildNPSQuestion", () => {
|
||||
test("creates an NPS question with required fields", () => {
|
||||
const question = buildNPSQuestion({
|
||||
headline: "NPS Question",
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(question).toMatchObject({
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: { default: "NPS Question" },
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: false,
|
||||
isColorCodingEnabled: false,
|
||||
});
|
||||
expect(question.id).toBeDefined();
|
||||
});
|
||||
|
||||
test("applies all optional parameters correctly", () => {
|
||||
const logic: TSurveyLogic[] = [
|
||||
{
|
||||
id: "logic-1",
|
||||
conditions: {
|
||||
id: "cond-1",
|
||||
connector: "and",
|
||||
conditions: [],
|
||||
},
|
||||
actions: [],
|
||||
},
|
||||
];
|
||||
|
||||
const question = buildNPSQuestion({
|
||||
id: "custom-id",
|
||||
headline: "NPS Question",
|
||||
subheader: "How likely are you to recommend us?",
|
||||
lowerLabel: "Not likely",
|
||||
upperLabel: "Very likely",
|
||||
buttonLabel: "Submit",
|
||||
backButtonLabel: "Previous",
|
||||
required: false,
|
||||
isColorCodingEnabled: true,
|
||||
logic,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(question.id).toBe("custom-id");
|
||||
expect(question.subheader).toEqual({ default: "How likely are you to recommend us?" });
|
||||
expect(question.lowerLabel).toEqual({ default: "Not likely" });
|
||||
expect(question.upperLabel).toEqual({ default: "Very likely" });
|
||||
expect(question.buttonLabel).toEqual({ default: "Submit" });
|
||||
expect(question.backButtonLabel).toEqual({ default: "Previous" });
|
||||
expect(question.required).toBe(false);
|
||||
expect(question.isColorCodingEnabled).toBe(true);
|
||||
expect(question.logic).toBe(logic);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildConsentQuestion", () => {
|
||||
test("creates a consent question with required fields", () => {
|
||||
const question = buildConsentQuestion({
|
||||
headline: "Consent Question",
|
||||
subheader: "",
|
||||
label: "I agree to terms",
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(question).toMatchObject({
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
headline: { default: "Consent Question" },
|
||||
subheader: { default: "" },
|
||||
label: { default: "I agree to terms" },
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: false,
|
||||
});
|
||||
expect(question.id).toBeDefined();
|
||||
});
|
||||
|
||||
test("applies all optional parameters correctly", () => {
|
||||
const logic: TSurveyLogic[] = [
|
||||
{
|
||||
id: "logic-1",
|
||||
conditions: {
|
||||
id: "cond-1",
|
||||
connector: "and",
|
||||
conditions: [],
|
||||
},
|
||||
actions: [],
|
||||
},
|
||||
];
|
||||
|
||||
const question = buildConsentQuestion({
|
||||
id: "custom-id",
|
||||
headline: "Consent Question",
|
||||
subheader: "Please read the terms",
|
||||
label: "I agree to terms",
|
||||
buttonLabel: "Submit",
|
||||
backButtonLabel: "Previous",
|
||||
required: false,
|
||||
logic,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(question.id).toBe("custom-id");
|
||||
expect(question.subheader).toEqual({ default: "Please read the terms" });
|
||||
expect(question.label).toEqual({ default: "I agree to terms" });
|
||||
expect(question.buttonLabel).toEqual({ default: "Submit" });
|
||||
expect(question.backButtonLabel).toEqual({ default: "Previous" });
|
||||
expect(question.required).toBe(false);
|
||||
expect(question.logic).toBe(logic);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCTAQuestion", () => {
|
||||
test("creates a CTA question with required fields", () => {
|
||||
const question = buildCTAQuestion({
|
||||
headline: "CTA Question",
|
||||
subheader: "",
|
||||
buttonExternal: false,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(question).toMatchObject({
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA Question" },
|
||||
subheader: { default: "" },
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: false,
|
||||
buttonExternal: false,
|
||||
});
|
||||
expect(question.id).toBeDefined();
|
||||
});
|
||||
|
||||
test("applies all optional parameters correctly", () => {
|
||||
const logic: TSurveyLogic[] = [
|
||||
{
|
||||
id: "logic-1",
|
||||
conditions: {
|
||||
id: "cond-1",
|
||||
connector: "and",
|
||||
conditions: [],
|
||||
},
|
||||
actions: [],
|
||||
},
|
||||
];
|
||||
|
||||
const question = buildCTAQuestion({
|
||||
id: "custom-id",
|
||||
headline: "CTA Question",
|
||||
subheader: "<p>Click the button</p>",
|
||||
buttonLabel: "Click me",
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://example.com",
|
||||
backButtonLabel: "Previous",
|
||||
required: false,
|
||||
dismissButtonLabel: "No thanks",
|
||||
logic,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(question.id).toBe("custom-id");
|
||||
expect(question.subheader).toEqual({ default: "<p>Click the button</p>" });
|
||||
expect(question.buttonLabel).toEqual({ default: "Click me" });
|
||||
expect(question.buttonExternal).toBe(true);
|
||||
expect(question.buttonUrl).toBe("https://example.com");
|
||||
expect(question.backButtonLabel).toEqual({ default: "Previous" });
|
||||
expect(question.required).toBe(false);
|
||||
expect(question.dismissButtonLabel).toEqual({ default: "No thanks" });
|
||||
expect(question.logic).toBe(logic);
|
||||
});
|
||||
|
||||
test("handles external button with URL", () => {
|
||||
const question = buildCTAQuestion({
|
||||
headline: "CTA Question",
|
||||
subheader: "",
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://formbricks.com",
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(question.buttonExternal).toBe(true);
|
||||
expect(question.buttonUrl).toBe("https://formbricks.com");
|
||||
});
|
||||
});
|
||||
|
||||
// Test combinations of parameters for edge cases
|
||||
describe("Edge cases", () => {
|
||||
test("multiple choice question with empty choices array", () => {
|
||||
const question = buildMultipleChoiceQuestion({
|
||||
headline: "Test Question",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
choices: [],
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(question.choices).toEqual([]);
|
||||
});
|
||||
|
||||
test("open text question with all parameters", () => {
|
||||
const question = buildOpenTextQuestion({
|
||||
id: "custom-id",
|
||||
headline: "Open Question",
|
||||
subheader: "Answer this question",
|
||||
placeholder: "Type here",
|
||||
buttonLabel: "Submit",
|
||||
backButtonLabel: "Previous",
|
||||
required: false,
|
||||
longAnswer: true,
|
||||
inputType: "email",
|
||||
logic: [],
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(question).toMatchObject({
|
||||
id: "custom-id",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Open Question" },
|
||||
subheader: { default: "Answer this question" },
|
||||
placeholder: { default: "Type here" },
|
||||
buttonLabel: { default: "Submit" },
|
||||
backButtonLabel: { default: "Previous" },
|
||||
required: false,
|
||||
longAnswer: true,
|
||||
inputType: "email",
|
||||
logic: [],
|
||||
});
|
||||
// default values from getDefaultSurveyPreset
|
||||
expect(survey.preset.welcomeCard).toHaveProperty("headline");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Helper Functions", () => {
|
||||
test("createJumpLogic returns valid jump logic", () => {
|
||||
const sourceId = "q1";
|
||||
const targetId = "q2";
|
||||
const operator: "isClicked" = "isClicked";
|
||||
const logic = createJumpLogic(sourceId, targetId, operator);
|
||||
|
||||
// Check structure
|
||||
expect(logic).toHaveProperty("id");
|
||||
expect(logic).toHaveProperty("conditions");
|
||||
expect(logic.conditions).toHaveProperty("conditions");
|
||||
expect(Array.isArray(logic.conditions.conditions)).toBe(true);
|
||||
|
||||
// Check one of the inner conditions
|
||||
const condition = logic.conditions.conditions[0];
|
||||
// Need to use type checking to ensure condition is a TSingleCondition not a TConditionGroup
|
||||
if (!("connector" in condition)) {
|
||||
expect(condition.leftOperand.value).toBe(sourceId);
|
||||
expect(condition.operator).toBe(operator);
|
||||
}
|
||||
|
||||
// Check actions
|
||||
expect(Array.isArray(logic.actions)).toBe(true);
|
||||
const action = logic.actions[0];
|
||||
if (action.objective === "jumpToQuestion") {
|
||||
expect(action.target).toBe(targetId);
|
||||
}
|
||||
});
|
||||
|
||||
test("createChoiceJumpLogic returns valid jump logic based on choice selection", () => {
|
||||
const sourceId = "q1";
|
||||
const choiceId = "choice1";
|
||||
const targetId = "q2";
|
||||
const logic = createChoiceJumpLogic(sourceId, choiceId, targetId);
|
||||
|
||||
expect(logic).toHaveProperty("id");
|
||||
expect(logic.conditions).toHaveProperty("conditions");
|
||||
|
||||
const condition = logic.conditions.conditions[0];
|
||||
if (!("connector" in condition)) {
|
||||
expect(condition.leftOperand.value).toBe(sourceId);
|
||||
expect(condition.operator).toBe("equals");
|
||||
expect(condition.rightOperand?.value).toBe(choiceId);
|
||||
}
|
||||
|
||||
const action = logic.actions[0];
|
||||
if (action.objective === "jumpToQuestion") {
|
||||
expect(action.target).toBe(targetId);
|
||||
}
|
||||
});
|
||||
|
||||
test("getDefaultWelcomeCard returns expected welcome card", () => {
|
||||
const card = getDefaultWelcomeCard(mockT);
|
||||
expect(card.enabled).toBe(false);
|
||||
expect(card.headline).toEqual({ default: "templates.default_welcome_card_headline" });
|
||||
expect(card.subheader).toEqual({ default: "templates.default_welcome_card_html" });
|
||||
expect(card.buttonLabel).toEqual({ default: "templates.default_welcome_card_button_label" });
|
||||
// boolean flags
|
||||
expect(card.timeToFinish).toBe(false);
|
||||
expect(card.showResponseCount).toBe(false);
|
||||
});
|
||||
|
||||
test("getDefaultEndingCard returns expected end screen card", () => {
|
||||
// Pass empty languages array to simulate no languages
|
||||
const card = getDefaultEndingCard([], mockT);
|
||||
expect(card).toHaveProperty("id");
|
||||
expect(card.type).toBe("endScreen");
|
||||
expect(card.headline).toEqual({ default: "templates.default_ending_card_headline" });
|
||||
expect(card.subheader).toEqual({ default: "templates.default_ending_card_subheader" });
|
||||
expect(card.buttonLabel).toEqual({ default: "templates.default_ending_card_button_label" });
|
||||
expect(card.buttonLink).toBe("https://formbricks.com");
|
||||
});
|
||||
|
||||
test("getDefaultSurveyPreset returns expected default survey preset", () => {
|
||||
const preset = getDefaultSurveyPreset(mockT);
|
||||
expect(preset.name).toBe("New Survey");
|
||||
expect(preset.questions).toEqual([]);
|
||||
// test welcomeCard and endings
|
||||
expect(preset.welcomeCard).toHaveProperty("headline");
|
||||
expect(Array.isArray(preset.endings)).toBe(true);
|
||||
expect(preset.hiddenFields).toEqual(hiddenFieldsDefault);
|
||||
});
|
||||
|
||||
test("buildSurvey returns built survey with overridden preset properties", () => {
|
||||
const config = {
|
||||
name: "Custom Survey",
|
||||
industries: ["eCommerce"] as string[],
|
||||
channels: ["link"],
|
||||
description: "Test survey",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText, // changed from "OpenText"
|
||||
headline: { default: "Question 1" },
|
||||
inputType: "text",
|
||||
buttonLabel: { default: "Next" },
|
||||
backButtonLabel: { default: "Back" },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
endings: [
|
||||
{
|
||||
id: "end1",
|
||||
type: "endScreen",
|
||||
headline: { default: "End Screen" },
|
||||
subheader: { default: "Thanks" },
|
||||
buttonLabel: { default: "Finish" },
|
||||
buttonLink: "https://formbricks.com",
|
||||
},
|
||||
],
|
||||
hiddenFields: { enabled: false, fieldIds: ["f1"] },
|
||||
};
|
||||
|
||||
const survey = buildSurvey(config as any, mockT);
|
||||
expect(survey.name).toBe(config.name);
|
||||
expect(survey.industries).toEqual(config.industries);
|
||||
expect(survey.channels).toEqual(config.channels);
|
||||
expect(survey.description).toBe(config.description);
|
||||
// preset overrides
|
||||
expect(survey.preset.name).toBe(config.name);
|
||||
expect(survey.preset.questions).toEqual(config.questions);
|
||||
expect(survey.preset.endings).toEqual(config.endings);
|
||||
expect(survey.preset.hiddenFields).toEqual(config.hiddenFields);
|
||||
});
|
||||
|
||||
test("hiddenFieldsDefault has expected default configuration", () => {
|
||||
expect(hiddenFieldsDefault).toEqual({ enabled: true, fieldIds: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,284 +1,17 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
TShuffleOption,
|
||||
TSurveyCTAQuestion,
|
||||
TSurveyConsentQuestion,
|
||||
import type { TFunction } from "i18next";
|
||||
import type { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import type {
|
||||
TSurveyEndScreenCard,
|
||||
TSurveyEnding,
|
||||
TSurveyHiddenFields,
|
||||
TSurveyLanguage,
|
||||
TSurveyLogic,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyNPSQuestion,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyOpenTextQuestionInputType,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyRatingQuestion,
|
||||
TSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TTemplate, TTemplateRole } from "@formbricks/types/templates";
|
||||
import type { TTemplate, TTemplateRole } from "@formbricks/types/templates";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
|
||||
const getDefaultButtonLabel = (label: string | undefined, t: TFunction) =>
|
||||
createI18nString(label || t("common.next"), []);
|
||||
|
||||
const getDefaultBackButtonLabel = (label: string | undefined, t: TFunction) =>
|
||||
createI18nString(label || t("common.back"), []);
|
||||
|
||||
export const buildMultipleChoiceQuestion = ({
|
||||
id,
|
||||
headline,
|
||||
type,
|
||||
subheader,
|
||||
choices,
|
||||
choiceIds,
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
shuffleOption,
|
||||
required,
|
||||
logic,
|
||||
containsOther = false,
|
||||
t,
|
||||
}: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti | TSurveyQuestionTypeEnum.MultipleChoiceSingle;
|
||||
subheader?: string;
|
||||
choices: string[];
|
||||
choiceIds?: string[];
|
||||
buttonLabel?: string;
|
||||
backButtonLabel?: string;
|
||||
shuffleOption?: TShuffleOption;
|
||||
required?: boolean;
|
||||
logic?: TSurveyLogic[];
|
||||
containsOther?: boolean;
|
||||
t: TFunction;
|
||||
}): TSurveyMultipleChoiceQuestion => {
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type,
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
choices: choices.map((choice, index) => {
|
||||
const isLastIndex = index === choices.length - 1;
|
||||
const id = containsOther && isLastIndex ? "other" : choiceIds ? choiceIds[index] : createId();
|
||||
return { id, label: createI18nString(choice, []) };
|
||||
}),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
shuffleOption: shuffleOption || "none",
|
||||
required: required ?? false,
|
||||
logic,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildOpenTextQuestion = ({
|
||||
id,
|
||||
headline,
|
||||
subheader,
|
||||
placeholder,
|
||||
inputType,
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
required,
|
||||
logic,
|
||||
longAnswer,
|
||||
t,
|
||||
}: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
subheader?: string;
|
||||
placeholder?: string;
|
||||
buttonLabel?: string;
|
||||
backButtonLabel?: string;
|
||||
required?: boolean;
|
||||
logic?: TSurveyLogic[];
|
||||
inputType: TSurveyOpenTextQuestionInputType;
|
||||
longAnswer?: boolean;
|
||||
t: TFunction;
|
||||
}): TSurveyOpenTextQuestion => {
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
inputType,
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
placeholder: placeholder ? createI18nString(placeholder, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
required: required ?? false,
|
||||
longAnswer,
|
||||
logic,
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const buildRatingQuestion = ({
|
||||
id,
|
||||
headline,
|
||||
subheader,
|
||||
scale,
|
||||
range,
|
||||
lowerLabel,
|
||||
upperLabel,
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
required,
|
||||
logic,
|
||||
isColorCodingEnabled = false,
|
||||
t,
|
||||
}: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
scale: TSurveyRatingQuestion["scale"];
|
||||
range: TSurveyRatingQuestion["range"];
|
||||
lowerLabel?: string;
|
||||
upperLabel?: string;
|
||||
subheader?: string;
|
||||
placeholder?: string;
|
||||
buttonLabel?: string;
|
||||
backButtonLabel?: string;
|
||||
required?: boolean;
|
||||
logic?: TSurveyLogic[];
|
||||
isColorCodingEnabled?: boolean;
|
||||
t: TFunction;
|
||||
}): TSurveyRatingQuestion => {
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
scale,
|
||||
range,
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
required: required ?? false,
|
||||
isColorCodingEnabled,
|
||||
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
|
||||
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
|
||||
logic,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildNPSQuestion = ({
|
||||
id,
|
||||
headline,
|
||||
subheader,
|
||||
lowerLabel,
|
||||
upperLabel,
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
required,
|
||||
logic,
|
||||
isColorCodingEnabled = false,
|
||||
t,
|
||||
}: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
lowerLabel?: string;
|
||||
upperLabel?: string;
|
||||
subheader?: string;
|
||||
placeholder?: string;
|
||||
buttonLabel?: string;
|
||||
backButtonLabel?: string;
|
||||
required?: boolean;
|
||||
logic?: TSurveyLogic[];
|
||||
isColorCodingEnabled?: boolean;
|
||||
t: TFunction;
|
||||
}): TSurveyNPSQuestion => {
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
required: required ?? false,
|
||||
isColorCodingEnabled,
|
||||
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
|
||||
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
|
||||
logic,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildConsentQuestion = ({
|
||||
id,
|
||||
headline,
|
||||
subheader,
|
||||
label,
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
required,
|
||||
logic,
|
||||
t,
|
||||
}: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
subheader: string;
|
||||
buttonLabel?: string;
|
||||
backButtonLabel?: string;
|
||||
required?: boolean;
|
||||
logic?: TSurveyLogic[];
|
||||
label: string;
|
||||
t: TFunction;
|
||||
}): TSurveyConsentQuestion => {
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
subheader: createI18nString(subheader, []),
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
required: required ?? false,
|
||||
label: createI18nString(label, []),
|
||||
logic,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildCTAQuestion = ({
|
||||
id,
|
||||
headline,
|
||||
subheader,
|
||||
buttonLabel,
|
||||
buttonExternal,
|
||||
backButtonLabel,
|
||||
required,
|
||||
logic,
|
||||
dismissButtonLabel,
|
||||
buttonUrl,
|
||||
t,
|
||||
}: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
buttonExternal: boolean;
|
||||
subheader: string;
|
||||
buttonLabel?: string;
|
||||
backButtonLabel?: string;
|
||||
required?: boolean;
|
||||
logic?: TSurveyLogic[];
|
||||
dismissButtonLabel?: string;
|
||||
buttonUrl?: string;
|
||||
t: TFunction;
|
||||
}): TSurveyCTAQuestion => {
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
subheader: createI18nString(subheader, []),
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
|
||||
required: required ?? false,
|
||||
buttonExternal,
|
||||
buttonUrl,
|
||||
logic,
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to create standard jump logic based on operator
|
||||
export const createJumpLogic = (
|
||||
sourceQuestionId: string,
|
||||
@@ -294,7 +27,7 @@ export const createJumpLogic = (
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: sourceQuestionId,
|
||||
type: "question",
|
||||
type: "element",
|
||||
},
|
||||
operator: operator,
|
||||
},
|
||||
@@ -324,7 +57,7 @@ export const createChoiceJumpLogic = (
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: sourceQuestionId,
|
||||
type: "question",
|
||||
type: "element",
|
||||
},
|
||||
operator: "equals",
|
||||
rightOperand: {
|
||||
@@ -377,13 +110,13 @@ export const getDefaultSurveyPreset = (t: TFunction): TTemplate["preset"] => {
|
||||
welcomeCard: getDefaultWelcomeCard(t),
|
||||
endings: [getDefaultEndingCard([], t)],
|
||||
hiddenFields: hiddenFieldsDefault,
|
||||
questions: [],
|
||||
blocks: [],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic builder for survey.
|
||||
* @param config - The configuration for survey settings and questions.
|
||||
* @param config - The configuration for survey settings and blocks.
|
||||
* @param t - The translation function.
|
||||
*/
|
||||
export const buildSurvey = (
|
||||
@@ -393,9 +126,9 @@ export const buildSurvey = (
|
||||
channels: ("link" | "app" | "website")[];
|
||||
role: TTemplateRole;
|
||||
description: string;
|
||||
questions: TSurveyQuestion[];
|
||||
endings?: TSurveyEnding[];
|
||||
hiddenFields?: TSurveyHiddenFields;
|
||||
blocks: TSurveyBlock[];
|
||||
endings: TSurveyEnding[];
|
||||
hiddenFields: TSurveyHiddenFields;
|
||||
},
|
||||
t: TFunction
|
||||
): TTemplate => {
|
||||
@@ -409,7 +142,7 @@ export const buildSurvey = (
|
||||
preset: {
|
||||
...localSurvey,
|
||||
name: config.name,
|
||||
questions: config.questions,
|
||||
blocks: config.blocks ?? [],
|
||||
endings: config.endings ?? localSurvey.endings,
|
||||
hiddenFields: config.hiddenFields ?? hiddenFieldsDefault,
|
||||
},
|
||||
|
||||
@@ -2,19 +2,15 @@ import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { TLanguage } from "@formbricks/types/project";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyLanguage,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { type TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import {
|
||||
DateRange,
|
||||
SelectedFilterValue,
|
||||
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { generateQuestionAndFilterOptions, getFormattedFilters, getTodayDate } from "./surveys";
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementsComboBox";
|
||||
import { generateElementAndFilterOptions, getFormattedFilters, getTodayDate } from "./surveys";
|
||||
|
||||
describe("surveys", () => {
|
||||
afterEach(() => {
|
||||
@@ -26,31 +22,42 @@ describe("surveys", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Open Text Question" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Open Text Question" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
} as TSurveyElement,
|
||||
],
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {}, []);
|
||||
const result = generateElementAndFilterOptions(survey, undefined, {}, {}, {}, []);
|
||||
|
||||
expect(result.questionOptions.length).toBeGreaterThan(0);
|
||||
expect(result.questionOptions[0].header).toBe(OptionsType.QUESTIONS);
|
||||
expect(result.questionFilterOptions.length).toBe(1);
|
||||
expect(result.questionFilterOptions[0].id).toBe("q1");
|
||||
expect(result.elementOptions.length).toBeGreaterThan(0);
|
||||
expect(result.elementOptions[0].header).toBe(OptionsType.ELEMENTS);
|
||||
expect(result.elementFilterOptions.length).toBe(1);
|
||||
expect(result.elementFilterOptions[0].id).toBe("q1");
|
||||
});
|
||||
|
||||
test("should include tags in options when provided", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -62,9 +69,9 @@ describe("surveys", () => {
|
||||
{ id: "tag1", name: "Tag 1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
|
||||
const result = generateQuestionAndFilterOptions(survey, tags, {}, {}, {}, []);
|
||||
const result = generateElementAndFilterOptions(survey, tags, {}, {}, {}, []);
|
||||
|
||||
const tagsHeader = result.questionOptions.find((opt) => opt.header === OptionsType.TAGS);
|
||||
const tagsHeader = result.elementOptions.find((opt) => opt.header === OptionsType.TAGS);
|
||||
expect(tagsHeader).toBeDefined();
|
||||
expect(tagsHeader?.option.length).toBe(1);
|
||||
expect(tagsHeader?.option[0].label).toBe("Tag 1");
|
||||
@@ -74,6 +81,7 @@ describe("surveys", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -85,9 +93,9 @@ describe("surveys", () => {
|
||||
role: ["admin", "user"],
|
||||
};
|
||||
|
||||
const result = generateQuestionAndFilterOptions(survey, undefined, attributes, {}, {}, []);
|
||||
const result = generateElementAndFilterOptions(survey, undefined, attributes, {}, {}, []);
|
||||
|
||||
const attributesHeader = result.questionOptions.find((opt) => opt.header === OptionsType.ATTRIBUTES);
|
||||
const attributesHeader = result.elementOptions.find((opt) => opt.header === OptionsType.ATTRIBUTES);
|
||||
expect(attributesHeader).toBeDefined();
|
||||
expect(attributesHeader?.option.length).toBe(1);
|
||||
expect(attributesHeader?.option[0].label).toBe("role");
|
||||
@@ -97,6 +105,7 @@ describe("surveys", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -108,9 +117,9 @@ describe("surveys", () => {
|
||||
source: ["web", "mobile"],
|
||||
};
|
||||
|
||||
const result = generateQuestionAndFilterOptions(survey, undefined, {}, meta, {}, []);
|
||||
const result = generateElementAndFilterOptions(survey, undefined, {}, meta, {}, []);
|
||||
|
||||
const metaHeader = result.questionOptions.find((opt) => opt.header === OptionsType.META);
|
||||
const metaHeader = result.elementOptions.find((opt) => opt.header === OptionsType.META);
|
||||
expect(metaHeader).toBeDefined();
|
||||
expect(metaHeader?.option.length).toBe(1);
|
||||
expect(metaHeader?.option[0].label).toBe("source");
|
||||
@@ -120,6 +129,7 @@ describe("surveys", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -131,9 +141,9 @@ describe("surveys", () => {
|
||||
segment: ["free", "paid"],
|
||||
};
|
||||
|
||||
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, hiddenFields, []);
|
||||
const result = generateElementAndFilterOptions(survey, undefined, {}, {}, hiddenFields, []);
|
||||
|
||||
const hiddenFieldsHeader = result.questionOptions.find(
|
||||
const hiddenFieldsHeader = result.elementOptions.find(
|
||||
(opt) => opt.header === OptionsType.HIDDEN_FIELDS
|
||||
);
|
||||
expect(hiddenFieldsHeader).toBeDefined();
|
||||
@@ -145,6 +155,7 @@ describe("surveys", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -153,9 +164,9 @@ describe("surveys", () => {
|
||||
languages: [{ language: { code: "en" } as unknown as TLanguage } as unknown as TSurveyLanguage],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {}, []);
|
||||
const result = generateElementAndFilterOptions(survey, undefined, {}, {}, {}, []);
|
||||
|
||||
const othersHeader = result.questionOptions.find((opt) => opt.header === OptionsType.OTHERS);
|
||||
const othersHeader = result.elementOptions.find((opt) => opt.header === OptionsType.OTHERS);
|
||||
expect(othersHeader).toBeDefined();
|
||||
expect(othersHeader?.option.some((o) => o.label === "Language")).toBeTruthy();
|
||||
});
|
||||
@@ -164,78 +175,107 @@ describe("surveys", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Open Text" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Multiple Choice Single" },
|
||||
choices: [{ id: "c1", label: "Choice 1" }],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q3",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Multiple Choice Multi" },
|
||||
choices: [
|
||||
{ id: "c1", label: "Choice 1" },
|
||||
{ id: "other", label: "Other" },
|
||||
],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q4",
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: { default: "NPS" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q5",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rating" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q6",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q7",
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
headline: { default: "Picture Selection" },
|
||||
choices: [
|
||||
{ id: "p1", imageUrl: "url1" },
|
||||
{ id: "p2", imageUrl: "url2" },
|
||||
],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q8",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Matrix" },
|
||||
rows: [{ id: "r1", label: "Row 1" }],
|
||||
columns: [{ id: "c1", label: "Column 1" }],
|
||||
} as unknown as TSurveyQuestion,
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Open Text" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Multiple Choice Single" },
|
||||
required: false,
|
||||
choices: [{ id: "c1", label: { default: "Choice 1" } }],
|
||||
shuffleOption: "none",
|
||||
},
|
||||
{
|
||||
id: "q3",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Multiple Choice Multi" },
|
||||
required: false,
|
||||
choices: [
|
||||
{ id: "c1", label: { default: "Choice 1" } },
|
||||
{ id: "other", label: { default: "Other" } },
|
||||
],
|
||||
shuffleOption: "none",
|
||||
},
|
||||
{
|
||||
id: "q4",
|
||||
type: TSurveyElementTypeEnum.NPS,
|
||||
headline: { default: "NPS" },
|
||||
required: false,
|
||||
lowerLabel: { default: "Not likely" },
|
||||
upperLabel: { default: "Very likely" },
|
||||
},
|
||||
{
|
||||
id: "q5",
|
||||
type: TSurveyElementTypeEnum.Rating,
|
||||
headline: { default: "Rating" },
|
||||
required: false,
|
||||
scale: "number",
|
||||
range: 5,
|
||||
lowerLabel: { default: "Low" },
|
||||
upperLabel: { default: "High" },
|
||||
},
|
||||
{
|
||||
id: "q6",
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
required: false,
|
||||
buttonLabel: { default: "Click me" },
|
||||
buttonExternal: false,
|
||||
},
|
||||
{
|
||||
id: "q7",
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
headline: { default: "Picture Selection" },
|
||||
required: false,
|
||||
allowMultiple: false,
|
||||
choices: [
|
||||
{ id: "p1", imageUrl: "url1" },
|
||||
{ id: "p2", imageUrl: "url2" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "q8",
|
||||
type: TSurveyElementTypeEnum.Matrix,
|
||||
headline: { default: "Matrix" },
|
||||
required: false,
|
||||
rows: [{ id: "r1", label: { default: "Row 1" } }],
|
||||
columns: [{ id: "c1", label: { default: "Column 1" } }],
|
||||
},
|
||||
] as TSurveyElement[],
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {}, []);
|
||||
const result = generateElementAndFilterOptions(survey, undefined, {}, {}, {}, []);
|
||||
|
||||
expect(result.questionFilterOptions.length).toBe(8);
|
||||
expect(result.questionFilterOptions.some((o) => o.id === "q1")).toBeTruthy();
|
||||
expect(result.questionFilterOptions.some((o) => o.id === "q2")).toBeTruthy();
|
||||
expect(result.questionFilterOptions.some((o) => o.id === "q7")).toBeTruthy();
|
||||
expect(result.questionFilterOptions.some((o) => o.id === "q8")).toBeTruthy();
|
||||
expect(result.elementFilterOptions.length).toBe(8);
|
||||
expect(result.elementFilterOptions.some((o) => o.id === "q1")).toBeTruthy();
|
||||
expect(result.elementFilterOptions.some((o) => o.id === "q2")).toBeTruthy();
|
||||
expect(result.elementFilterOptions.some((o) => o.id === "q7")).toBeTruthy();
|
||||
expect(result.elementFilterOptions.some((o) => o.id === "q8")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should provide extended filter options for URL meta field", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
blocks: [],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -248,10 +288,10 @@ describe("surveys", () => {
|
||||
source: ["web", "mobile"],
|
||||
};
|
||||
|
||||
const result = generateQuestionAndFilterOptions(survey, undefined, {}, meta, {}, []);
|
||||
const result = generateElementAndFilterOptions(survey, undefined, {}, meta, {}, []);
|
||||
|
||||
const urlFilterOption = result.questionFilterOptions.find((o) => o.id === "url");
|
||||
const sourceFilterOption = result.questionFilterOptions.find((o) => o.id === "source");
|
||||
const urlFilterOption = result.elementFilterOptions.find((o) => o.id === "url");
|
||||
const sourceFilterOption = result.elementFilterOptions.find((o) => o.id === "source");
|
||||
|
||||
expect(urlFilterOption).toBeDefined();
|
||||
expect(urlFilterOption?.filterOptions).toEqual([
|
||||
@@ -268,82 +308,185 @@ describe("surveys", () => {
|
||||
expect(sourceFilterOption).toBeDefined();
|
||||
expect(sourceFilterOption?.filterOptions).toEqual(["Equals", "Not equals"]);
|
||||
});
|
||||
|
||||
test("should include quota options in filter options when quotas are provided", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
blocks: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const quotas = [{ id: "quota1" }];
|
||||
|
||||
const result = generateElementAndFilterOptions(survey, undefined, {}, {}, {}, quotas as any);
|
||||
|
||||
const quotaFilterOption = result.elementFilterOptions.find((o) => o.id === "quota1");
|
||||
expect(quotaFilterOption).toBeDefined();
|
||||
expect(quotaFilterOption?.type).toBe("Quotas");
|
||||
expect(quotaFilterOption?.filterOptions).toEqual(["Status"]);
|
||||
expect(quotaFilterOption?.filterComboBoxOptions).toEqual([
|
||||
"Screened in",
|
||||
"Screened out (overquota)",
|
||||
"Not in quota",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should include multiple quota options when multiple quotas are provided", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
blocks: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const quotas = [{ id: "quota1" }, { id: "quota2" }];
|
||||
|
||||
const result = generateElementAndFilterOptions(survey, undefined, {}, {}, {}, quotas as any);
|
||||
|
||||
const quota1 = result.elementFilterOptions.find((o) => o.id === "quota1");
|
||||
const quota2 = result.elementFilterOptions.find((o) => o.id === "quota2");
|
||||
|
||||
expect(quota1).toBeDefined();
|
||||
expect(quota2).toBeDefined();
|
||||
expect(quota1?.filterComboBoxOptions).toEqual([
|
||||
"Screened in",
|
||||
"Screened out (overquota)",
|
||||
"Not in quota",
|
||||
]);
|
||||
expect(quota2?.filterComboBoxOptions).toEqual([
|
||||
"Screened in",
|
||||
"Screened out (overquota)",
|
||||
"Not in quota",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFormattedFilters", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [
|
||||
blocks: [
|
||||
{
|
||||
id: "openTextQ",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Open Text" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "mcSingleQ",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Multiple Choice Single" },
|
||||
choices: [{ id: "c1", label: "Choice 1" }],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "mcMultiQ",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Multiple Choice Multi" },
|
||||
choices: [{ id: "c1", label: "Choice 1" }],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "npsQ",
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: { default: "NPS" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "ratingQ",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rating" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "ctaQ",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "consentQ",
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
headline: { default: "Consent" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "pictureQ",
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
headline: { default: "Picture Selection" },
|
||||
choices: [
|
||||
{ id: "p1", imageUrl: "url1" },
|
||||
{ id: "p2", imageUrl: "url2" },
|
||||
],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "matrixQ",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Matrix" },
|
||||
rows: [{ id: "r1", label: "Row 1" }],
|
||||
columns: [{ id: "c1", label: "Column 1" }],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "addressQ",
|
||||
type: TSurveyQuestionTypeEnum.Address,
|
||||
headline: { default: "Address" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "contactQ",
|
||||
type: TSurveyQuestionTypeEnum.ContactInfo,
|
||||
headline: { default: "Contact Info" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "rankingQ",
|
||||
type: TSurveyQuestionTypeEnum.Ranking,
|
||||
headline: { default: "Ranking" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "openTextQ",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Open Text" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
{
|
||||
id: "mcSingleQ",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Multiple Choice Single" },
|
||||
required: false,
|
||||
choices: [{ id: "c1", label: { default: "Choice 1" } }],
|
||||
shuffleOption: "none",
|
||||
},
|
||||
{
|
||||
id: "mcMultiQ",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Multiple Choice Multi" },
|
||||
required: false,
|
||||
choices: [{ id: "c1", label: { default: "Choice 1" } }],
|
||||
shuffleOption: "none",
|
||||
},
|
||||
{
|
||||
id: "npsQ",
|
||||
type: TSurveyElementTypeEnum.NPS,
|
||||
headline: { default: "NPS" },
|
||||
required: false,
|
||||
lowerLabel: { default: "Not likely" },
|
||||
upperLabel: { default: "Very likely" },
|
||||
},
|
||||
{
|
||||
id: "ratingQ",
|
||||
type: TSurveyElementTypeEnum.Rating,
|
||||
headline: { default: "Rating" },
|
||||
required: false,
|
||||
scale: "number",
|
||||
range: 5,
|
||||
lowerLabel: { default: "Low" },
|
||||
upperLabel: { default: "High" },
|
||||
},
|
||||
{
|
||||
id: "ctaQ",
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
required: false,
|
||||
buttonLabel: { default: "Click me" },
|
||||
buttonExternal: false,
|
||||
},
|
||||
{
|
||||
id: "consentQ",
|
||||
type: TSurveyElementTypeEnum.Consent,
|
||||
headline: { default: "Consent" },
|
||||
required: false,
|
||||
label: { default: "I agree" },
|
||||
},
|
||||
{
|
||||
id: "pictureQ",
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
headline: { default: "Picture Selection" },
|
||||
required: false,
|
||||
allowMultiple: false,
|
||||
choices: [
|
||||
{ id: "p1", imageUrl: "url1" },
|
||||
{ id: "p2", imageUrl: "url2" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "matrixQ",
|
||||
type: TSurveyElementTypeEnum.Matrix,
|
||||
headline: { default: "Matrix" },
|
||||
required: false,
|
||||
rows: [{ id: "r1", label: { default: "Row 1" } }],
|
||||
columns: [{ id: "c1", label: { default: "Column 1" } }],
|
||||
},
|
||||
{
|
||||
id: "addressQ",
|
||||
type: TSurveyElementTypeEnum.Address,
|
||||
headline: { default: "Address" },
|
||||
required: false,
|
||||
zip: { show: true, required: false, placeholder: { default: "Zip" } },
|
||||
city: { show: true, required: false, placeholder: { default: "City" } },
|
||||
state: { show: true, required: false, placeholder: { default: "State" } },
|
||||
country: { show: true, required: false, placeholder: { default: "Country" } },
|
||||
addressLine1: { show: true, required: false, placeholder: { default: "Address Line 1" } },
|
||||
addressLine2: { show: true, required: false, placeholder: { default: "Address Line 2" } },
|
||||
},
|
||||
{
|
||||
id: "contactQ",
|
||||
type: TSurveyElementTypeEnum.ContactInfo,
|
||||
headline: { default: "Contact Info" },
|
||||
required: false,
|
||||
firstName: { show: true, required: false, placeholder: { default: "First Name" } },
|
||||
lastName: { show: true, required: false, placeholder: { default: "Last Name" } },
|
||||
email: { show: true, required: false, placeholder: { default: "Email" } },
|
||||
phone: { show: true, required: false, placeholder: { default: "Phone" } },
|
||||
company: { show: true, required: false, placeholder: { default: "Company" } },
|
||||
},
|
||||
{
|
||||
id: "rankingQ",
|
||||
type: TSurveyElementTypeEnum.Ranking,
|
||||
headline: { default: "Ranking" },
|
||||
required: false,
|
||||
choices: [{ id: "r1", label: { default: "Option 1" } }],
|
||||
},
|
||||
] as TSurveyElement[],
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
@@ -395,11 +538,11 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Tags", label: "Tag 1", id: "tag1" },
|
||||
elementType: { type: "Tags", label: "Tag 1", id: "tag1" },
|
||||
filterType: { filterComboBoxValue: "Applied" },
|
||||
},
|
||||
{
|
||||
questionType: { type: "Tags", label: "Tag 2", id: "tag2" },
|
||||
elementType: { type: "Tags", label: "Tag 2", id: "tag2" },
|
||||
filterType: { filterComboBoxValue: "Not applied" },
|
||||
},
|
||||
] as any,
|
||||
@@ -416,11 +559,11 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
elementType: {
|
||||
type: "Elements",
|
||||
label: "Open Text",
|
||||
id: "openTextQ",
|
||||
questionType: TSurveyQuestionTypeEnum.OpenText,
|
||||
elementType: TSurveyElementTypeEnum.OpenText,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Filled out" },
|
||||
},
|
||||
@@ -437,11 +580,11 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
elementType: {
|
||||
type: "Elements",
|
||||
label: "Address",
|
||||
id: "addressQ",
|
||||
questionType: TSurveyQuestionTypeEnum.Address,
|
||||
elementType: TSurveyElementTypeEnum.Address,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Skipped" },
|
||||
},
|
||||
@@ -458,11 +601,11 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
elementType: {
|
||||
type: "Elements",
|
||||
label: "Contact Info",
|
||||
id: "contactQ",
|
||||
questionType: TSurveyQuestionTypeEnum.ContactInfo,
|
||||
elementType: TSurveyElementTypeEnum.ContactInfo,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Filled out" },
|
||||
},
|
||||
@@ -479,11 +622,11 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
elementType: {
|
||||
type: "Elements",
|
||||
label: "Ranking",
|
||||
id: "rankingQ",
|
||||
questionType: TSurveyQuestionTypeEnum.Ranking,
|
||||
elementType: TSurveyElementTypeEnum.Ranking,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Filled out" },
|
||||
},
|
||||
@@ -500,11 +643,11 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
elementType: {
|
||||
type: "Elements",
|
||||
label: "MC Single",
|
||||
id: "mcSingleQ",
|
||||
questionType: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
elementType: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
},
|
||||
filterType: { filterValue: "Includes either", filterComboBoxValue: ["Choice 1"] },
|
||||
},
|
||||
@@ -521,11 +664,11 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
elementType: {
|
||||
type: "Elements",
|
||||
label: "MC Multi",
|
||||
id: "mcMultiQ",
|
||||
questionType: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
elementType: TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
},
|
||||
filterType: { filterValue: "Includes all", filterComboBoxValue: ["Choice 1", "Choice 2"] },
|
||||
},
|
||||
@@ -542,11 +685,11 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
elementType: {
|
||||
type: "Elements",
|
||||
label: "NPS",
|
||||
id: "npsQ",
|
||||
questionType: TSurveyQuestionTypeEnum.NPS,
|
||||
elementType: TSurveyElementTypeEnum.NPS,
|
||||
},
|
||||
filterType: { filterValue: "Is equal to", filterComboBoxValue: "7" },
|
||||
},
|
||||
@@ -563,11 +706,11 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
elementType: {
|
||||
type: "Elements",
|
||||
label: "Rating",
|
||||
id: "ratingQ",
|
||||
questionType: TSurveyQuestionTypeEnum.Rating,
|
||||
elementType: TSurveyElementTypeEnum.Rating,
|
||||
},
|
||||
filterType: { filterValue: "Is less than", filterComboBoxValue: "4" },
|
||||
},
|
||||
@@ -584,11 +727,11 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
elementType: {
|
||||
type: "Elements",
|
||||
label: "CTA",
|
||||
id: "ctaQ",
|
||||
questionType: TSurveyQuestionTypeEnum.CTA,
|
||||
elementType: TSurveyElementTypeEnum.CTA,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Clicked" },
|
||||
},
|
||||
@@ -605,11 +748,11 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
elementType: {
|
||||
type: "Elements",
|
||||
label: "Consent",
|
||||
id: "consentQ",
|
||||
questionType: TSurveyQuestionTypeEnum.Consent,
|
||||
elementType: TSurveyElementTypeEnum.Consent,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Accepted" },
|
||||
},
|
||||
@@ -626,11 +769,11 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
elementType: {
|
||||
type: "Elements",
|
||||
label: "Picture",
|
||||
id: "pictureQ",
|
||||
questionType: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
elementType: TSurveyElementTypeEnum.PictureSelection,
|
||||
},
|
||||
filterType: { filterValue: "Includes either", filterComboBoxValue: ["Picture 1"] },
|
||||
},
|
||||
@@ -647,11 +790,11 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
elementType: {
|
||||
type: "Elements",
|
||||
label: "Matrix",
|
||||
id: "matrixQ",
|
||||
questionType: TSurveyQuestionTypeEnum.Matrix,
|
||||
elementType: TSurveyElementTypeEnum.Matrix,
|
||||
},
|
||||
filterType: { filterValue: "Row 1", filterComboBoxValue: "Column 1" },
|
||||
},
|
||||
@@ -668,7 +811,7 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Hidden Fields", label: "plan", id: "plan" },
|
||||
elementType: { type: "Hidden Fields", label: "plan", id: "plan" },
|
||||
filterType: { filterValue: "Equals", filterComboBoxValue: "pro" },
|
||||
},
|
||||
],
|
||||
@@ -684,7 +827,7 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Attributes", label: "role", id: "role" },
|
||||
elementType: { type: "Attributes", label: "role", id: "role" },
|
||||
filterType: { filterValue: "Not equals", filterComboBoxValue: "admin" },
|
||||
},
|
||||
],
|
||||
@@ -700,7 +843,7 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Other Filters", label: "Language", id: "language" },
|
||||
elementType: { type: "Other Filters", label: "Language", id: "language" },
|
||||
filterType: { filterValue: "Equals", filterComboBoxValue: "en" },
|
||||
},
|
||||
],
|
||||
@@ -716,7 +859,7 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Meta", label: "source", id: "source" },
|
||||
elementType: { type: "Meta", label: "source", id: "source" },
|
||||
filterType: { filterValue: "Not equals", filterComboBoxValue: "web" },
|
||||
},
|
||||
],
|
||||
@@ -732,16 +875,16 @@ describe("surveys", () => {
|
||||
responseStatus: "complete",
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
elementType: {
|
||||
type: "Elements",
|
||||
label: "NPS",
|
||||
id: "npsQ",
|
||||
questionType: TSurveyQuestionTypeEnum.NPS,
|
||||
elementType: TSurveyElementTypeEnum.NPS,
|
||||
},
|
||||
filterType: { filterValue: "Is more than", filterComboBoxValue: "7" },
|
||||
},
|
||||
{
|
||||
questionType: { type: "Tags", label: "Tag 1", id: "tag1" },
|
||||
elementType: { type: "Tags", label: "Tag 1", id: "tag1" },
|
||||
filterType: { filterComboBoxValue: "Applied" },
|
||||
},
|
||||
],
|
||||
@@ -760,7 +903,7 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Meta", label: "url", id: "url" },
|
||||
elementType: { type: "Meta", label: "url", id: "url" },
|
||||
filterType: { filterValue: "Contains", filterComboBoxValue: "example.com" },
|
||||
},
|
||||
],
|
||||
@@ -788,7 +931,7 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Meta", label: "url", id: "url" },
|
||||
elementType: { type: "Meta", label: "url", id: "url" },
|
||||
filterType: { filterValue, filterComboBoxValue: expected.value },
|
||||
},
|
||||
],
|
||||
@@ -804,7 +947,7 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Meta", label: "url", id: "url" },
|
||||
elementType: { type: "Meta", label: "url", id: "url" },
|
||||
filterType: { filterValue: "Contains", filterComboBoxValue: "" },
|
||||
},
|
||||
],
|
||||
@@ -820,7 +963,7 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Meta", label: "url", id: "url" },
|
||||
elementType: { type: "Meta", label: "url", id: "url" },
|
||||
filterType: { filterValue: "Contains", filterComboBoxValue: " " },
|
||||
},
|
||||
],
|
||||
@@ -836,7 +979,7 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Meta", label: "source", id: "source" },
|
||||
elementType: { type: "Meta", label: "source", id: "source" },
|
||||
filterType: { filterValue: "Equals", filterComboBoxValue: ["google"] },
|
||||
},
|
||||
],
|
||||
@@ -852,11 +995,11 @@ describe("surveys", () => {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Meta", label: "url", id: "url" },
|
||||
elementType: { type: "Meta", label: "url", id: "url" },
|
||||
filterType: { filterValue: "Contains", filterComboBoxValue: "formbricks.com" },
|
||||
},
|
||||
{
|
||||
questionType: { type: "Meta", label: "source", id: "source" },
|
||||
elementType: { type: "Meta", label: "source", id: "source" },
|
||||
filterType: { filterValue: "Equals", filterComboBoxValue: ["newsletter"] },
|
||||
},
|
||||
],
|
||||
@@ -867,6 +1010,75 @@ describe("surveys", () => {
|
||||
expect(result.meta?.url).toEqual({ op: "contains", value: "formbricks.com" });
|
||||
expect(result.meta?.source).toEqual({ op: "equals", value: "newsletter" });
|
||||
});
|
||||
|
||||
test("should filter by quota with screened in status", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
elementType: { type: "Quotas", label: "Quota 1", id: "quota1" },
|
||||
filterType: { filterComboBoxValue: "Screened in" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.quotas?.quota1).toEqual({ op: "screenedIn" });
|
||||
});
|
||||
|
||||
test("should filter by quota with screened out status", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
elementType: { type: "Quotas", label: "Quota 1", id: "quota1" },
|
||||
filterType: { filterComboBoxValue: "Screened out (overquota)" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.quotas?.quota1).toEqual({ op: "screenedOut" });
|
||||
});
|
||||
|
||||
test("should filter by quota with not in quota status", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
elementType: { type: "Quotas", label: "Quota 1", id: "quota1" },
|
||||
filterType: { filterComboBoxValue: "Not in quota" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.quotas?.quota1).toEqual({ op: "screenedOutNotInQuota" });
|
||||
});
|
||||
|
||||
test("should filter by multiple quotas with different statuses", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
elementType: { type: "Quotas", label: "Quota 1", id: "quota1" },
|
||||
filterType: { filterComboBoxValue: "Screened in" },
|
||||
},
|
||||
{
|
||||
elementType: { type: "Quotas", label: "Quota 2", id: "quota2" },
|
||||
filterType: { filterComboBoxValue: "Not in quota" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.quotas?.quota1).toEqual({ op: "screenedIn" });
|
||||
expect(result.quotas?.quota2).toEqual({ op: "screenedOutNotInQuota" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTodayDate", () => {
|
||||
|
||||
@@ -5,24 +5,26 @@ import {
|
||||
TSurveyContactAttributes,
|
||||
TSurveyMetaFieldFilter,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import {
|
||||
DateRange,
|
||||
FilterValue,
|
||||
SelectedFilterValue,
|
||||
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import {
|
||||
ElementOption,
|
||||
ElementOptions,
|
||||
OptionsType,
|
||||
QuestionOption,
|
||||
QuestionOptions,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementsComboBox";
|
||||
import { ElementFilterOptions } 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 = {
|
||||
const conditionOptions: Record<string, string[]> = {
|
||||
openText: ["is"],
|
||||
multipleChoiceSingle: ["Includes either"],
|
||||
multipleChoiceMulti: ["Includes all", "Includes either"],
|
||||
@@ -39,7 +41,7 @@ const conditionOptions = {
|
||||
contactInfo: ["is"],
|
||||
ranking: ["is"],
|
||||
};
|
||||
const filterOptions = {
|
||||
const filterOptions: Record<string, string[]> = {
|
||||
openText: ["Filled out", "Skipped"],
|
||||
rating: ["1", "2", "3", "4", "5"],
|
||||
nps: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
|
||||
@@ -51,6 +53,51 @@ const filterOptions = {
|
||||
ranking: ["Filled out", "Skipped"],
|
||||
};
|
||||
|
||||
// Helper function to get filter options for a specific element type
|
||||
const getElementFilterOption = (
|
||||
element: ReturnType<typeof getElementsFromBlocks>[number]
|
||||
): ElementFilterOptions | null => {
|
||||
if (!Object.keys(conditionOptions).includes(element.type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseOption = {
|
||||
type: element.type,
|
||||
filterOptions: conditionOptions[element.type],
|
||||
id: element.id,
|
||||
};
|
||||
|
||||
switch (element.type) {
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||
return {
|
||||
...baseOption,
|
||||
filterComboBoxOptions: element.choices?.map((c) => c.label) ?? [""],
|
||||
};
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti:
|
||||
return {
|
||||
...baseOption,
|
||||
filterComboBoxOptions: element.choices?.filter((c) => c.id !== "other").map((c) => c.label) ?? [""],
|
||||
};
|
||||
case TSurveyElementTypeEnum.PictureSelection:
|
||||
return {
|
||||
...baseOption,
|
||||
filterComboBoxOptions: element.choices?.map((_, idx) => `Picture ${idx + 1}`) ?? [""],
|
||||
};
|
||||
case TSurveyElementTypeEnum.Matrix:
|
||||
return {
|
||||
type: element.type,
|
||||
filterOptions: element.rows.map((row) => getLocalizedValue(row.label, "default")),
|
||||
filterComboBoxOptions: element.columns.map((column) => getLocalizedValue(column.label, "default")),
|
||||
id: element.id,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
...baseOption,
|
||||
filterComboBoxOptions: filterOptions[element.type],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// URL/meta text operators mapping
|
||||
const META_OP_MAP = {
|
||||
Equals: "equals",
|
||||
@@ -63,8 +110,7 @@ const META_OP_MAP = {
|
||||
"Does not end with": "doesNotEndWith",
|
||||
} as const;
|
||||
|
||||
// creating the options for the filtering to be selected there are 4 types questions, attributes, tags and metadata
|
||||
export const generateQuestionAndFilterOptions = (
|
||||
export const generateElementAndFilterOptions = (
|
||||
survey: TSurvey,
|
||||
environmentTags: TTag[] | undefined,
|
||||
attributes: TSurveyContactAttributes,
|
||||
@@ -72,67 +118,32 @@ export const generateQuestionAndFilterOptions = (
|
||||
hiddenFields: TResponseHiddenFieldsFilter,
|
||||
quotas: TSurveyQuota[]
|
||||
): {
|
||||
questionOptions: QuestionOptions[];
|
||||
questionFilterOptions: QuestionFilterOptions[];
|
||||
elementOptions: ElementOptions[];
|
||||
elementFilterOptions: ElementFilterOptions[];
|
||||
} => {
|
||||
let questionOptions: QuestionOptions[] = [];
|
||||
let questionFilterOptions: any = [];
|
||||
let elementOptions: ElementOptions[] = [];
|
||||
let elementFilterOptions: ElementFilterOptions[] = [];
|
||||
let elementsOptions: ElementOption[] = [];
|
||||
|
||||
let questionsOptions: any = [];
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
survey.questions.forEach((q) => {
|
||||
elements.forEach((q) => {
|
||||
if (Object.keys(conditionOptions).includes(q.type)) {
|
||||
questionsOptions.push({
|
||||
elementsOptions.push({
|
||||
label: getTextContent(
|
||||
getLocalizedValue(recallToHeadline(q.headline, survey, false, "default"), "default")
|
||||
),
|
||||
questionType: q.type,
|
||||
type: OptionsType.QUESTIONS,
|
||||
elementType: q.type,
|
||||
type: OptionsType.ELEMENTS,
|
||||
id: q.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
questionOptions = [...questionOptions, { header: OptionsType.QUESTIONS, option: questionsOptions }];
|
||||
survey.questions.forEach((q) => {
|
||||
if (Object.keys(conditionOptions).includes(q.type)) {
|
||||
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 === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
|
||||
questionFilterOptions.push({
|
||||
type: q.type,
|
||||
filterOptions: conditionOptions[q.type],
|
||||
filterComboBoxOptions: q?.choices
|
||||
? q?.choices?.filter((c) => c.id !== "other")?.map((c) => c?.label)
|
||||
: [""],
|
||||
id: q.id,
|
||||
});
|
||||
} 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 === TSurveyQuestionTypeEnum.Matrix) {
|
||||
questionFilterOptions.push({
|
||||
type: q.type,
|
||||
filterOptions: q.rows.flatMap((row) => Object.values(row)),
|
||||
filterComboBoxOptions: q.columns.flatMap((column) => Object.values(column)),
|
||||
id: q.id,
|
||||
});
|
||||
} else {
|
||||
questionFilterOptions.push({
|
||||
type: q.type,
|
||||
filterOptions: conditionOptions[q.type],
|
||||
filterComboBoxOptions: filterOptions[q.type],
|
||||
id: q.id,
|
||||
});
|
||||
}
|
||||
elementOptions = [...elementOptions, { header: OptionsType.ELEMENTS, option: elementsOptions }];
|
||||
elements.forEach((q) => {
|
||||
const filterOption = getElementFilterOption(q);
|
||||
if (filterOption) {
|
||||
elementFilterOptions.push(filterOption);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -140,9 +151,9 @@ export const generateQuestionAndFilterOptions = (
|
||||
return { label: t.name, type: OptionsType.TAGS, id: t.id };
|
||||
});
|
||||
if (tagsOptions && tagsOptions?.length > 0) {
|
||||
questionOptions = [...questionOptions, { header: OptionsType.TAGS, option: tagsOptions }];
|
||||
elementOptions = [...elementOptions, { header: OptionsType.TAGS, option: tagsOptions }];
|
||||
environmentTags?.forEach((t) => {
|
||||
questionFilterOptions.push({
|
||||
elementFilterOptions.push({
|
||||
type: "Tags",
|
||||
filterOptions: conditionOptions.tags,
|
||||
filterComboBoxOptions: filterOptions.tags,
|
||||
@@ -152,8 +163,8 @@ export const generateQuestionAndFilterOptions = (
|
||||
}
|
||||
|
||||
if (attributes) {
|
||||
questionOptions = [
|
||||
...questionOptions,
|
||||
elementOptions = [
|
||||
...elementOptions,
|
||||
{
|
||||
header: OptionsType.ATTRIBUTES,
|
||||
option: Object.keys(attributes).map((a) => {
|
||||
@@ -162,7 +173,7 @@ export const generateQuestionAndFilterOptions = (
|
||||
},
|
||||
];
|
||||
Object.keys(attributes).forEach((a) => {
|
||||
questionFilterOptions.push({
|
||||
elementFilterOptions.push({
|
||||
type: "Attributes",
|
||||
filterOptions: conditionOptions.userAttributes,
|
||||
filterComboBoxOptions: attributes[a],
|
||||
@@ -172,8 +183,8 @@ export const generateQuestionAndFilterOptions = (
|
||||
}
|
||||
|
||||
if (meta) {
|
||||
questionOptions = [
|
||||
...questionOptions,
|
||||
elementOptions = [
|
||||
...elementOptions,
|
||||
{
|
||||
header: OptionsType.META,
|
||||
option: Object.keys(meta).map((m) => {
|
||||
@@ -182,7 +193,7 @@ export const generateQuestionAndFilterOptions = (
|
||||
},
|
||||
];
|
||||
Object.keys(meta).forEach((m) => {
|
||||
questionFilterOptions.push({
|
||||
elementFilterOptions.push({
|
||||
type: "Meta",
|
||||
filterOptions: m === "url" ? Object.keys(META_OP_MAP) : ["Equals", "Not equals"],
|
||||
filterComboBoxOptions: meta[m],
|
||||
@@ -192,8 +203,8 @@ export const generateQuestionAndFilterOptions = (
|
||||
}
|
||||
|
||||
if (hiddenFields) {
|
||||
questionOptions = [
|
||||
...questionOptions,
|
||||
elementOptions = [
|
||||
...elementOptions,
|
||||
{
|
||||
header: OptionsType.HIDDEN_FIELDS,
|
||||
option: Object.keys(hiddenFields).map((hiddenField) => {
|
||||
@@ -202,7 +213,7 @@ export const generateQuestionAndFilterOptions = (
|
||||
},
|
||||
];
|
||||
Object.keys(hiddenFields).forEach((hiddenField) => {
|
||||
questionFilterOptions.push({
|
||||
elementFilterOptions.push({
|
||||
type: "Hidden Fields",
|
||||
filterOptions: ["Equals", "Not equals"],
|
||||
filterComboBoxOptions: hiddenFields[hiddenField],
|
||||
@@ -211,38 +222,324 @@ export const generateQuestionAndFilterOptions = (
|
||||
});
|
||||
}
|
||||
|
||||
let languageQuestion: QuestionOption[] = [];
|
||||
let languageElement: ElementOption[] = [];
|
||||
|
||||
//can be extended to include more properties
|
||||
if (survey.languages?.length > 0) {
|
||||
languageQuestion.push({ label: "Language", type: OptionsType.OTHERS, id: "language" });
|
||||
languageElement.push({ label: "Language", type: OptionsType.OTHERS, id: "language" });
|
||||
const languageOptions = survey.languages.map((sl) => sl.language.code);
|
||||
questionFilterOptions.push({
|
||||
elementFilterOptions.push({
|
||||
type: OptionsType.OTHERS,
|
||||
filterOptions: conditionOptions.languages,
|
||||
filterComboBoxOptions: languageOptions,
|
||||
id: "language",
|
||||
});
|
||||
}
|
||||
questionOptions = [...questionOptions, { header: OptionsType.OTHERS, option: languageQuestion }];
|
||||
elementOptions = [...elementOptions, { header: OptionsType.OTHERS, option: languageElement }];
|
||||
|
||||
if (quotas.length > 0) {
|
||||
const quotaOptions = quotas.map((quota) => {
|
||||
return { label: quota.name, type: OptionsType.QUOTAS, id: quota.id };
|
||||
});
|
||||
questionOptions = [...questionOptions, { header: OptionsType.QUOTAS, option: quotaOptions }];
|
||||
elementOptions = [...elementOptions, { header: OptionsType.QUOTAS, option: quotaOptions }];
|
||||
|
||||
quotas.forEach((quota) => {
|
||||
questionFilterOptions.push({
|
||||
elementFilterOptions.push({
|
||||
type: "Quotas",
|
||||
filterOptions: ["Status"],
|
||||
filterComboBoxOptions: ["Screened in", "Screened out (overquota)", "Screened out (not in quota)"],
|
||||
filterComboBoxOptions: ["Screened in", "Screened out (overquota)", "Not in quota"],
|
||||
id: quota.id,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { questionOptions: [...questionOptions], questionFilterOptions: [...questionFilterOptions] };
|
||||
return { elementOptions: [...elementOptions], elementFilterOptions: [...elementFilterOptions] };
|
||||
};
|
||||
|
||||
// Helper function to process filled out/skipped filters
|
||||
const processFilledOutSkippedFilter = (
|
||||
filterType: FilterValue["filterType"],
|
||||
elementId: string,
|
||||
filters: TResponseFilterCriteria
|
||||
) => {
|
||||
if (filterType.filterComboBoxValue === "Filled out") {
|
||||
filters.data![elementId] = { op: "filledOut" };
|
||||
} else if (filterType.filterComboBoxValue === "Skipped") {
|
||||
filters.data![elementId] = { op: "skipped" };
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to process ranking filters
|
||||
const processRankingFilter = (
|
||||
filterType: FilterValue["filterType"],
|
||||
elementId: string,
|
||||
filters: TResponseFilterCriteria
|
||||
) => {
|
||||
if (filterType.filterComboBoxValue === "Filled out") {
|
||||
filters.data![elementId] = { op: "submitted" };
|
||||
} else if (filterType.filterComboBoxValue === "Skipped") {
|
||||
filters.data![elementId] = { op: "skipped" };
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to process multiple choice filters
|
||||
const processMultipleChoiceFilter = (
|
||||
filterType: FilterValue["filterType"],
|
||||
elementId: string,
|
||||
filters: TResponseFilterCriteria
|
||||
) => {
|
||||
if (filterType.filterValue === "Includes either") {
|
||||
filters.data![elementId] = {
|
||||
op: "includesOne",
|
||||
value: filterType.filterComboBoxValue as string[],
|
||||
};
|
||||
} else if (filterType.filterValue === "Includes all") {
|
||||
filters.data![elementId] = {
|
||||
op: "includesAll",
|
||||
value: filterType.filterComboBoxValue as string[],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to process NPS/Rating filters
|
||||
const processNPSRatingFilter = (
|
||||
filterType: FilterValue["filterType"],
|
||||
elementId: string,
|
||||
filters: TResponseFilterCriteria
|
||||
) => {
|
||||
if (filterType.filterValue === "Is equal to") {
|
||||
filters.data![elementId] = {
|
||||
op: "equals",
|
||||
value: parseInt(filterType.filterComboBoxValue as string),
|
||||
};
|
||||
} else if (filterType.filterValue === "Is less than") {
|
||||
filters.data![elementId] = {
|
||||
op: "lessThan",
|
||||
value: parseInt(filterType.filterComboBoxValue as string),
|
||||
};
|
||||
} else if (filterType.filterValue === "Is more than") {
|
||||
filters.data![elementId] = {
|
||||
op: "greaterThan",
|
||||
value: parseInt(filterType.filterComboBoxValue as string),
|
||||
};
|
||||
} else if (filterType.filterValue === "Submitted") {
|
||||
filters.data![elementId] = { op: "submitted" };
|
||||
} else if (filterType.filterValue === "Skipped") {
|
||||
filters.data![elementId] = { op: "skipped" };
|
||||
} else if (filterType.filterValue === "Includes either") {
|
||||
filters.data![elementId] = {
|
||||
op: "includesOne",
|
||||
value: (filterType.filterComboBoxValue as string[]).map((value) => parseInt(value)),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to process CTA filters
|
||||
const processCTAFilter = (
|
||||
filterType: FilterValue["filterType"],
|
||||
elementId: string,
|
||||
filters: TResponseFilterCriteria
|
||||
) => {
|
||||
if (filterType.filterComboBoxValue === "Clicked") {
|
||||
filters.data![elementId] = { op: "clicked" };
|
||||
} else if (filterType.filterComboBoxValue === "Dismissed") {
|
||||
filters.data![elementId] = { op: "skipped" };
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to process Consent filters
|
||||
const processConsentFilter = (
|
||||
filterType: FilterValue["filterType"],
|
||||
elementId: string,
|
||||
filters: TResponseFilterCriteria
|
||||
) => {
|
||||
if (filterType.filterComboBoxValue === "Accepted") {
|
||||
filters.data![elementId] = { op: "accepted" };
|
||||
} else if (filterType.filterComboBoxValue === "Dismissed") {
|
||||
filters.data![elementId] = { op: "skipped" };
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to process Picture Selection filters
|
||||
const processPictureSelectionFilter = (
|
||||
filterType: FilterValue["filterType"],
|
||||
elementId: string,
|
||||
element: ReturnType<typeof getElementsFromBlocks>[number] | undefined,
|
||||
filters: TResponseFilterCriteria
|
||||
) => {
|
||||
if (
|
||||
element?.type !== TSurveyElementTypeEnum.PictureSelection ||
|
||||
!Array.isArray(filterType.filterComboBoxValue)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedOptions = filterType.filterComboBoxValue.map((option) => {
|
||||
const index = parseInt(option.split(" ")[1]);
|
||||
return element?.choices[index - 1].id;
|
||||
});
|
||||
|
||||
if (filterType.filterValue === "Includes all") {
|
||||
filters.data![elementId] = { op: "includesAll", value: selectedOptions };
|
||||
} else if (filterType.filterValue === "Includes either") {
|
||||
filters.data![elementId] = { op: "includesOne", value: selectedOptions };
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to process Matrix filters
|
||||
const processMatrixFilter = (
|
||||
filterType: FilterValue["filterType"],
|
||||
elementId: string,
|
||||
filters: TResponseFilterCriteria
|
||||
) => {
|
||||
if (
|
||||
filterType.filterValue &&
|
||||
filterType.filterComboBoxValue &&
|
||||
typeof filterType.filterComboBoxValue === "string"
|
||||
) {
|
||||
filters.data![elementId] = {
|
||||
op: "matrix",
|
||||
value: { [filterType.filterValue]: filterType.filterComboBoxValue },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to process element filters
|
||||
const processElementFilters = (
|
||||
elements: FilterValue[],
|
||||
survey: TSurvey,
|
||||
filters: TResponseFilterCriteria
|
||||
) => {
|
||||
if (!elements.length) return;
|
||||
|
||||
const surveyElements = getElementsFromBlocks(survey.blocks);
|
||||
filters.data = filters.data || {};
|
||||
|
||||
elements.forEach(({ filterType, elementType }) => {
|
||||
const elementId = elementType.id ?? "";
|
||||
const element = surveyElements.find((q) => q.id === elementId);
|
||||
|
||||
switch (elementType.elementType) {
|
||||
case TSurveyElementTypeEnum.OpenText:
|
||||
case TSurveyElementTypeEnum.Address:
|
||||
case TSurveyElementTypeEnum.ContactInfo:
|
||||
processFilledOutSkippedFilter(filterType, elementId, filters);
|
||||
break;
|
||||
case TSurveyElementTypeEnum.Ranking:
|
||||
processRankingFilter(filterType, elementId, filters);
|
||||
break;
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti:
|
||||
processMultipleChoiceFilter(filterType, elementId, filters);
|
||||
break;
|
||||
case TSurveyElementTypeEnum.NPS:
|
||||
case TSurveyElementTypeEnum.Rating:
|
||||
processNPSRatingFilter(filterType, elementId, filters);
|
||||
break;
|
||||
case TSurveyElementTypeEnum.CTA:
|
||||
processCTAFilter(filterType, elementId, filters);
|
||||
break;
|
||||
case TSurveyElementTypeEnum.Consent:
|
||||
processConsentFilter(filterType, elementId, filters);
|
||||
break;
|
||||
case TSurveyElementTypeEnum.PictureSelection:
|
||||
processPictureSelectionFilter(filterType, elementId, element, filters);
|
||||
break;
|
||||
case TSurveyElementTypeEnum.Matrix:
|
||||
processMatrixFilter(filterType, elementId, filters);
|
||||
break;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to process equals/not equals filters (for hiddenFields, attributes, others)
|
||||
const processEqualsNotEqualsFilter = (
|
||||
filterType: FilterValue["filterType"],
|
||||
label: string | undefined,
|
||||
filters: TResponseFilterCriteria,
|
||||
targetKey: "data" | "contactAttributes" | "others"
|
||||
) => {
|
||||
if (!filterType.filterComboBoxValue) return;
|
||||
|
||||
if (targetKey === "data") {
|
||||
filters.data = filters.data || {};
|
||||
if (filterType.filterValue === "Equals") {
|
||||
filters.data[label ?? ""] = { op: "equals", value: filterType.filterComboBoxValue as string };
|
||||
} else if (filterType.filterValue === "Not equals") {
|
||||
filters.data[label ?? ""] = { op: "notEquals", value: filterType.filterComboBoxValue as string };
|
||||
}
|
||||
} else if (targetKey === "contactAttributes") {
|
||||
filters.contactAttributes = filters.contactAttributes || {};
|
||||
if (filterType.filterValue === "Equals") {
|
||||
filters.contactAttributes[label ?? ""] = {
|
||||
op: "equals",
|
||||
value: filterType.filterComboBoxValue as string,
|
||||
};
|
||||
} else if (filterType.filterValue === "Not equals") {
|
||||
filters.contactAttributes[label ?? ""] = {
|
||||
op: "notEquals",
|
||||
value: filterType.filterComboBoxValue as string,
|
||||
};
|
||||
}
|
||||
} else if (targetKey === "others") {
|
||||
filters.others = filters.others || {};
|
||||
if (filterType.filterValue === "Equals") {
|
||||
filters.others[label ?? ""] = { op: "equals", value: filterType.filterComboBoxValue as string };
|
||||
} else if (filterType.filterValue === "Not equals") {
|
||||
filters.others[label ?? ""] = { op: "notEquals", value: filterType.filterComboBoxValue as string };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to process meta filters
|
||||
const processMetaFilters = (meta: FilterValue[], filters: TResponseFilterCriteria) => {
|
||||
if (!meta.length) return;
|
||||
|
||||
filters.meta = filters.meta || {};
|
||||
|
||||
meta.forEach(({ filterType, elementType }) => {
|
||||
const label = elementType.label ?? "";
|
||||
const metaFilters = filters.meta!; // Safe because we initialized it above
|
||||
|
||||
// For text input cases (URL filtering)
|
||||
if (typeof filterType.filterComboBoxValue === "string" && filterType.filterComboBoxValue.length > 0) {
|
||||
const value = filterType.filterComboBoxValue.trim();
|
||||
const op = META_OP_MAP[filterType.filterValue as keyof typeof META_OP_MAP];
|
||||
if (op) {
|
||||
metaFilters[label] = { op, value };
|
||||
}
|
||||
}
|
||||
// For dropdown/select cases (existing metadata fields)
|
||||
else if (Array.isArray(filterType.filterComboBoxValue) && filterType.filterComboBoxValue.length > 0) {
|
||||
const value = filterType.filterComboBoxValue[0];
|
||||
if (filterType.filterValue === "Equals") {
|
||||
metaFilters[label] = { op: "equals", value };
|
||||
} else if (filterType.filterValue === "Not equals") {
|
||||
metaFilters[label] = { op: "notEquals", value };
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to process quota filters
|
||||
const processQuotaFilters = (quotas: FilterValue[], filters: TResponseFilterCriteria) => {
|
||||
if (!quotas.length) return;
|
||||
|
||||
filters.quotas = filters.quotas || {};
|
||||
|
||||
const statusMap: Record<string, "screenedIn" | "screenedOut" | "screenedOutNotInQuota"> = {
|
||||
"Screened in": "screenedIn",
|
||||
"Screened out (overquota)": "screenedOut",
|
||||
"Not in quota": "screenedOutNotInQuota",
|
||||
};
|
||||
|
||||
quotas.forEach(({ filterType, elementType }) => {
|
||||
const quotaId = elementType.id;
|
||||
if (!quotaId) return;
|
||||
|
||||
const op = statusMap[String(filterType.filterComboBoxValue)];
|
||||
if (op) filters.quotas![quotaId] = { op };
|
||||
});
|
||||
};
|
||||
|
||||
// get the formatted filter expression to fetch filtered responses
|
||||
@@ -253,7 +550,7 @@ export const getFormattedFilters = (
|
||||
): TResponseFilterCriteria => {
|
||||
const filters: TResponseFilterCriteria = {};
|
||||
|
||||
const questions: FilterValue[] = [];
|
||||
const elements: FilterValue[] = [];
|
||||
const tags: FilterValue[] = [];
|
||||
const attributes: FilterValue[] = [];
|
||||
const others: FilterValue[] = [];
|
||||
@@ -262,19 +559,19 @@ export const getFormattedFilters = (
|
||||
const quotas: FilterValue[] = [];
|
||||
|
||||
selectedFilter.filter.forEach((filter) => {
|
||||
if (filter.questionType?.type === "Questions") {
|
||||
questions.push(filter);
|
||||
} else if (filter.questionType?.type === "Tags") {
|
||||
if (filter.elementType?.type === "Elements") {
|
||||
elements.push(filter);
|
||||
} else if (filter.elementType?.type === "Tags") {
|
||||
tags.push(filter);
|
||||
} else if (filter.questionType?.type === "Attributes") {
|
||||
} else if (filter.elementType?.type === "Attributes") {
|
||||
attributes.push(filter);
|
||||
} else if (filter.questionType?.type === "Other Filters") {
|
||||
} else if (filter.elementType?.type === "Other Filters") {
|
||||
others.push(filter);
|
||||
} else if (filter.questionType?.type === "Meta") {
|
||||
} else if (filter.elementType?.type === "Meta") {
|
||||
meta.push(filter);
|
||||
} else if (filter.questionType?.type === "Hidden Fields") {
|
||||
} else if (filter.elementType?.type === "Hidden Fields") {
|
||||
hiddenFields.push(filter);
|
||||
} else if (filter.questionType?.type === "Quotas") {
|
||||
} else if (filter.elementType?.type === "Quotas") {
|
||||
quotas.push(filter);
|
||||
}
|
||||
});
|
||||
@@ -302,259 +599,41 @@ export const getFormattedFilters = (
|
||||
};
|
||||
tags.forEach((tag) => {
|
||||
if (tag.filterType.filterComboBoxValue === "Applied") {
|
||||
filters.tags?.applied?.push(tag.questionType.label ?? "");
|
||||
filters.tags?.applied?.push(tag.elementType.label ?? "");
|
||||
} else {
|
||||
filters.tags?.notApplied?.push(tag.questionType.label ?? "");
|
||||
filters.tags?.notApplied?.push(tag.elementType.label ?? "");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// for questions
|
||||
if (questions.length) {
|
||||
questions.forEach(({ filterType, questionType }) => {
|
||||
if (!filters.data) filters.data = {};
|
||||
switch (questionType.questionType) {
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
case TSurveyQuestionTypeEnum.Address:
|
||||
case TSurveyQuestionTypeEnum.ContactInfo: {
|
||||
if (filterType.filterComboBoxValue === "Filled out") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "filledOut",
|
||||
};
|
||||
} else if (filterType.filterComboBoxValue === "Skipped") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "skipped",
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Ranking: {
|
||||
if (filterType.filterComboBoxValue === "Filled out") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "submitted",
|
||||
};
|
||||
} else if (filterType.filterComboBoxValue === "Skipped") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "skipped",
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti: {
|
||||
if (filterType.filterValue === "Includes either") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "includesOne",
|
||||
value: filterType.filterComboBoxValue as string[],
|
||||
};
|
||||
} else if (filterType.filterValue === "Includes all") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "includesAll",
|
||||
value: filterType.filterComboBoxValue as string[],
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.NPS:
|
||||
case TSurveyQuestionTypeEnum.Rating: {
|
||||
if (filterType.filterValue === "Is equal to") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "equals",
|
||||
value: parseInt(filterType.filterComboBoxValue as string),
|
||||
};
|
||||
} else if (filterType.filterValue === "Is less than") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "lessThan",
|
||||
value: parseInt(filterType.filterComboBoxValue as string),
|
||||
};
|
||||
} else if (filterType.filterValue === "Is more than") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "greaterThan",
|
||||
value: parseInt(filterType.filterComboBoxValue as string),
|
||||
};
|
||||
} else if (filterType.filterValue === "Submitted") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "submitted",
|
||||
};
|
||||
} else if (filterType.filterValue === "Skipped") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "skipped",
|
||||
};
|
||||
} else if (filterType.filterValue === "Includes either") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "includesOne",
|
||||
value: (filterType.filterComboBoxValue as string[]).map((value) => parseInt(value)),
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.CTA: {
|
||||
if (filterType.filterComboBoxValue === "Clicked") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "clicked",
|
||||
};
|
||||
} else if (filterType.filterComboBoxValue === "Dismissed") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "skipped",
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Consent: {
|
||||
if (filterType.filterComboBoxValue === "Accepted") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "accepted",
|
||||
};
|
||||
} else if (filterType.filterComboBoxValue === "Dismissed") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "skipped",
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.PictureSelection: {
|
||||
const questionId = questionType.id ?? "";
|
||||
const question = survey.questions.find((q) => q.id === questionId);
|
||||
|
||||
if (
|
||||
question?.type !== TSurveyQuestionTypeEnum.PictureSelection ||
|
||||
!Array.isArray(filterType.filterComboBoxValue)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedOptions = filterType.filterComboBoxValue.map((option) => {
|
||||
const index = parseInt(option.split(" ")[1]);
|
||||
return question?.choices[index - 1].id;
|
||||
});
|
||||
|
||||
if (filterType.filterValue === "Includes all") {
|
||||
filters.data[questionId] = {
|
||||
op: "includesAll",
|
||||
value: selectedOptions,
|
||||
};
|
||||
} else if (filterType.filterValue === "Includes either") {
|
||||
filters.data[questionId] = {
|
||||
op: "includesOne",
|
||||
value: selectedOptions,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Matrix: {
|
||||
if (
|
||||
filterType.filterValue &&
|
||||
filterType.filterComboBoxValue &&
|
||||
typeof filterType.filterComboBoxValue === "string"
|
||||
) {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "matrix",
|
||||
value: { [filterType.filterValue]: filterType.filterComboBoxValue },
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
processElementFilters(elements, survey, filters);
|
||||
|
||||
// for hidden fields
|
||||
if (hiddenFields.length) {
|
||||
hiddenFields.forEach(({ filterType, questionType }) => {
|
||||
if (!filters.data) filters.data = {};
|
||||
if (!filterType.filterComboBoxValue) return;
|
||||
if (filterType.filterValue === "Equals") {
|
||||
filters.data[questionType.label ?? ""] = {
|
||||
op: "equals",
|
||||
value: filterType.filterComboBoxValue as string,
|
||||
};
|
||||
} else if (filterType.filterValue === "Not equals") {
|
||||
filters.data[questionType.label ?? ""] = {
|
||||
op: "notEquals",
|
||||
value: filterType.filterComboBoxValue as string,
|
||||
};
|
||||
}
|
||||
filters.data = filters.data || {};
|
||||
hiddenFields.forEach(({ filterType, elementType }) => {
|
||||
processEqualsNotEqualsFilter(filterType, elementType.label, filters, "data");
|
||||
});
|
||||
}
|
||||
|
||||
// for attributes
|
||||
if (attributes.length) {
|
||||
attributes.forEach(({ filterType, questionType }) => {
|
||||
if (!filters.contactAttributes) filters.contactAttributes = {};
|
||||
if (!filterType.filterComboBoxValue) return;
|
||||
if (filterType.filterValue === "Equals") {
|
||||
filters.contactAttributes[questionType.label ?? ""] = {
|
||||
op: "equals",
|
||||
value: filterType.filterComboBoxValue as string,
|
||||
};
|
||||
} else if (filterType.filterValue === "Not equals") {
|
||||
filters.contactAttributes[questionType.label ?? ""] = {
|
||||
op: "notEquals",
|
||||
value: filterType.filterComboBoxValue as string,
|
||||
};
|
||||
}
|
||||
filters.contactAttributes = filters.contactAttributes || {};
|
||||
attributes.forEach(({ filterType, elementType }) => {
|
||||
processEqualsNotEqualsFilter(filterType, elementType.label, filters, "contactAttributes");
|
||||
});
|
||||
}
|
||||
|
||||
// for others
|
||||
if (others.length) {
|
||||
others.forEach(({ filterType, questionType }) => {
|
||||
if (!filters.others) filters.others = {};
|
||||
if (!filterType.filterComboBoxValue) return;
|
||||
if (filterType.filterValue === "Equals") {
|
||||
filters.others[questionType.label ?? ""] = {
|
||||
op: "equals",
|
||||
value: filterType.filterComboBoxValue as string,
|
||||
};
|
||||
} else if (filterType.filterValue === "Not equals") {
|
||||
filters.others[questionType.label ?? ""] = {
|
||||
op: "notEquals",
|
||||
value: filterType.filterComboBoxValue as string,
|
||||
};
|
||||
}
|
||||
filters.others = filters.others || {};
|
||||
others.forEach(({ filterType, elementType }) => {
|
||||
processEqualsNotEqualsFilter(filterType, elementType.label, filters, "others");
|
||||
});
|
||||
}
|
||||
|
||||
// for meta
|
||||
if (meta.length) {
|
||||
meta.forEach(({ filterType, questionType }) => {
|
||||
if (!filters.meta) filters.meta = {};
|
||||
|
||||
// For text input cases (URL filtering)
|
||||
if (typeof filterType.filterComboBoxValue === "string" && filterType.filterComboBoxValue.length > 0) {
|
||||
const value = filterType.filterComboBoxValue.trim();
|
||||
const op = META_OP_MAP[filterType.filterValue as keyof typeof META_OP_MAP];
|
||||
if (op) {
|
||||
filters.meta[questionType.label ?? ""] = { op, value };
|
||||
}
|
||||
}
|
||||
// For dropdown/select cases (existing metadata fields)
|
||||
else if (Array.isArray(filterType.filterComboBoxValue) && filterType.filterComboBoxValue.length > 0) {
|
||||
const value = filterType.filterComboBoxValue[0]; // Take first selected value
|
||||
if (filterType.filterValue === "Equals") {
|
||||
filters.meta[questionType.label ?? ""] = { op: "equals", value };
|
||||
} else if (filterType.filterValue === "Not equals") {
|
||||
filters.meta[questionType.label ?? ""] = { op: "notEquals", value };
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (quotas.length) {
|
||||
quotas.forEach(({ filterType, questionType }) => {
|
||||
filters.quotas ??= {};
|
||||
const quotaId = questionType.id;
|
||||
if (!quotaId) return;
|
||||
|
||||
const statusMap: Record<string, "screenedIn" | "screenedOut" | "screenedOutNotInQuota"> = {
|
||||
"Screened in": "screenedIn",
|
||||
"Screened out (overquota)": "screenedOut",
|
||||
"Screened out (not in quota)": "screenedOutNotInQuota",
|
||||
};
|
||||
const op = statusMap[String(filterType.filterComboBoxValue)];
|
||||
if (op) filters.quotas[quotaId] = { op };
|
||||
});
|
||||
}
|
||||
processMetaFilters(meta, filters);
|
||||
processQuotaFilters(quotas, filters);
|
||||
|
||||
return filters;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user