Compare commits

..

1 Commits

Author SHA1 Message Date
Johannes
b05a636914 feat: add Phase 1 testing utilities (TEST_IDS, FIXTURES, setupTestEnvironment)
- Create centralized TEST_IDS constants to eliminate 200+ magic string occurrences
- Create FIXTURES for common test data to reduce 47+ duplicate definitions
- Add setupTestEnvironment() helper to standardize cleanup patterns (36+ occurrences)
- Export utilities from vitestSetup.ts for easy access
- Add comprehensive README with usage examples

This is Phase 1 (Quick Wins) of the testing infrastructure refactor.
Estimated impact: 30-50% reduction in test boilerplate for new tests.

Related analysis documents: TESTING_ANALYSIS_README.md, ANALYSIS_SUMMARY.txt
2025-11-24 13:54:59 +01:00
467 changed files with 20149 additions and 36362 deletions

View File

@@ -1,8 +1,13 @@
--- ---
description: > description: >
globs: schema.prisma This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
alwaysApply: false 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
--- ---
# Formbricks Database Schema Reference # 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. 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.

View File

@@ -0,0 +1,5 @@
---
description:
globs:
alwaysApply: false
---

View File

@@ -0,0 +1,5 @@
---
description:
globs:
alwaysApply: false
---

View File

@@ -3,9 +3,13 @@ name: E2E Tests
on: on:
workflow_call: workflow_call:
secrets: secrets:
PLAYWRIGHT_SERVICE_URL: AZURE_CLIENT_ID:
required: false required: false
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: AZURE_TENANT_ID:
required: false
AZURE_SUBSCRIPTION_ID:
required: false
PLAYWRIGHT_SERVICE_URL:
required: false required: false
ENTERPRISE_LICENSE_KEY: ENTERPRISE_LICENSE_KEY:
required: true required: true
@@ -13,10 +17,12 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
TELEMETRY_DISABLED: 1
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }} TURBO_TEAM: ${{ vars.TURBO_TEAM }}
permissions: permissions:
id-token: write
contents: read contents: read
actions: read actions: read
@@ -109,7 +115,7 @@ jobs:
- name: Start MinIO Server - name: Start MinIO Server
run: | run: |
set -euo pipefail set -euo pipefail
# Start MinIO server in background # Start MinIO server in background
docker run -d \ docker run -d \
--name minio-server \ --name minio-server \
@@ -119,7 +125,7 @@ jobs:
-e MINIO_ROOT_PASSWORD=devminio123 \ -e MINIO_ROOT_PASSWORD=devminio123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \ minio/minio:RELEASE.2025-09-07T16-13-09Z \
server /data --console-address :9001 server /data --console-address :9001
echo "MinIO server started" echo "MinIO server started"
- name: Wait for MinIO and create S3 bucket - name: Wait for MinIO and create S3 bucket
@@ -202,30 +208,32 @@ jobs:
- name: Install Playwright - name: Install Playwright
run: pnpm exec playwright install --with-deps run: pnpm exec playwright install --with-deps
- name: Determine Playwright execution mode - name: Set Azure Secret Variables
shell: bash
env:
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
run: | run: |
set -euo pipefail if [[ -n "${{ secrets.AZURE_CLIENT_ID }}" && -n "${{ secrets.AZURE_TENANT_ID }}" && -n "${{ secrets.AZURE_SUBSCRIPTION_ID }}" ]]; then
echo "AZURE_ENABLED=true" >> $GITHUB_ENV
if [[ -n "${PLAYWRIGHT_SERVICE_URL}" && -n "${PLAYWRIGHT_SERVICE_ACCESS_TOKEN}" ]]; then
echo "PW_MODE=service" >> "$GITHUB_ENV"
else else
echo "PW_MODE=local" >> "$GITHUB_ENV" echo "AZURE_ENABLED=false" >> $GITHUB_ENV
fi fi
- name: Run E2E Tests (Playwright Service) - name: Azure login
if: env.PW_MODE == 'service' if: env.AZURE_ENABLED == 'true'
uses: azure/login@a65d910e8af852a8061c627c456678983e180302 # v2.2.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Run E2E Tests (Azure)
if: env.AZURE_ENABLED == 'true'
env: env:
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }} PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
CI: true CI: true
run: pnpm test-e2e:azure run: |
pnpm test-e2e:azure
- name: Run E2E Tests (Local) - name: Run E2E Tests (Local)
if: env.PW_MODE == 'local' if: env.AZURE_ENABLED == 'false'
env: env:
CI: true CI: true
run: | run: |

View File

@@ -32,22 +32,14 @@ const mockProject: TProject = {
}; };
const mockTemplate: TXMTemplate = { const mockTemplate: TXMTemplate = {
name: "$[projectName] Survey", name: "$[projectName] Survey",
blocks: [ questions: [
{ {
id: "block1", id: "q1",
name: "Block 1", inputType: "text",
elements: [ type: "email" as any,
{ headline: { default: "$[projectName] Question" },
id: "q1", required: false,
type: "openText" as const, charLimit: { enabled: true, min: 400, max: 1000 },
inputType: "text" as const,
headline: { default: "$[projectName] Question" },
subheader: { default: "" },
required: false,
placeholder: { default: "" },
charLimit: 1000,
},
],
}, },
], ],
endings: [ endings: [
@@ -74,9 +66,9 @@ describe("replacePresetPlaceholders", () => {
expect(result.name).toBe("Test Project Survey"); expect(result.name).toBe("Test Project Survey");
}); });
test("replaces projectName placeholder in element headline", () => { test("replaces projectName placeholder in question headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject); const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question"); expect(result.questions[0].headline.default).toBe("Test Project Question");
}); });
test("returns a new object without mutating the original template", () => { test("returns a new object without mutating the original template", () => {

View File

@@ -1,16 +1,13 @@
import { TProject } from "@formbricks/types/project"; import { TProject } from "@formbricks/types/project";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TXMTemplate } from "@formbricks/types/templates"; import { TXMTemplate } from "@formbricks/types/templates";
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates"; import { replaceQuestionPresetPlaceholders } from "@/lib/utils/templates";
// replace all occurences of projectName with the actual project name in the current template // replace all occurences of projectName with the actual project name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => { export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject) => {
const survey = structuredClone(template); const survey = structuredClone(template);
survey.name = survey.name.replace("$[projectName]", project.name);
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({ survey.questions = survey.questions.map((question) => {
...block, return replaceQuestionPresetPlaceholders(question, project);
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)), });
})); return { ...template, ...survey };
return { ...survey, name: survey.name.replace("$[projectName]", project.name), blocks: modifiedBlocks };
}; };

View File

@@ -20,7 +20,7 @@ describe("xm-templates", () => {
expect(result).toEqual({ expect(result).toEqual({
name: "", name: "",
endings: expect.any(Array), endings: expect.any(Array),
blocks: [], questions: [],
styling: { styling: {
overwriteThemeStyling: true, overwriteThemeStyling: true,
}, },

View File

@@ -3,21 +3,19 @@ import { TFunction } from "i18next";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { TXMTemplate } from "@formbricks/types/templates"; import { TXMTemplate } from "@formbricks/types/templates";
import { import {
buildBlock, buildCTAQuestion,
buildCTAElement, buildNPSQuestion,
buildNPSElement, buildOpenTextQuestion,
buildOpenTextElement, buildRatingQuestion,
buildRatingElement, getDefaultEndingCard,
createBlockJumpLogic, } from "@/app/lib/survey-builder";
} from "@/app/lib/survey-block-builder";
import { getDefaultEndingCard } from "@/app/lib/survey-builder";
export const getXMSurveyDefault = (t: TFunction): TXMTemplate => { export const getXMSurveyDefault = (t: TFunction): TXMTemplate => {
try { try {
return { return {
name: "", name: "",
endings: [getDefaultEndingCard([], t)], endings: [getDefaultEndingCard([], t)],
blocks: [], questions: [],
styling: { styling: {
overwriteThemeStyling: true, overwriteThemeStyling: true,
}, },
@@ -32,40 +30,25 @@ const npsSurvey = (t: TFunction): TXMTemplate => {
return { return {
...getXMSurveyDefault(t), ...getXMSurveyDefault(t),
name: t("templates.nps_survey_name"), name: t("templates.nps_survey_name"),
blocks: [ questions: [
buildBlock({ buildNPSQuestion({
name: "Block 1", headline: t("templates.nps_survey_question_1_headline"),
elements: [ required: true,
buildNPSElement({ lowerLabel: t("templates.nps_survey_question_1_lower_label"),
headline: t("templates.nps_survey_question_1_headline"), upperLabel: t("templates.nps_survey_question_1_upper_label"),
required: true, isColorCodingEnabled: true,
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
upperLabel: t("templates.nps_survey_question_1_upper_label"),
isColorCodingEnabled: true,
}),
],
t, t,
}), }),
buildBlock({ buildOpenTextQuestion({
name: "Block 2", headline: t("templates.nps_survey_question_2_headline"),
elements: [ required: false,
buildOpenTextElement({ inputType: "text",
headline: t("templates.nps_survey_question_2_headline"),
required: false,
inputType: "text",
}),
],
t, t,
}), }),
buildBlock({ buildOpenTextQuestion({
name: "Block 3", headline: t("templates.nps_survey_question_3_headline"),
elements: [ required: false,
buildOpenTextElement({ inputType: "text",
headline: t("templates.nps_survey_question_3_headline"),
required: false,
inputType: "text",
}),
],
t, t,
}), }),
], ],
@@ -73,27 +56,15 @@ const npsSurvey = (t: TFunction): TXMTemplate => {
}; };
const starRatingSurvey = (t: TFunction): TXMTemplate => { const starRatingSurvey = (t: TFunction): TXMTemplate => {
const reusableElementIds = [createId(), createId(), createId()]; const reusableQuestionIds = [createId(), createId(), createId()];
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
const defaultSurvey = getXMSurveyDefault(t); const defaultSurvey = getXMSurveyDefault(t);
return { return {
...defaultSurvey, ...defaultSurvey,
name: t("templates.star_rating_survey_name"), name: t("templates.star_rating_survey_name"),
blocks: [ questions: [
buildBlock({ buildRatingQuestion({
name: "Block 1", id: reusableQuestionIds[0],
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: [ logic: [
{ {
id: createId(), id: createId(),
@@ -104,8 +75,8 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
{ {
id: createId(), id: createId(),
leftOperand: { leftOperand: {
value: reusableElementIds[0], value: reusableQuestionIds[0],
type: "element", type: "question",
}, },
operator: "isLessThanOrEqual", operator: "isLessThanOrEqual",
rightOperand: { rightOperand: {
@@ -118,44 +89,64 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
actions: [ actions: [
{ {
id: createId(), id: createId(),
objective: "jumpToBlock", objective: "jumpToQuestion",
target: block3Id, target: reusableQuestionIds[2],
}, },
], ],
}, },
], ],
range: 5,
scale: "number",
headline: t("templates.star_rating_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
t, t,
}), }),
buildBlock({ buildCTAQuestion({
name: "Block 2", id: reusableQuestionIds[1],
elements: [ subheader: t("templates.star_rating_survey_question_2_html"),
buildCTAElement({ logic: [
id: reusableElementIds[1], {
subheader: t("templates.star_rating_survey_question_2_html"), id: createId(),
headline: t("templates.star_rating_survey_question_2_headline"), conditions: {
required: false, id: createId(),
buttonUrl: "https://formbricks.com/github", connector: "and",
buttonExternal: true, conditions: [
ctaButtonLabel: t("templates.star_rating_survey_question_2_button_label"), {
}), id: createId(),
leftOperand: {
value: reusableQuestionIds[1],
type: "question",
},
operator: "isClicked",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: defaultSurvey.endings[0].id,
},
],
},
], ],
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")], headline: t("templates.star_rating_survey_question_2_headline"),
required: true,
buttonUrl: "https://formbricks.com/github",
buttonLabel: t("templates.star_rating_survey_question_2_button_label"),
buttonExternal: true,
t, t,
}), }),
buildBlock({ buildOpenTextQuestion({
id: block3Id, id: reusableQuestionIds[2],
name: "Block 3", headline: t("templates.star_rating_survey_question_3_headline"),
elements: [ required: true,
buildOpenTextElement({ subheader: t("templates.star_rating_survey_question_3_subheader"),
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"), buttonLabel: t("templates.star_rating_survey_question_3_button_label"),
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
inputType: "text",
t, t,
}), }),
], ],
@@ -163,27 +154,15 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
}; };
const csatSurvey = (t: TFunction): TXMTemplate => { const csatSurvey = (t: TFunction): TXMTemplate => {
const reusableElementIds = [createId(), createId(), createId()]; const reusableQuestionIds = [createId(), createId(), createId()];
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
const defaultSurvey = getXMSurveyDefault(t); const defaultSurvey = getXMSurveyDefault(t);
return { return {
...defaultSurvey, ...defaultSurvey,
name: t("templates.csat_survey_name"), name: t("templates.csat_survey_name"),
blocks: [ questions: [
buildBlock({ buildRatingQuestion({
name: "Block 1", id: reusableQuestionIds[0],
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: [ logic: [
{ {
id: createId(), id: createId(),
@@ -194,8 +173,8 @@ const csatSurvey = (t: TFunction): TXMTemplate => {
{ {
id: createId(), id: createId(),
leftOperand: { leftOperand: {
value: reusableElementIds[0], value: reusableQuestionIds[0],
type: "element", type: "question",
}, },
operator: "isLessThanOrEqual", operator: "isLessThanOrEqual",
rightOperand: { rightOperand: {
@@ -208,40 +187,60 @@ const csatSurvey = (t: TFunction): TXMTemplate => {
actions: [ actions: [
{ {
id: createId(), id: createId(),
objective: "jumpToBlock", objective: "jumpToQuestion",
target: block3Id, target: reusableQuestionIds[2],
}, },
], ],
}, },
], ],
range: 5,
scale: "smiley",
headline: t("templates.csat_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
upperLabel: t("templates.csat_survey_question_1_upper_label"),
t, t,
}), }),
buildBlock({ buildOpenTextQuestion({
name: "Block 2", id: reusableQuestionIds[1],
elements: [ logic: [
buildOpenTextElement({ {
id: reusableElementIds[1], id: createId(),
headline: t("templates.csat_survey_question_2_headline"), conditions: {
required: false, id: createId(),
placeholder: t("templates.csat_survey_question_2_placeholder"), connector: "and",
inputType: "text", conditions: [
}), {
id: createId(),
leftOperand: {
value: reusableQuestionIds[1],
type: "question",
},
operator: "isSubmitted",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: defaultSurvey.endings[0].id,
},
],
},
], ],
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isSubmitted")], headline: t("templates.csat_survey_question_2_headline"),
required: false,
placeholder: t("templates.csat_survey_question_2_placeholder"),
inputType: "text",
t, t,
}), }),
buildBlock({ buildOpenTextQuestion({
id: block3Id, id: reusableQuestionIds[2],
name: "Block 3", headline: t("templates.csat_survey_question_3_headline"),
elements: [ required: false,
buildOpenTextElement({ placeholder: t("templates.csat_survey_question_3_placeholder"),
id: reusableElementIds[2], inputType: "text",
headline: t("templates.csat_survey_question_3_headline"),
required: false,
placeholder: t("templates.csat_survey_question_3_placeholder"),
inputType: "text",
}),
],
t, t,
}), }),
], ],
@@ -252,31 +251,21 @@ const cessSurvey = (t: TFunction): TXMTemplate => {
return { return {
...getXMSurveyDefault(t), ...getXMSurveyDefault(t),
name: t("templates.cess_survey_name"), name: t("templates.cess_survey_name"),
blocks: [ questions: [
buildBlock({ buildRatingQuestion({
name: "Block 1", range: 5,
elements: [ scale: "number",
buildRatingElement({ headline: t("templates.cess_survey_question_1_headline"),
range: 5, required: true,
scale: "number", lowerLabel: t("templates.cess_survey_question_1_lower_label"),
headline: t("templates.cess_survey_question_1_headline"), upperLabel: t("templates.cess_survey_question_1_upper_label"),
required: true,
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
upperLabel: t("templates.cess_survey_question_1_upper_label"),
}),
],
t, t,
}), }),
buildBlock({ buildOpenTextQuestion({
name: "Block 2", headline: t("templates.cess_survey_question_2_headline"),
elements: [ required: true,
buildOpenTextElement({ placeholder: t("templates.cess_survey_question_2_placeholder"),
headline: t("templates.cess_survey_question_2_headline"), inputType: "text",
required: true,
placeholder: t("templates.cess_survey_question_2_placeholder"),
inputType: "text",
}),
],
t, t,
}), }),
], ],
@@ -284,27 +273,15 @@ const cessSurvey = (t: TFunction): TXMTemplate => {
}; };
const smileysRatingSurvey = (t: TFunction): TXMTemplate => { const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
const reusableElementIds = [createId(), createId(), createId()]; const reusableQuestionIds = [createId(), createId(), createId()];
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
const defaultSurvey = getXMSurveyDefault(t); const defaultSurvey = getXMSurveyDefault(t);
return { return {
...defaultSurvey, ...defaultSurvey,
name: t("templates.smileys_survey_name"), name: t("templates.smileys_survey_name"),
blocks: [ questions: [
buildBlock({ buildRatingQuestion({
name: "Block 1", id: reusableQuestionIds[0],
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: [ logic: [
{ {
id: createId(), id: createId(),
@@ -315,8 +292,8 @@ const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
{ {
id: createId(), id: createId(),
leftOperand: { leftOperand: {
value: reusableElementIds[0], value: reusableQuestionIds[0],
type: "element", type: "question",
}, },
operator: "isLessThanOrEqual", operator: "isLessThanOrEqual",
rightOperand: { rightOperand: {
@@ -329,44 +306,64 @@ const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
actions: [ actions: [
{ {
id: createId(), id: createId(),
objective: "jumpToBlock", objective: "jumpToQuestion",
target: block3Id, target: reusableQuestionIds[2],
}, },
], ],
}, },
], ],
range: 5,
scale: "smiley",
headline: t("templates.smileys_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
t, t,
}), }),
buildBlock({ buildCTAQuestion({
name: "Block 2", id: reusableQuestionIds[1],
elements: [ subheader: t("templates.smileys_survey_question_2_html"),
buildCTAElement({ logic: [
id: reusableElementIds[1], {
subheader: t("templates.smileys_survey_question_2_html"), id: createId(),
headline: t("templates.smileys_survey_question_2_headline"), conditions: {
required: false, id: createId(),
buttonUrl: "https://formbricks.com/github", connector: "and",
buttonExternal: true, conditions: [
ctaButtonLabel: t("templates.smileys_survey_question_2_button_label"), {
}), id: createId(),
leftOperand: {
value: reusableQuestionIds[1],
type: "question",
},
operator: "isClicked",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: defaultSurvey.endings[0].id,
},
],
},
], ],
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")], headline: t("templates.smileys_survey_question_2_headline"),
required: true,
buttonUrl: "https://formbricks.com/github",
buttonLabel: t("templates.smileys_survey_question_2_button_label"),
buttonExternal: true,
t, t,
}), }),
buildBlock({ buildOpenTextQuestion({
id: block3Id, id: reusableQuestionIds[2],
name: "Block 3", headline: t("templates.smileys_survey_question_3_headline"),
elements: [ required: true,
buildOpenTextElement({ subheader: t("templates.smileys_survey_question_3_subheader"),
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"), buttonLabel: t("templates.smileys_survey_question_3_button_label"),
placeholder: t("templates.smileys_survey_question_3_placeholder"),
inputType: "text",
t, t,
}), }),
], ],
@@ -377,40 +374,25 @@ const enpsSurvey = (t: TFunction): TXMTemplate => {
return { return {
...getXMSurveyDefault(t), ...getXMSurveyDefault(t),
name: t("templates.enps_survey_name"), name: t("templates.enps_survey_name"),
blocks: [ questions: [
buildBlock({ buildNPSQuestion({
name: "Block 1", headline: t("templates.enps_survey_question_1_headline"),
elements: [ required: false,
buildNPSElement({ lowerLabel: t("templates.enps_survey_question_1_lower_label"),
headline: t("templates.enps_survey_question_1_headline"), upperLabel: t("templates.enps_survey_question_1_upper_label"),
required: false, isColorCodingEnabled: true,
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
upperLabel: t("templates.enps_survey_question_1_upper_label"),
isColorCodingEnabled: true,
}),
],
t, t,
}), }),
buildBlock({ buildOpenTextQuestion({
name: "Block 2", headline: t("templates.enps_survey_question_2_headline"),
elements: [ required: false,
buildOpenTextElement({ inputType: "text",
headline: t("templates.enps_survey_question_2_headline"),
required: false,
inputType: "text",
}),
],
t, t,
}), }),
buildBlock({ buildOpenTextQuestion({
name: "Block 3", headline: t("templates.enps_survey_question_3_headline"),
elements: [ required: false,
buildOpenTextElement({ inputType: "text",
headline: t("templates.enps_survey_question_3_headline"),
required: false,
inputType: "text",
}),
],
t, t,
}), }),
], ],

View File

@@ -1,6 +1,8 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AuthorizationError } from "@formbricks/types/errors"; 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 { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
@@ -38,6 +40,14 @@ const ProjectOnboardingLayout = async (props) => {
return ( return (
<div className="flex-1 bg-slate-50"> <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 /> <ToasterClient />
{children} {children}
</div> </div>

View File

@@ -1,13 +1,14 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getEnvironment } from "@/lib/environment/service"; import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils"; import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
const SurveyEditorEnvironmentLayout = async (props) => { const SurveyEditorEnvironmentLayout = async (props) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
const { t, session, user } = await environmentIdLayoutChecks(params.environmentId); const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
if (!session) { if (!session) {
return redirect(`/auth/login`); return redirect(`/auth/login`);
@@ -24,9 +25,15 @@ const SurveyEditorEnvironmentLayout = async (props) => {
} }
return ( return (
<div className="flex h-screen flex-col"> <EnvironmentIdBaseLayout
<div className="h-full overflow-y-auto bg-slate-50">{children}</div> environmentId={params.environmentId}
</div> 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>
); );
}; };

View File

@@ -0,0 +1,61 @@
"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;
};

View File

@@ -2,14 +2,14 @@
import React, { createContext, useCallback, useContext, useState } from "react"; import React, { createContext, useCallback, useContext, useState } from "react";
import { import {
ElementOption, QuestionOption,
ElementOptions, QuestionOptions,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementsComboBox"; } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { ElementFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter"; import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
import { getTodayDate } from "@/app/lib/surveys/surveys"; import { getTodayDate } from "@/app/lib/surveys/surveys";
export interface FilterValue { export interface FilterValue {
elementType: Partial<ElementOption>; questionType: Partial<QuestionOption>;
filterType: { filterType: {
filterValue: string | undefined; filterValue: string | undefined;
filterComboBoxValue: string | string[] | undefined; filterComboBoxValue: string | string[] | undefined;
@@ -24,8 +24,8 @@ export interface SelectedFilterValue {
} }
interface SelectedFilterOptions { interface SelectedFilterOptions {
elementOptions: ElementOptions[]; questionOptions: QuestionOptions[];
elementFilterOptions: ElementFilterOptions[]; questionFilterOptions: QuestionFilterOptions[];
} }
export interface DateRange { export interface DateRange {
@@ -53,8 +53,8 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
}); });
// state holds all the options of the responses fetched // state holds all the options of the responses fetched
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({ const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
elementFilterOptions: [], questionFilterOptions: [],
elementOptions: [], questionOptions: [],
}); });
const [dateRange, setDateRange] = useState<DateRange>({ const [dateRange, setDateRange] = useState<DateRange>({

View File

@@ -135,7 +135,7 @@ export const OrganizationBreadcrumb = ({
}, },
{ {
id: "teams", id: "teams",
label: t("common.members_and_teams"), label: t("common.teams"),
href: `/environments/${currentEnvironmentId}/settings/teams`, href: `/environments/${currentEnvironmentId}/settings/teams`,
}, },
{ {

View File

@@ -4,6 +4,7 @@ import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/comp
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context"; import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils"; import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler"; import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
const EnvLayout = async (props: { const EnvLayout = async (props: {
@@ -23,7 +24,11 @@ const EnvLayout = async (props: {
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id); const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
return ( return (
<> <EnvironmentIdBaseLayout
environmentId={params.environmentId}
session={layoutData.session}
user={layoutData.user}
organization={layoutData.organization}>
<EnvironmentStorageHandler environmentId={params.environmentId} /> <EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentContextWrapper <EnvironmentContextWrapper
environment={layoutData.environment} environment={layoutData.environment}
@@ -31,7 +36,7 @@ const EnvLayout = async (props: {
organization={layoutData.organization}> organization={layoutData.organization}>
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout> <EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
</EnvironmentContextWrapper> </EnvironmentContextWrapper>
</> </EnvironmentIdBaseLayout>
); );
}; };

View File

@@ -3,7 +3,7 @@
import { TFunction } from "i18next"; import { TFunction } from "i18next";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { Control, Controller, useForm } from "react-hook-form"; import { Control, Controller, useForm } from "react-hook-form";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -14,15 +14,14 @@ import {
TIntegrationAirtableInput, TIntegrationAirtableInput,
TIntegrationAirtableTables, TIntegrationAirtableTables,
} from "@formbricks/types/integration/airtable"; } from "@formbricks/types/integration/airtable";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions"; import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown"; 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 { fetchTables } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
import AirtableLogo from "@/images/airtableLogo.svg"; import AirtableLogo from "@/images/airtableLogo.svg";
import { recallToHeadline } from "@/lib/utils/recall"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings"; import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -46,45 +45,6 @@ import {
} from "@/modules/ui/components/select"; } from "@/modules/ui/components/select";
import { IntegrationModalInputs } from "../lib/types"; 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 = type EditModeProps =
| { isEditMode: false; defaultData?: never } | { isEditMode: false; defaultData?: never }
| { isEditMode: true; defaultData: IntegrationModalInputs & { index: number } }; | { isEditMode: true; defaultData: IntegrationModalInputs & { index: number } };
@@ -108,10 +68,9 @@ const NoBaseFoundError = () => {
); );
}; };
const renderElementSelection = ({ const renderQuestionSelection = ({
t, t,
selectedSurvey, selectedSurvey,
elements,
control, control,
includeVariables, includeVariables,
setIncludeVariables, setIncludeVariables,
@@ -124,7 +83,6 @@ const renderElementSelection = ({
}: { }: {
t: TFunction; t: TFunction;
selectedSurvey: TSurvey; selectedSurvey: TSurvey;
elements: TSurveyElement[];
control: Control<IntegrationModalInputs>; control: Control<IntegrationModalInputs>;
includeVariables: boolean; includeVariables: boolean;
setIncludeVariables: (value: boolean) => void; setIncludeVariables: (value: boolean) => void;
@@ -141,13 +99,31 @@ const renderElementSelection = ({
<Label htmlFor="Surveys">{t("common.questions")}</Label> <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="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"> <div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{elements.map((element) => ( {replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
<Controller <Controller
key={element.id} key={question.id}
control={control} control={control}
name={"elements"} name={"questions"}
render={({ field }) => ( render={({ field }) => (
<ElementCheckbox element={element} selectedSurvey={selectedSurvey} field={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>
)} )}
/> />
))} ))}
@@ -218,11 +194,6 @@ export const AddIntegrationModal = ({
}; };
const selectedSurvey = surveys.find((item) => item.id === survey); const selectedSurvey = surveys.find((item) => item.id === survey);
const elements = useMemo(
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
[selectedSurvey]
);
const submitHandler = async (data: IntegrationModalInputs) => { const submitHandler = async (data: IntegrationModalInputs) => {
try { try {
if (!data.base || data.base === "") { if (!data.base || data.base === "") {
@@ -237,7 +208,7 @@ export const AddIntegrationModal = ({
throw new Error(t("environments.integrations.please_select_a_survey_error")); throw new Error(t("environments.integrations.please_select_a_survey_error"));
} }
if (data.elements.length === 0) { if (data.questions.length === 0) {
throw new Error(t("environments.integrations.select_at_least_one_question_error")); throw new Error(t("environments.integrations.select_at_least_one_question_error"));
} }
@@ -245,9 +216,9 @@ export const AddIntegrationModal = ({
const integrationData: TIntegrationAirtableConfigData = { const integrationData: TIntegrationAirtableConfigData = {
surveyId: selectedSurvey.id, surveyId: selectedSurvey.id,
surveyName: selectedSurvey.name, surveyName: selectedSurvey.name,
elementIds: data.elements, questionIds: data.questions,
elements: questions:
data.elements.length === elements.length data.questions.length === selectedSurvey.questions.length
? t("common.all_questions") ? t("common.all_questions")
: t("common.selected_questions"), : t("common.selected_questions"),
createdAt: new Date(), createdAt: new Date(),
@@ -395,7 +366,7 @@ export const AddIntegrationModal = ({
required required
onValueChange={(val) => { onValueChange={(val) => {
field.onChange(val); field.onChange(val);
setValue("elements", []); setValue("questions", []);
}} }}
defaultValue={defaultData?.survey}> defaultValue={defaultData?.survey}>
<SelectTrigger> <SelectTrigger>
@@ -421,10 +392,9 @@ export const AddIntegrationModal = ({
{survey && {survey &&
selectedSurvey && selectedSurvey &&
renderElementSelection({ renderQuestionSelection({
t, t,
selectedSurvey, selectedSurvey,
elements: elements,
control, control,
includeVariables, includeVariables,
setIncludeVariables, setIncludeVariables,

View File

@@ -108,7 +108,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
onClick={() => { onClick={() => {
setDefaultValues({ setDefaultValues({
base: data.baseId, base: data.baseId,
elements: data.elementIds, questions: data.questionIds,
survey: data.surveyId, survey: data.surveyId,
table: data.tableId, table: data.tableId,
includeVariables: !!data.includeVariables, includeVariables: !!data.includeVariables,
@@ -121,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.surveyName}</div>
<div className="col-span-2 text-center">{data.tableName}</div> <div className="col-span-2 text-center">{data.tableName}</div>
<div className="col-span-2 text-center">{data.elements}</div> <div className="col-span-2 text-center">{data.questions}</div>
<div className="col-span-2 text-center"> <div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), props.locale)} {timeSince(data.createdAt.toString(), props.locale)}
</div> </div>

View File

@@ -2,7 +2,7 @@ export type IntegrationModalInputs = {
base: string; base: string;
table: string; table: string;
survey: string; survey: string;
elements: string[]; questions: string[];
includeVariables: boolean; includeVariables: boolean;
includeHiddenFields: boolean; includeHiddenFields: boolean;
includeMetadata: boolean; includeMetadata: boolean;

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -20,9 +20,9 @@ import {
isValidGoogleSheetsUrl, isValidGoogleSheetsUrl,
} from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/util"; } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/util";
import GoogleSheetLogo from "@/images/googleSheetsLogo.png"; import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall"; import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings"; import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox"; import { Checkbox } from "@/modules/ui/components/checkbox";
@@ -62,12 +62,12 @@ export const AddIntegrationModal = ({
spreadsheetName: "", spreadsheetName: "",
surveyId: "", surveyId: "",
surveyName: "", surveyName: "",
elementIds: [""], questionIds: [""],
elements: "", questions: "",
createdAt: new Date(), createdAt: new Date(),
}; };
const { handleSubmit } = useForm(); const { handleSubmit } = useForm();
const [selectedElements, setSelectedElements] = useState<string[]>([]); const [selectedQuestions, setSelectedQuestions] = useState<string[]>([]);
const [isLinkingSheet, setIsLinkingSheet] = useState(false); const [isLinkingSheet, setIsLinkingSheet] = useState(false);
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null); const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
const [spreadsheetUrl, setSpreadsheetUrl] = useState(""); const [spreadsheetUrl, setSpreadsheetUrl] = useState("");
@@ -86,17 +86,12 @@ export const AddIntegrationModal = ({
}, },
}; };
const surveyElements = useMemo(
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
[selectedSurvey]
);
useEffect(() => { useEffect(() => {
if (selectedSurvey && !selectedIntegration) { if (selectedSurvey && !selectedIntegration) {
const elementIds = surveyElements.map((element) => element.id); const questionIds = selectedSurvey.questions.map((question) => question.id);
setSelectedElements(elementIds); setSelectedQuestions(questionIds);
} }
}, [surveyElements, selectedIntegration, selectedSurvey]); }, [selectedIntegration, selectedSurvey]);
useEffect(() => { useEffect(() => {
if (selectedIntegration) { if (selectedIntegration) {
@@ -106,7 +101,7 @@ export const AddIntegrationModal = ({
return survey.id === selectedIntegration.surveyId; return survey.id === selectedIntegration.surveyId;
})! })!
); );
setSelectedElements(selectedIntegration.elementIds); setSelectedQuestions(selectedIntegration.questionIds);
setIncludeVariables(!!selectedIntegration.includeVariables); setIncludeVariables(!!selectedIntegration.includeVariables);
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields); setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
setIncludeMetadata(!!selectedIntegration.includeMetadata); setIncludeMetadata(!!selectedIntegration.includeMetadata);
@@ -126,7 +121,7 @@ export const AddIntegrationModal = ({
if (!selectedSurvey) { if (!selectedSurvey) {
throw new Error(t("environments.integrations.please_select_a_survey_error")); throw new Error(t("environments.integrations.please_select_a_survey_error"));
} }
if (selectedElements.length === 0) { if (selectedQuestions.length === 0) {
throw new Error(t("environments.integrations.select_at_least_one_question_error")); throw new Error(t("environments.integrations.select_at_least_one_question_error"));
} }
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl); const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
@@ -148,9 +143,9 @@ export const AddIntegrationModal = ({
integrationData.spreadsheetName = spreadsheetName; integrationData.spreadsheetName = spreadsheetName;
integrationData.surveyId = selectedSurvey.id; integrationData.surveyId = selectedSurvey.id;
integrationData.surveyName = selectedSurvey.name; integrationData.surveyName = selectedSurvey.name;
integrationData.elementIds = selectedElements; integrationData.questionIds = selectedQuestions;
integrationData.elements = integrationData.questions =
selectedElements.length === surveyElements.length selectedQuestions.length === selectedSurvey?.questions.length
? t("common.all_questions") ? t("common.all_questions")
: t("common.selected_questions"); : t("common.selected_questions");
integrationData.createdAt = new Date(); integrationData.createdAt = new Date();
@@ -181,7 +176,7 @@ export const AddIntegrationModal = ({
}; };
const handleCheckboxChange = (questionId: TSurveyQuestionId) => { const handleCheckboxChange = (questionId: TSurveyQuestionId) => {
setSelectedElements((prevValues) => setSelectedQuestions((prevValues) =>
prevValues.includes(questionId) prevValues.includes(questionId)
? prevValues.filter((value) => value !== questionId) ? prevValues.filter((value) => value !== questionId)
: [...prevValues, questionId] : [...prevValues, questionId]
@@ -268,7 +263,7 @@ export const AddIntegrationModal = ({
<Label htmlFor="Surveys">{t("common.questions")}</Label> <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="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"> <div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{surveyElements.map((question) => ( {replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2"> <div key={question.id} className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center"> <label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox <Checkbox
@@ -276,17 +271,13 @@ export const AddIntegrationModal = ({
id={question.id} id={question.id}
value={question.id} value={question.id}
className="bg-white" className="bg-white"
checked={selectedElements.includes(question.id)} checked={selectedQuestions.includes(question.id)}
onCheckedChange={() => { onCheckedChange={() => {
handleCheckboxChange(question.id); handleCheckboxChange(question.id);
}} }}
/> />
<span className="ml-2 w-[30rem] truncate"> <span className="ml-2 w-[30rem] truncate">
{getTextContent( {getTextContent(getLocalizedValue(question.headline, "default"))}
recallToHeadline(question.headline, selectedSurvey, false, "default")[
"default"
]
)}
</span> </span>
</label> </label>
</div> </div>

View File

@@ -110,7 +110,7 @@ export const ManageIntegration = ({
}}> }}>
<div className="col-span-2 text-center">{data.surveyName}</div> <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.spreadsheetName}</div>
<div className="col-span-2 text-center">{data.elements}</div> <div className="col-span-2 text-center">{data.questions}</div>
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div> <div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
</button> </button>
); );

View File

@@ -12,8 +12,7 @@ import {
TIntegrationNotionConfigData, TIntegrationNotionConfigData,
TIntegrationNotionDatabase, TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion"; } from "@formbricks/types/integration/notion";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions"; import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { import {
@@ -22,10 +21,10 @@ import {
UNSUPPORTED_TYPES_BY_NOTION, UNSUPPORTED_TYPES_BY_NOTION,
} from "@/app/(app)/environments/[environmentId]/project/integrations/notion/constants"; } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/constants";
import NotionLogo from "@/images/notion.png"; import NotionLogo from "@/images/notion.png";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone"; import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { recallToHeadline } from "@/lib/utils/recall"; import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { getElementTypes } from "@/modules/survey/lib/elements";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { import {
Dialog, Dialog,
@@ -39,59 +38,6 @@ import {
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector"; import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label"; 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 { interface AddIntegrationModalProps {
environmentId: string; environmentId: string;
surveys: TSurvey[]; surveys: TSurvey[];
@@ -118,7 +64,7 @@ export const AddIntegrationModal = ({
const [mapping, setMapping] = useState< const [mapping, setMapping] = useState<
{ {
column: { id: string; name: string; type: string }; column: { id: string; name: string; type: string };
element: { id: string; name: string; type: string }; question: { id: string; name: string; type: string };
error?: { error?: {
type: string; type: string;
msg: React.ReactNode | string; msg: React.ReactNode | string;
@@ -127,7 +73,7 @@ export const AddIntegrationModal = ({
>([ >([
{ {
column: { id: "", name: "", type: "" }, column: { id: "", name: "", type: "" },
element: { id: "", name: "", type: "" }, question: { id: "", name: "", type: "" },
}, },
]); ]);
const [isDeleting, setIsDeleting] = useState<boolean>(false); const [isDeleting, setIsDeleting] = useState<boolean>(false);
@@ -140,17 +86,12 @@ export const AddIntegrationModal = ({
mapping: [ mapping: [
{ {
column: { id: "", name: "", type: "" }, column: { id: "", name: "", type: "" },
element: { id: "", name: "", type: "" }, question: { id: "", name: "", type: "" },
}, },
], ],
createdAt: new Date(), createdAt: new Date(),
}; };
const elements = useMemo(
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
[selectedSurvey]
);
const notionIntegrationData: TIntegrationInput = { const notionIntegrationData: TIntegrationInput = {
type: "notion", type: "notion",
config: { config: {
@@ -178,12 +119,12 @@ export const AddIntegrationModal = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDatabase?.id]); }, [selectedDatabase?.id]);
const elementItems = useMemo(() => { const questionItems = useMemo(() => {
const mappedElements = selectedSurvey const questions = selectedSurvey
? elements.map((el) => ({ ? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
id: el.id, id: q.id,
name: getTextContent(recallToHeadline(el.headline, selectedSurvey, false, "default")["default"]), name: getTextContent(getLocalizedValue(q.headline, "default")),
type: el.type, type: q.type,
})) }))
: []; : [];
@@ -191,31 +132,31 @@ export const AddIntegrationModal = ({
selectedSurvey?.variables.map((variable) => ({ selectedSurvey?.variables.map((variable) => ({
id: variable.id, id: variable.id,
name: variable.name, name: variable.name,
type: TSurveyElementTypeEnum.OpenText, type: TSurveyQuestionTypeEnum.OpenText,
})) || []; })) || [];
const hiddenFields = const hiddenFields =
selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({ selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
id: fId, id: fId,
name: `${t("common.hidden_field")} : ${fId}`, name: `${t("common.hidden_field")} : ${fId}`,
type: TSurveyElementTypeEnum.OpenText, type: TSurveyQuestionTypeEnum.OpenText,
})) || []; })) || [];
const Metadata = [ const Metadata = [
{ {
id: "metadata", id: "metadata",
name: t("common.metadata"), name: t("common.metadata"),
type: TSurveyElementTypeEnum.OpenText, type: TSurveyQuestionTypeEnum.OpenText,
}, },
]; ];
const createdAt = [ const createdAt = [
{ {
id: "createdAt", id: "createdAt",
name: t("common.created_at"), name: t("common.created_at"),
type: TSurveyElementTypeEnum.Date, type: TSurveyQuestionTypeEnum.Date,
}, },
]; ];
return [...mappedElements, ...variables, ...hiddenFields, ...Metadata, ...createdAt]; return [...questions, ...variables, ...hiddenFields, ...Metadata, ...createdAt];
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSurvey?.id]); }, [selectedSurvey?.id]);
@@ -249,7 +190,7 @@ export const AddIntegrationModal = ({
throw new Error(t("environments.integrations.please_select_a_survey_error")); throw new Error(t("environments.integrations.please_select_a_survey_error"));
} }
if (mapping.length === 1 && (!mapping[0].element.id || !mapping[0].column.id)) { if (mapping.length === 1 && (!mapping[0].question.id || !mapping[0].column.id)) {
throw new Error(t("environments.integrations.notion.please_select_at_least_one_mapping")); throw new Error(t("environments.integrations.notion.please_select_at_least_one_mapping"));
} }
@@ -258,8 +199,8 @@ export const AddIntegrationModal = ({
} }
if ( if (
mapping.filter((m) => m.column.id && !m.element.id).length >= 1 || mapping.filter((m) => m.column.id && !m.question.id).length >= 1 ||
mapping.filter((m) => m.element.id && !m.column.id).length >= 1 mapping.filter((m) => m.question.id && !m.column.id).length >= 1
) { ) {
throw new Error( throw new Error(
t("environments.integrations.notion.please_complete_mapping_fields_with_notion_property") t("environments.integrations.notion.please_complete_mapping_fields_with_notion_property")
@@ -320,23 +261,23 @@ export const AddIntegrationModal = ({
setSelectedDatabase(null); setSelectedDatabase(null);
setSelectedSurvey(null); setSelectedSurvey(null);
}; };
const getFilteredElementItems = (selectedIdx) => { const getFilteredQuestionItems = (selectedIdx) => {
const selectedElementIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.element.id); const selectedQuestionIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.question.id);
return elementItems.filter((el) => !selectedElementIds.includes(el.id)); return questionItems.filter((q) => !selectedQuestionIds.includes(q.id));
}; };
const createCopy = (item) => structuredClone(item); const createCopy = (item) => structuredClone(item);
const MappingRow = ({ idx }: { idx: number }) => { const MappingRow = ({ idx }: { idx: number }) => {
const filteredElementItems = getFilteredElementItems(idx); const filteredQuestionItems = getFilteredQuestionItems(idx);
const addRow = () => { const addRow = () => {
setMapping((prev) => [ setMapping((prev) => [
...prev, ...prev,
{ {
column: { id: "", name: "", type: "" }, column: { id: "", name: "", type: "" },
element: { id: "", name: "", type: "" }, question: { id: "", name: "", type: "" },
}, },
]); ]);
}; };
@@ -347,6 +288,49 @@ 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 getFilteredDbItems = () => {
const colMapping = mapping.map((m) => m.column.id); const colMapping = mapping.map((m) => m.column.id);
return dbItems.filter((item) => !colMapping.includes(item.id)); return dbItems.filter((item) => !colMapping.includes(item.id));
@@ -354,20 +338,19 @@ export const AddIntegrationModal = ({
return ( return (
<div className="w-full"> <div className="w-full">
<MappingErrorMessage <ErrorMsg
key={idx} key={idx}
error={mapping[idx]?.error} error={mapping[idx]?.error}
col={mapping[idx].column} col={mapping[idx].column}
elem={mapping[idx].element} ques={mapping[idx].question}
t={t}
/> />
<div className="flex w-full items-center space-x-2"> <div className="flex w-full items-center space-x-2">
<div className="flex w-full items-center"> <div className="flex w-full items-center">
<div className="max-w-full flex-1"> <div className="max-w-full flex-1">
<DropdownSelector <DropdownSelector
placeholder={t("environments.integrations.notion.select_a_survey_question")} placeholder={t("environments.integrations.notion.select_a_survey_question")}
items={filteredElementItems} items={filteredQuestionItems}
selectedItem={mapping?.[idx]?.element} selectedItem={mapping?.[idx]?.question}
setSelectedItem={(item) => { setSelectedItem={(item) => {
setMapping((prev) => { setMapping((prev) => {
const copy = createCopy(prev); const copy = createCopy(prev);
@@ -379,7 +362,7 @@ export const AddIntegrationModal = ({
error: { error: {
type: ERRORS.UNSUPPORTED_TYPE, type: ERRORS.UNSUPPORTED_TYPE,
}, },
element: item, question: item,
}; };
return copy; return copy;
} }
@@ -391,7 +374,7 @@ export const AddIntegrationModal = ({
error: { error: {
type: ERRORS.MAPPING, type: ERRORS.MAPPING,
}, },
element: item, question: item,
}; };
return copy; return copy;
} }
@@ -399,13 +382,13 @@ export const AddIntegrationModal = ({
copy[idx] = { copy[idx] = {
...copy[idx], ...copy[idx],
element: item, question: item,
error: null, error: null,
}; };
return copy; return copy;
}); });
}} }}
disabled={elementItems.length === 0} disabled={questionItems.length === 0}
/> />
</div> </div>
<div className="h-px w-4 border-t border-t-slate-300" /> <div className="h-px w-4 border-t border-t-slate-300" />
@@ -417,9 +400,9 @@ export const AddIntegrationModal = ({
setSelectedItem={(item) => { setSelectedItem={(item) => {
setMapping((prev) => { setMapping((prev) => {
const copy = createCopy(prev); const copy = createCopy(prev);
const elem = copy[idx].element; const ques = copy[idx].question;
if (elem.id) { if (ques.id) {
const isValidElemType = TYPE_MAPPING[elem.type].includes(item.type); const isValidQuesType = TYPE_MAPPING[ques.type].includes(item.type);
if (UNSUPPORTED_TYPES_BY_NOTION.includes(item.type)) { if (UNSUPPORTED_TYPES_BY_NOTION.includes(item.type)) {
copy[idx] = { copy[idx] = {
@@ -432,7 +415,7 @@ export const AddIntegrationModal = ({
return copy; return copy;
} }
if (!isValidElemType) { if (!isValidQuesType) {
copy[idx] = { copy[idx] = {
...copy[idx], ...copy[idx],
error: { error: {

View File

@@ -13,12 +13,12 @@ import {
TIntegrationSlackConfigData, TIntegrationSlackConfigData,
TIntegrationSlackInput, TIntegrationSlackInput,
} from "@formbricks/types/integration/slack"; } from "@formbricks/types/integration/slack";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions"; import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import SlackLogo from "@/images/slacklogo.png"; import SlackLogo from "@/images/slacklogo.png";
import { recallToHeadline } from "@/lib/utils/recall"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings"; import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox"; import { Checkbox } from "@/modules/ui/components/checkbox";
@@ -55,7 +55,7 @@ export const AddChannelMappingModal = ({
}: AddChannelMappingModalProps) => { }: AddChannelMappingModalProps) => {
const { handleSubmit } = useForm(); const { handleSubmit } = useForm();
const { t } = useTranslation(); const { t } = useTranslation();
const [selectedElements, setSelectedElements] = useState<string[]>([]); const [selectedQuestions, setSelectedQuestions] = useState<string[]>([]);
const [isLinkingChannel, setIsLinkingChannel] = useState(false); const [isLinkingChannel, setIsLinkingChannel] = useState(false);
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null); const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
const [selectedChannel, setSelectedChannel] = useState<TIntegrationItem | null>(null); const [selectedChannel, setSelectedChannel] = useState<TIntegrationItem | null>(null);
@@ -73,19 +73,14 @@ export const AddChannelMappingModal = ({
}, },
}; };
const surveyElements = useMemo(
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
[selectedSurvey]
);
useEffect(() => { useEffect(() => {
if (selectedSurvey) { if (selectedSurvey) {
const elementIds = surveyElements.map((element) => element.id); const questionIds = selectedSurvey.questions.map((question) => question.id);
if (!selectedIntegration) { if (!selectedIntegration) {
setSelectedElements(elementIds); setSelectedQuestions(questionIds);
} }
} }
}, [surveyElements, selectedIntegration, selectedSurvey]); }, [selectedIntegration, selectedSurvey]);
useEffect(() => { useEffect(() => {
if (selectedIntegration) { if (selectedIntegration) {
@@ -98,7 +93,7 @@ export const AddChannelMappingModal = ({
return survey.id === selectedIntegration.surveyId; return survey.id === selectedIntegration.surveyId;
})! })!
); );
setSelectedElements(selectedIntegration.elementIds); setSelectedQuestions(selectedIntegration.questionIds);
setIncludeVariables(!!selectedIntegration.includeVariables); setIncludeVariables(!!selectedIntegration.includeVariables);
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields); setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
setIncludeMetadata(!!selectedIntegration.includeMetadata); setIncludeMetadata(!!selectedIntegration.includeMetadata);
@@ -117,7 +112,7 @@ export const AddChannelMappingModal = ({
throw new Error(t("environments.integrations.please_select_a_survey_error")); throw new Error(t("environments.integrations.please_select_a_survey_error"));
} }
if (selectedElements.length === 0) { if (selectedQuestions.length === 0) {
throw new Error(t("environments.integrations.select_at_least_one_question_error")); throw new Error(t("environments.integrations.select_at_least_one_question_error"));
} }
setIsLinkingChannel(true); setIsLinkingChannel(true);
@@ -126,9 +121,9 @@ export const AddChannelMappingModal = ({
channelName: selectedChannel.name, channelName: selectedChannel.name,
surveyId: selectedSurvey.id, surveyId: selectedSurvey.id,
surveyName: selectedSurvey.name, surveyName: selectedSurvey.name,
elementIds: selectedElements, questionIds: selectedQuestions,
elements: questions:
selectedElements.length === surveyElements.length selectedQuestions.length === selectedSurvey?.questions.length
? t("common.all_questions") ? t("common.all_questions")
: t("common.selected_questions"), : t("common.selected_questions"),
createdAt: new Date(), createdAt: new Date(),
@@ -159,11 +154,11 @@ export const AddChannelMappingModal = ({
} }
}; };
const handleCheckboxChange = (elementId: string) => { const handleCheckboxChange = (questionId: TSurveyQuestionId) => {
setSelectedElements((prevValues) => setSelectedQuestions((prevValues) =>
prevValues.includes(elementId) prevValues.includes(questionId)
? prevValues.filter((value) => value !== elementId) ? prevValues.filter((value) => value !== questionId)
: [...prevValues, elementId] : [...prevValues, questionId]
); );
}; };
@@ -274,25 +269,21 @@ export const AddChannelMappingModal = ({
<Label htmlFor="Surveys">{t("common.questions")}</Label> <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="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"> <div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{surveyElements.map((element) => ( {replaceHeadlineRecall(selectedSurvey, "default")?.questions?.map((question) => (
<div key={element.id} className="my-1 flex items-center space-x-2"> <div key={question.id} className="my-1 flex items-center space-x-2">
<label htmlFor={element.id} className="flex cursor-pointer items-center"> <label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox <Checkbox
type="button" type="button"
id={element.id} id={question.id}
value={element.id} value={question.id}
className="bg-white" className="bg-white"
checked={selectedElements.includes(element.id)} checked={selectedQuestions.includes(question.id)}
onCheckedChange={() => { onCheckedChange={() => {
handleCheckboxChange(element.id); handleCheckboxChange(question.id);
}} }}
/> />
<span className="ml-2"> <span className="ml-2">
{getTextContent( {getTextContent(getLocalizedValue(question.headline, "default"))}
recallToHeadline(element.headline, selectedSurvey, false, "default")[
"default"
]
)}
</span> </span>
</label> </label>
</div> </div>

View File

@@ -126,7 +126,7 @@ export const ManageIntegration = ({
}}> }}>
<div className="col-span-2 text-center">{data.surveyName}</div> <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.channelName}</div>
<div className="col-span-2 text-center">{data.elements}</div> <div className="col-span-2 text-center">{data.questions}</div>
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div> <div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
</button> </button>
); );

View File

@@ -36,7 +36,7 @@ export const OrganizationSettingsNavbar = ({
}, },
{ {
id: "teams", id: "teams",
label: t("common.members_and_teams"), label: t("common.teams"),
href: `/environments/${environmentId}/settings/teams`, href: `/environments/${environmentId}/settings/teams`,
current: pathname?.includes("/teams"), current: pathname?.includes("/teams"),
}, },

View File

@@ -1,6 +1,5 @@
import { Metadata } from "next"; import { Metadata } from "next";
import { getServerSession } from "next-auth"; 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 { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -26,7 +25,7 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
}; };
const SurveyLayout = async ({ children }) => { const SurveyLayout = async ({ children }) => {
return <ResponseFilterProvider>{children}</ResponseFilterProvider>; return <>{children}</>;
}; };
export default SurveyLayout; export default SurveyLayout;

View File

@@ -10,7 +10,6 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user"; import { TUser, TUserLocale } from "@formbricks/types/user";
import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable"; import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
interface ResponseDataViewProps { interface ResponseDataViewProps {
survey: TSurvey; survey: TSurvey;
@@ -56,11 +55,9 @@ export const formatContactInfoData = (responseValue: TResponseDataValue): Record
export const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => { export const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => {
const responseData: Record<string, any> = {}; const responseData: Record<string, any> = {};
const elements = getElementsFromBlocks(survey.blocks); for (const question of survey.questions) {
const responseValue = response.data[question.id];
for (const element of elements) { switch (question.type) {
const responseValue = response.data[element.id];
switch (element.type) {
case "matrix": case "matrix":
if (typeof responseValue === "object") { if (typeof responseValue === "object") {
Object.assign(responseData, responseValue); Object.assign(responseData, responseValue);
@@ -73,7 +70,7 @@ export const extractResponseData = (response: TResponseWithQuotas, survey: TSurv
Object.assign(responseData, formatContactInfoData(responseValue)); Object.assign(responseData, formatContactInfoData(responseValue));
break; break;
default: default:
responseData[element.id] = responseValue; responseData[question.id] = responseValue;
} }
} }

View File

@@ -8,8 +8,8 @@ import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user"; 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 { 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 { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter"; import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { getFormattedFilters } from "@/app/lib/surveys/surveys"; import { getFormattedFilters } from "@/app/lib/surveys/surveys";

View File

@@ -5,8 +5,7 @@ import { TFunction } from "i18next";
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react"; import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { TResponseTableData } from "@formbricks/types/responses"; import { TResponseTableData } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements"; import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils"; import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
@@ -14,8 +13,7 @@ import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime"; import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { recallToHeadline } from "@/lib/utils/recall"; import { recallToHeadline } from "@/lib/utils/recall";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse"; import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@/modules/survey/lib/questions";
import { VARIABLES_ICON_MAP, getElementIconMap } from "@/modules/survey/lib/elements";
import { getSelectionColumn } from "@/modules/ui/components/data-table"; import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { IdBadge } from "@/modules/ui/components/id-badge"; import { IdBadge } from "@/modules/ui/components/id-badge";
import { ResponseBadges } from "@/modules/ui/components/response-badges"; import { ResponseBadges } from "@/modules/ui/components/response-badges";
@@ -30,33 +28,35 @@ import {
getMetadataValue, getMetadataValue,
} from "../lib/utils"; } from "../lib/utils";
const getElementColumnsData = ( const getQuestionColumnsData = (
element: TSurveyElement, question: TSurveyQuestion,
survey: TSurvey, survey: TSurvey,
isExpanded: boolean, isExpanded: boolean,
t: TFunction t: TFunction
): ColumnDef<TResponseTableData>[] => { ): ColumnDef<TResponseTableData>[] => {
const ELEMENTS_ICON_MAP = getElementIconMap(t); const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
const addressFields = ["addressLine1", "addressLine2", "city", "state", "zip", "country"]; const addressFields = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"]; const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
// Helper function to create consistent column headers // Helper function to create consistent column headers
const createElementHeader = (elementType: string, headline: string, suffix?: string) => { const createQuestionHeader = (questionType: string, headline: string, suffix?: string) => {
const title = suffix ? `${headline} - ${suffix}` : headline; const title = suffix ? `${headline} - ${suffix}` : headline;
const ElementHeader = () => ( const QuestionHeader = () => (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden"> <div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{ELEMENTS_ICON_MAP[elementType]}</span> <span className="h-4 w-4">{QUESTIONS_ICON_MAP[questionType]}</span>
<span className="truncate">{title}</span> <span className="truncate">{title}</span>
</div> </div>
</div> </div>
); );
return ElementHeader; QuestionHeader.displayName = "QuestionHeader";
return QuestionHeader;
}; };
const getElementHeadline = (element: TSurveyElement, survey: TSurvey) => { // Helper function to get localized question headline
const getQuestionHeadline = (question: TSurveyQuestion, survey: TSurvey) => {
return getTextContent( return getTextContent(
getLocalizedValue(recallToHeadline(element.headline, survey, false, "default"), "default") getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default")
); );
}; };
@@ -75,18 +75,18 @@ const getElementColumnsData = (
); );
}; };
switch (element.type) { switch (question.type) {
case "matrix": case "matrix":
return element.rows.map((matrixRow) => { return question.rows.map((matrixRow) => {
return { return {
accessorKey: "ELEMENT_" + element.id + "_" + matrixRow.label.default, accessorKey: "QUESTION_" + question.id + "_" + matrixRow.label.default,
header: () => { header: () => {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden"> <div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{ELEMENTS_ICON_MAP["matrix"]}</span> <span className="h-4 w-4">{QUESTIONS_ICON_MAP["matrix"]}</span>
<span className="truncate"> <span className="truncate">
{getTextContent(getLocalizedValue(element.headline, "default")) + {getTextContent(getLocalizedValue(question.headline, "default")) +
" - " + " - " +
getLocalizedValue(matrixRow.label, "default")} getLocalizedValue(matrixRow.label, "default")}
</span> </span>
@@ -106,12 +106,12 @@ const getElementColumnsData = (
case "address": case "address":
return addressFields.map((addressField) => { return addressFields.map((addressField) => {
return { return {
accessorKey: "ELEMENT_" + element.id + "_" + addressField, accessorKey: "QUESTION_" + question.id + "_" + addressField,
header: () => { header: () => {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden"> <div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{ELEMENTS_ICON_MAP["address"]}</span> <span className="h-4 w-4">{QUESTIONS_ICON_MAP["address"]}</span>
<span className="truncate">{getAddressFieldLabel(addressField, t)}</span> <span className="truncate">{getAddressFieldLabel(addressField, t)}</span>
</div> </div>
</div> </div>
@@ -129,12 +129,12 @@ const getElementColumnsData = (
case "contactInfo": case "contactInfo":
return contactInfoFields.map((contactInfoField) => { return contactInfoFields.map((contactInfoField) => {
return { return {
accessorKey: "ELEMENT_" + element.id + "_" + contactInfoField, accessorKey: "QUESTION_" + question.id + "_" + contactInfoField,
header: () => { header: () => {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden"> <div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{ELEMENTS_ICON_MAP["contactInfo"]}</span> <span className="h-4 w-4">{QUESTIONS_ICON_MAP["contactInfo"]}</span>
<span className="truncate">{getContactInfoFieldLabel(contactInfoField, t)}</span> <span className="truncate">{getContactInfoFieldLabel(contactInfoField, t)}</span>
</div> </div>
</div> </div>
@@ -153,17 +153,17 @@ const getElementColumnsData = (
case "multipleChoiceSingle": case "multipleChoiceSingle":
case "ranking": case "ranking":
case "pictureSelection": { case "pictureSelection": {
const elementHeadline = getElementHeadline(element, survey); const questionHeadline = getQuestionHeadline(question, survey);
return [ return [
{ {
accessorKey: "ELEMENT_" + element.id, accessorKey: "QUESTION_" + question.id,
header: createElementHeader(element.type, elementHeadline), header: createQuestionHeader(question.type, questionHeadline),
cell: ({ row }) => { cell: ({ row }) => {
const responseValue = row.original.responseData[element.id]; const responseValue = row.original.responseData[question.id];
const language = row.original.language; const language = row.original.language;
return ( return (
<RenderResponse <RenderResponse
element={element} question={question}
survey={survey} survey={survey}
responseData={responseValue} responseData={responseValue}
language={language} language={language}
@@ -174,15 +174,15 @@ const getElementColumnsData = (
}, },
}, },
{ {
accessorKey: "ELEMENT_" + element.id + "optionIds", accessorKey: "QUESTION_" + question.id + "optionIds",
header: createElementHeader(element.type, elementHeadline, t("common.option_id")), header: createQuestionHeader(question.type, questionHeadline, t("common.option_id")),
cell: ({ row }) => { cell: ({ row }) => {
const responseValue = row.original.responseData[element.id]; const responseValue = row.original.responseData[question.id];
// Type guard to ensure responseValue is the correct type // Type guard to ensure responseValue is the correct type
if (typeof responseValue === "string" || Array.isArray(responseValue)) { if (typeof responseValue === "string" || Array.isArray(responseValue)) {
const choiceIds = extractChoiceIdsFromResponse( const choiceIds = extractChoiceIdsFromResponse(
responseValue, responseValue,
element, question,
row.original.language || undefined row.original.language || undefined
); );
return renderChoiceIdBadges(choiceIds, isExpanded); return renderChoiceIdBadges(choiceIds, isExpanded);
@@ -196,25 +196,28 @@ const getElementColumnsData = (
default: default:
return [ return [
{ {
accessorKey: "ELEMENT_" + element.id, accessorKey: "QUESTION_" + question.id,
header: () => ( header: () => (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden"> <div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{ELEMENTS_ICON_MAP[element.type]}</span> <span className="h-4 w-4">{QUESTIONS_ICON_MAP[question.type]}</span>
<span className="truncate"> <span className="truncate">
{getTextContent( {getTextContent(
getLocalizedValue(recallToHeadline(element.headline, survey, false, "default"), "default") getLocalizedValue(
recallToHeadline(question.headline, survey, false, "default"),
"default"
)
)} )}
</span> </span>
</div> </div>
</div> </div>
), ),
cell: ({ row }) => { cell: ({ row }) => {
const responseValue = row.original.responseData[element.id]; const responseValue = row.original.responseData[question.id];
const language = row.original.language; const language = row.original.language;
return ( return (
<RenderResponse <RenderResponse
element={element} question={question}
survey={survey} survey={survey}
responseData={responseValue} responseData={responseValue}
language={language} language={language}
@@ -262,8 +265,9 @@ export const generateResponseTableColumns = (
t: TFunction, t: TFunction,
showQuotasColumn: boolean showQuotasColumn: boolean
): ColumnDef<TResponseTableData>[] => { ): ColumnDef<TResponseTableData>[] => {
const elements = getElementsFromBlocks(survey.blocks); const questionColumns = survey.questions.flatMap((question) =>
const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t)); getQuestionColumnsData(question, survey, isExpanded, t)
);
const dateColumn: ColumnDef<TResponseTableData> = { const dateColumn: ColumnDef<TResponseTableData> = {
accessorKey: "createdAt", accessorKey: "createdAt",
@@ -410,7 +414,7 @@ export const generateResponseTableColumns = (
), ),
}; };
// Combine the selection column with the dynamic element columns // Combine the selection column with the dynamic question columns
const baseColumns = [ const baseColumns = [
personColumn, personColumn,
singleUseIdColumn, singleUseIdColumn,
@@ -418,7 +422,7 @@ export const generateResponseTableColumns = (
...(showQuotasColumn ? [quotasColumn] : []), ...(showQuotasColumn ? [quotasColumn] : []),
statusColumn, statusColumn,
...(survey.isVerifyEmailEnabled ? [verifiedEmailColumn] : []), ...(survey.isVerifyEmailEnabled ? [verifiedEmailColumn] : []),
...elementColumns, ...questionColumns,
...variableColumns, ...variableColumns,
...hiddenFieldColumns, ...hiddenFieldColumns,
...metadataColumns, ...metadataColumns,

View File

@@ -2,27 +2,26 @@
import Link from "next/link"; import Link from "next/link";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummaryAddress } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveyQuestionSummaryAddress } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time"; import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact"; import { getContactIdentifier } from "@/lib/utils/contact";
import { ArrayResponse } from "@/modules/ui/components/array-response"; import { ArrayResponse } from "@/modules/ui/components/array-response";
import { PersonAvatar } from "@/modules/ui/components/avatars"; import { PersonAvatar } from "@/modules/ui/components/avatars";
import { EmptyState } from "@/modules/ui/components/empty-state"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
interface AddressSummaryProps { interface AddressSummaryProps {
elementSummary: TSurveyElementSummaryAddress; questionSummary: TSurveyQuestionSummaryAddress;
environmentId: string; environmentId: string;
survey: TSurvey; survey: TSurvey;
locale: TUserLocale; locale: TUserLocale;
} }
export const AddressSummary = ({ elementSummary, environmentId, survey, locale }: AddressSummaryProps) => { export const AddressSummary = ({ questionSummary, environmentId, survey, locale }: AddressSummaryProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div> <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="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="pl-4 md:pl-6">{t("common.user")}</div>
@@ -30,48 +29,42 @@ export const AddressSummary = ({ elementSummary, environmentId, survey, locale }
<div className="px-4 md:px-6">{t("common.time")}</div> <div className="px-4 md:px-6">{t("common.time")}</div>
</div> </div>
<div className="max-h-[62vh] w-full overflow-y-auto"> <div className="max-h-[62vh] w-full overflow-y-auto">
{elementSummary.samples.length === 0 ? ( {questionSummary.samples.map((response) => {
<div className="p-8"> return (
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" /> <div
</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">
elementSummary.samples.map((response) => { <div className="pl-4 md:pl-6">
return ( {response.contact ? (
<div <Link
key={response.id} className="ph-no-capture group flex items-center"
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"> href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="pl-4 md:pl-6"> <div className="hidden md:flex">
{response.contact ? ( <PersonAvatar personId={response.contact.id} />
<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>
)} <p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
</div> {getContactIdentifier(response.contact, response.contactAttributes)}
<div className="ph-no-capture col-span-2 pl-6 font-semibold"> </p>
<ArrayResponse value={response.value} /> </Link>
</div> ) : (
<div className="group flex items-center">
<div className="px-4 text-slate-500 md:px-6"> <div className="hidden md:flex">
{timeSince(new Date(response.updatedAt).toISOString(), locale)} <PersonAvatar personId="anonymous" />
</div> </div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</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>
</div>
);
})}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,39 +2,39 @@
import { InboxIcon } from "lucide-react"; import { InboxIcon } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummaryCta } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveyQuestionSummaryCta } from "@formbricks/types/surveys/types";
import { ProgressBar } from "@/modules/ui/components/progress-bar"; import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { convertFloatToNDecimal } from "../lib/utils"; import { convertFloatToNDecimal } from "../lib/utils";
import { ElementSummaryHeader } from "./ElementSummaryHeader"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface CTASummaryProps { interface CTASummaryProps {
elementSummary: TSurveyElementSummaryCta; questionSummary: TSurveyQuestionSummaryCta;
survey: TSurvey; survey: TSurvey;
} }
export const CTASummary = ({ elementSummary, survey }: CTASummaryProps) => { export const CTASummary = ({ questionSummary, survey }: CTASummaryProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader <QuestionSummaryHeader
survey={survey} survey={survey}
elementSummary={elementSummary} questionSummary={questionSummary}
showResponses={false} showResponses={false}
additionalInfo={ additionalInfo={
<> <>
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{`${elementSummary.impressionCount} ${t("common.impressions")}`} {`${questionSummary.impressionCount} ${t("common.impressions")}`}
</div> </div>
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{`${elementSummary.clickCount} ${t("common.clicks")}`} {`${questionSummary.clickCount} ${t("common.clicks")}`}
</div> </div>
{!elementSummary.element.required && ( {!questionSummary.question.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{`${elementSummary.skipCount} ${t("common.skips")}`} {`${questionSummary.skipCount} ${t("common.skips")}`}
</div> </div>
)} )}
</> </>
@@ -46,16 +46,16 @@ export const CTASummary = ({ elementSummary, survey }: CTASummaryProps) => {
<p className="font-semibold text-slate-700">CTR</p> <p className="font-semibold text-slate-700">CTR</p>
<div> <div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700"> <p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(elementSummary.ctr.percentage, 2)}% {convertFloatToNDecimal(questionSummary.ctr.percentage, 2)}%
</p> </p>
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary.ctr.count}{" "} {questionSummary.ctr.count}{" "}
{elementSummary.ctr.count === 1 ? t("common.click") : t("common.clicks")} {questionSummary.ctr.count === 1 ? t("common.click") : t("common.clicks")}
</p> </p>
</div> </div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.ctr.percentage / 100} /> <ProgressBar barColor="bg-brand-dark" progress={questionSummary.ctr.percentage / 100} />
</div> </div>
</div> </div>
); );

View File

@@ -1,23 +1,23 @@
"use client"; "use client";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummaryCal } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveyQuestionSummaryCal } from "@formbricks/types/surveys/types";
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils"; import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { ProgressBar } from "@/modules/ui/components/progress-bar"; import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { ElementSummaryHeader } from "./ElementSummaryHeader"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface CalSummaryProps { interface CalSummaryProps {
elementSummary: TSurveyElementSummaryCal; questionSummary: TSurveyQuestionSummaryCal;
environmentId: string; environmentId: string;
survey: TSurvey; survey: TSurvey;
} }
export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => { export const CalSummary = ({ questionSummary, survey }: CalSummaryProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div> <div>
<div className="text flex justify-between px-2 pb-2"> <div className="text flex justify-between px-2 pb-2">
@@ -25,16 +25,16 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
<p className="font-semibold text-slate-700">{t("common.booked")}</p> <p className="font-semibold text-slate-700">{t("common.booked")}</p>
<div> <div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700"> <p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(elementSummary.booked.percentage, 2)}% {convertFloatToNDecimal(questionSummary.booked.percentage, 2)}%
</p> </p>
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary.booked.count}{" "} {questionSummary.booked.count}{" "}
{elementSummary.booked.count === 1 ? t("common.response") : t("common.responses")} {questionSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} /> <ProgressBar barColor="bg-brand-dark" progress={questionSummary.booked.percentage / 100} />
</div> </div>
<div> <div>
<div className="text flex justify-between px-2 pb-2"> <div className="text flex justify-between px-2 pb-2">
@@ -42,16 +42,16 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p> <p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
<div> <div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700"> <p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(elementSummary.skipped.percentage, 2)}% {convertFloatToNDecimal(questionSummary.skipped.percentage, 2)}%
</p> </p>
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary.skipped.count}{" "} {questionSummary.skipped.count}{" "}
{elementSummary.skipped.count === 1 ? t("common.response") : t("common.responses")} {questionSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} /> <ProgressBar barColor="bg-brand-dark" progress={questionSummary.skipped.percentage / 100} />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,42 +1,46 @@
"use client"; "use client";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n"; import {
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; TI18nString,
import { TSurvey, TSurveyElementSummaryConsent } from "@formbricks/types/surveys/types"; TSurvey,
TSurveyQuestionId,
TSurveyQuestionSummaryConsent,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { ProgressBar } from "@/modules/ui/components/progress-bar"; import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { convertFloatToNDecimal } from "../lib/utils"; import { convertFloatToNDecimal } from "../lib/utils";
import { ElementSummaryHeader } from "./ElementSummaryHeader"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface ConsentSummaryProps { interface ConsentSummaryProps {
elementSummary: TSurveyElementSummaryConsent; questionSummary: TSurveyQuestionSummaryConsent;
survey: TSurvey; survey: TSurvey;
setFilter: ( setFilter: (
elementId: string, questionId: TSurveyQuestionId,
label: TI18nString, label: TI18nString,
elementType: TSurveyElementTypeEnum, questionType: TSurveyQuestionTypeEnum,
filterValue: string, filterValue: string,
filterComboBoxValue?: string | string[] filterComboBoxValue?: string | string[]
) => void; ) => void;
} }
export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSummaryProps) => { export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSummaryProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const summaryItems = [ const summaryItems = [
{ {
title: t("common.accepted"), title: t("common.accepted"),
percentage: elementSummary.accepted.percentage, percentage: questionSummary.accepted.percentage,
count: elementSummary.accepted.count, count: questionSummary.accepted.count,
}, },
{ {
title: t("common.dismissed"), title: t("common.dismissed"),
percentage: elementSummary.dismissed.percentage, percentage: questionSummary.dismissed.percentage,
count: elementSummary.dismissed.count, count: questionSummary.dismissed.count,
}, },
]; ];
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{summaryItems.map((summaryItem) => { {summaryItems.map((summaryItem) => {
return ( return (
@@ -45,9 +49,9 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
key={summaryItem.title} key={summaryItem.title}
onClick={() => onClick={() =>
setFilter( setFilter(
elementSummary.element.id, questionSummary.question.id,
elementSummary.element.headline, questionSummary.question.headline,
elementSummary.element.type, questionSummary.question.type,
"is", "is",
summaryItem.title summaryItem.title
) )

View File

@@ -2,24 +2,23 @@
import Link from "next/link"; import Link from "next/link";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummaryContactInfo } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveyQuestionSummaryContactInfo } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time"; import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact"; import { getContactIdentifier } from "@/lib/utils/contact";
import { ArrayResponse } from "@/modules/ui/components/array-response"; import { ArrayResponse } from "@/modules/ui/components/array-response";
import { PersonAvatar } from "@/modules/ui/components/avatars"; import { PersonAvatar } from "@/modules/ui/components/avatars";
import { EmptyState } from "@/modules/ui/components/empty-state"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
interface ContactInfoSummaryProps { interface ContactInfoSummaryProps {
elementSummary: TSurveyElementSummaryContactInfo; questionSummary: TSurveyQuestionSummaryContactInfo;
environmentId: string; environmentId: string;
survey: TSurvey; survey: TSurvey;
locale: TUserLocale; locale: TUserLocale;
} }
export const ContactInfoSummary = ({ export const ContactInfoSummary = ({
elementSummary, questionSummary,
environmentId, environmentId,
survey, survey,
locale, locale,
@@ -27,7 +26,7 @@ export const ContactInfoSummary = ({
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div> <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="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="pl-4 md:pl-6">{t("common.user")}</div>
@@ -35,48 +34,42 @@ export const ContactInfoSummary = ({
<div className="px-4 md:px-6">{t("common.time")}</div> <div className="px-4 md:px-6">{t("common.time")}</div>
</div> </div>
<div className="max-h-[62vh] w-full overflow-y-auto"> <div className="max-h-[62vh] w-full overflow-y-auto">
{elementSummary.samples.length === 0 ? ( {questionSummary.samples.map((response) => {
<div className="p-8"> return (
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" /> <div
</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">
elementSummary.samples.map((response) => { <div className="pl-4 md:pl-6">
return ( {response.contact ? (
<div <Link
key={response.id} className="ph-no-capture group flex items-center"
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"> href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="pl-4 md:pl-6"> <div className="hidden md:flex">
{response.contact ? ( <PersonAvatar personId={response.contact.id} />
<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>
)} <p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
</div> {getContactIdentifier(response.contact, response.contactAttributes)}
<div className="ph-no-capture col-span-2 pl-6 font-semibold"> </p>
<ArrayResponse value={response.value} /> </Link>
</div> ) : (
<div className="group flex items-center">
<div className="px-4 text-slate-500 md:px-6"> <div className="hidden md:flex">
{timeSince(new Date(response.updatedAt).toISOString(), locale)} <PersonAvatar personId="anonymous" />
</div> </div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</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>
</div>
);
})}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,104 +0,0 @@
"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>
);
};

View File

@@ -0,0 +1,102 @@
"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>
);
};

View File

@@ -4,25 +4,24 @@ import { DownloadIcon, FileIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummaryFileUpload } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveyQuestionSummaryFileUpload } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time"; import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact"; import { getContactIdentifier } from "@/lib/utils/contact";
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils"; import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
import { PersonAvatar } from "@/modules/ui/components/avatars"; import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
interface FileUploadSummaryProps { interface FileUploadSummaryProps {
elementSummary: TSurveyElementSummaryFileUpload; questionSummary: TSurveyQuestionSummaryFileUpload;
environmentId: string; environmentId: string;
survey: TSurvey; survey: TSurvey;
locale: TUserLocale; locale: TUserLocale;
} }
export const FileUploadSummary = ({ export const FileUploadSummary = ({
elementSummary, questionSummary,
environmentId, environmentId,
survey, survey,
locale, locale,
@@ -32,13 +31,13 @@ export const FileUploadSummary = ({
const handleLoadMore = () => { const handleLoadMore = () => {
// Increase the number of visible responses by 10, not exceeding the total number of responses // Increase the number of visible responses by 10, not exceeding the total number of responses
setVisibleResponses((prevVisibleResponses) => setVisibleResponses((prevVisibleResponses) =>
Math.min(prevVisibleResponses + 10, elementSummary.files.length) Math.min(prevVisibleResponses + 10, questionSummary.files.length)
); );
}; };
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className=""> <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="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="pl-4 md:pl-6">{t("common.user")}</div>
@@ -46,77 +45,71 @@ export const FileUploadSummary = ({
<div className="px-4 md:px-6">{t("common.time")}</div> <div className="px-4 md:px-6">{t("common.time")}</div>
</div> </div>
<div className="max-h-[62vh] w-full overflow-y-auto"> <div className="max-h-[62vh] w-full overflow-y-auto">
{elementSummary.files.length === 0 ? ( {questionSummary.files.slice(0, visibleResponses).map((response) => (
<div className="p-8"> <div
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" /> key={response.id}
</div> 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">
elementSummary.files.slice(0, visibleResponses).map((response) => ( {response.contact ? (
<div <Link
key={response.id} className="ph-no-capture group flex items-center"
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"> href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="pl-4 md:pl-6"> <div className="hidden md:flex">
{response.contact ? ( <PersonAvatar personId={response.contact.id} />
<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>
)} <p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
</div> {getContactIdentifier(response.contact, response.contactAttributes)}
</p>
<div className="col-span-2 grid"> </Link>
{Array.isArray(response.value) && ) : (
(response.value.length > 0 ? ( <div className="group flex items-center">
response.value.map((fileUrl) => { <div className="hidden md:flex">
const fileName = getOriginalFileNameFromUrl(fileUrl); <PersonAvatar personId="anonymous" />
</div>
return ( <p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}> </div>
<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>
))
)} <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> </div>
{elementSummary.files.length > 0 && visibleResponses < elementSummary.files.length && ( {visibleResponses < questionSummary.files.length && (
<div className="flex justify-center py-4"> <div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm"> <Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")} {t("common.load_more")}

View File

@@ -4,34 +4,33 @@ import { InboxIcon, Link, MessageSquareTextIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TSurveyElementSummaryHiddenFields } from "@formbricks/types/surveys/types"; import { TSurveyQuestionSummaryHiddenFields } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time"; import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact"; import { getContactIdentifier } from "@/lib/utils/contact";
import { PersonAvatar } from "@/modules/ui/components/avatars"; import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
interface HiddenFieldsSummaryProps { interface HiddenFieldsSummaryProps {
environment: TEnvironment; environment: TEnvironment;
elementSummary: TSurveyElementSummaryHiddenFields; questionSummary: TSurveyQuestionSummaryHiddenFields;
locale: TUserLocale; locale: TUserLocale;
} }
export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: HiddenFieldsSummaryProps) => { export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: HiddenFieldsSummaryProps) => {
const [visibleResponses, setVisibleResponses] = useState(10); const [visibleResponses, setVisibleResponses] = useState(10);
const { t } = useTranslation(); const { t } = useTranslation();
const handleLoadMore = () => { const handleLoadMore = () => {
// Increase the number of visible responses by 10, not exceeding the total number of responses // Increase the number of visible responses by 10, not exceeding the total number of responses
setVisibleResponses((prevVisibleResponses) => setVisibleResponses((prevVisibleResponses) =>
Math.min(prevVisibleResponses + 10, elementSummary.samples.length) Math.min(prevVisibleResponses + 10, questionSummary.samples.length)
); );
}; };
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <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="space-y-2 px-4 pb-5 pt-6 md:px-6">
<div className={"align-center flex justify-between gap-4"}> <div className={"align-center flex justify-between gap-4"}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{elementSummary.id}</h3> <h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{questionSummary.id}</h3>
</div> </div>
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm"> <div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
@@ -41,8 +40,8 @@ export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: Hid
</div> </div>
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{elementSummary.responseCount}{" "} {questionSummary.responseCount}{" "}
{elementSummary.responseCount === 1 ? t("common.response") : t("common.responses")} {questionSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
</div> </div>
</div> </div>
</div> </div>
@@ -52,46 +51,40 @@ export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: Hid
<div className="col-span-2 pl-4 md:pl-6">{t("common.response")}</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 className="px-4 md:px-6">{t("common.time")}</div>
</div> </div>
{elementSummary.samples.length === 0 ? ( {questionSummary.samples.slice(0, visibleResponses).map((response, idx) => (
<div className="p-8"> <div
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" /> key={`${response.value}-${idx}`}
</div> 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">
elementSummary.samples.slice(0, visibleResponses).map((response, idx) => ( {response.contact ? (
<div <Link
key={`${response.value}-${idx}`} className="ph-no-capture group flex items-center"
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base"> href={`/environments/${environment.id}/contacts/${response.contact.id}`}>
<div className="pl-4 md:pl-6"> <div className="hidden md:flex">
{response.contact ? ( <PersonAvatar personId={response.contact.id} />
<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>
)} <p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
</div> {getContactIdentifier(response.contact, response.contactAttributes)}
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold"> </p>
{response.value} </Link>
</div> ) : (
<div className="px-4 text-slate-500 md:px-6"> <div className="group flex items-center">
{timeSince(new Date(response.updatedAt).toISOString(), locale)} <div className="hidden md:flex">
</div> <PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</div> </div>
)) <div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
)} {response.value}
{elementSummary.samples.length > 0 && visibleResponses < elementSummary.samples.length && ( </div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div>
))}
{visibleResponses < questionSummary.samples.length && (
<div className="flex justify-center py-4"> <div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm"> <Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")} {t("common.load_more")}

View File

@@ -1,25 +1,29 @@
"use client"; "use client";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n"; import {
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; TI18nString,
import { TSurvey, TSurveyElementSummaryMatrix } from "@formbricks/types/surveys/types"; TSurvey,
TSurveyQuestionId,
TSurveyQuestionSummaryMatrix,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TooltipRenderer } from "@/modules/ui/components/tooltip"; import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { ElementSummaryHeader } from "./ElementSummaryHeader"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface MatrixElementSummaryProps { interface MatrixQuestionSummaryProps {
elementSummary: TSurveyElementSummaryMatrix; questionSummary: TSurveyQuestionSummaryMatrix;
survey: TSurvey; survey: TSurvey;
setFilter: ( setFilter: (
elementId: string, questionId: TSurveyQuestionId,
label: TI18nString, label: TI18nString,
elementType: TSurveyElementTypeEnum, questionType: TSurveyQuestionTypeEnum,
filterValue: string, filterValue: string,
filterComboBoxValue?: string | string[] filterComboBoxValue?: string | string[]
) => void; ) => void;
} }
export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: MatrixElementSummaryProps) => { export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: MatrixQuestionSummaryProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const getOpacityLevel = (percentage: number): string => { const getOpacityLevel = (percentage: number): string => {
const parsedPercentage = percentage; const parsedPercentage = percentage;
@@ -36,11 +40,13 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
return ""; return "";
}; };
const columns = elementSummary.data[0] ? elementSummary.data[0].columnPercentages.map((c) => c.column) : []; const columns = questionSummary.data[0]
? questionSummary.data[0].columnPercentages.map((c) => c.column)
: [];
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="overflow-x-auto p-6"> <div className="overflow-x-auto p-6">
{/* Summary Table */} {/* Summary Table */}
<table className="mx-auto border-collapse cursor-default text-left"> <table className="mx-auto border-collapse cursor-default text-left">
@@ -57,7 +63,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{elementSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => ( {questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
<tr key={rowLabel}> <tr key={rowLabel}>
<td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4"> <td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4">
<TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}> <TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
@@ -73,16 +79,16 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
tooltipContent={getTooltipContent( tooltipContent={getTooltipContent(
undefined, undefined,
percentage, percentage,
elementSummary.data[rowIndex].totalResponsesForRow questionSummary.data[rowIndex].totalResponsesForRow
)}> )}>
<button <button
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }} 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" 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={() => onClick={() =>
setFilter( setFilter(
elementSummary.element.id, questionSummary.question.id,
elementSummary.element.headline, questionSummary.question.headline,
elementSummary.element.type, questionSummary.question.type,
rowLabel, rowLabel,
column column
) )

View File

@@ -4,9 +4,14 @@ import { InboxIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Fragment, useState } from "react"; import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TI18nString } from "@formbricks/types/i18n"; import {
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; TI18nString,
import { TSurvey, TSurveyElementSummaryMultipleChoice, TSurveyType } from "@formbricks/types/surveys/types"; TSurvey,
TSurveyQuestionId,
TSurveyQuestionSummaryMultipleChoice,
TSurveyQuestionTypeEnum,
TSurveyType,
} from "@formbricks/types/surveys/types";
import { getChoiceIdByValue } from "@/lib/response/utils"; import { getChoiceIdByValue } from "@/lib/response/utils";
import { getContactIdentifier } from "@/lib/utils/contact"; import { getContactIdentifier } from "@/lib/utils/contact";
import { PersonAvatar } from "@/modules/ui/components/avatars"; import { PersonAvatar } from "@/modules/ui/components/avatars";
@@ -14,24 +19,24 @@ import { Button } from "@/modules/ui/components/button";
import { IdBadge } from "@/modules/ui/components/id-badge"; import { IdBadge } from "@/modules/ui/components/id-badge";
import { ProgressBar } from "@/modules/ui/components/progress-bar"; import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { convertFloatToNDecimal } from "../lib/utils"; import { convertFloatToNDecimal } from "../lib/utils";
import { ElementSummaryHeader } from "./ElementSummaryHeader"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface MultipleChoiceSummaryProps { interface MultipleChoiceSummaryProps {
elementSummary: TSurveyElementSummaryMultipleChoice; questionSummary: TSurveyQuestionSummaryMultipleChoice;
environmentId: string; environmentId: string;
surveyType: TSurveyType; surveyType: TSurveyType;
survey: TSurvey; survey: TSurvey;
setFilter: ( setFilter: (
elementId: string, questionId: TSurveyQuestionId,
label: TI18nString, label: TI18nString,
elementType: TSurveyElementTypeEnum, questionType: TSurveyQuestionTypeEnum,
filterValue: string, filterValue: string,
filterComboBoxValue?: string | string[] filterComboBoxValue?: string | string[]
) => void; ) => void;
} }
export const MultipleChoiceSummary = ({ export const MultipleChoiceSummary = ({
elementSummary, questionSummary,
environmentId, environmentId,
surveyType, surveyType,
survey, survey,
@@ -39,9 +44,9 @@ export const MultipleChoiceSummary = ({
}: MultipleChoiceSummaryProps) => { }: MultipleChoiceSummaryProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [visibleOtherResponses, setVisibleOtherResponses] = useState(10); const [visibleOtherResponses, setVisibleOtherResponses] = useState(10);
const otherValue = elementSummary.element.choices.find((choice) => choice.id === "other")?.label.default; const otherValue = questionSummary.question.choices.find((choice) => choice.id === "other")?.label.default;
// sort by count and transform to array // sort by count and transform to array
const results = Object.values(elementSummary.choices).sort((a, b) => { const results = Object.values(questionSummary.choices).sort((a, b) => {
const aHasOthers = (a.others?.length ?? 0) > 0; const aHasOthers = (a.others?.length ?? 0) > 0;
const bHasOthers = (b.others?.length ?? 0) > 0; const bHasOthers = (b.others?.length ?? 0) > 0;
@@ -68,111 +73,108 @@ export const MultipleChoiceSummary = ({
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader <QuestionSummaryHeader
elementSummary={elementSummary} questionSummary={questionSummary}
survey={survey} survey={survey}
additionalInfo={ additionalInfo={
elementSummary.type === "multipleChoiceMulti" ? ( questionSummary.type === "multipleChoiceMulti" ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{`${elementSummary.selectionCount} ${t("common.selections")}`} {`${questionSummary.selectionCount} ${t("common.selections")}`}
</div> </div>
) : undefined ) : undefined
} }
/> />
<div className="px-4 pb-6 pt-4 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="space-y-5"> {results.map((result) => {
{results.map((result) => { const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
const choiceId = getChoiceIdByValue(result.value, elementSummary.element); return (
return ( <Fragment key={result.value}>
<Fragment key={result.value}> <button
<button type="button"
type="button" className="group w-full cursor-pointer"
className="group w-full cursor-pointer" onClick={() =>
onClick={() => setFilter(
setFilter( questionSummary.question.id,
elementSummary.element.id, questionSummary.question.headline,
elementSummary.element.headline, questionSummary.question.type,
elementSummary.element.type, questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
elementSummary.type === TSurveyElementTypeEnum.MultipleChoiceSingle || ? t("environments.surveys.summary.includes_either")
otherValue === result.value : t("environments.surveys.summary.includes_all"),
? t("environments.surveys.summary.includes_either") [result.value]
: 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">
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row"> <p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal"> {result.value}
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline"> </p>
{result.value} {choiceId && <IdBadge id={choiceId} />}
</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>
<div className="group-hover:opacity-80"> <div className="flex w-full space-x-2">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} /> <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>
</button> </div>
{result.others && result.others.length > 0 && ( <div className="group-hover:opacity-80">
<div className="mt-4 rounded-lg border border-slate-200"> <ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
<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>
<div className="col-span-1 pl-6"> </button>
{t("environments.surveys.summary.other_values_found")} {result.others && result.others.length > 0 && (
</div> <div className="mt-4 rounded-lg border border-slate-200">
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div> <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>
{result.others <div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
.filter((otherValue) => otherValue.value !== "") </div>
.slice(0, visibleOtherResponses) {result.others
.map((otherValue, idx) => ( .filter((otherValue) => otherValue.value !== "")
<div key={`${idx}-${otherValue}`} dir="auto"> .slice(0, visibleOtherResponses)
{surveyType === "link" && ( .map((otherValue, idx) => (
<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"> <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">
<span>{otherValue.value}</span> <span>{otherValue.value}</span>
</div> </div>
)} <div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
{surveyType === "app" && otherValue.contact && ( {otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
<Link <span>
href={ {getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
otherValue.contact.id </span>
? `/environments/${environmentId}/contacts/${otherValue.contact.id}` </div>
: { pathname: null } </Link>
} )}
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> </div>
)} ))}
</div> {visibleOtherResponses < result.others.length && (
)} <div className="flex justify-center py-4">
</Fragment> <Button onClick={handleLoadMore} variant="secondary" size="sm">
); {t("common.load_more")}
})} </Button>
</div> </div>
)}
</div>
)}
</Fragment>
);
})}
</div> </div>
</div> </div>
); );

View File

@@ -3,24 +3,28 @@
import { BarChart, BarChartHorizontal } from "lucide-react"; import { BarChart, BarChartHorizontal } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n"; import {
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; TI18nString,
import { TSurvey, TSurveyElementSummaryNps } from "@formbricks/types/surveys/types"; TSurvey,
TSurveyQuestionId,
TSurveyQuestionSummaryNps,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { HalfCircle, ProgressBar } from "@/modules/ui/components/progress-bar"; import { HalfCircle, ProgressBar } from "@/modules/ui/components/progress-bar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { TooltipProvider } from "@/modules/ui/components/tooltip"; import { TooltipProvider } from "@/modules/ui/components/tooltip";
import { convertFloatToNDecimal } from "../lib/utils"; import { convertFloatToNDecimal } from "../lib/utils";
import { ClickableBarSegment } from "./ClickableBarSegment"; import { ClickableBarSegment } from "./ClickableBarSegment";
import { ElementSummaryHeader } from "./ElementSummaryHeader"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
import { SatisfactionIndicator } from "./SatisfactionIndicator"; import { SatisfactionIndicator } from "./SatisfactionIndicator";
interface NPSSummaryProps { interface NPSSummaryProps {
elementSummary: TSurveyElementSummaryNps; questionSummary: TSurveyQuestionSummaryNps;
survey: TSurvey; survey: TSurvey;
setFilter: ( setFilter: (
elementId: string, questionId: TSurveyQuestionId,
label: TI18nString, label: TI18nString,
elementType: TSurveyElementTypeEnum, questionType: TSurveyQuestionTypeEnum,
filterValue: string, filterValue: string,
filterComboBoxValue?: string | string[] filterComboBoxValue?: string | string[]
) => void; ) => void;
@@ -36,7 +40,7 @@ const calculateNPSOpacity = (rating: number): number => {
return 0.8 + ((rating - 8) / 2) * 0.2; return 0.8 + ((rating - 8) / 2) * 0.2;
}; };
export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProps) => { export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated"); const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
@@ -64,9 +68,9 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
if (filter) { if (filter) {
setFilter( setFilter(
elementSummary.element.id, questionSummary.question.id,
elementSummary.element.headline, questionSummary.question.headline,
elementSummary.element.type, questionSummary.question.type,
filter.comparison, filter.comparison,
filter.values filter.values
); );
@@ -75,15 +79,15 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader <QuestionSummaryHeader
elementSummary={elementSummary} questionSummary={questionSummary}
survey={survey} survey={survey}
additionalInfo={ additionalInfo={
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2"> <div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.promoters.percentage} /> <SatisfactionIndicator percentage={questionSummary.promoters.percentage} />
<div> <div>
{t("environments.surveys.summary.promoters")}:{" "} {t("environments.surveys.summary.promoters")}:{" "}
{convertFloatToNDecimal(elementSummary.promoters.percentage, 2)}% {convertFloatToNDecimal(questionSummary.promoters.percentage, 2)}%
</div> </div>
</div> </div>
} }
@@ -102,45 +106,43 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
</div> </div>
<TabsContent value="aggregated" className="mt-4"> <TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6"> <div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="space-y-5 text-sm md:text-base"> {["promoters", "passives", "detractors", "dismissed"].map((group) => (
{["promoters", "passives", "detractors", "dismissed"].map((group) => ( <button
<button className="w-full cursor-pointer hover:opacity-80"
className="w-full cursor-pointer hover:opacity-80" key={group}
key={group} onClick={() => applyFilter(group)}>
onClick={() => applyFilter(group)}> <div
<div className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
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">
<div className="mr-8 flex space-x-1"> <p
<p className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}> {group}
{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> </p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
</p>
</div>
</div> </div>
<ProgressBar <p className="flex w-32 items-end justify-end text-slate-600">
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"} {questionSummary[group]?.count}{" "}
progress={elementSummary[group]?.percentage / 100} {questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
/> </p>
</button> </div>
))} <ProgressBar
</div> barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={questionSummary[group]?.percentage / 100}
/>
</button>
))}
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="individual" className="mt-4"> <TabsContent value="individual" className="mt-4">
<TooltipProvider delayDuration={200}> <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"> <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) => { {questionSummary.choices.map((choice) => {
const opacity = calculateNPSOpacity(choice.rating); const opacity = calculateNPSOpacity(choice.rating);
return ( return (
@@ -149,9 +151,9 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
className="group flex cursor-pointer flex-col items-center" className="group flex cursor-pointer flex-col items-center"
onClick={() => onClick={() =>
setFilter( setFilter(
elementSummary.element.id, questionSummary.question.id,
elementSummary.element.headline, questionSummary.question.headline,
elementSummary.element.type, questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"), t("environments.surveys.summary.is_equal_to"),
choice.rating.toString() choice.rating.toString()
) )
@@ -183,7 +185,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
</Tabs> </Tabs>
<div className="flex justify-center pb-4 pt-4"> <div className="flex justify-center pb-4 pt-4">
<HalfCircle value={elementSummary.score} /> <HalfCircle value={questionSummary.score} />
</div> </div>
</div> </div>
); );

View File

@@ -3,98 +3,91 @@
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummaryOpenText } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time"; import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact"; import { getContactIdentifier } from "@/lib/utils/contact";
import { renderHyperlinkedContent } from "@/modules/analysis/utils"; import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { PersonAvatar } from "@/modules/ui/components/avatars"; import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { ElementSummaryHeader } from "./ElementSummaryHeader"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface OpenTextSummaryProps { interface OpenTextSummaryProps {
elementSummary: TSurveyElementSummaryOpenText; questionSummary: TSurveyQuestionSummaryOpenText;
environmentId: string; environmentId: string;
survey: TSurvey; survey: TSurvey;
locale: TUserLocale; locale: TUserLocale;
} }
export const OpenTextSummary = ({ elementSummary, environmentId, survey, locale }: OpenTextSummaryProps) => { export const OpenTextSummary = ({ questionSummary, environmentId, survey, locale }: OpenTextSummaryProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [visibleResponses, setVisibleResponses] = useState(10); const [visibleResponses, setVisibleResponses] = useState(10);
const handleLoadMore = () => { const handleLoadMore = () => {
// Increase the number of visible responses by 10, not exceeding the total number of responses // Increase the number of visible responses by 10, not exceeding the total number of responses
setVisibleResponses((prevVisibleResponses) => setVisibleResponses((prevVisibleResponses) =>
Math.min(prevVisibleResponses + 10, elementSummary.samples.length) Math.min(prevVisibleResponses + 10, questionSummary.samples.length)
); );
}; };
return ( return (
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="border-t border-slate-200"></div> <div className="border-t border-slate-200"></div>
{elementSummary.samples.length === 0 ? ( <div className="max-h-[40vh] overflow-y-auto">
<div className="p-8"> <Table>
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" /> <TableHeader className="bg-slate-100">
</div> <TableRow>
) : ( <TableHead>{t("common.user")}</TableHead>
<div className="max-h-[40vh] overflow-y-auto"> <TableHead>{t("common.response")}</TableHead>
<Table> <TableHead>{t("common.time")}</TableHead>
<TableHeader className="bg-slate-100"> </TableRow>
<TableRow> </TableHeader>
<TableHead className="w-1/4">{t("common.user")}</TableHead> <TableBody>
<TableHead className="w-2/4">{t("common.response")}</TableHead> {questionSummary.samples.slice(0, visibleResponses).map((response) => (
<TableHead className="w-1/4">{t("common.time")}</TableHead> <TableRow key={response.id}>
</TableRow> <TableCell>
</TableHeader> {response.contact ? (
<TableBody> <Link
{elementSummary.samples.slice(0, visibleResponses).map((response) => ( className="ph-no-capture group flex items-center"
<TableRow key={response.id}> href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<TableCell className="w-1/4"> <div className="hidden md:flex">
{response.contact ? ( <PersonAvatar personId={response.contact.id} />
<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> </div>
)} <p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
</TableCell> {getContactIdentifier(response.contact, response.contactAttributes)}
<TableCell className="w-2/4 font-medium"> </p>
{typeof response.value === "string" </Link>
? renderHyperlinkedContent(response.value) ) : (
: response.value} <div className="group flex items-center">
</TableCell> <div className="hidden md:flex">
<TableCell className="w-1/4"> <PersonAvatar personId="anonymous" />
{timeSince(new Date(response.updatedAt).toISOString(), locale)} </div>
</TableCell> <p className="break-normal text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</TableRow> </div>
))} )}
</TableBody> </TableCell>
</Table> <TableCell className="font-medium">
{visibleResponses < elementSummary.samples.length && ( {typeof response.value === "string"
<div className="flex justify-center py-4"> ? renderHyperlinkedContent(response.value)
<Button onClick={handleLoadMore} variant="secondary" size="sm"> : response.value}
{t("common.load_more")} </TableCell>
</Button> <TableCell width={120}>
</div> {timeSince(new Date(response.updatedAt).toISOString(), locale)}
)} </TableCell>
</div> </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>
</div> </div>
); );
}; };

View File

@@ -3,48 +3,52 @@
import { InboxIcon } from "lucide-react"; import { InboxIcon } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n"; import {
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; TI18nString,
import { TSurvey, TSurveyElementSummaryPictureSelection } from "@formbricks/types/surveys/types"; TSurvey,
TSurveyQuestionId,
TSurveyQuestionSummaryPictureSelection,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { getChoiceIdByValue } from "@/lib/response/utils"; import { getChoiceIdByValue } from "@/lib/response/utils";
import { IdBadge } from "@/modules/ui/components/id-badge"; import { IdBadge } from "@/modules/ui/components/id-badge";
import { ProgressBar } from "@/modules/ui/components/progress-bar"; import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { convertFloatToNDecimal } from "../lib/utils"; import { convertFloatToNDecimal } from "../lib/utils";
import { ElementSummaryHeader } from "./ElementSummaryHeader"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface PictureChoiceSummaryProps { interface PictureChoiceSummaryProps {
elementSummary: TSurveyElementSummaryPictureSelection; questionSummary: TSurveyQuestionSummaryPictureSelection;
survey: TSurvey; survey: TSurvey;
setFilter: ( setFilter: (
elementId: string, questionId: TSurveyQuestionId,
label: TI18nString, label: TI18nString,
elementType: TSurveyElementTypeEnum, questionType: TSurveyQuestionTypeEnum,
filterValue: string, filterValue: string,
filterComboBoxValue?: string | string[] filterComboBoxValue?: string | string[]
) => void; ) => void;
} }
export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: PictureChoiceSummaryProps) => { export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: PictureChoiceSummaryProps) => {
const results = elementSummary.choices; const results = questionSummary.choices;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader <QuestionSummaryHeader
elementSummary={elementSummary} questionSummary={questionSummary}
survey={survey} survey={survey}
additionalInfo={ additionalInfo={
elementSummary.element.allowMulti ? ( questionSummary.question.allowMulti ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{`${elementSummary.selectionCount} ${t("common.selections")}`} {`${questionSummary.selectionCount} ${t("common.selections")}`}
</div> </div>
) : undefined ) : undefined
} }
/> />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, index) => { {results.map((result, index) => {
const choiceId = getChoiceIdByValue(result.imageUrl, elementSummary.element); const choiceId = getChoiceIdByValue(result.imageUrl, questionSummary.question);
return ( return (
<button <button
type="button" type="button"
@@ -52,9 +56,9 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
key={result.id} key={result.id}
onClick={() => onClick={() =>
setFilter( setFilter(
elementSummary.element.id, questionSummary.question.id,
elementSummary.element.headline, questionSummary.question.headline,
elementSummary.element.type, questionSummary.question.type,
t("environments.surveys.summary.includes_all"), t("environments.surveys.summary.includes_all"),
[`${t("environments.surveys.edit.picture_idx", { idx: index + 1 })}`] [`${t("environments.surveys.edit.picture_idx", { idx: index + 1 })}`]
) )

View File

@@ -3,28 +3,28 @@
import { InboxIcon } from "lucide-react"; import { InboxIcon } from "lucide-react";
import type { JSX } from "react"; import type { JSX } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummary } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveyQuestionSummary } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; import { getTextContent } from "@formbricks/types/surveys/validation";
import { recallToHeadline } from "@/lib/utils/recall"; import { recallToHeadline } from "@/lib/utils/recall";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils"; import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { getElementTypes } from "@/modules/survey/lib/elements"; import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { IdBadge } from "@/modules/ui/components/id-badge"; import { IdBadge } from "@/modules/ui/components/id-badge";
interface HeadProps { interface HeadProps {
elementSummary: TSurveyElementSummary; questionSummary: TSurveyQuestionSummary;
showResponses?: boolean; showResponses?: boolean;
additionalInfo?: JSX.Element; additionalInfo?: JSX.Element;
survey: TSurvey; survey: TSurvey;
} }
export const ElementSummaryHeader = ({ export const QuestionSummaryHeader = ({
elementSummary, questionSummary,
additionalInfo, additionalInfo,
showResponses = true, showResponses = true,
survey, survey,
}: HeadProps) => { }: HeadProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const elementType = getElementTypes(t).find((type) => type.id === elementSummary.element.type); const questionType = getQuestionTypes(t).find((type) => type.id === questionSummary.question.type);
return ( return (
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6"> <div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
@@ -32,7 +32,7 @@ export const ElementSummaryHeader = ({
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl"> <h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{formatTextWithSlashes( {formatTextWithSlashes(
getTextContent( getTextContent(
recallToHeadline(elementSummary.element.headline, survey, true, "default")["default"] recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"]
), ),
"@", "@",
["text-lg"] ["text-lg"]
@@ -41,23 +41,23 @@ export const ElementSummaryHeader = ({
</div> </div>
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm"> <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"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
{elementType && <elementType.icon className="mr-2 h-4 w-4" />} {questionType && <questionType.icon className="mr-2 h-4 w-4" />}
{elementType ? elementType.label : t("environments.surveys.summary.unknown_question_type")}{" "} {questionType ? questionType.label : t("environments.surveys.summary.unknown_question_type")}{" "}
{t("common.question")} {t("common.question")}
</div> </div>
{showResponses && ( {showResponses && (
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{`${elementSummary.responseCount} ${t("common.responses")}`} {`${questionSummary.responseCount} ${t("common.responses")}`}
</div> </div>
)} )}
{additionalInfo} {additionalInfo}
{!elementSummary.element.required && ( {!questionSummary.question.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
{t("environments.surveys.edit.optional")} {t("environments.surveys.edit.optional")}
</div> </div>
)} )}
<IdBadge id={elementSummary.element.id} /> <IdBadge id={questionSummary.question.id} />
</div> </div>
</div> </div>
); );

View File

@@ -1,28 +1,28 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummaryRanking } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveyQuestionSummaryRanking } from "@formbricks/types/surveys/types";
import { getChoiceIdByValue } from "@/lib/response/utils"; import { getChoiceIdByValue } from "@/lib/response/utils";
import { IdBadge } from "@/modules/ui/components/id-badge"; import { IdBadge } from "@/modules/ui/components/id-badge";
import { convertFloatToNDecimal } from "../lib/utils"; import { convertFloatToNDecimal } from "../lib/utils";
import { ElementSummaryHeader } from "./ElementSummaryHeader"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface RankingSummaryProps { interface RankingSummaryProps {
elementSummary: TSurveyElementSummaryRanking; questionSummary: TSurveyQuestionSummaryRanking;
survey: TSurvey; survey: TSurvey;
} }
export const RankingSummary = ({ elementSummary, survey }: RankingSummaryProps) => { export const RankingSummary = ({ questionSummary, survey }: RankingSummaryProps) => {
// sort by count and transform to array // sort by count and transform to array
const { t } = useTranslation(); const { t } = useTranslation();
const results = Object.values(elementSummary.choices).sort((a, b) => { const results = Object.values(questionSummary.choices).sort((a, b) => {
return a.avgRanking - b.avgRanking; // Sort by count return a.avgRanking - b.avgRanking; // Sort by count
}); });
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => { {results.map((result, resultsIdx) => {
const choiceId = getChoiceIdByValue(result.value, elementSummary.element); const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
return ( return (
<div key={result.value} className="group cursor-pointer"> <div key={result.value} className="group cursor-pointer">
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row"> <div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">

View File

@@ -3,61 +3,65 @@
import { BarChart, BarChartHorizontal, CircleSlash2, SmileIcon, StarIcon } from "lucide-react"; import { BarChart, BarChartHorizontal, CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n"; import {
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; TI18nString,
import { TSurvey, TSurveyElementSummaryRating } from "@formbricks/types/surveys/types"; TSurvey,
TSurveyQuestionId,
TSurveyQuestionSummaryRating,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils"; 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 { ProgressBar } from "@/modules/ui/components/progress-bar";
import { RatingResponse } from "@/modules/ui/components/rating-response"; import { RatingResponse } from "@/modules/ui/components/rating-response";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { TooltipProvider } from "@/modules/ui/components/tooltip"; import { TooltipProvider } from "@/modules/ui/components/tooltip";
import { ClickableBarSegment } from "./ClickableBarSegment"; import { ClickableBarSegment } from "./ClickableBarSegment";
import { ElementSummaryHeader } from "./ElementSummaryHeader"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
import { RatingScaleLegend } from "./RatingScaleLegend"; import { RatingScaleLegend } from "./RatingScaleLegend";
import { SatisfactionIndicator } from "./SatisfactionIndicator"; import { SatisfactionIndicator } from "./SatisfactionIndicator";
interface RatingSummaryProps { interface RatingSummaryProps {
elementSummary: TSurveyElementSummaryRating; questionSummary: TSurveyQuestionSummaryRating;
survey: TSurvey; survey: TSurvey;
setFilter: ( setFilter: (
elementId: string, questionId: TSurveyQuestionId,
label: TI18nString, label: TI18nString,
elementType: TSurveyElementTypeEnum, questionType: TSurveyQuestionTypeEnum,
filterValue: string, filterValue: string,
filterComboBoxValue?: string | string[] filterComboBoxValue?: string | string[]
) => void; ) => void;
} }
export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSummaryProps) => { export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSummaryProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated"); const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
const getIconBasedOnScale = useMemo(() => { const getIconBasedOnScale = useMemo(() => {
const scale = elementSummary.element.scale; const scale = questionSummary.question.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />; 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 === "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" />; else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
}, [elementSummary]); }, [questionSummary]);
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader <QuestionSummaryHeader
elementSummary={elementSummary} questionSummary={questionSummary}
survey={survey} survey={survey}
additionalInfo={ additionalInfo={
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2"> <div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale} {getIconBasedOnScale}
<div> <div>
{t("environments.surveys.summary.overall")}: {elementSummary.average.toFixed(2)} {t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)}
</div> </div>
</div> </div>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2"> <div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.csat.satisfiedPercentage} /> <SatisfactionIndicator percentage={questionSummary.csat.satisfiedPercentage} />
<div> <div>
CSAT: {elementSummary.csat.satisfiedPercentage}% {t("environments.surveys.summary.satisfied")} CSAT: {questionSummary.csat.satisfiedPercentage}%{" "}
{t("environments.surveys.summary.satisfied")}
</div> </div>
</div> </div>
</div> </div>
@@ -78,25 +82,29 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
<TabsContent value="aggregated" className="mt-4"> <TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6"> <div className="px-4 pb-6 pt-4 md:px-6">
{elementSummary.responseCount === 0 ? ( {questionSummary.responseCount === 0 ? (
<> <>
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" /> <div className="rounded-lg border border-slate-200 bg-slate-50 p-8 text-center">
<p className="text-sm text-slate-500">
{t("environments.surveys.summary.no_responses_found")}
</p>
</div>
<RatingScaleLegend <RatingScaleLegend
scale={elementSummary.element.scale} scale={questionSummary.question.scale}
range={elementSummary.element.range} range={questionSummary.question.range}
/> />
</> </>
) : ( ) : (
<> <>
<TooltipProvider delayDuration={200}> <TooltipProvider delayDuration={200}>
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200"> <div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
{elementSummary.choices.map((result, index) => { {questionSummary.choices.map((result, index) => {
if (result.percentage === 0) return null; if (result.percentage === 0) return null;
const range = elementSummary.element.range; const range = questionSummary.question.range;
const opacity = 0.3 + (result.rating / range) * 0.8; const opacity = 0.3 + (result.rating / range) * 0.8;
const isFirst = index === 0; const isFirst = index === 0;
const isLast = index === elementSummary.choices.length - 1; const isLast = index === questionSummary.choices.length - 1;
return ( return (
<ClickableBarSegment <ClickableBarSegment
@@ -108,9 +116,9 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
}} }}
onClick={() => onClick={() =>
setFilter( setFilter(
elementSummary.element.id, questionSummary.question.id,
elementSummary.element.headline, questionSummary.question.headline,
elementSummary.element.type, questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"), t("environments.surveys.summary.is_equal_to"),
result.rating.toString() result.rating.toString()
) )
@@ -125,7 +133,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
</div> </div>
</TooltipProvider> </TooltipProvider>
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50"> <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) => { {questionSummary.choices.map((result, index) => {
if (result.percentage === 0) return null; if (result.percentage === 0) return null;
return ( return (
@@ -135,15 +143,15 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
style={{ style={{
width: `${result.percentage}%`, width: `${result.percentage}%`,
borderRight: borderRight:
index < elementSummary.choices.length - 1 index < questionSummary.choices.length - 1
? "1px solid rgb(226, 232, 240)" ? "1px solid rgb(226, 232, 240)"
: "none", : "none",
}}> }}>
<div className="mb-1 flex items-center justify-center"> <div className="mb-1 flex items-center justify-center">
<RatingResponse <RatingResponse
scale={elementSummary.element.scale} scale={questionSummary.question.scale}
answer={result.rating} answer={result.rating}
range={elementSummary.element.range} range={questionSummary.question.range}
addColors={false} addColors={false}
variant="aggregated" variant="aggregated"
/> />
@@ -156,8 +164,8 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
})} })}
</div> </div>
<RatingScaleLegend <RatingScaleLegend
scale={elementSummary.element.scale} scale={questionSummary.question.scale}
range={elementSummary.element.range} range={questionSummary.question.range}
/> />
</> </>
)} )}
@@ -167,15 +175,15 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
<TabsContent value="individual" className="mt-4"> <TabsContent value="individual" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6"> <div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base"> <div className="space-y-5 text-sm md:text-base">
{elementSummary.choices.map((result) => ( {questionSummary.choices.map((result) => (
<div key={result.rating}> <div key={result.rating}>
<button <button
className="w-full cursor-pointer hover:opacity-80" className="w-full cursor-pointer hover:opacity-80"
onClick={() => onClick={() =>
setFilter( setFilter(
elementSummary.element.id, questionSummary.question.id,
elementSummary.element.headline, questionSummary.question.headline,
elementSummary.element.type, questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"), t("environments.surveys.summary.is_equal_to"),
result.rating.toString() result.rating.toString()
) )
@@ -184,10 +192,10 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
<div className="mr-8 flex items-center space-x-1"> <div className="mr-8 flex items-center space-x-1">
<div className="font-semibold text-slate-700"> <div className="font-semibold text-slate-700">
<RatingResponse <RatingResponse
scale={elementSummary.element.scale} scale={questionSummary.question.scale}
answer={result.rating} answer={result.rating}
range={elementSummary.element.range} range={questionSummary.question.range}
addColors={elementSummary.element.isColorCodingEnabled} addColors={questionSummary.question.isColorCodingEnabled}
variant="individual" variant="individual"
/> />
</div> </div>
@@ -209,14 +217,14 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
</div> </div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
{elementSummary.dismissed && elementSummary.dismissed.count > 0 && ( {questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
<div className="rounded-b-lg border-t bg-white px-6 py-4"> <div className="rounded-b-lg border-t bg-white px-6 py-4">
<div key="dismissed"> <div key="dismissed">
<div className="text flex justify-between px-2"> <div className="text flex justify-between px-2">
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p> <p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary.dismissed.count}{" "} {questionSummary.dismissed.count}{" "}
{elementSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")} {questionSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -2,11 +2,10 @@
import { TimerIcon } from "lucide-react"; import { TimerIcon } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurvey, TSurveyQuestionType, TSurveySummary } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { recallToHeadline } from "@/lib/utils/recall"; import { recallToHeadline } from "@/lib/utils/recall";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils"; import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { getElementIcon } from "@/modules/survey/lib/elements"; import { getQuestionIcon } from "@/modules/survey/lib/questions";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface SummaryDropOffsProps { interface SummaryDropOffsProps {
@@ -16,8 +15,8 @@ interface SummaryDropOffsProps {
export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => { export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const getIcon = (elementType: TSurveyElementTypeEnum) => { const getIcon = (questionType: TSurveyQuestionType) => {
const Icon = getElementIcon(elementType, t); const Icon = getQuestionIcon(questionType, t);
return <Icon className="mt-[3px] h-5 w-5 shrink-0 text-slate-600" />; return <Icon className="mt-[3px] h-5 w-5 shrink-0 text-slate-600" />;
}; };
@@ -45,10 +44,10 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
</div> </div>
{dropOff.map((quesDropOff) => ( {dropOff.map((quesDropOff) => (
<div <div
key={quesDropOff.elementId} key={quesDropOff.questionId}
className="grid grid-cols-6 items-start border-b border-slate-100 text-xs text-slate-800 md:text-sm"> 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"> <div className="col-span-3 flex gap-3 px-4 py-2 md:px-6">
{getIcon(quesDropOff.elementType)} {getIcon(quesDropOff.questionType)}
<p> <p>
{formatTextWithSlashes( {formatTextWithSlashes(
recallToHeadline( recallToHeadline(

View File

@@ -3,25 +3,28 @@
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TI18nString } from "@formbricks/types/i18n"; import {
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; TI18nString,
import { TSurveySummary } from "@formbricks/types/surveys/types"; TSurvey,
import { TSurvey } from "@formbricks/types/surveys/types"; TSurveyQuestionId,
TSurveyQuestionTypeEnum,
TSurveySummary,
} from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { EmptyAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
import { import {
SelectedFilterValue, SelectedFilterValue,
useResponseFilter, useResponseFilter,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context"; } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { EmptyAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
import { CTASummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary"; 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 { 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 { 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 { ContactInfoSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ContactInfoSummary";
import { DateElementSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateElementSummary"; import { DateQuestionSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary";
import { FileUploadSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary"; 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 { HiddenFieldsSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary";
import { MatrixElementSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixElementSummary"; import { MatrixQuestionSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary";
import { MultipleChoiceSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary"; 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 { 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"; import { OpenTextSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary";
@@ -29,7 +32,7 @@ import { PictureChoiceSummary } from "@/app/(app)/environments/[environmentId]/s
import { RankingSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary"; 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 { 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 { constructToastMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementsComboBox"; import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { EmptyState } from "@/modules/ui/components/empty-state"; import { EmptyState } from "@/modules/ui/components/empty-state";
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader"; import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
@@ -47,29 +50,29 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
const { setSelectedFilter, selectedFilter } = useResponseFilter(); const { setSelectedFilter, selectedFilter } = useResponseFilter();
const { t } = useTranslation(); const { t } = useTranslation();
const setFilter = ( const setFilter = (
elementId: string, questionId: TSurveyQuestionId,
label: TI18nString, label: TI18nString,
elementType: TSurveyElementTypeEnum, questionType: TSurveyQuestionTypeEnum,
filterValue: string, filterValue: string,
filterComboBoxValue?: string | string[] filterComboBoxValue?: string | string[]
) => { ) => {
const filterObject: SelectedFilterValue = { ...selectedFilter }; const filterObject: SelectedFilterValue = { ...selectedFilter };
const value = { const value = {
id: elementId, id: questionId,
label: getTextContent(getLocalizedValue(label, "default")), label: getTextContent(getLocalizedValue(label, "default")),
elementType, questionType: questionType,
type: OptionsType.ELEMENTS, type: OptionsType.QUESTIONS,
}; };
// Find the index of the existing filter with the same elementId // Find the index of the existing filter with the same questionId
const existingFilterIndex = filterObject.filter.findIndex( const existingFilterIndex = filterObject.filter.findIndex(
(filter) => filter.elementType.id === elementId (filter) => filter.questionType.id === questionId
); );
if (existingFilterIndex !== -1) { if (existingFilterIndex !== -1) {
// Replace the existing filter // Replace the existing filter
filterObject.filter[existingFilterIndex] = { filterObject.filter[existingFilterIndex] = {
elementType: value, questionType: value,
filterType: { filterType: {
filterComboBoxValue: filterComboBoxValue, filterComboBoxValue: filterComboBoxValue,
filterValue: filterValue, filterValue: filterValue,
@@ -79,14 +82,14 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
} else { } else {
// Add new filter // Add new filter
filterObject.filter.push({ filterObject.filter.push({
elementType: value, questionType: value,
filterType: { filterType: {
filterComboBoxValue: filterComboBoxValue, filterComboBoxValue: filterComboBoxValue,
filterValue: filterValue, filterValue: filterValue,
}, },
}); });
toast.success( toast.success(
constructToastMessage(elementType, filterValue, survey, elementId, t, filterComboBoxValue) ?? constructToastMessage(questionType, filterValue, survey, questionId, t, filterComboBoxValue) ??
t("environments.surveys.summary.filter_added_successfully"), t("environments.surveys.summary.filter_added_successfully"),
{ duration: 5000 } { duration: 5000 }
); );
@@ -107,12 +110,12 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
) : responseCount === 0 ? ( ) : responseCount === 0 ? (
<EmptyState text={t("environments.surveys.summary.no_responses_found")} /> <EmptyState text={t("environments.surveys.summary.no_responses_found")} />
) : ( ) : (
summary.map((elementSummary) => { summary.map((questionSummary) => {
if (elementSummary.type === TSurveyElementTypeEnum.OpenText) { if (questionSummary.type === TSurveyQuestionTypeEnum.OpenText) {
return ( return (
<OpenTextSummary <OpenTextSummary
key={elementSummary.element.id} key={questionSummary.question.id}
elementSummary={elementSummary} questionSummary={questionSummary}
environmentId={environment.id} environmentId={environment.id}
survey={survey} survey={survey}
locale={locale} locale={locale}
@@ -120,13 +123,13 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
); );
} }
if ( if (
elementSummary.type === TSurveyElementTypeEnum.MultipleChoiceSingle || questionSummary.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
elementSummary.type === TSurveyElementTypeEnum.MultipleChoiceMulti questionSummary.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
) { ) {
return ( return (
<MultipleChoiceSummary <MultipleChoiceSummary
key={elementSummary.element.id} key={questionSummary.question.id}
elementSummary={elementSummary} questionSummary={questionSummary}
environmentId={environment.id} environmentId={environment.id}
surveyType={survey.type} surveyType={survey.type}
survey={survey} survey={survey}
@@ -134,128 +137,132 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
/> />
); );
} }
if (elementSummary.type === TSurveyElementTypeEnum.NPS) { if (questionSummary.type === TSurveyQuestionTypeEnum.NPS) {
return ( return (
<NPSSummary <NPSSummary
key={elementSummary.element.id} key={questionSummary.question.id}
elementSummary={elementSummary} questionSummary={questionSummary}
survey={survey} survey={survey}
setFilter={setFilter} setFilter={setFilter}
/> />
); );
} }
if (elementSummary.type === TSurveyElementTypeEnum.CTA) { if (questionSummary.type === TSurveyQuestionTypeEnum.CTA) {
return ( return (
<CTASummary key={elementSummary.element.id} elementSummary={elementSummary} survey={survey} /> <CTASummary
key={questionSummary.question.id}
questionSummary={questionSummary}
survey={survey}
/>
); );
} }
if (elementSummary.type === TSurveyElementTypeEnum.Rating) { if (questionSummary.type === TSurveyQuestionTypeEnum.Rating) {
return ( return (
<RatingSummary <RatingSummary
key={elementSummary.element.id} key={questionSummary.question.id}
elementSummary={elementSummary} questionSummary={questionSummary}
survey={survey} survey={survey}
setFilter={setFilter} setFilter={setFilter}
/> />
); );
} }
if (elementSummary.type === TSurveyElementTypeEnum.Consent) { if (questionSummary.type === TSurveyQuestionTypeEnum.Consent) {
return ( return (
<ConsentSummary <ConsentSummary
key={elementSummary.element.id} key={questionSummary.question.id}
elementSummary={elementSummary} questionSummary={questionSummary}
survey={survey} survey={survey}
setFilter={setFilter} setFilter={setFilter}
/> />
); );
} }
if (elementSummary.type === TSurveyElementTypeEnum.PictureSelection) { if (questionSummary.type === TSurveyQuestionTypeEnum.PictureSelection) {
return ( return (
<PictureChoiceSummary <PictureChoiceSummary
key={elementSummary.element.id} key={questionSummary.question.id}
elementSummary={elementSummary} questionSummary={questionSummary}
survey={survey} survey={survey}
setFilter={setFilter} setFilter={setFilter}
/> />
); );
} }
if (elementSummary.type === TSurveyElementTypeEnum.Date) { if (questionSummary.type === TSurveyQuestionTypeEnum.Date) {
return ( return (
<DateElementSummary <DateQuestionSummary
key={elementSummary.element.id} key={questionSummary.question.id}
elementSummary={elementSummary} questionSummary={questionSummary}
environmentId={environment.id} environmentId={environment.id}
survey={survey} survey={survey}
locale={locale} locale={locale}
/> />
); );
} }
if (elementSummary.type === TSurveyElementTypeEnum.FileUpload) { if (questionSummary.type === TSurveyQuestionTypeEnum.FileUpload) {
return ( return (
<FileUploadSummary <FileUploadSummary
key={elementSummary.element.id} key={questionSummary.question.id}
elementSummary={elementSummary} questionSummary={questionSummary}
environmentId={environment.id} environmentId={environment.id}
survey={survey} survey={survey}
locale={locale} locale={locale}
/> />
); );
} }
if (elementSummary.type === TSurveyElementTypeEnum.Cal) { if (questionSummary.type === TSurveyQuestionTypeEnum.Cal) {
return ( return (
<CalSummary <CalSummary
key={elementSummary.element.id} key={questionSummary.question.id}
elementSummary={elementSummary} questionSummary={questionSummary}
environmentId={environment.id} environmentId={environment.id}
survey={survey} survey={survey}
/> />
); );
} }
if (elementSummary.type === TSurveyElementTypeEnum.Matrix) { if (questionSummary.type === TSurveyQuestionTypeEnum.Matrix) {
return ( return (
<MatrixElementSummary <MatrixQuestionSummary
key={elementSummary.element.id} key={questionSummary.question.id}
elementSummary={elementSummary} questionSummary={questionSummary}
survey={survey} survey={survey}
setFilter={setFilter} setFilter={setFilter}
/> />
); );
} }
if (elementSummary.type === TSurveyElementTypeEnum.Address) { if (questionSummary.type === TSurveyQuestionTypeEnum.Address) {
return ( return (
<AddressSummary <AddressSummary
key={elementSummary.element.id} key={questionSummary.question.id}
elementSummary={elementSummary} questionSummary={questionSummary}
environmentId={environment.id} environmentId={environment.id}
survey={survey} survey={survey}
locale={locale} locale={locale}
/> />
); );
} }
if (elementSummary.type === TSurveyElementTypeEnum.Ranking) { if (questionSummary.type === TSurveyQuestionTypeEnum.Ranking) {
return ( return (
<RankingSummary <RankingSummary
key={elementSummary.element.id} key={questionSummary.question.id}
elementSummary={elementSummary} questionSummary={questionSummary}
survey={survey} survey={survey}
/> />
); );
} }
if (elementSummary.type === "hiddenField") { if (questionSummary.type === "hiddenField") {
return ( return (
<HiddenFieldsSummary <HiddenFieldsSummary
key={elementSummary.id} key={questionSummary.id}
elementSummary={elementSummary} questionSummary={questionSummary}
environment={environment} environment={environment}
locale={locale} locale={locale}
/> />
); );
} }
if (elementSummary.type === TSurveyElementTypeEnum.ContactInfo) { if (questionSummary.type === TSurveyQuestionTypeEnum.ContactInfo) {
return ( return (
<ContactInfoSummary <ContactInfoSummary
key={elementSummary.element.id} key={questionSummary.question.id}
elementSummary={elementSummary} questionSummary={questionSummary}
environmentId={environment.id} environmentId={environment.id}
survey={survey} survey={survey}
locale={locale} locale={locale}

View File

@@ -8,7 +8,6 @@ import { cn } from "@/modules/ui/lib/utils";
interface SummaryMetadataProps { interface SummaryMetadataProps {
surveySummary: TSurveySummary["meta"]; surveySummary: TSurveySummary["meta"];
quotasCount: number;
isLoading: boolean; isLoading: boolean;
tab: "dropOffs" | "quotas" | undefined; tab: "dropOffs" | "quotas" | undefined;
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>; setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
@@ -32,7 +31,6 @@ const formatTime = (ttc) => {
export const SummaryMetadata = ({ export const SummaryMetadata = ({
surveySummary, surveySummary,
quotasCount,
isLoading, isLoading,
tab, tab,
setTab, setTab,
@@ -63,7 +61,7 @@ export const SummaryMetadata = ({
<div <div
className={cn( 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`, `grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6" isQuotasAllowed && "2xl:grid-cols-6"
)}> )}>
<StatCard <StatCard
label={t("environments.surveys.summary.impressions")} label={t("environments.surveys.summary.impressions")}
@@ -107,7 +105,7 @@ export const SummaryMetadata = ({
isLoading={isLoading} isLoading={isLoading}
/> />
{isQuotasAllowed && quotasCount > 0 && ( {isQuotasAllowed && (
<InteractiveCard <InteractiveCard
key="quotas" key="quotas"
tab="quotas" tab="quotas"

View File

@@ -5,8 +5,8 @@ import { useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; 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 { 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 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 { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter"; import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
@@ -115,7 +115,6 @@ export const SummaryPage = ({
<> <>
<SummaryMetadata <SummaryMetadata
surveySummary={surveySummary.meta} surveySummary={surveySummary.meta}
quotasCount={surveySummary.quotas?.length ?? 0}
isLoading={isLoading} isLoading={isLoading}
tab={tab} tab={tab}
setTab={setTab} setTab={setTab}

View File

@@ -5,8 +5,7 @@ import { useForm } from "react-hook-form";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils"; import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TI18nString } from "@formbricks/types/i18n"; import { TI18nString, TSurvey, TSurveyMetadata } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyMetadata } from "@formbricks/types/surveys/types";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context"; import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { createI18nString, extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils"; import { createI18nString, extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
import { updateSurveyAction } from "@/modules/survey/editor/actions"; import { updateSurveyAction } from "@/modules/survey/editor/actions";

View File

@@ -14,24 +14,23 @@ import {
TResponseVariables, TResponseVariables,
ZResponseFilterCriteria, ZResponseFilterCriteria,
} from "@formbricks/types/responses"; } from "@formbricks/types/responses";
import {
TSurveyElement,
TSurveyElementChoice,
TSurveyElementTypeEnum,
} from "@formbricks/types/surveys/elements";
import { import {
TSurvey, TSurvey,
TSurveyElementSummaryAddress, TSurveyContactInfoQuestion,
TSurveyElementSummaryContactInfo,
TSurveyElementSummaryDate,
TSurveyElementSummaryFileUpload,
TSurveyElementSummaryHiddenFields,
TSurveyElementSummaryMultipleChoice,
TSurveyElementSummaryOpenText,
TSurveyElementSummaryPictureSelection,
TSurveyElementSummaryRanking,
TSurveyElementSummaryRating,
TSurveyLanguage, TSurveyLanguage,
TSurveyMultipleChoiceQuestion,
TSurveyQuestion,
TSurveyQuestionId,
TSurveyQuestionSummaryAddress,
TSurveyQuestionSummaryDate,
TSurveyQuestionSummaryFileUpload,
TSurveyQuestionSummaryHiddenFields,
TSurveyQuestionSummaryMultipleChoice,
TSurveyQuestionSummaryOpenText,
TSurveyQuestionSummaryPictureSelection,
TSurveyQuestionSummaryRanking,
TSurveyQuestionSummaryRating,
TSurveyQuestionTypeEnum,
TSurveySummary, TSurveySummary,
} from "@formbricks/types/surveys/types"; } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; import { getTextContent } from "@formbricks/types/surveys/validation";
@@ -41,7 +40,6 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { buildWhereClause } from "@/lib/response/utils"; import { buildWhereClause } from "@/lib/response/utils";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { findElementLocation, getElementsFromBlocks } from "@/lib/survey/utils";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils"; import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { convertFloatTo2Decimal } from "./utils"; import { convertFloatTo2Decimal } from "./utils";
@@ -97,44 +95,39 @@ export const getSurveySummaryMeta = (
}; };
}; };
const evaluateLogicAndGetNextElementId = ( const evaluateLogicAndGetNextQuestionId = (
localSurvey: TSurvey, localSurvey: TSurvey,
elements: TSurveyElement[],
data: TResponseData, data: TResponseData,
localVariables: TResponseVariables, localVariables: TResponseVariables,
currentElementIndex: number, currentQuestionIndex: number,
currElementTemp: TSurveyElement, currQuesTemp: TSurveyQuestion,
selectedLanguage: string | null selectedLanguage: string | null
): { ): {
nextElementId: string | undefined; nextQuestionId: TSurveyQuestionId | undefined;
updatedSurvey: TSurvey; updatedSurvey: TSurvey;
updatedVariables: TResponseVariables; updatedVariables: TResponseVariables;
} => { } => {
const questions = localSurvey.questions;
let updatedSurvey = { ...localSurvey }; let updatedSurvey = { ...localSurvey };
let updatedVariables = { ...localVariables }; let updatedVariables = { ...localVariables };
let firstJumpTarget: string | undefined; let firstJumpTarget: string | undefined;
const { block: currentBlock } = findElementLocation(localSurvey, currElementTemp.id); if (currQuesTemp.logic && currQuesTemp.logic.length > 0) {
for (const logic of currQuesTemp.logic) {
if (currentBlock?.logic && currentBlock.logic.length > 0) {
for (const logic of currentBlock.logic) {
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) { if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
const { jumpTarget, requiredElementIds, calculations } = performActions( const { jumpTarget, requiredQuestionIds, calculations } = performActions(
updatedSurvey, updatedSurvey,
logic.actions, logic.actions,
data, data,
updatedVariables updatedVariables
); );
if (requiredElementIds.length > 0) { if (requiredQuestionIds.length > 0) {
// Update blocks to mark elements as required updatedSurvey.questions = updatedSurvey.questions.map((q) =>
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({ requiredQuestionIds.includes(q.id) ? { ...q, required: true } : q
...block, );
elements: block.elements.map((e) =>
requiredElementIds.includes(e.id) ? { ...e, required: true } : e
),
}));
} }
updatedVariables = { ...updatedVariables, ...calculations }; updatedVariables = { ...updatedVariables, ...calculations };
@@ -146,33 +139,32 @@ const evaluateLogicAndGetNextElementId = (
} }
// If no jump target was set, check for a fallback logic // If no jump target was set, check for a fallback logic
if (!firstJumpTarget && currentBlock?.logicFallback) { if (!firstJumpTarget && currQuesTemp.logicFallback) {
firstJumpTarget = currentBlock.logicFallback; firstJumpTarget = currQuesTemp.logicFallback;
} }
// Return the first jump target if found, otherwise go to the next element // Return the first jump target if found, otherwise go to the next question
const nextElementId = firstJumpTarget || elements[currentElementIndex + 1]?.id || undefined; const nextQuestionId = firstJumpTarget || questions[currentQuestionIndex + 1]?.id || undefined;
return { nextElementId, updatedSurvey, updatedVariables }; return { nextQuestionId, updatedSurvey, updatedVariables };
}; };
export const getSurveySummaryDropOff = ( export const getSurveySummaryDropOff = (
survey: TSurvey, survey: TSurvey,
elements: TSurveyElement[],
responses: TSurveySummaryResponse[], responses: TSurveySummaryResponse[],
displayCount: number displayCount: number
): TSurveySummary["dropOff"] => { ): TSurveySummary["dropOff"] => {
const initialTtc = elements.reduce((acc: Record<string, number>, element) => { const initialTtc = survey.questions.reduce((acc: Record<string, number>, question) => {
acc[element.id] = 0; acc[question.id] = 0;
return acc; return acc;
}, {}); }, {});
let totalTtc = { ...initialTtc }; let totalTtc = { ...initialTtc };
let responseCounts = { ...initialTtc }; let responseCounts = { ...initialTtc };
let dropOffArr = new Array(elements.length).fill(0) as number[]; let dropOffArr = new Array(survey.questions.length).fill(0) as number[];
let impressionsArr = new Array(elements.length).fill(0) as number[]; let impressionsArr = new Array(survey.questions.length).fill(0) as number[];
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[]; let dropOffPercentageArr = new Array(survey.questions.length).fill(0) as number[];
const surveyVariablesData = survey.variables?.reduce( const surveyVariablesData = survey.variables?.reduce(
(acc, variable) => { (acc, variable) => {
@@ -184,10 +176,10 @@ export const getSurveySummaryDropOff = (
responses.forEach((response) => { responses.forEach((response) => {
// Calculate total time-to-completion // Calculate total time-to-completion
Object.keys(totalTtc).forEach((elementId) => { Object.keys(totalTtc).forEach((questionId) => {
if (response.ttc && response.ttc[elementId]) { if (response.ttc && response.ttc[questionId]) {
totalTtc[elementId] += response.ttc[elementId]; totalTtc[questionId] += response.ttc[questionId];
responseCounts[elementId]++; responseCounts[questionId]++;
} }
}); });
@@ -199,11 +191,11 @@ export const getSurveySummaryDropOff = (
let currQuesIdx = 0; let currQuesIdx = 0;
while (currQuesIdx < elements.length) { while (currQuesIdx < localSurvey.questions.length) {
const currQues = elements[currQuesIdx]; const currQues = localSurvey.questions[currQuesIdx];
if (!currQues) break; if (!currQues) break;
// element is not answered and required // question is not answered and required
if (response.data[currQues.id] === undefined && currQues.required) { if (response.data[currQues.id] === undefined && currQues.required) {
dropOffArr[currQuesIdx]++; dropOffArr[currQuesIdx]++;
impressionsArr[currQuesIdx]++; impressionsArr[currQuesIdx]++;
@@ -212,9 +204,8 @@ export const getSurveySummaryDropOff = (
impressionsArr[currQuesIdx]++; impressionsArr[currQuesIdx]++;
const { nextElementId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextElementId( const { nextQuestionId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextQuestionId(
localSurvey, localSurvey,
elements,
localResponseData, localResponseData,
localVariables, localVariables,
currQuesIdx, currQuesIdx,
@@ -225,9 +216,9 @@ export const getSurveySummaryDropOff = (
localSurvey = updatedSurvey; localSurvey = updatedSurvey;
localVariables = updatedVariables; localVariables = updatedVariables;
if (nextElementId) { if (nextQuestionId) {
const nextQuesIdx = elements.findIndex((q) => q.id === nextElementId); const nextQuesIdx = survey.questions.findIndex((q) => q.id === nextQuestionId);
if (!response.data[nextElementId] && !response.finished) { if (!response.data[nextQuestionId] && !response.finished) {
dropOffArr[nextQuesIdx]++; dropOffArr[nextQuesIdx]++;
impressionsArr[nextQuesIdx]++; impressionsArr[nextQuesIdx]++;
break; break;
@@ -239,9 +230,10 @@ export const getSurveySummaryDropOff = (
} }
}); });
// Calculate the average time for each element // Calculate the average time for each question
Object.keys(totalTtc).forEach((elementId) => { Object.keys(totalTtc).forEach((questionId) => {
totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0; totalTtc[questionId] =
responseCounts[questionId] > 0 ? totalTtc[questionId] / responseCounts[questionId] : 0;
}); });
if (!survey.welcomeCard.enabled) { if (!survey.welcomeCard.enabled) {
@@ -258,18 +250,18 @@ export const getSurveySummaryDropOff = (
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100; dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
} }
for (let i = 1; i < elements.length; i++) { for (let i = 1; i < survey.questions.length; i++) {
if (impressionsArr[i] !== 0) { if (impressionsArr[i] !== 0) {
dropOffPercentageArr[i] = (dropOffArr[i] / impressionsArr[i]) * 100; dropOffPercentageArr[i] = (dropOffArr[i] / impressionsArr[i]) * 100;
} }
} }
const dropOff = elements.map((element, index) => { const dropOff = survey.questions.map((question, index) => {
return { return {
elementId: element.id, questionId: question.id,
elementType: element.type, questionType: question.type,
headline: getTextContent(getLocalizedValue(element.headline, "default")), headline: getTextContent(getLocalizedValue(question.headline, "default")),
ttc: convertFloatTo2Decimal(totalTtc[element.id]) || 0, ttc: convertFloatTo2Decimal(totalTtc[question.id]) || 0,
impressions: impressionsArr[index] || 0, impressions: impressionsArr[index] || 0,
dropOffCount: dropOffArr[index] || 0, dropOffCount: dropOffArr[index] || 0,
dropOffPercentage: convertFloatTo2Decimal(dropOffPercentageArr[index]) || 0, dropOffPercentage: convertFloatTo2Decimal(dropOffPercentageArr[index]) || 0,
@@ -285,66 +277,51 @@ const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: strin
return language?.default ? "default" : language?.language.code || "default"; return language?.default ? "default" : language?.language.code || "default";
}; };
const checkForI18n = ( const checkForI18n = (responseData: TResponseData, id: string, survey: TSurvey, languageCode: string) => {
responseData: TResponseData, const question = survey.questions.find((question) => question.id === id);
id: string,
elements: TSurveyElement[],
languageCode: string
) => {
const element = elements.find((element) => element.id === id);
if (element?.type === "multipleChoiceMulti" || element?.type === "ranking") { if (question?.type === "multipleChoiceMulti" || question?.type === "ranking") {
// Initialize an array to hold the choice values // Initialize an array to hold the choice values
let choiceValues = [] as string[]; let choiceValues = [] as string[];
// Type guard: both element types have choices property
const hasChoices = "choices" in element;
if (!hasChoices) return [];
(typeof responseData[id] === "string" (typeof responseData[id] === "string"
? ([responseData[id]] as string[]) ? ([responseData[id]] as string[])
: (responseData[id] as string[]) : (responseData[id] as string[])
)?.forEach((data) => { )?.forEach((data) => {
choiceValues.push( choiceValues.push(
getLocalizedValue( getLocalizedValue(
element.choices.find((choice) => choice.label[languageCode] === data)?.label, question.choices.find((choice) => choice.label[languageCode] === data)?.label,
"default" "default"
) || data ) || data
); );
}); });
// Return the array of localized choice values of multiSelect multi elements // Return the array of localized choice values of multiSelect multi questions
return choiceValues; return choiceValues;
} }
// Return the localized value of the choice fo multiSelect single element // Return the localized value of the choice fo multiSelect single question
if (element && "choices" in element) { const choice = (question as TSurveyMultipleChoiceQuestion)?.choices.find(
const choice = element.choices?.find( (choice) => choice.label[languageCode] === responseData[id]
(choice: TSurveyElementChoice) => choice.label?.[languageCode] === responseData[id] );
);
return choice && "label" in choice
? getLocalizedValue(choice.label, "default") || responseData[id]
: responseData[id];
}
return responseData[id]; return getLocalizedValue(choice?.label, "default") || responseData[id];
}; };
export const getElementSummary = async ( export const getQuestionSummary = async (
survey: TSurvey, survey: TSurvey,
elements: TSurveyElement[],
responses: TSurveySummaryResponse[], responses: TSurveySummaryResponse[],
dropOff: TSurveySummary["dropOff"] dropOff: TSurveySummary["dropOff"]
): Promise<TSurveySummary["summary"]> => { ): Promise<TSurveySummary["summary"]> => {
const VALUES_LIMIT = 50; const VALUES_LIMIT = 50;
let summary: TSurveySummary["summary"] = []; let summary: TSurveySummary["summary"] = [];
for (const element of elements) { for (const question of survey.questions) {
switch (element.type) { switch (question.type) {
case TSurveyElementTypeEnum.OpenText: { case TSurveyQuestionTypeEnum.OpenText: {
let values: TSurveyElementSummaryOpenText["samples"] = []; let values: TSurveyQuestionSummaryOpenText["samples"] = [];
responses.forEach((response) => { responses.forEach((response) => {
const answer = response.data[element.id]; const answer = response.data[question.id];
if (answer && typeof answer === "string") { if (answer && typeof answer === "string") {
values.push({ values.push({
id: response.id, id: response.id,
@@ -357,8 +334,8 @@ export const getElementSummary = async (
}); });
summary.push({ summary.push({
type: element.type, type: question.type,
element: element, question,
responseCount: values.length, responseCount: values.length,
samples: values.slice(0, VALUES_LIMIT), samples: values.slice(0, VALUES_LIMIT),
}); });
@@ -366,18 +343,18 @@ export const getElementSummary = async (
values = []; values = [];
break; break;
} }
case TSurveyElementTypeEnum.MultipleChoiceSingle: case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
case TSurveyElementTypeEnum.MultipleChoiceMulti: { case TSurveyQuestionTypeEnum.MultipleChoiceMulti: {
let values: TSurveyElementSummaryMultipleChoice["choices"] = []; let values: TSurveyQuestionSummaryMultipleChoice["choices"] = [];
const otherOption = element.choices.find((choice) => choice.id === "other"); const otherOption = question.choices.find((choice) => choice.id === "other");
const noneOption = element.choices.find((choice) => choice.id === "none"); const noneOption = question.choices.find((choice) => choice.id === "none");
const elementChoices = element.choices const questionChoices = question.choices
.filter((choice) => choice.id !== "other" && choice.id !== "none") .filter((choice) => choice.id !== "other" && choice.id !== "none")
.map((choice) => getLocalizedValue(choice.label, "default")); .map((choice) => getLocalizedValue(choice.label, "default"));
const choiceCountMap = elementChoices.reduce((acc: Record<string, number>, choice) => { const choiceCountMap = questionChoices.reduce((acc: Record<string, number>, choice) => {
acc[choice] = 0; acc[choice] = 0;
return acc; return acc;
}, {}); }, {});
@@ -386,7 +363,7 @@ export const getElementSummary = async (
const noneLabel = noneOption ? getLocalizedValue(noneOption.label, "default") : null; const noneLabel = noneOption ? getLocalizedValue(noneOption.label, "default") : null;
let noneCount = 0; let noneCount = 0;
const otherValues: TSurveyElementSummaryMultipleChoice["choices"][number]["others"] = []; const otherValues: TSurveyQuestionSummaryMultipleChoice["choices"][number]["others"] = [];
let totalSelectionCount = 0; let totalSelectionCount = 0;
let totalResponseCount = 0; let totalResponseCount = 0;
responses.forEach((response) => { responses.forEach((response) => {
@@ -394,16 +371,16 @@ export const getElementSummary = async (
const answer = const answer =
responseLanguageCode === "default" responseLanguageCode === "default"
? response.data[element.id] ? response.data[question.id]
: checkForI18n(response.data, element.id, elements, responseLanguageCode); : checkForI18n(response.data, question.id, survey, responseLanguageCode);
let hasValidAnswer = false; let hasValidAnswer = false;
if (Array.isArray(answer) && element.type === TSurveyElementTypeEnum.MultipleChoiceMulti) { if (Array.isArray(answer) && question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
answer.forEach((value) => { answer.forEach((value) => {
if (value) { if (value) {
totalSelectionCount++; totalSelectionCount++;
if (elementChoices.includes(value)) { if (questionChoices.includes(value)) {
choiceCountMap[value]++; choiceCountMap[value]++;
} else if (noneLabel && value === noneLabel) { } else if (noneLabel && value === noneLabel) {
noneCount++; noneCount++;
@@ -419,11 +396,11 @@ export const getElementSummary = async (
}); });
} else if ( } else if (
typeof answer === "string" && typeof answer === "string" &&
element.type === TSurveyElementTypeEnum.MultipleChoiceSingle question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle
) { ) {
if (answer) { if (answer) {
totalSelectionCount++; totalSelectionCount++;
if (elementChoices.includes(answer)) { if (questionChoices.includes(answer)) {
choiceCountMap[answer]++; choiceCountMap[answer]++;
} else if (noneLabel && answer === noneLabel) { } else if (noneLabel && answer === noneLabel) {
noneCount++; noneCount++;
@@ -475,8 +452,8 @@ export const getElementSummary = async (
} }
summary.push({ summary.push({
type: element.type, type: question.type,
element, question,
responseCount: totalResponseCount, responseCount: totalResponseCount,
selectionCount: totalSelectionCount, selectionCount: totalSelectionCount,
choices: values, choices: values,
@@ -485,18 +462,18 @@ export const getElementSummary = async (
values = []; values = [];
break; break;
} }
case TSurveyElementTypeEnum.PictureSelection: { case TSurveyQuestionTypeEnum.PictureSelection: {
let values: TSurveyElementSummaryPictureSelection["choices"] = []; let values: TSurveyQuestionSummaryPictureSelection["choices"] = [];
const choiceCountMap: Record<string, number> = {}; const choiceCountMap: Record<string, number> = {};
element.choices.forEach((choice) => { question.choices.forEach((choice) => {
choiceCountMap[choice.id] = 0; choiceCountMap[choice.id] = 0;
}); });
let totalResponseCount = 0; let totalResponseCount = 0;
let totalSelectionCount = 0; let totalSelectionCount = 0;
responses.forEach((response) => { responses.forEach((response) => {
const answer = response.data[element.id]; const answer = response.data[question.id];
if (Array.isArray(answer)) { if (Array.isArray(answer)) {
totalResponseCount++; totalResponseCount++;
answer.forEach((value) => { answer.forEach((value) => {
@@ -506,7 +483,7 @@ export const getElementSummary = async (
} }
}); });
element.choices.forEach((choice) => { question.choices.forEach((choice) => {
values.push({ values.push({
id: choice.id, id: choice.id,
imageUrl: choice.imageUrl, imageUrl: choice.imageUrl,
@@ -519,8 +496,8 @@ export const getElementSummary = async (
}); });
summary.push({ summary.push({
type: element.type, type: question.type,
element, question,
responseCount: totalResponseCount, responseCount: totalResponseCount,
selectionCount: totalSelectionCount, selectionCount: totalSelectionCount,
choices: values, choices: values,
@@ -529,10 +506,10 @@ export const getElementSummary = async (
values = []; values = [];
break; break;
} }
case TSurveyElementTypeEnum.Rating: { case TSurveyQuestionTypeEnum.Rating: {
let values: TSurveyElementSummaryRating["choices"] = []; let values: TSurveyQuestionSummaryRating["choices"] = [];
const choiceCountMap: Record<number, number> = {}; const choiceCountMap: Record<number, number> = {};
const range = element.range; const range = question.range;
for (let i = 1; i <= range; i++) { for (let i = 1; i <= range; i++) {
choiceCountMap[i] = 0; choiceCountMap[i] = 0;
@@ -543,12 +520,12 @@ export const getElementSummary = async (
let dismissed = 0; let dismissed = 0;
responses.forEach((response) => { responses.forEach((response) => {
const answer = response.data[element.id]; const answer = response.data[question.id];
if (typeof answer === "number") { if (typeof answer === "number") {
totalResponseCount++; totalResponseCount++;
choiceCountMap[answer]++; choiceCountMap[answer]++;
totalRating += answer; totalRating += answer;
} else if (response.ttc && response.ttc[element.id] > 0) { } else if (response.ttc && response.ttc[question.id] > 0) {
dismissed++; dismissed++;
} }
}); });
@@ -581,8 +558,8 @@ export const getElementSummary = async (
totalResponseCount > 0 ? Math.round((satisfiedCount / totalResponseCount) * 100) : 0; totalResponseCount > 0 ? Math.round((satisfiedCount / totalResponseCount) * 100) : 0;
summary.push({ summary.push({
type: element.type, type: question.type,
element, question,
average: convertFloatTo2Decimal(totalRating / totalResponseCount) || 0, average: convertFloatTo2Decimal(totalRating / totalResponseCount) || 0,
responseCount: totalResponseCount, responseCount: totalResponseCount,
choices: values, choices: values,
@@ -598,7 +575,7 @@ export const getElementSummary = async (
values = []; values = [];
break; break;
} }
case TSurveyElementTypeEnum.NPS: { case TSurveyQuestionTypeEnum.NPS: {
const data = { const data = {
promoters: 0, promoters: 0,
passives: 0, passives: 0,
@@ -615,7 +592,7 @@ export const getElementSummary = async (
} }
responses.forEach((response) => { responses.forEach((response) => {
const value = response.data[element.id]; const value = response.data[question.id];
if (typeof value === "number") { if (typeof value === "number") {
data.total++; data.total++;
scoreCountMap[value]++; scoreCountMap[value]++;
@@ -626,7 +603,7 @@ export const getElementSummary = async (
} else { } else {
data.detractors++; data.detractors++;
} }
} else if (response.ttc && response.ttc[element.id] > 0) { } else if (response.ttc && response.ttc[question.id] > 0) {
data.total++; data.total++;
data.dismissed++; data.dismissed++;
} }
@@ -645,8 +622,8 @@ export const getElementSummary = async (
})); }));
summary.push({ summary.push({
type: element.type, type: question.type,
element, question,
responseCount: data.total, responseCount: data.total,
total: data.total, total: data.total,
score: data.score, score: data.score,
@@ -670,19 +647,14 @@ export const getElementSummary = async (
}); });
break; break;
} }
case TSurveyElementTypeEnum.CTA: { case TSurveyQuestionTypeEnum.CTA: {
// Only calculate summary for CTA elements with external buttons (CTR tracking is only meaningful for external links)
if (!element.buttonExternal) {
break;
}
const data = { const data = {
clicked: 0, clicked: 0,
dismissed: 0, dismissed: 0,
}; };
responses.forEach((response) => { responses.forEach((response) => {
const value = response.data[element.id]; const value = response.data[question.id];
if (value === "clicked") { if (value === "clicked") {
data.clicked++; data.clicked++;
} else if (value === "dismissed") { } else if (value === "dismissed") {
@@ -691,12 +663,12 @@ export const getElementSummary = async (
}); });
const totalResponses = data.clicked + data.dismissed; const totalResponses = data.clicked + data.dismissed;
const idx = elements.findIndex((q) => q.id === element.id); const idx = survey.questions.findIndex((q) => q.id === question.id);
const impressions = dropOff[idx].impressions; const impressions = dropOff[idx].impressions;
summary.push({ summary.push({
type: element.type, type: question.type,
element, question,
impressionCount: impressions, impressionCount: impressions,
clickCount: data.clicked, clickCount: data.clicked,
skipCount: data.dismissed, skipCount: data.dismissed,
@@ -708,17 +680,17 @@ export const getElementSummary = async (
}); });
break; break;
} }
case TSurveyElementTypeEnum.Consent: { case TSurveyQuestionTypeEnum.Consent: {
const data = { const data = {
accepted: 0, accepted: 0,
dismissed: 0, dismissed: 0,
}; };
responses.forEach((response) => { responses.forEach((response) => {
const value = response.data[element.id]; const value = response.data[question.id];
if (value === "accepted") { if (value === "accepted") {
data.accepted++; data.accepted++;
} else if (response.ttc && response.ttc[element.id] > 0) { } else if (response.ttc && response.ttc[question.id] > 0) {
data.dismissed++; data.dismissed++;
} }
}); });
@@ -726,8 +698,8 @@ export const getElementSummary = async (
const totalResponses = data.accepted + data.dismissed; const totalResponses = data.accepted + data.dismissed;
summary.push({ summary.push({
type: element.type, type: question.type,
element, question,
responseCount: totalResponses, responseCount: totalResponses,
accepted: { accepted: {
count: data.accepted, count: data.accepted,
@@ -743,10 +715,10 @@ export const getElementSummary = async (
break; break;
} }
case TSurveyElementTypeEnum.Date: { case TSurveyQuestionTypeEnum.Date: {
let values: TSurveyElementSummaryDate["samples"] = []; let values: TSurveyQuestionSummaryDate["samples"] = [];
responses.forEach((response) => { responses.forEach((response) => {
const answer = response.data[element.id]; const answer = response.data[question.id];
if (answer && typeof answer === "string") { if (answer && typeof answer === "string") {
values.push({ values.push({
id: response.id, id: response.id,
@@ -759,8 +731,8 @@ export const getElementSummary = async (
}); });
summary.push({ summary.push({
type: element.type, type: question.type,
element, question,
responseCount: values.length, responseCount: values.length,
samples: values.slice(0, VALUES_LIMIT), samples: values.slice(0, VALUES_LIMIT),
}); });
@@ -768,10 +740,10 @@ export const getElementSummary = async (
values = []; values = [];
break; break;
} }
case TSurveyElementTypeEnum.FileUpload: { case TSurveyQuestionTypeEnum.FileUpload: {
let values: TSurveyElementSummaryFileUpload["files"] = []; let values: TSurveyQuestionSummaryFileUpload["files"] = [];
responses.forEach((response) => { responses.forEach((response) => {
const answer = response.data[element.id]; const answer = response.data[question.id];
if (Array.isArray(answer)) { if (Array.isArray(answer)) {
values.push({ values.push({
id: response.id, id: response.id,
@@ -784,8 +756,8 @@ export const getElementSummary = async (
}); });
summary.push({ summary.push({
type: element.type, type: question.type,
element, question,
responseCount: values.length, responseCount: values.length,
files: values.slice(0, VALUES_LIMIT), files: values.slice(0, VALUES_LIMIT),
}); });
@@ -793,25 +765,25 @@ export const getElementSummary = async (
values = []; values = [];
break; break;
} }
case TSurveyElementTypeEnum.Cal: { case TSurveyQuestionTypeEnum.Cal: {
const data = { const data = {
booked: 0, booked: 0,
skipped: 0, skipped: 0,
}; };
responses.forEach((response) => { responses.forEach((response) => {
const value = response.data[element.id]; const value = response.data[question.id];
if (value === "booked") { if (value === "booked") {
data.booked++; data.booked++;
} else if (response.ttc && response.ttc[element.id] > 0) { } else if (response.ttc && response.ttc[question.id] > 0) {
data.skipped++; data.skipped++;
} }
}); });
const totalResponses = data.booked + data.skipped; const totalResponses = data.booked + data.skipped;
summary.push({ summary.push({
type: element.type, type: question.type,
element, question,
responseCount: totalResponses, responseCount: totalResponses,
booked: { booked: {
count: data.booked, count: data.booked,
@@ -826,9 +798,9 @@ export const getElementSummary = async (
break; break;
} }
case TSurveyElementTypeEnum.Matrix: { case TSurveyQuestionTypeEnum.Matrix: {
const rows = element.rows.map((row) => getLocalizedValue(row.label, "default")); const rows = question.rows.map((row) => getLocalizedValue(row.label, "default"));
const columns = element.columns.map((column) => getLocalizedValue(column.label, "default")); const columns = question.columns.map((column) => getLocalizedValue(column.label, "default"));
let totalResponseCount = 0; let totalResponseCount = 0;
// Initialize count object // Initialize count object
@@ -841,13 +813,13 @@ export const getElementSummary = async (
}, {}); }, {});
responses.forEach((response) => { responses.forEach((response) => {
const selectedResponses = response.data[element.id] as Record<string, string>; const selectedResponses = response.data[question.id] as Record<string, string>;
const responseLanguageCode = getLanguageCode(survey.languages, response.language); const responseLanguageCode = getLanguageCode(survey.languages, response.language);
if (selectedResponses) { if (selectedResponses) {
totalResponseCount++; totalResponseCount++;
element.rows.forEach((row) => { question.rows.forEach((row) => {
const localizedRow = getLocalizedValue(row.label, responseLanguageCode); const localizedRow = getLocalizedValue(row.label, responseLanguageCode);
const colValue = element.columns.find((column) => { const colValue = question.columns.find((column) => {
return ( return (
getLocalizedValue(column.label, responseLanguageCode) === selectedResponses[localizedRow] getLocalizedValue(column.label, responseLanguageCode) === selectedResponses[localizedRow]
); );
@@ -880,17 +852,18 @@ export const getElementSummary = async (
}); });
summary.push({ summary.push({
type: element.type, type: question.type,
element, question,
responseCount: totalResponseCount, responseCount: totalResponseCount,
data: matrixSummary, data: matrixSummary,
}); });
break; break;
} }
case TSurveyElementTypeEnum.Address: { case TSurveyQuestionTypeEnum.Address:
let values: TSurveyElementSummaryAddress["samples"] = []; case TSurveyQuestionTypeEnum.ContactInfo: {
let values: TSurveyQuestionSummaryAddress["samples"] = [];
responses.forEach((response) => { responses.forEach((response) => {
const answer = response.data[element.id]; const answer = response.data[question.id];
if (Array.isArray(answer) && answer.length > 0) { if (Array.isArray(answer) && answer.length > 0) {
values.push({ values.push({
id: response.id, id: response.id,
@@ -903,8 +876,8 @@ export const getElementSummary = async (
}); });
summary.push({ summary.push({
type: TSurveyElementTypeEnum.Address, type: question.type as TSurveyQuestionTypeEnum.ContactInfo,
element, question: question as TSurveyContactInfoQuestion,
responseCount: values.length, responseCount: values.length,
samples: values.slice(0, VALUES_LIMIT), samples: values.slice(0, VALUES_LIMIT),
}); });
@@ -912,39 +885,13 @@ export const getElementSummary = async (
values = []; values = [];
break; break;
} }
case TSurveyElementTypeEnum.ContactInfo: { case TSurveyQuestionTypeEnum.Ranking: {
let values: TSurveyElementSummaryContactInfo["samples"] = []; let values: TSurveyQuestionSummaryRanking["choices"] = [];
responses.forEach((response) => { const questionChoices = question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
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; let totalResponseCount = 0;
const choiceRankSums: Record<string, number> = {}; const choiceRankSums: Record<string, number> = {};
const choiceCountMap: Record<string, number> = {}; const choiceCountMap: Record<string, number> = {};
questionChoices.forEach((choice) => {
elementChoices.forEach((choice: string) => {
choiceRankSums[choice] = 0; choiceRankSums[choice] = 0;
choiceCountMap[choice] = 0; choiceCountMap[choice] = 0;
}); });
@@ -954,14 +901,14 @@ export const getElementSummary = async (
const answer = const answer =
responseLanguageCode === "default" responseLanguageCode === "default"
? response.data[element.id] ? response.data[question.id]
: checkForI18n(response.data, element.id, elements, responseLanguageCode); : checkForI18n(response.data, question.id, survey, responseLanguageCode);
if (Array.isArray(answer)) { if (Array.isArray(answer)) {
totalResponseCount++; totalResponseCount++;
answer.forEach((value, index) => { answer.forEach((value, index) => {
const ranking = index + 1; // Calculate ranking based on index const ranking = index + 1; // Calculate ranking based on index
if (elementChoices.includes(value)) { if (questionChoices.includes(value)) {
choiceRankSums[value] += ranking; choiceRankSums[value] += ranking;
choiceCountMap[value]++; choiceCountMap[value]++;
} }
@@ -969,7 +916,7 @@ export const getElementSummary = async (
} }
}); });
elementChoices.forEach((choice: string) => { questionChoices.forEach((choice) => {
const count = choiceCountMap[choice]; const count = choiceCountMap[choice];
const avgRanking = count > 0 ? choiceRankSums[choice] / count : 0; const avgRanking = count > 0 ? choiceRankSums[choice] / count : 0;
values.push({ values.push({
@@ -980,8 +927,8 @@ export const getElementSummary = async (
}); });
summary.push({ summary.push({
type: element.type, type: question.type,
element, question,
responseCount: totalResponseCount, responseCount: totalResponseCount,
choices: values, choices: values,
}); });
@@ -992,7 +939,7 @@ export const getElementSummary = async (
} }
survey.hiddenFields?.fieldIds?.forEach((hiddenFieldId) => { survey.hiddenFields?.fieldIds?.forEach((hiddenFieldId) => {
let values: TSurveyElementSummaryHiddenFields["samples"] = []; let values: TSurveyQuestionSummaryHiddenFields["samples"] = [];
responses.forEach((response) => { responses.forEach((response) => {
const answer = response.data[hiddenFieldId]; const answer = response.data[hiddenFieldId];
if (answer && typeof answer === "string") { if (answer && typeof answer === "string") {
@@ -1028,8 +975,6 @@ export const getSurveySummary = reactCache(
throw new ResourceNotFoundError("Survey", surveyId); throw new ResourceNotFoundError("Survey", surveyId);
} }
const elements = getElementsFromBlocks(survey.blocks);
const batchSize = 5000; const batchSize = 5000;
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0; const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
@@ -1060,16 +1005,16 @@ export const getSurveySummary = reactCache(
getQuotasSummary(surveyId), getQuotasSummary(surveyId),
]); ]);
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount); const dropOff = getSurveySummaryDropOff(survey, responses, displayCount);
const [meta, elementSummary] = await Promise.all([ const [meta, questionWiseSummary] = await Promise.all([
getSurveySummaryMeta(responses, displayCount, quotas), getSurveySummaryMeta(responses, displayCount, quotas),
getElementSummary(survey, elements, responses, dropOff), getQuestionSummary(survey, responses, dropOff),
]); ]);
return { return {
meta, meta,
dropOff, dropOff,
summary: elementSummary, summary: questionWiseSummary,
quotas, quotas,
}; };
} catch (error) { } catch (error) {

View File

@@ -1,6 +1,5 @@
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys/types";
import { constructToastMessage, convertFloatTo2Decimal, convertFloatToNDecimal } from "./utils"; import { constructToastMessage, convertFloatTo2Decimal, convertFloatToNDecimal } from "./utils";
describe("Utils Tests", () => { describe("Utils Tests", () => {
@@ -35,40 +34,29 @@ describe("Utils Tests", () => {
type: "app", type: "app",
environmentId: "env1", environmentId: "env1",
status: "draft", status: "draft",
blocks: [ questions: [
{ {
id: "block1", id: "q1",
name: "Block 1", type: TSurveyQuestionTypeEnum.OpenText,
elements: [ headline: { default: "Q1" },
{ required: false,
id: "q1", } as unknown as TSurveyQuestion,
type: TSurveyElementTypeEnum.OpenText, {
headline: { default: "Q1" }, id: "q2",
required: false, type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
charLimit: { enabled: false }, headline: { default: "Q2" },
}, required: false,
{ choices: [{ id: "c1", label: { default: "Choice 1" } }],
id: "q2", },
type: TSurveyElementTypeEnum.MultipleChoiceSingle, {
headline: { default: "Q2" }, id: "q3",
required: false, type: TSurveyQuestionTypeEnum.Matrix,
choices: [{ id: "c1", label: { default: "Choice 1" } }], headline: { default: "Q3" },
buttonLabel: { default: "Next" }, required: false,
shuffleOption: "none", rows: [{ id: "r1", label: { default: "Row 1" } }],
}, columns: [{ id: "col1", label: { default: "Col 1" } }],
{
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: [], triggers: [],
recontactDays: null, recontactDays: null,
autoClose: null, autoClose: null,
@@ -86,7 +74,7 @@ describe("Utils Tests", () => {
test("should construct message for matrix question type", () => { test("should construct message for matrix question type", () => {
const message = constructToastMessage( const message = constructToastMessage(
TSurveyElementTypeEnum.Matrix, TSurveyQuestionTypeEnum.Matrix,
"is", "is",
mockSurvey, mockSurvey,
"q3", "q3",
@@ -107,7 +95,7 @@ describe("Utils Tests", () => {
}); });
test("should construct message for matrix question type with array filterComboBoxValue", () => { test("should construct message for matrix question type with array filterComboBoxValue", () => {
const message = constructToastMessage(TSurveyElementTypeEnum.Matrix, "is", mockSurvey, "q3", mockT, [ const message = constructToastMessage(TSurveyQuestionTypeEnum.Matrix, "is", mockSurvey, "q3", mockT, [
"MatrixValue1", "MatrixValue1",
"MatrixValue2", "MatrixValue2",
]); ]);
@@ -126,7 +114,7 @@ describe("Utils Tests", () => {
test("should construct message when filterComboBoxValue is undefined (skipped)", () => { test("should construct message when filterComboBoxValue is undefined (skipped)", () => {
const message = constructToastMessage( const message = constructToastMessage(
TSurveyElementTypeEnum.OpenText, TSurveyQuestionTypeEnum.OpenText,
"is skipped", "is skipped",
mockSurvey, mockSurvey,
"q1", "q1",
@@ -146,7 +134,7 @@ describe("Utils Tests", () => {
test("should construct message for non-matrix question with string filterComboBoxValue", () => { test("should construct message for non-matrix question with string filterComboBoxValue", () => {
const message = constructToastMessage( const message = constructToastMessage(
TSurveyElementTypeEnum.MultipleChoiceSingle, TSurveyQuestionTypeEnum.MultipleChoiceSingle,
"is", "is",
mockSurvey, mockSurvey,
"q2", "q2",
@@ -168,7 +156,7 @@ describe("Utils Tests", () => {
test("should construct message for non-matrix question with array filterComboBoxValue", () => { test("should construct message for non-matrix question with array filterComboBoxValue", () => {
const message = constructToastMessage( const message = constructToastMessage(
TSurveyElementTypeEnum.MultipleChoiceMulti, TSurveyQuestionTypeEnum.MultipleChoiceMulti,
"includes all of", "includes all of",
mockSurvey, mockSurvey,
"q2", // Assuming q2 can be multi for this test case logic "q2", // Assuming q2 can be multi for this test case logic
@@ -190,7 +178,7 @@ describe("Utils Tests", () => {
test("should handle questionId not found in survey", () => { test("should handle questionId not found in survey", () => {
const message = constructToastMessage( const message = constructToastMessage(
TSurveyElementTypeEnum.OpenText, TSurveyQuestionTypeEnum.OpenText,
"is", "is",
mockSurvey, mockSurvey,
"qNonExistent", "qNonExistent",

View File

@@ -1,7 +1,5 @@
import { TFunction } from "i18next"; import { TFunction } from "i18next";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurvey, TSurveyQuestionId, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
export const convertFloatToNDecimal = (num: number, N: number = 2) => { export const convertFloatToNDecimal = (num: number, N: number = 2) => {
return Math.round(num * Math.pow(10, N)) / Math.pow(10, N); return Math.round(num * Math.pow(10, N)) / Math.pow(10, N);
@@ -12,28 +10,27 @@ export const convertFloatTo2Decimal = (num: number) => {
}; };
export const constructToastMessage = ( export const constructToastMessage = (
elementType: TSurveyElementTypeEnum, questionType: TSurveyQuestionTypeEnum,
filterValue: string, filterValue: string,
survey: TSurvey, survey: TSurvey,
elementId: string, questionId: TSurveyQuestionId,
t: TFunction, t: TFunction,
filterComboBoxValue?: string | string[] filterComboBoxValue?: string | string[]
) => { ) => {
const elements = getElementsFromBlocks(survey.blocks); const questionIdx = survey.questions.findIndex((question) => question.id === questionId);
const elementIdx = elements.findIndex((element) => element.id === elementId); if (questionType === "matrix") {
if (elementType === "matrix") {
return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question", { return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question", {
questionIdx: elementIdx + 1, questionIdx: questionIdx + 1,
filterComboBoxValue: filterComboBoxValue?.toString() ?? "", filterComboBoxValue: filterComboBoxValue?.toString() ?? "",
filterValue, filterValue,
}); });
} else if (filterComboBoxValue === undefined) { } else if (filterComboBoxValue === undefined) {
return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question_is_skipped", { return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question_is_skipped", {
questionIdx: elementIdx + 1, questionIdx: questionIdx + 1,
}); });
} else { } else {
return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question", { return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question", {
questionIdx: elementIdx + 1, questionIdx: questionIdx + 1,
filterComboBoxValue: Array.isArray(filterComboBoxValue) filterComboBoxValue: Array.isArray(filterComboBoxValue)
? filterComboBoxValue.join(",") ? filterComboBoxValue.join(",")
: filterComboBoxValue, : filterComboBoxValue,

View File

@@ -25,7 +25,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { import {
DateRange, DateRange,
useResponseFilter, useResponseFilter,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context"; } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions"; import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils"; import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys"; import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys";
@@ -164,12 +164,12 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
const datePickerRef = useRef<HTMLDivElement>(null); const datePickerRef = useRef<HTMLDivElement>(null);
const extractMetadataKeys = useCallback((obj, parentKey = "") => { const extracMetadataKeys = useCallback((obj, parentKey = "") => {
let keys: string[] = []; let keys: string[] = [];
for (let key in obj) { for (let key in obj) {
if (typeof obj[key] === "object" && obj[key] !== null) { if (typeof obj[key] === "object" && obj[key] !== null) {
keys = keys.concat(extractMetadataKeys(obj[key], parentKey + key + " - ")); keys = keys.concat(extracMetadataKeys(obj[key], parentKey + key + " - "));
} else { } else {
keys.push(parentKey + key); keys.push(parentKey + key);
} }

View File

@@ -4,9 +4,8 @@ import clsx from "clsx";
import { ChevronDown, ChevronUp, X } from "lucide-react"; import { ChevronDown, ChevronUp, X } from "lucide-react";
import { useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TI18nString } from "@formbricks/types/i18n"; import { TI18nString, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; 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 { getLocalizedValue } from "@/lib/i18n/utils";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside"; import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -26,52 +25,20 @@ import {
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
const DEFAULT_LANGUAGE_CODE = "default"; type QuestionFilterComboBoxProps = {
// 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; filterOptions: (string | TI18nString)[] | undefined;
filterComboBoxOptions: (string | TI18nString)[] | undefined; filterComboBoxOptions: (string | TI18nString)[] | undefined;
filterValue: string | undefined; filterValue: string | undefined;
filterComboBoxValue: string | string[] | undefined; filterComboBoxValue: string | string[] | undefined;
onChangeFilterValue: (o: string) => void; onChangeFilterValue: (o: string) => void;
onChangeFilterComboBoxValue: (o: string | string[]) => void; onChangeFilterComboBoxValue: (o: string | string[]) => void;
type?: TSurveyElementTypeEnum | Omit<OptionsType, OptionsType.ELEMENTS>; type?: TSurveyQuestionTypeEnum | Omit<OptionsType, OptionsType.QUESTIONS>;
handleRemoveMultiSelect: (value: string[]) => void; handleRemoveMultiSelect: (value: string[]) => void;
disabled?: boolean; disabled?: boolean;
fieldId?: string; fieldId?: string;
}; };
// Helper function to check if multiple selection is allowed export const QuestionFilterComboBox = ({
const checkIsMultiple = (
type: TSurveyElementTypeEnum | Omit<OptionsType, OptionsType.ELEMENTS> | undefined,
filterValue: string | undefined
): boolean => {
const isMultiSelectType =
type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
type === TSurveyElementTypeEnum.PictureSelection;
const isNPSIncludesEither = type === TSurveyElementTypeEnum.NPS && filterValue === "Includes either";
return isMultiSelectType || isNPSIncludesEither;
};
// Helper function to check if combo box should be disabled
const checkIsDisabledComboBox = (
type: TSurveyElementTypeEnum | Omit<OptionsType, OptionsType.ELEMENTS> | undefined,
filterValue: string | undefined
): boolean => {
const isNPSOrRating = type === TSurveyElementTypeEnum.NPS || type === TSurveyElementTypeEnum.Rating;
const isSubmittedOrSkipped = filterValue === "Submitted" || filterValue === "Skipped";
return isNPSOrRating && isSubmittedOrSkipped;
};
export const ElementFilterComboBox = ({
filterComboBoxOptions, filterComboBoxOptions,
filterComboBoxValue, filterComboBoxValue,
filterOptions, filterOptions,
@@ -82,7 +49,7 @@ export const ElementFilterComboBox = ({
handleRemoveMultiSelect, handleRemoveMultiSelect,
disabled = false, disabled = false,
fieldId, fieldId,
}: ElementFilterComboBoxProps) => { }: QuestionFilterComboBoxProps) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const commandRef = useRef(null); const commandRef = useRef(null);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -90,19 +57,32 @@ export const ElementFilterComboBox = ({
useClickOutside(commandRef, () => setOpen(false)); useClickOutside(commandRef, () => setOpen(false));
const isMultiple = checkIsMultiple(type, filterValue); 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]
);
// Filter out already selected options for multi-select // Filter out already selected options for multi-select
const options = useMemo(() => { const options = useMemo(() => {
if (!isMultiple) return filterComboBoxOptions; if (!isMultiple) return filterComboBoxOptions;
return filterComboBoxOptions?.filter((o) => { return filterComboBoxOptions?.filter((o) => {
const optionValue = getOptionValue(o); const optionValue = typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
return !filterComboBoxValue?.includes(optionValue); return !filterComboBoxValue?.includes(optionValue);
}); });
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue]); }, [isMultiple, filterComboBoxOptions, filterComboBoxValue, defaultLanguageCode]);
const isDisabledComboBox = checkIsDisabledComboBox(type, filterValue); // Disable combo box for NPS/Rating when Submitted/Skipped
const isDisabledComboBox =
(type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) &&
(filterValue === "Submitted" || filterValue === "Skipped");
// Check if this is a text input field (URL meta field) // Check if this is a text input field (URL meta field)
const isTextInputField = type === OptionsType.META && fieldId === "url"; const isTextInputField = type === OptionsType.META && fieldId === "url";
@@ -111,14 +91,15 @@ export const ElementFilterComboBox = ({
const filteredOptions = useMemo( const filteredOptions = useMemo(
() => () =>
options?.filter((o) => { options?.filter((o) => {
const optionValue = getOptionValue(o); const optionValue =
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
return optionValue.toLowerCase().includes(searchQuery.toLowerCase()); return optionValue.toLowerCase().includes(searchQuery.toLowerCase());
}), }),
[options, searchQuery] [options, searchQuery, defaultLanguageCode]
); );
const handleCommandItemSelect = (o: string | TI18nString) => { const handleCommandItemSelect = (o: string | TI18nString) => {
const value = getOptionValue(o); const value = typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
if (isMultiple) { if (isMultiple) {
const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value]; const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value];
@@ -131,56 +112,12 @@ export const ElementFilterComboBox = ({
}; };
const isComboBoxDisabled = disabled || isDisabledComboBox || !filterValue; const isComboBoxDisabled = disabled || isDisabledComboBox || !filterValue;
const ChevronIcon = open ? ChevronUp : ChevronDown;
// Render filter options dropdown
const renderFilterOptionsDropdown = () => {
if (!filterOptions || filterOptions.length <= 1) {
return (
<div className="flex h-9 max-w-fit items-center rounded-md rounded-r-none border-r border-slate-300 bg-white px-2 text-sm text-slate-600">
<p className="mr-1 max-w-[50px] truncate sm:max-w-[100px]">{filterValue}</p>
</div>
);
}
return (
<DropdownMenu
onOpenChange={(value) => {
if (value) setOpen(false);
}}>
<DropdownMenuTrigger
disabled={disabled}
className={clsx(
"flex h-9 max-w-fit items-center justify-between gap-2 rounded-md rounded-r-none border-r border-slate-300 bg-white px-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
disabled ? "opacity-50" : "cursor-pointer hover:bg-slate-50"
)}>
{filterValue ? (
<p className="max-w-[50px] truncate sm:max-w-[80px]">{filterValue}</p>
) : (
<p className="text-slate-400">{t("common.select")}...</p>
)}
{filterOptions.length > 1 && <ChevronIcon className="h-4 w-4 flex-shrink-0 opacity-50" />}
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-white">
{filterOptions.map((o, index) => {
const optionValue = getOptionValue(o);
return (
<DropdownMenuItem
key={`${optionValue}-${index}`}
className="cursor-pointer"
onClick={() => onChangeFilterValue(optionValue)}>
{optionValue}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
};
const handleOpenDropdown = () => { const handleOpenDropdown = () => {
if (isComboBoxDisabled) return; if (isComboBoxDisabled) return;
setOpen(true); setOpen(true);
}; };
const ChevronIcon = open ? ChevronUp : ChevronDown;
// Helper to filter out a specific value from the array // Helper to filter out a specific value from the array
const getFilteredValues = (valueToRemove: string): string[] => { const getFilteredValues = (valueToRemove: string): string[] => {
@@ -239,7 +176,46 @@ export const ElementFilterComboBox = ({
return ( return (
<div className="inline-flex h-fit w-full flex-row rounded-md border border-slate-300 hover:border-slate-400"> <div className="inline-flex h-fit w-full flex-row rounded-md border border-slate-300 hover:border-slate-400">
{renderFilterOptionsDropdown()} {filterOptions && filterOptions.length <= 1 ? (
<div className="flex h-9 max-w-fit items-center rounded-md rounded-r-none border-r border-slate-300 bg-white px-2 text-sm text-slate-600">
<p className="mr-1 max-w-[50px] truncate sm:max-w-[100px]">{filterValue}</p>
</div>
) : (
<DropdownMenu
onOpenChange={(value) => {
if (value) setOpen(false);
}}>
<DropdownMenuTrigger
disabled={disabled}
className={clsx(
"flex h-9 max-w-fit items-center justify-between gap-2 rounded-md rounded-r-none border-r border-slate-300 bg-white px-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
disabled ? "opacity-50" : "cursor-pointer hover:bg-slate-50"
)}>
{filterValue ? (
<p className="max-w-[50px] truncate sm:max-w-[80px]">{filterValue}</p>
) : (
<p className="text-slate-400">{t("common.select")}...</p>
)}
{filterOptions && filterOptions.length > 1 && (
<ChevronIcon className="h-4 w-4 flex-shrink-0 opacity-50" />
)}
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-white">
{filterOptions?.map((o, index) => {
const optionValue =
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
return (
<DropdownMenuItem
key={`${optionValue}-${index}`}
className="cursor-pointer"
onClick={() => onChangeFilterValue(optionValue)}>
{optionValue}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
{isTextInputField ? ( {isTextInputField ? (
<Input <Input
@@ -298,7 +274,8 @@ export const ElementFilterComboBox = ({
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty> <CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
<CommandGroup> <CommandGroup>
{filteredOptions?.map((o) => { {filteredOptions?.map((o) => {
const optionValue = getOptionValue(o); const optionValue =
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
return ( return (
<CommandItem <CommandItem
key={optionValue} key={optionValue}

View File

@@ -29,7 +29,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Fragment, useRef, useState } from "react"; import { Fragment, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside"; import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -44,7 +44,7 @@ import {
import { NetPromoterScoreIcon } from "@/modules/ui/components/icons"; import { NetPromoterScoreIcon } from "@/modules/ui/components/icons";
export enum OptionsType { export enum OptionsType {
ELEMENTS = "Elements", QUESTIONS = "Questions",
TAGS = "Tags", TAGS = "Tags",
ATTRIBUTES = "Attributes", ATTRIBUTES = "Attributes",
OTHERS = "Other Filters", OTHERS = "Other Filters",
@@ -53,37 +53,37 @@ export enum OptionsType {
QUOTAS = "Quotas", QUOTAS = "Quotas",
} }
export type ElementOption = { export type QuestionOption = {
label: string; label: string;
elementType?: TSurveyElementTypeEnum; questionType?: TSurveyQuestionTypeEnum;
type: OptionsType; type: OptionsType;
id: string; id: string;
}; };
export type ElementOptions = { export type QuestionOptions = {
header: OptionsType; header: OptionsType;
option: ElementOption[]; option: QuestionOption[];
}; };
interface ElementComboBoxProps { interface QuestionComboBoxProps {
options: ElementOptions[]; options: QuestionOptions[];
selected: Partial<ElementOption>; selected: Partial<QuestionOption>;
onChangeValue: (option: ElementOption) => void; onChangeValue: (option: QuestionOption) => void;
} }
const elementIcons = { const questionIcons = {
// elements // questions
[TSurveyElementTypeEnum.OpenText]: MessageSquareTextIcon, [TSurveyQuestionTypeEnum.OpenText]: MessageSquareTextIcon,
[TSurveyElementTypeEnum.Rating]: StarIcon, [TSurveyQuestionTypeEnum.Rating]: StarIcon,
[TSurveyElementTypeEnum.CTA]: MousePointerClickIcon, [TSurveyQuestionTypeEnum.CTA]: MousePointerClickIcon,
[TSurveyElementTypeEnum.MultipleChoiceMulti]: ListIcon, [TSurveyQuestionTypeEnum.MultipleChoiceMulti]: ListIcon,
[TSurveyElementTypeEnum.MultipleChoiceSingle]: Rows3Icon, [TSurveyQuestionTypeEnum.MultipleChoiceSingle]: Rows3Icon,
[TSurveyElementTypeEnum.NPS]: NetPromoterScoreIcon, [TSurveyQuestionTypeEnum.NPS]: NetPromoterScoreIcon,
[TSurveyElementTypeEnum.Consent]: CheckIcon, [TSurveyQuestionTypeEnum.Consent]: CheckIcon,
[TSurveyElementTypeEnum.PictureSelection]: ImageIcon, [TSurveyQuestionTypeEnum.PictureSelection]: ImageIcon,
[TSurveyElementTypeEnum.Matrix]: GridIcon, [TSurveyQuestionTypeEnum.Matrix]: GridIcon,
[TSurveyElementTypeEnum.Ranking]: ListOrderedIcon, [TSurveyQuestionTypeEnum.Ranking]: ListOrderedIcon,
[TSurveyElementTypeEnum.Address]: HomeIcon, [TSurveyQuestionTypeEnum.Address]: HomeIcon,
[TSurveyElementTypeEnum.ContactInfo]: ContactIcon, [TSurveyQuestionTypeEnum.ContactInfo]: ContactIcon,
// attributes // attributes
[OptionsType.ATTRIBUTES]: User, [OptionsType.ATTRIBUTES]: User,
@@ -111,14 +111,14 @@ const elementIcons = {
}; };
const getIcon = (type: string) => { const getIcon = (type: string) => {
const IconComponent = elementIcons[type]; const IconComponent = questionIcons[type];
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null; return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
}; };
const getIconBackground = (type: OptionsType | string): string => { const getIconBackground = (type: OptionsType | string): string => {
const backgroundMap: Record<string, string> = { const backgroundMap: Record<string, string> = {
[OptionsType.ATTRIBUTES]: "bg-indigo-500", [OptionsType.ATTRIBUTES]: "bg-indigo-500",
[OptionsType.ELEMENTS]: "bg-brand-dark", [OptionsType.QUESTIONS]: "bg-brand-dark",
[OptionsType.TAGS]: "bg-indigo-500", [OptionsType.TAGS]: "bg-indigo-500",
[OptionsType.QUOTAS]: "bg-slate-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"; return label === "os" || label === "url" ? "uppercase" : "capitalize";
}; };
export const SelectedCommandItem = ({ label, elementType, type }: Partial<ElementOption>) => { export const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
const getDisplayIcon = () => { const getDisplayIcon = () => {
if (!type) return null; if (!type) return null;
if (type === OptionsType.ELEMENTS && elementType) return getIcon(elementType); if (type === OptionsType.QUESTIONS && questionType) return getIcon(questionType);
if (type === OptionsType.ATTRIBUTES) return getIcon(OptionsType.ATTRIBUTES); if (type === OptionsType.ATTRIBUTES) return getIcon(OptionsType.ATTRIBUTES);
if (type === OptionsType.HIDDEN_FIELDS) return getIcon(OptionsType.HIDDEN_FIELDS); if (type === OptionsType.HIDDEN_FIELDS) return getIcon(OptionsType.HIDDEN_FIELDS);
if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) return getIcon(label); if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) return getIcon(label);
@@ -158,7 +158,7 @@ export const SelectedCommandItem = ({ label, elementType, type }: Partial<Elemen
); );
}; };
export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementComboBoxProps) => { export const QuestionsComboBox = ({ options, selected, onChangeValue }: QuestionComboBoxProps) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const commandRef = useRef(null); const commandRef = useRef(null);
@@ -209,7 +209,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
{open && ( {open && (
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md shadow-md outline-none"> <div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md shadow-md outline-none">
<CommandList className="max-h-[600px]"> <CommandList>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty> <CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
{options?.map((data) => ( {options?.map((data) => (
<Fragment key={data.header}> <Fragment key={data.header}>

View File

@@ -4,17 +4,15 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react"; import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TI18nString } from "@formbricks/types/i18n"; import { TI18nString, TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { import {
SelectedFilterValue, SelectedFilterValue,
TResponseStatus, TResponseStatus,
useResponseFilter, useResponseFilter,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context"; } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions"; import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { ElementFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementFilterComboBox"; import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox";
import { generateElementAndFilterOptions } from "@/app/lib/surveys/surveys"; import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
@@ -25,11 +23,11 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/modules/ui/components/select"; } from "@/modules/ui/components/select";
import { ElementOption, ElementsComboBox, OptionsType } from "./ElementsComboBox"; import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
export type ElementFilterOptions = { export type QuestionFilterOptions = {
type: type:
| TSurveyElementTypeEnum | TSurveyQuestionTypeEnum
| "Attributes" | "Attributes"
| "Tags" | "Tags"
| "Languages" | "Languages"
@@ -80,7 +78,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
const [isOpen, setIsOpen] = useState<boolean>(false); const [isOpen, setIsOpen] = useState<boolean>(false);
const [filterValue, setFilterValue] = useState<SelectedFilterValue>(selectedFilter); const [filterValue, setFilterValue] = useState<SelectedFilterValue>(selectedFilter);
const getDefaultFilterValue = (option?: ElementFilterOptions): string | undefined => { const getDefaultFilterValue = (option?: QuestionFilterOptions): string | undefined => {
if (!option || option.filterOptions.length === 0) return undefined; if (!option || option.filterOptions.length === 0) return undefined;
const firstOption = option.filterOptions[0]; const firstOption = option.filterOptions[0];
return typeof firstOption === "object" ? getLocalizedValue(firstOption, "default") : firstOption; return typeof firstOption === "object" ? getLocalizedValue(firstOption, "default") : firstOption;
@@ -95,7 +93,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
if (!surveyFilterData?.data) return; if (!surveyFilterData?.data) return;
const { attributes, meta, environmentTags, hiddenFields, quotas } = surveyFilterData.data; const { attributes, meta, environmentTags, hiddenFields, quotas } = surveyFilterData.data;
const { elementFilterOptions, elementOptions } = generateElementAndFilterOptions( const { questionFilterOptions, questionOptions } = generateQuestionAndFilterOptions(
survey, survey,
environmentTags, environmentTags,
attributes, attributes,
@@ -103,23 +101,23 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
hiddenFields, hiddenFields,
quotas quotas
); );
setSelectedOptions({ elementFilterOptions: elementFilterOptions, elementOptions: elementOptions }); setSelectedOptions({ questionFilterOptions, questionOptions });
} }
}; };
handleInitialData(); handleInitialData();
}, [isOpen, setSelectedOptions, survey]); }, [isOpen, setSelectedOptions, survey]);
const handleOnChangeElementComboBoxValue = (value: ElementOption, index: number) => { const handleOnChangeQuestionComboBoxValue = (value: QuestionOption, index: number) => {
const matchingFilterOption = selectedOptions.elementFilterOptions.find( const matchingFilterOption = selectedOptions.questionFilterOptions.find(
(q) => q.type === value.type || q.type === value.elementType (q) => q.type === value.type || q.type === value.questionType
); );
const defaultFilterValue = getDefaultFilterValue(matchingFilterOption); const defaultFilterValue = getDefaultFilterValue(matchingFilterOption);
if (filterValue.filter[index].elementType) { if (filterValue.filter[index].questionType) {
// Create a new array and copy existing values from SelectedFilter // Create a new array and copy existing values from SelectedFilter
filterValue.filter[index] = { filterValue.filter[index] = {
elementType: value, questionType: value,
filterType: { filterType: {
filterComboBoxValue: undefined, filterComboBoxValue: undefined,
filterValue: defaultFilterValue, filterValue: defaultFilterValue,
@@ -128,7 +126,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
setFilterValue({ filter: [...filterValue.filter], responseStatus: filterValue.responseStatus }); setFilterValue({ filter: [...filterValue.filter], responseStatus: filterValue.responseStatus });
} else { } else {
// Update the existing value at the specified index // Update the existing value at the specified index
filterValue.filter[index].elementType = value; filterValue.filter[index].questionType = value;
filterValue.filter[index].filterType = { filterValue.filter[index].filterType = {
filterComboBoxValue: undefined, filterComboBoxValue: undefined,
filterValue: defaultFilterValue, filterValue: defaultFilterValue,
@@ -141,8 +139,8 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
const clearItem = () => { const clearItem = () => {
setFilterValue({ setFilterValue({
filter: filterValue.filter.filter((s) => { filter: filterValue.filter.filter((s) => {
// keep the filter if elementType is selected and filterComboBoxValue is selected // keep the filter if questionType is selected and filterComboBoxValue is selected
return s.elementType.hasOwnProperty("label") && s.filterType.filterComboBoxValue?.length; return s.questionType.hasOwnProperty("label") && s.filterType.filterComboBoxValue?.length;
}), }),
responseStatus: filterValue.responseStatus, responseStatus: filterValue.responseStatus,
}); });
@@ -162,7 +160,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
filter: [ filter: [
...filterValue.filter, ...filterValue.filter,
{ {
elementType: {}, questionType: {},
filterType: { filterComboBoxValue: undefined, filterValue: undefined }, filterType: { filterComboBoxValue: undefined, filterValue: undefined },
}, },
], ],
@@ -214,10 +212,10 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
}; };
// remove the filter which has already been selected // remove the filter which has already been selected
const elementComboBoxOptions = selectedOptions.elementOptions.map((q) => { const questionComboBoxOptions = selectedOptions.questionOptions.map((q) => {
return { return {
...q, ...q,
option: q.option.filter((o) => !filterValue.filter.some((f) => f?.elementType?.id === o?.id)), option: q.option.filter((o) => !filterValue.filter.some((f) => f?.questionType?.id === o?.id)),
}; };
}); });
@@ -280,41 +278,41 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
<div className="flex w-full flex-wrap gap-3 md:flex-nowrap"> <div className="flex w-full flex-wrap gap-3 md:flex-nowrap">
<div <div
className="grid w-full grid-cols-1 items-center gap-3 md:grid-cols-2" className="grid w-full grid-cols-1 items-center gap-3 md:grid-cols-2"
key={`${s.elementType.id}-${i}-${s.elementType.label}`}> key={`${s.questionType.id}-${i}-${s.questionType.label}`}>
<ElementsComboBox <QuestionsComboBox
key={`${s.elementType.label}-${i}-${s.elementType.id}`} key={`${s.questionType.label}-${i}-${s.questionType.id}`}
options={elementComboBoxOptions} options={questionComboBoxOptions}
selected={s.elementType} selected={s.questionType}
onChangeValue={(value) => handleOnChangeElementComboBoxValue(value, i)} onChangeValue={(value) => handleOnChangeQuestionComboBoxValue(value, i)}
/> />
<ElementFilterComboBox <QuestionFilterComboBox
key={`${s.elementType.id}-${i}`} key={`${s.questionType.id}-${i}`}
filterOptions={ filterOptions={
selectedOptions.elementFilterOptions.find( selectedOptions.questionFilterOptions.find(
(q) => (q) =>
(q.type === s.elementType.elementType || q.type === s.elementType.type) && (q.type === s.questionType.questionType || q.type === s.questionType.type) &&
q.id === s.elementType.id q.id === s.questionType.id
)?.filterOptions )?.filterOptions
} }
filterComboBoxOptions={ filterComboBoxOptions={
selectedOptions.elementFilterOptions.find( selectedOptions.questionFilterOptions.find(
(q) => (q) =>
(q.type === s.elementType.elementType || q.type === s.elementType.type) && (q.type === s.questionType.questionType || q.type === s.questionType.type) &&
q.id === s.elementType.id q.id === s.questionType.id
)?.filterComboBoxOptions )?.filterComboBoxOptions
} }
filterValue={filterValue.filter[i].filterType.filterValue} filterValue={filterValue.filter[i].filterType.filterValue}
filterComboBoxValue={filterValue.filter[i].filterType.filterComboBoxValue} filterComboBoxValue={filterValue.filter[i].filterType.filterComboBoxValue}
type={ type={
s?.elementType?.type === OptionsType.ELEMENTS s?.questionType?.type === OptionsType.QUESTIONS
? s?.elementType?.elementType ? s?.questionType?.questionType
: s?.elementType?.type : s?.questionType?.type
} }
fieldId={s?.elementType?.id} fieldId={s?.questionType?.id}
handleRemoveMultiSelect={(value) => handleRemoveMultiSelect(value, i)} handleRemoveMultiSelect={(value) => handleRemoveMultiSelect(value, i)}
onChangeFilterComboBoxValue={(value) => handleOnChangeFilterComboBoxValue(value, i)} onChangeFilterComboBoxValue={(value) => handleOnChangeFilterComboBoxValue(value, i)}
onChangeFilterValue={(value) => handleOnChangeFilterValue(value, i)} onChangeFilterValue={(value) => handleOnChangeFilterValue(value, i)}
disabled={!s?.elementType?.label} disabled={!s?.questionType?.label}
/> />
</div> </div>
<div className="flex w-full items-center justify-end gap-1 md:w-auto"> <div className="flex w-full items-center justify-end gap-1 md:w-auto">

View File

@@ -1,9 +1,12 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { Suspense } from "react";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper"; 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 { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout"; import { ClientLogout } from "@/modules/ui/components/client-logout";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay"; 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"; import { ToasterClient } from "@/modules/ui/components/toaster-client";
const AppLayout = async ({ children }) => { const AppLayout = async ({ children }) => {
@@ -18,9 +21,20 @@ const AppLayout = async ({ children }) => {
return ( return (
<> <>
<NoMobileOverlay /> <NoMobileOverlay />
<IntercomClientWrapper user={user} /> <Suspense>
<ToasterClient /> <PostHogPageview
{children} posthogEnabled={IS_POSTHOG_CONFIGURED}
postHogApiHost={POSTHOG_API_HOST}
postHogApiKey={POSTHOG_API_KEY}
/>
</Suspense>
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
<>
<IntercomClientWrapper user={user} />
<ToasterClient />
{children}
</>
</PHProvider>
</> </>
); );
}; };

View File

@@ -23,8 +23,12 @@ import {
TIntegrationSlackCredential, TIntegrationSlackCredential,
} from "@formbricks/types/integration/slack"; } from "@formbricks/types/integration/slack";
import { TResponse, TResponseMeta } from "@formbricks/types/responses"; import { TResponse, TResponseMeta } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import {
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; TSurvey,
TSurveyOpenTextQuestion,
TSurveyPictureSelectionQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines"; import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { writeData as airtableWriteData } from "@/lib/airtable/service"; import { writeData as airtableWriteData } from "@/lib/airtable/service";
import { writeData as googleSheetWriteData } from "@/lib/googleSheet/service"; import { writeData as googleSheetWriteData } from "@/lib/googleSheet/service";
@@ -97,47 +101,33 @@ const mockPipelineInput = {
const mockSurvey = { const mockSurvey = {
id: surveyId, id: surveyId,
name: "Test Survey", name: "Test Survey",
blocks: [ questions: [
{ {
id: "block1", id: questionId1,
name: "Block 1", type: TSurveyQuestionTypeEnum.OpenText,
elements: [ headline: { default: "Question 1 {{recall:q2}}" },
{ required: true,
id: questionId1, } as unknown as TSurveyOpenTextQuestion,
type: TSurveyElementTypeEnum.OpenText, {
headline: { default: "Question 1 {{recall:q2}}" }, id: questionId2,
required: true, type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
inputType: "text", headline: { default: "Question 2" },
charLimit: 1000, required: true,
subheader: { default: "" }, choices: [
placeholder: { default: "" }, { id: "choice1", label: { default: "Choice 1" } },
}, { id: "choice2", label: { default: "Choice 2" } },
{
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: { hiddenFields: {
enabled: true, enabled: true,
@@ -172,7 +162,7 @@ const mockAirtableIntegration: TIntegrationAirtable = {
data: [ data: [
{ {
surveyId: surveyId, surveyId: surveyId,
elementIds: [questionId1, questionId2], questionIds: [questionId1, questionId2],
baseId: "base1", baseId: "base1",
tableId: "table1", tableId: "table1",
createdAt: new Date(), createdAt: new Date(),
@@ -196,8 +186,8 @@ const mockGoogleSheetsIntegration: TIntegrationGoogleSheets = {
surveyId: surveyId, surveyId: surveyId,
spreadsheetId: "sheet1", spreadsheetId: "sheet1",
spreadsheetName: "Sheet Name", spreadsheetName: "Sheet Name",
elementIds: [questionId1], questionIds: [questionId1],
elements: "What is Q1?", questions: "What is Q1?",
createdAt: new Date("2024-01-01T00:00:00.000Z"), createdAt: new Date("2024-01-01T00:00:00.000Z"),
includeHiddenFields: false, includeHiddenFields: false,
includeMetadata: false, includeMetadata: false,
@@ -219,8 +209,8 @@ const mockSlackIntegration: TIntegrationSlack = {
surveyId: surveyId, surveyId: surveyId,
channelId: "channel1", channelId: "channel1",
channelName: "Channel 1", channelName: "Channel 1",
elementIds: [questionId1, questionId2, questionId3], questionIds: [questionId1, questionId2, questionId3],
elements: "Q1, Q2, Q3", questions: "Q1, Q2, Q3",
createdAt: new Date(), createdAt: new Date(),
includeHiddenFields: true, includeHiddenFields: true,
includeMetadata: true, includeMetadata: true,
@@ -249,19 +239,19 @@ const mockNotionIntegration: TIntegrationNotion = {
databaseName: "DB 1", databaseName: "DB 1",
mapping: [ mapping: [
{ {
element: { id: questionId1, name: "Question 1", type: TSurveyQuestionTypeEnum.OpenText }, question: { id: questionId1, name: "Question 1", type: TSurveyQuestionTypeEnum.OpenText },
column: { id: "col1", name: "Column 1", type: "rich_text" }, column: { id: "col1", name: "Column 1", type: "rich_text" },
}, },
{ {
element: { id: questionId3, name: "Question 3", type: TSurveyQuestionTypeEnum.PictureSelection }, question: { id: questionId3, name: "Question 3", type: TSurveyQuestionTypeEnum.PictureSelection },
column: { id: "col3", name: "Column 3", type: "url" }, column: { id: "col3", name: "Column 3", type: "url" },
}, },
{ {
element: { id: "metadata", name: "Metadata", type: "metadata" }, question: { id: "metadata", name: "Metadata", type: "metadata" },
column: { id: "col_meta", name: "Metadata Col", type: "rich_text" }, column: { id: "col_meta", name: "Metadata Col", type: "rich_text" },
}, },
{ {
element: { id: "createdAt", name: "Created At", type: "createdAt" }, question: { id: "createdAt", name: "Created At", type: "createdAt" },
column: { id: "col_created", name: "Created Col", type: "date" }, column: { id: "col_created", name: "Created Col", type: "date" },
}, },
], ],
@@ -351,14 +341,16 @@ describe("handleIntegrations", () => {
mockAirtableIntegration.config.key, mockAirtableIntegration.config.key,
mockAirtableIntegration.config.data[0], mockAirtableIntegration.config.data[0],
[ [
"Answer 1", [
"Choice 1, Choice 2", "Answer 1",
"Hidden Value", "Choice 1, Choice 2",
expectedMetadataString, "Hidden Value",
"Variable Value", expectedMetadataString,
"2024-01-01 12:00", "Variable Value",
], // responses + hidden + meta + var + created "2024-01-01 12:00",
["Question 1 {{recall:q2}}", "Question 2", hiddenFieldId, "Metadata", "Variable 1", "Created At"] // elements (raw headline for Airtable) + hidden + meta + var + created ], // 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
]
); );
}); });
@@ -393,8 +385,10 @@ describe("handleIntegrations", () => {
expect(googleSheetWriteData).toHaveBeenCalledWith( expect(googleSheetWriteData).toHaveBeenCalledWith(
expectedIntegrationData, expectedIntegrationData,
mockGoogleSheetsIntegration.config.data[0].spreadsheetId, mockGoogleSheetsIntegration.config.data[0].spreadsheetId,
["Answer 1"], // responses [
["Question 1 {{recall:q2}}"] // elements (raw headline for Google Sheets) ["Answer 1"], // responses
["Question 1 {{recall:q2}}"], // questions (raw headline for Google Sheets)
]
); );
}); });

View File

@@ -5,9 +5,8 @@ import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet"; import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion"; import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
import { TIntegrationSlack } from "@formbricks/types/integration/slack"; import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import { TResponseDataValue, TResponseMeta } from "@formbricks/types/responses"; import { TResponseMeta } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; import { getTextContent } from "@formbricks/types/surveys/validation";
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines"; import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { writeData as airtableWriteData } from "@/lib/airtable/service"; import { writeData as airtableWriteData } from "@/lib/airtable/service";
@@ -17,7 +16,6 @@ import { getLocalizedValue } from "@/lib/i18n/utils";
import { writeData as writeNotionData } from "@/lib/notion/service"; import { writeData as writeNotionData } from "@/lib/notion/service";
import { processResponseData } from "@/lib/responses"; import { processResponseData } from "@/lib/responses";
import { writeDataToSlack } from "@/lib/slack/service"; import { writeDataToSlack } from "@/lib/slack/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getFormattedDateTimeString } from "@/lib/utils/datetime"; import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { parseRecallInfo } from "@/lib/utils/recall"; import { parseRecallInfo } from "@/lib/utils/recall";
import { truncateText } from "@/lib/utils/strings"; import { truncateText } from "@/lib/utils/strings";
@@ -44,40 +42,33 @@ const processDataForIntegration = async (
includeMetadata: boolean, includeMetadata: boolean,
includeHiddenFields: boolean, includeHiddenFields: boolean,
includeCreatedAt: boolean, includeCreatedAt: boolean,
elementIds: string[] questionIds: string[]
): Promise<{ ): Promise<string[][]> => {
responses: string[];
elements: string[];
}> => {
const ids = const ids =
includeHiddenFields && survey.hiddenFields.fieldIds includeHiddenFields && survey.hiddenFields.fieldIds
? [...elementIds, ...survey.hiddenFields.fieldIds] ? [...questionIds, ...survey.hiddenFields.fieldIds]
: elementIds; : questionIds;
const { responses, elements } = await extractResponses(integrationType, data, ids, survey); const values = await extractResponses(integrationType, data, ids, survey);
if (includeMetadata) { if (includeMetadata) {
responses.push(convertMetaObjectToString(data.response.meta)); values[0].push(convertMetaObjectToString(data.response.meta));
elements.push("Metadata"); values[1].push("Metadata");
} }
if (includeVariables) { if (includeVariables) {
survey.variables?.forEach((variable) => { survey.variables.forEach((variable) => {
const value = data.response.variables[variable.id]; const value = data.response.variables[variable.id];
if (value !== undefined) { if (value !== undefined) {
responses.push(String(data.response.variables[variable.id])); values[0].push(String(data.response.variables[variable.id]));
elements.push(variable.name); values[1].push(variable.name);
} }
}); });
} }
if (includeCreatedAt) { if (includeCreatedAt) {
const date = new Date(data.response.createdAt); const date = new Date(data.response.createdAt);
responses.push(`${getFormattedDateTimeString(date)}`); values[0].push(`${getFormattedDateTimeString(date)}`);
elements.push("Created At"); values[1].push("Created At");
} }
return { return values;
responses,
elements,
};
}; };
export const handleIntegrations = async ( export const handleIntegrations = async (
@@ -140,9 +131,9 @@ const handleAirtableIntegration = async (
!!element.includeMetadata, !!element.includeMetadata,
!!element.includeHiddenFields, !!element.includeHiddenFields,
!!element.includeCreatedAt, !!element.includeCreatedAt,
element.elementIds element.questionIds
); );
await airtableWriteData(integration.config.key, element, values.responses, values.elements); await airtableWriteData(integration.config.key, element, values);
} }
} }
} }
@@ -176,14 +167,14 @@ const handleGoogleSheetsIntegration = async (
!!element.includeMetadata, !!element.includeMetadata,
!!element.includeHiddenFields, !!element.includeHiddenFields,
!!element.includeCreatedAt, !!element.includeCreatedAt,
element.elementIds element.questionIds
); );
const integrationData = structuredClone(integration); const integrationData = structuredClone(integration);
integrationData.config.data.forEach((data) => { integrationData.config.data.forEach((data) => {
data.createdAt = new Date(data.createdAt); data.createdAt = new Date(data.createdAt);
}); });
await writeData(integrationData, element.spreadsheetId, values.responses, values.elements); await writeData(integrationData, element.spreadsheetId, values);
} }
} }
} }
@@ -217,15 +208,9 @@ const handleSlackIntegration = async (
!!element.includeMetadata, !!element.includeMetadata,
!!element.includeHiddenFields, !!element.includeHiddenFields,
!!element.includeCreatedAt, !!element.includeCreatedAt,
element.elementIds element.questionIds
);
await writeDataToSlack(
integration.config.key,
element.channelId,
values.responses,
values.elements,
survey?.name
); );
await writeDataToSlack(integration.config.key, element.channelId, values, survey?.name);
} }
} }
} }
@@ -242,81 +227,63 @@ 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 ( const extractResponses = async (
integrationType: TIntegrationType, integrationType: TIntegrationType,
pipelineData: TPipelineInput, pipelineData: TPipelineInput,
elementIds: string[], questionIds: string[],
survey: TSurvey survey: TSurvey
): Promise<{ ): Promise<string[][]> => {
responses: string[];
elements: string[];
}> => {
const responses: string[] = []; const responses: string[] = [];
const elements: string[] = []; const questions: string[] = [];
const surveyElements = getElementsFromBlocks(survey.blocks);
const emptyResponseObject = createEmptyResponseObject(pipelineData.response.data);
for (const elementId of elementIds) { for (const questionId of questionIds) {
// Check for hidden field Ids //check for hidden field Ids
if (survey.hiddenFields.fieldIds?.includes(elementId)) { if (survey.hiddenFields.fieldIds?.includes(questionId)) {
responses.push(processResponseData(pipelineData.response.data[elementId])); responses.push(processResponseData(pipelineData.response.data[questionId]));
elements.push(elementId); questions.push(questionId);
continue;
}
const question = survey?.questions.find((q) => q.id === questionId);
if (!question) {
continue; continue;
} }
const element = surveyElements.find((q) => q.id === elementId); const responseValue = pipelineData.response.data[questionId];
if (!element) {
continue; 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("");
} }
// Create emptyResponseObject with same keys but empty string values
const responseValue = pipelineData.response.data[elementId]; const emptyResponseObject = Object.keys(pipelineData.response.data).reduce(
responses.push(processElementResponse(element, responseValue)); (acc, key) => {
acc[key] = "";
const responseDataForRecall = return acc;
integrationType === "slack" ? pipelineData.response.data : emptyResponseObject; },
const variablesForRecall = integrationType === "slack" ? pipelineData.response.variables : {}; {} as Record<string, string>
);
elements.push( questions.push(
parseRecallInfo( parseRecallInfo(
getTextContent(getLocalizedValue(element.headline, "default")), getTextContent(getLocalizedValue(question?.headline, "default")),
responseDataForRecall, integrationType === "slack" ? pipelineData.response.data : emptyResponseObject,
variablesForRecall integrationType === "slack" ? pipelineData.response.variables : {}
) || "" ) || ""
); );
} }
return { responses, elements }; return [responses, questions];
}; };
const handleNotionIntegration = async ( const handleNotionIntegration = async (
@@ -354,34 +321,32 @@ const buildNotionPayloadProperties = (
const properties: any = {}; const properties: any = {};
const responses = data.response.data; const responses = data.response.data;
const surveyElements = getElementsFromBlocks(surveyData.blocks); const mappingQIds = mapping
.filter((m) => m.question.type === TSurveyQuestionTypeEnum.PictureSelection)
const mappingElementIds = mapping .map((m) => m.question.id);
.filter((m) => m.element.type === TSurveyElementTypeEnum.PictureSelection)
.map((m) => m.element.id);
Object.keys(responses).forEach((resp) => { Object.keys(responses).forEach((resp) => {
if (mappingElementIds.find((elementId) => elementId === resp)) { if (mappingQIds.find((qId) => qId === resp)) {
const selectedChoiceIds = responses[resp] as string[]; const selectedChoiceIds = responses[resp] as string[];
const pictureElement = surveyElements.find((el) => el.id === resp); const pictureQuestion = surveyData.questions.find((q) => q.id === resp);
responses[resp] = (pictureElement as any)?.choices responses[resp] = (pictureQuestion as any)?.choices
.filter((choice) => selectedChoiceIds.includes(choice.id)) .filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => choice.imageUrl); .map((choice) => choice.imageUrl);
} }
}); });
mapping.forEach((map) => { mapping.forEach((map) => {
if (map.element.id === "metadata") { if (map.question.id === "metadata") {
properties[map.column.name] = { properties[map.column.name] = {
[map.column.type]: getValue(map.column.type, convertMetaObjectToString(data.response.meta)) || null, [map.column.type]: getValue(map.column.type, convertMetaObjectToString(data.response.meta)) || null,
}; };
} else if (map.element.id === "createdAt") { } else if (map.question.id === "createdAt") {
properties[map.column.name] = { properties[map.column.name] = {
[map.column.type]: getValue(map.column.type, data.response.createdAt) || null, [map.column.type]: getValue(map.column.type, data.response.createdAt) || null,
}; };
} else { } else {
const value = responses[map.element.id]; const value = responses[map.question.id];
properties[map.column.name] = { properties[map.column.name] = {
[map.column.type]: getValue(map.column.type, value) || null, [map.column.type]: getValue(map.column.type, value) || null,
}; };

View File

@@ -1,272 +0,0 @@
import { IntegrationType } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { sendTelemetryEvents } from "./telemetry";
// Mock dependencies
vi.mock("@formbricks/cache");
vi.mock("@formbricks/database", () => ({
prisma: {
organization: {
findFirst: vi.fn(),
count: vi.fn(),
},
user: { count: vi.fn() },
team: { count: vi.fn() },
project: { count: vi.fn() },
survey: { count: vi.fn() },
response: {
count: vi.fn(),
findFirst: vi.fn(),
},
display: { count: vi.fn() },
contact: { count: vi.fn() },
segment: { count: vi.fn() },
integration: { findMany: vi.fn() },
account: { findMany: vi.fn() },
$queryRaw: vi.fn(),
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock("@/lib/env", () => ({
env: {
SMTP_HOST: "smtp.example.com",
S3_BUCKET_NAME: "my-bucket",
PROMETHEUS_ENABLED: true,
RECAPTCHA_SITE_KEY: "site-key",
RECAPTCHA_SECRET_KEY: "secret-key",
GITHUB_ID: "github-id",
},
}));
// Mock fetch
const fetchMock = vi.fn();
globalThis.fetch = fetchMock;
const mockCacheService = {
get: vi.fn(),
set: vi.fn(),
tryLock: vi.fn(),
del: vi.fn(),
};
describe("sendTelemetryEvents", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.useFakeTimers();
// Set a fixed time far in the past to ensure we can always send telemetry
vi.setSystemTime(new Date("2024-01-01T00:00:00.000Z"));
// Setup default cache behavior
vi.mocked(getCacheService).mockResolvedValue({
ok: true,
data: mockCacheService as any,
});
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true }); // Lock acquired
mockCacheService.del.mockResolvedValue({ ok: true, data: undefined });
mockCacheService.get.mockResolvedValue({ ok: true, data: null }); // No last sent time
mockCacheService.set.mockResolvedValue({ ok: true, data: undefined });
// Setup default prisma behavior
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
id: "org-123",
createdAt: new Date("2023-01-01"),
} as any);
// Mock raw SQL query for counts (batched query)
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{
organizationCount: BigInt(1),
userCount: BigInt(5),
teamCount: BigInt(2),
projectCount: BigInt(3),
surveyCount: BigInt(10),
inProgressSurveyCount: BigInt(4),
completedSurveyCount: BigInt(6),
responseCountAllTime: BigInt(100),
responseCountSinceLastUpdate: BigInt(10),
displayCount: BigInt(50),
contactCount: BigInt(20),
segmentCount: BigInt(4),
newestResponseAt: new Date("2024-01-01T00:00:00.000Z"),
},
] as any);
// Mock other queries
vi.mocked(prisma.integration.findMany).mockResolvedValue([{ type: IntegrationType.notion }] as any);
vi.mocked(prisma.account.findMany).mockResolvedValue([{ provider: "github" }] as any);
fetchMock.mockResolvedValue({ ok: true });
});
afterEach(() => {
vi.useRealTimers();
});
test("should send telemetry successfully when conditions are met", async () => {
await sendTelemetryEvents();
// Check lock acquisition
expect(mockCacheService.tryLock).toHaveBeenCalledWith(
"telemetry_lock",
"locked",
60 * 1000 // 1 minute TTL
);
// Check data gathering
expect(prisma.organization.findFirst).toHaveBeenCalled();
expect(prisma.$queryRaw).toHaveBeenCalled();
// Check fetch call
expect(fetchMock).toHaveBeenCalledTimes(1);
const payload = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(payload.organizationCount).toBe(1);
expect(payload.userCount).toBe(5);
expect(payload.integrations.notion).toBe(true);
expect(payload.sso.github).toBe(true);
// Check cache update (no TTL parameter)
expect(mockCacheService.set).toHaveBeenCalledWith("telemetry_last_sent_ts", expect.any(String));
// Check lock release
expect(mockCacheService.del).toHaveBeenCalledWith(["telemetry_lock"]);
});
test("should skip if in-memory check fails", async () => {
// Run once to set nextTelemetryCheck
await sendTelemetryEvents();
vi.clearAllMocks();
// Run again immediately (should fail in-memory check)
await sendTelemetryEvents();
expect(getCacheService).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
test("should skip if Redis last sent time is recent", async () => {
// Mock last sent time as recent
const recentTime = Date.now() - 1000 * 60 * 60; // 1 hour ago
mockCacheService.get.mockResolvedValue({ ok: true, data: String(recentTime) });
await sendTelemetryEvents();
expect(mockCacheService.tryLock).not.toHaveBeenCalled(); // No lock attempt
expect(fetchMock).not.toHaveBeenCalled();
});
test("should skip if lock cannot be acquired", async () => {
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: false }); // Lock not acquired
await sendTelemetryEvents();
expect(fetchMock).not.toHaveBeenCalled();
expect(mockCacheService.del).not.toHaveBeenCalled(); // Shouldn't try to delete lock we didn't acquire
});
test("should handle cache service failure gracefully", async () => {
vi.mocked(getCacheService).mockResolvedValue({
ok: false,
error: new Error("Cache error"),
} as any);
await sendTelemetryEvents();
expect(fetchMock).not.toHaveBeenCalled();
// Should verify that nextTelemetryCheck was updated, but it's a module variable.
// We can infer it by running again and checking calls
vi.clearAllMocks();
await sendTelemetryEvents();
expect(getCacheService).not.toHaveBeenCalled(); // Should be blocked by in-memory check
});
test("should handle telemetry send failure and apply cooldown", async () => {
// Reset module to clear nextTelemetryCheck state from previous tests
vi.resetModules();
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
// Ensure we can acquire lock by setting last sent time far in the past
const oldTime = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago
mockCacheService.get.mockResolvedValue({ ok: true, data: String(oldTime) });
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true }); // Lock acquired
// Make fetch fail to trigger the catch block
const networkError = new Error("Network error");
fetchMock.mockRejectedValue(networkError);
await freshSendTelemetryEvents();
// Verify lock was acquired
expect(mockCacheService.tryLock).toHaveBeenCalledWith("telemetry_lock", "locked", 60 * 1000);
// The error should be caught in the inner catch block
// The actual implementation logs as warning, not error
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
error: networkError,
message: "Network error",
}),
"Failed to send telemetry - applying 1h cooldown"
);
// Lock should be released in finally block
expect(mockCacheService.del).toHaveBeenCalledWith(["telemetry_lock"]);
// Cache should not be updated on failure
expect(mockCacheService.set).not.toHaveBeenCalled();
// Verify cooldown: run again immediately (should be blocked by in-memory check)
vi.clearAllMocks();
mockCacheService.get.mockResolvedValue({ ok: true, data: null });
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true });
await freshSendTelemetryEvents();
expect(getCacheService).not.toHaveBeenCalled(); // Should be blocked by in-memory check
});
test("should skip if no organization exists", async () => {
// Reset module to clear nextTelemetryCheck state from previous tests
vi.resetModules();
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
// Ensure we can acquire lock by setting last sent time far in the past
const oldTime = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago
// Re-setup mocks after resetModules
vi.mocked(getCacheService).mockResolvedValue({
ok: true,
data: mockCacheService as any,
});
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true }); // Lock acquired
mockCacheService.del.mockResolvedValue({ ok: true, data: undefined });
mockCacheService.get.mockResolvedValue({ ok: true, data: String(oldTime) });
mockCacheService.set.mockResolvedValue({ ok: true, data: undefined });
vi.mocked(prisma.organization.findFirst).mockResolvedValue(null);
await freshSendTelemetryEvents();
// sendTelemetry returns early when no org exists
// Since it returns (not throws), the try block completes successfully
// Then cache.set is called, and finally block executes
expect(fetchMock).not.toHaveBeenCalled();
// Verify lock was acquired (prerequisite for finally block to execute)
expect(mockCacheService.tryLock).toHaveBeenCalledWith("telemetry_lock", "locked", 60 * 1000);
// Lock should be released in finally block
expect(mockCacheService.del).toHaveBeenCalledWith(["telemetry_lock"]);
// Note: The current implementation calls cache.set even when no org exists
// This might be a bug, but we test the actual behavior
expect(mockCacheService.set).toHaveBeenCalled();
});
});

View File

@@ -1,270 +0,0 @@
import { IntegrationType } from "@prisma/client";
import { type CacheKey, getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { env } from "@/lib/env";
import { getInstanceInfo } from "@/lib/instance";
import packageJson from "@/package.json";
const TELEMETRY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
const TELEMETRY_LOCK_KEY = "telemetry_lock" as CacheKey;
const TELEMETRY_LAST_SENT_KEY = "telemetry_last_sent_ts" as CacheKey;
/**
* In-memory timestamp for the next telemetry check.
* This is a fast, process-local check to avoid unnecessary Redis calls.
* Updated after each check to prevent redundant executions.
*/
let nextTelemetryCheck = 0;
/**
* Sends telemetry events to Formbricks Enterprise endpoint.
* Uses a three-layer check system to prevent duplicate submissions:
* 1. In-memory check (fast, process-local)
* 2. Redis check (shared across instances, persists across restarts)
* 3. Distributed lock (prevents concurrent execution in multi-instance deployments)
*/
export const sendTelemetryEvents = async () => {
try {
const now = Date.now();
// ============================================================
// CHECK 1: In-Memory Check (Fast Path)
// ============================================================
// Purpose: Quick process-local check to avoid Redis calls if we recently checked.
// How it works: If current time is before nextTelemetryCheck, skip entirely.
// This is updated after each successful check or failure to prevent spam.
if (now < nextTelemetryCheck) {
return;
}
// ============================================================
// CHECK 2: Redis Check (Shared State)
// ============================================================
// Purpose: Check if telemetry was sent recently by ANY instance (shared across cluster).
// This persists across restarts and works in multi-instance deployments.
const cacheServiceResult = await getCacheService();
if (!cacheServiceResult.ok) {
// Redis unavailable: Fallback to in-memory cooldown to avoid spamming.
// Wait 1 hour before trying again. This prevents hammering Redis when it's down.
nextTelemetryCheck = now + 60 * 60 * 1000;
return;
}
const cache = cacheServiceResult.data;
// Get the timestamp of when telemetry was last sent (from any instance).
const lastSentResult = await cache.get(TELEMETRY_LAST_SENT_KEY);
const lastSentStr = lastSentResult.ok && lastSentResult.data ? (lastSentResult.data as string) : null;
const lastSent = lastSentStr ? Number.parseInt(lastSentStr, 10) : 0;
// If less than 24 hours have passed since last telemetry, skip.
// Update in-memory check to match remaining time for fast-path optimization.
if (now - lastSent < TELEMETRY_INTERVAL_MS) {
nextTelemetryCheck = lastSent + TELEMETRY_INTERVAL_MS;
return;
}
// ============================================================
// CHECK 3: Distributed Lock (Prevent Concurrent Execution)
// ============================================================
// Purpose: Ensure only ONE instance executes telemetry at a time in a cluster.
// How it works:
// - Uses Redis SET NX (only set if not exists) for atomic lock acquisition
// - Lock expires after 1 minute (TTL) to prevent deadlocks if instance crashes
// - If lock exists, another instance is already running telemetry, so we exit
// - Lock is released in finally block after telemetry completes or fails
const lockResult = await cache.tryLock(TELEMETRY_LOCK_KEY, "locked", 60 * 1000); // 1 minute TTL
if (!lockResult.ok || !lockResult.data) {
// Lock acquisition failed or already held by another instance.
// Exit silently - the other instance will handle telemetry.
// No need to update nextTelemetryCheck here since we didn't execute.
return;
}
// ============================================================
// EXECUTION: Send Telemetry
// ============================================================
// We've passed all checks and acquired the lock. Now execute telemetry.
try {
await sendTelemetry(lastSent);
// Success: Update Redis with current timestamp so other instances know telemetry was sent.
// No TTL - persists indefinitely to support low-volume instances (responses every few days/weeks).
await cache.set(TELEMETRY_LAST_SENT_KEY, now.toString());
// Update in-memory check to prevent this instance from checking again for 24h.
nextTelemetryCheck = now + TELEMETRY_INTERVAL_MS;
} catch (e) {
// Log as warning since telemetry is non-essential
const errorMessage = e instanceof Error ? e.message : String(e);
logger.warn(
{ error: e, message: errorMessage, lastSent, now },
"Failed to send telemetry - applying 1h cooldown"
);
// Failure cooldown: Prevent retrying immediately to avoid hammering the endpoint.
// Wait 1 hour before allowing this instance to try again.
// Note: Other instances can still try (they'll hit the lock or Redis check).
nextTelemetryCheck = now + 60 * 60 * 1000;
} finally {
// Always release the lock, even if telemetry failed.
// This allows other instances to retry if this one failed.
await cache.del([TELEMETRY_LOCK_KEY]);
}
} catch (error) {
// Catch-all for any unexpected errors in the wrapper logic (cache failures, lock issues, etc.)
// Log as warning since telemetry is non-essential functionality
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(
{ error, message: errorMessage, timestamp: Date.now() },
"Unexpected error in sendTelemetryEvents wrapper - telemetry check skipped"
);
}
};
/**
* Gathers telemetry data and sends it to Formbricks Enterprise endpoint.
* @param lastSent - Timestamp of last telemetry send (used to calculate incremental metrics)
*/
const sendTelemetry = async (lastSent: number) => {
// Get the instance info (hashed oldest organization ID and creation date).
// Using the oldest org ensures the ID doesn't change over time.
const instanceInfo = await getInstanceInfo();
if (!instanceInfo) return; // No organization exists, nothing to report
const { instanceId, createdAt: instanceCreatedAt } = instanceInfo;
// Optimize database queries to reduce connection pool usage:
// Instead of 15 parallel queries (which could exhaust the connection pool),
// we batch all count queries into a single raw SQL query.
// This reduces connection usage from 15 → 3 (batch counts + integrations + accounts).
const [countsResult, integrations, ssoProviders] = await Promise.all([
// Single query for all counts (13 metrics in one round-trip)
prisma.$queryRaw<
[
{
organizationCount: bigint;
userCount: bigint;
teamCount: bigint;
projectCount: bigint;
surveyCount: bigint;
inProgressSurveyCount: bigint;
completedSurveyCount: bigint;
responseCountAllTime: bigint;
responseCountSinceLastUpdate: bigint;
displayCount: bigint;
contactCount: bigint;
segmentCount: bigint;
newestResponseAt: Date | null;
},
]
>`
SELECT
(SELECT COUNT(*) FROM "Organization") as "organizationCount",
(SELECT COUNT(*) FROM "User") as "userCount",
(SELECT COUNT(*) FROM "Team") as "teamCount",
(SELECT COUNT(*) FROM "Project") as "projectCount",
(SELECT COUNT(*) FROM "Survey") as "surveyCount",
(SELECT COUNT(*) FROM "Survey" WHERE status = 'inProgress') as "inProgressSurveyCount",
(SELECT COUNT(*) FROM "Survey" WHERE status = 'completed') as "completedSurveyCount",
(SELECT COUNT(*) FROM "Response") as "responseCountAllTime",
(SELECT COUNT(*) FROM "Response" WHERE "created_at" > ${new Date(lastSent || 0)}) as "responseCountSinceLastUpdate",
(SELECT COUNT(*) FROM "Display") as "displayCount",
(SELECT COUNT(*) FROM "Contact") as "contactCount",
(SELECT COUNT(*) FROM "Segment") as "segmentCount",
(SELECT MAX("created_at") FROM "Response") as "newestResponseAt"
`,
// Keep these as separate queries since they need DISTINCT which is harder to optimize
prisma.integration.findMany({ select: { type: true }, distinct: ["type"] }),
prisma.account.findMany({ select: { provider: true }, distinct: ["provider"] }),
]);
// Extract metrics from the batched query result and convert bigints to numbers
const counts = countsResult[0];
const organizationCount = Number(counts.organizationCount);
const userCount = Number(counts.userCount);
const teamCount = Number(counts.teamCount);
const projectCount = Number(counts.projectCount);
const surveyCount = Number(counts.surveyCount);
const inProgressSurveyCount = Number(counts.inProgressSurveyCount);
const completedSurveyCount = Number(counts.completedSurveyCount);
const responseCountAllTime = Number(counts.responseCountAllTime);
const responseCountSinceLastUpdate = Number(counts.responseCountSinceLastUpdate);
const displayCount = Number(counts.displayCount);
const contactCount = Number(counts.contactCount);
const segmentCount = Number(counts.segmentCount);
const newestResponse = counts.newestResponseAt ? { createdAt: counts.newestResponseAt } : null;
// Convert integration array to boolean map indicating which integrations are configured.
const integrationMap = {
notion: integrations.some((i) => i.type === IntegrationType.notion),
googleSheets: integrations.some((i) => i.type === IntegrationType.googleSheets),
airtable: integrations.some((i) => i.type === IntegrationType.airtable),
slack: integrations.some((i) => i.type === IntegrationType.slack),
};
// Check SSO configuration: either via environment variables or database records.
// This detects which SSO providers are available/configured.
const ssoMap = {
github: !!env.GITHUB_ID || ssoProviders.some((p) => p.provider === "github"),
google: !!env.GOOGLE_CLIENT_ID || ssoProviders.some((p) => p.provider === "google"),
azureAd: !!env.AZUREAD_CLIENT_ID || ssoProviders.some((p) => p.provider === "azuread"),
oidc: !!env.OIDC_CLIENT_ID || ssoProviders.some((p) => p.provider === "openid"),
};
// Construct telemetry payload with usage statistics and configuration.
const payload = {
schemaVersion: 1, // Schema version for future compatibility
// Core entity counts
organizationCount,
userCount,
teamCount,
projectCount,
surveyCount,
inProgressSurveyCount,
completedSurveyCount,
// Response metrics
responseCountAllTime,
responseCountSinceLastUsageUpdate: responseCountSinceLastUpdate, // Incremental since last telemetry
displayCount,
contactCount,
segmentCount,
integrations: integrationMap,
infrastructure: {
smtp: !!env.SMTP_HOST,
s3: !!env.S3_BUCKET_NAME,
prometheus: !!env.PROMETHEUS_ENABLED,
},
security: {
recaptcha: !!(env.RECAPTCHA_SITE_KEY && env.RECAPTCHA_SECRET_KEY),
},
sso: ssoMap,
meta: {
version: packageJson.version, // Formbricks version for compatibility tracking
},
temporal: {
instanceCreatedAt: instanceCreatedAt.toISOString(), // When instance was first created
newestResponseAt: newestResponse?.createdAt.toISOString() || null, // Most recent activity
},
};
// Send telemetry to Formbricks Enterprise endpoint.
// This endpoint collects usage statistics for enterprise license validation and analytics.
const url = `https://ee.formbricks.com/api/v1/instances/${instanceId}/usage-updates`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout
await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timeout);
};

View File

@@ -3,7 +3,6 @@ import { headers } from "next/headers";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ResourceNotFoundError } from "@formbricks/types/errors";
import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry";
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines"; import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
@@ -51,22 +50,6 @@ export const POST = async (request: Request) => {
throw new ResourceNotFoundError("Organization", "Organization not found"); throw new ResourceNotFoundError("Organization", "Organization not found");
} }
// Fetch survey for webhook payload
const survey = await getSurvey(surveyId);
if (!survey) {
logger.error({ url: request.url, surveyId }, `Survey with id ${surveyId} not found`);
return responses.notFoundResponse("Survey", surveyId, true);
}
if (survey.environmentId !== environmentId) {
logger.error(
{ url: request.url, surveyId, environmentId, surveyEnvironmentId: survey.environmentId },
`Survey ${surveyId} does not belong to environment ${environmentId}`
);
return responses.badRequestResponse("Survey not found in this environment");
}
// Fetch webhooks // Fetch webhooks
const getWebhooksForPipeline = async (environmentId: string, event: PipelineTriggers, surveyId: string) => { const getWebhooksForPipeline = async (environmentId: string, event: PipelineTriggers, surveyId: string) => {
const webhooks = await prisma.webhook.findMany({ const webhooks = await prisma.webhook.findMany({
@@ -97,16 +80,7 @@ export const POST = async (request: Request) => {
body: JSON.stringify({ body: JSON.stringify({
webhookId: webhook.id, webhookId: webhook.id,
event, event,
data: { data: response,
...response,
survey: {
title: survey.name,
type: survey.type,
status: survey.status,
createdAt: survey.createdAt,
updatedAt: survey.updatedAt,
},
},
}), }),
}).catch((error) => { }).catch((error) => {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`); logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
@@ -114,12 +88,18 @@ export const POST = async (request: Request) => {
); );
if (event === "responseFinished") { if (event === "responseFinished") {
// Fetch integrations and responseCount in parallel // Fetch integrations, survey, and responseCount in parallel
const [integrations, responseCount] = await Promise.all([ const [integrations, survey, responseCount] = await Promise.all([
getIntegrations(environmentId), getIntegrations(environmentId),
getSurvey(surveyId),
getResponseCountBySurveyId(surveyId), getResponseCountBySurveyId(surveyId),
]); ]);
if (!survey) {
logger.error({ url: request.url, surveyId }, `Survey with id ${surveyId} not found`);
return new Response("Survey not found", { status: 404 });
}
if (integrations.length > 0) { if (integrations.length > 0) {
await handleIntegrations(integrations, inputValidation.data, survey); await handleIntegrations(integrations, inputValidation.data, survey);
} }
@@ -246,10 +226,6 @@ export const POST = async (request: Request) => {
} }
}); });
} }
if (event === "responseCreated") {
// Send telemetry events
await sendTelemetryEvents();
}
return Response.json({ data: {} }); return Response.json({ data: {} });
}; };

View File

@@ -0,0 +1,34 @@
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");
}
}
};

View File

@@ -18,6 +18,10 @@ import {
getMonthlyOrganizationResponseCount, getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId, getOrganizationByEnvironmentId,
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { COLOR_DEFAULTS } from "@/lib/styling/constants"; import { COLOR_DEFAULTS } from "@/lib/styling/constants";
@@ -54,6 +58,20 @@ const checkResponseLimit = async (environmentId: string): Promise<boolean> => {
const monthlyResponseLimit = organization.billing.limits.monthly.responses; const monthlyResponseLimit = organization.billing.limits.monthly.responses;
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit; 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; return isLimitReached;
}; };
@@ -93,7 +111,10 @@ export const GET = withV1ApiWrapper({
} }
if (!environment.appSetupCompleted) { if (!environment.appSetupCompleted) {
await updateEnvironment(environment.id, { appSetupCompleted: true }); await Promise.all([
updateEnvironment(environment.id, { appSetupCompleted: true }),
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
]);
} }
// check organization subscriptions and response limits // check organization subscriptions and response limits

View File

@@ -5,6 +5,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display"; import { createDisplay } from "./lib/display";
@@ -58,6 +59,7 @@ export const POST = withV1ApiWrapper({
try { try {
const response = await createDisplay(inputValidation.data); const response = await createDisplay(inputValidation.data);
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
return { return {
response: responses.successResponse(response, true), response: responses.successResponse(response, true),
}; };

View File

@@ -92,7 +92,6 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
welcomeCard: true, welcomeCard: true,
name: true, name: true,
questions: true, questions: true,
blocks: true,
variables: true, variables: true,
type: true, type: true,
showLanguageSwitch: true, showLanguageSwitch: true,

View File

@@ -8,11 +8,16 @@ import { TOrganization } from "@formbricks/types/organizations";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { cache } from "@/lib/cache"; import { cache } from "@/lib/cache";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service"; import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { EnvironmentStateData, getEnvironmentStateData } from "./data"; import { EnvironmentStateData, getEnvironmentStateData } from "./data";
import { getEnvironmentState } from "./environmentState"; import { getEnvironmentState } from "./environmentState";
// Mock dependencies // Mock dependencies
vi.mock("@/lib/organization/service"); vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/cache", () => ({ vi.mock("@/lib/cache", () => ({
cache: { cache: {
withCache: vi.fn(), withCache: vi.fn(),
@@ -38,6 +43,7 @@ vi.mock("@/lib/constants", () => ({
RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key", RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key",
IS_RECAPTCHA_CONFIGURED: true, IS_RECAPTCHA_CONFIGURED: true,
IS_PRODUCTION: true, IS_PRODUCTION: true,
IS_POSTHOG_CONFIGURED: false,
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key", ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
})); }));
@@ -182,7 +188,9 @@ describe("getEnvironmentState", () => {
expect(result.data).toEqual(expectedData); expect(result.data).toEqual(expectedData);
expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId); expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId);
expect(prisma.environment.update).not.toHaveBeenCalled(); expect(prisma.environment.update).not.toHaveBeenCalled();
expect(capturePosthogEnvironmentEvent).not.toHaveBeenCalled();
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id); expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
}); });
test("should throw ResourceNotFoundError if environment not found", async () => { test("should throw ResourceNotFoundError if environment not found", async () => {
@@ -218,6 +226,7 @@ describe("getEnvironmentState", () => {
where: { id: environmentId }, where: { id: environmentId },
data: { appSetupCompleted: true }, data: { appSetupCompleted: true },
}); });
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(environmentId, "app setup completed");
expect(result.data).toBeDefined(); expect(result.data).toBeDefined();
}); });
@@ -228,6 +237,16 @@ describe("getEnvironmentState", () => {
expect(result.data.surveys).toEqual([]); expect(result.data.surveys).toEqual([]);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id); 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 () => { test("should return surveys if monthly response limit not reached (Cloud)", async () => {
@@ -237,6 +256,21 @@ describe("getEnvironmentState", () => {
expect(result.data.surveys).toEqual(mockSurveys); expect(result.data.surveys).toEqual(mockSurveys);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id); 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 () => { test("should include recaptchaSiteKey if recaptcha variables are set", async () => {
@@ -279,6 +313,7 @@ describe("getEnvironmentState", () => {
// Should return surveys even with high count since limit is null (unlimited) // Should return surveys even with high count since limit is null (unlimited)
expect(result.data.surveys).toEqual(mockSurveys); expect(result.data.surveys).toEqual(mockSurveys);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
}); });
test("should propagate database update errors", async () => { test("should propagate database update errors", async () => {
@@ -296,6 +331,21 @@ describe("getEnvironmentState", () => {
await expect(getEnvironmentState(environmentId)).rejects.toThrow("Database error"); 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 () => { test("should include recaptchaSiteKey when IS_RECAPTCHA_CONFIGURED is true", async () => {
const result = await getEnvironmentState(environmentId); const result = await getEnvironmentState(environmentId);

View File

@@ -1,10 +1,15 @@
import "server-only"; import "server-only";
import { createCacheKey } from "@formbricks/cache"; import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TJsEnvironmentState } from "@formbricks/types/js"; import { TJsEnvironmentState } from "@formbricks/types/js";
import { cache } from "@/lib/cache"; import { cache } from "@/lib/cache";
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service"; import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { getEnvironmentStateData } from "./data"; import { getEnvironmentStateData } from "./data";
/** /**
@@ -28,10 +33,13 @@ export const getEnvironmentState = async (
// Handle app setup completion update if needed // Handle app setup completion update if needed
// This is a one-time setup flag that can tolerate TTL-based cache expiration // This is a one-time setup flag that can tolerate TTL-based cache expiration
if (!environment.appSetupCompleted) { if (!environment.appSetupCompleted) {
await prisma.environment.update({ await Promise.all([
where: { id: environmentId }, prisma.environment.update({
data: { appSetupCompleted: true }, where: { id: environmentId },
}); data: { appSetupCompleted: true },
}),
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
]);
} }
// Check monthly response limits for Formbricks Cloud // Check monthly response limits for Formbricks Cloud
@@ -41,6 +49,24 @@ export const getEnvironmentState = async (
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id); const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
isMonthlyResponsesLimitReached = isMonthlyResponsesLimitReached =
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit; 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 // Build the response data

View File

@@ -8,7 +8,7 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines"; import { sendToPipeline } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service"; import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element"; import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers"; import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils"; import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response"; import { updateResponseWithQuotaEvaluation } from "./lib/response";

View File

@@ -1,10 +1,15 @@
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurveyQuota } from "@formbricks/types/quota"; import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseInput } from "@formbricks/types/responses"; import { TResponseInput } from "@formbricks/types/responses";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service"; import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { createResponse, createResponseWithQuotaEvaluation } from "./response"; import { createResponse, createResponseWithQuotaEvaluation } from "./response";
@@ -19,13 +24,22 @@ vi.mock("@/lib/constants", () => ({
})); }));
vi.mock("@/lib/organization/service", () => ({ vi.mock("@/lib/organization/service", () => ({
getMonthlyOrganizationResponseCount: vi.fn(),
getOrganizationByEnvironmentId: vi.fn(), getOrganizationByEnvironmentId: vi.fn(),
})); }));
vi.mock("@/lib/posthogServer", () => ({
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn(),
}));
vi.mock("@/lib/response/utils", () => ({ vi.mock("@/lib/response/utils", () => ({
calculateTtcTotal: vi.fn((ttc) => ttc), calculateTtcTotal: vi.fn((ttc) => ttc),
})); }));
vi.mock("@/lib/telemetry", () => ({
captureTelemetry: vi.fn(),
}));
vi.mock("@/lib/utils/validate", () => ({ vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(), validateInputs: vi.fn(),
})); }));
@@ -124,6 +138,35 @@ 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 () => { test("should throw ResourceNotFoundError if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null); vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(ResourceNotFoundError); await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(ResourceNotFoundError);
@@ -143,6 +186,20 @@ describe("createResponse", () => {
vi.mocked(prisma.response.create).mockRejectedValue(genericError); vi.mocked(prisma.response.create).mockRejectedValue(genericError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(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", () => { describe("createResponseWithQuotaEvaluation", () => {

View File

@@ -6,9 +6,11 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota"; import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses"; import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils"; import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service"; import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContactByUserId } from "./contact"; import { getContactByUserId } from "./contact";
@@ -81,6 +83,7 @@ export const createResponse = async (
tx: Prisma.TransactionClient tx: Prisma.TransactionClient
): Promise<TResponse> => { ): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]); validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const { environmentId, userId, finished, ttc: initialTtc } = responseInput; const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
@@ -118,6 +121,8 @@ export const createResponse = async (
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
}; };
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
return response; return response;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -10,6 +10,7 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines"; import { sendToPipeline } from "@/app/lib/pipelines";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers"; import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
@@ -171,6 +172,11 @@ export const POST = withV1ApiWrapper({
}); });
} }
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
surveyId: responseData.surveyId,
surveyType: survey.type,
});
const quotaObj = createQuotaFullObject(quotaFull); const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = { const responseDataWithQuota = {

View File

@@ -4,7 +4,11 @@ import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseInput } from "@formbricks/types/responses"; import { TResponse, TResponseInput } from "@formbricks/types/responses";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { getResponseContact } from "@/lib/response/service"; import { getResponseContact } from "@/lib/response/service";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
@@ -92,6 +96,9 @@ const mockTransformedResponses = [mockResponse, { ...mockResponse, id: "response
// Mock dependencies // Mock dependencies
vi.mock("@/lib/constants", () => ({ vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true, 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", ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id", GITHUB_ID: "mock-github-id",
@@ -111,8 +118,10 @@ vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
})); }));
vi.mock("@/lib/organization/service"); vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/response/service"); vi.mock("@/lib/response/service");
vi.mock("@/lib/response/utils"); vi.mock("@/lib/response/utils");
vi.mock("@/lib/telemetry");
vi.mock("@/lib/utils/validate"); vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({ vi.mock("@formbricks/database", () => ({
prisma: { prisma: {
@@ -153,6 +162,7 @@ describe("Response Lib Tests", () => {
vi.mocked(mockTx.response.create).mockResolvedValue({ vi.mocked(mockTx.response.create).mockResolvedValue({
...mockResponsePrisma, ...mockResponsePrisma,
}); });
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
const response = await createResponse(mockResponseInputWithUserId, mockTx); const response = await createResponse(mockResponseInputWithUserId, mockTx);
@@ -207,6 +217,68 @@ describe("Response Lib Tests", () => {
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError); 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", () => { describe("getResponsesByEnvironmentIds", () => {

View File

@@ -8,12 +8,14 @@ import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses"; import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils"; import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { RESPONSES_PER_PAGE } from "@/lib/constants"; import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseContact } from "@/lib/response/service"; import { getResponseContact } from "@/lib/response/service";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service"; import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContactByUserId } from "./contact"; import { getContactByUserId } from "./contact";
@@ -91,6 +93,7 @@ export const createResponse = async (
tx?: Prisma.TransactionClient tx?: Prisma.TransactionClient
): Promise<TResponse> => { ): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]); validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const { environmentId, userId, finished, ttc: initialTtc } = responseInput; const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
@@ -128,6 +131,8 @@ export const createResponse = async (
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
}; };
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
return response; return response;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -6,11 +6,6 @@ import { handleErrorResponse } from "@/app/api/v1/auth";
import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys"; import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys";
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils"; import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
import { responses } from "@/app/lib/api/response"; 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 { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
@@ -50,22 +45,6 @@ export const GET = withV1ApiWrapper({
response: result.error, 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 { return {
response: responses.successResponse(result.survey), response: responses.successResponse(result.survey),
}; };
@@ -152,23 +131,6 @@ 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({ const inputValidation = ZSurveyUpdateInput.safeParse({
...result.survey, ...result.survey,
...surveyUpdate, ...surveyUpdate,
@@ -193,19 +155,6 @@ export const PUT = withV1ApiWrapper({
try { try {
const updatedSurvey = await updateSurvey({ ...inputValidation.data, id: params.surveyId }); const updatedSurvey = await updateSurvey({ ...inputValidation.data, id: params.surveyId });
auditLog.newObject = updatedSurvey; auditLog.newObject = updatedSurvey;
if (hasQuestions) {
const surveyWithQuestions = {
...updatedSurvey,
questions: transformBlocksToQuestions(updatedSurvey.blocks, updatedSurvey.endings),
blocks: [],
};
return {
response: responses.successResponse(surveyWithQuestions),
};
}
return { return {
response: responses.successResponse(updatedSurvey), response: responses.successResponse(updatedSurvey),
}; };

View File

@@ -4,11 +4,6 @@ import { DatabaseError } from "@formbricks/types/errors";
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types"; import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils"; import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
import { responses } from "@/app/lib/api/response"; 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 { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
@@ -32,30 +27,10 @@ export const GET = withV1ApiWrapper({
const environmentIds = authentication.environmentPermissions.map( const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId (permission) => permission.environmentId
); );
const surveys = await getSurveys(environmentIds, limit, offset); 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 { return {
response: responses.successResponse(surveysWithQuestions), response: responses.successResponse(surveys),
}; };
} catch (error) { } catch (error) {
if (error instanceof DatabaseError) { if (error instanceof DatabaseError) {
@@ -88,7 +63,6 @@ export const POST = withV1ApiWrapper({
response: responses.badRequestResponse("Malformed JSON input, please check your request body"), response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
}; };
} }
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput); const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
if (!inputValidation.success) { if (!inputValidation.success) {
@@ -118,20 +92,6 @@ export const POST = withV1ApiWrapper({
const surveyData = { ...inputValidation.data, environmentId }; 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); const featureCheckResult = await checkFeaturePermissions(surveyData, organization);
if (featureCheckResult) { if (featureCheckResult) {
return { return {
@@ -143,18 +103,6 @@ export const POST = withV1ApiWrapper({
auditLog.targetId = survey.id; auditLog.targetId = survey.id;
auditLog.newObject = survey; auditLog.newObject = survey;
if (hasQuestions) {
const surveyWithQuestions = {
...survey,
questions: transformBlocksToQuestions(survey.blocks, survey.endings),
blocks: [],
};
return {
response: responses.successResponse(surveyWithQuestions),
};
}
return { return {
response: responses.successResponse(survey), response: responses.successResponse(survey),
}; };

View File

@@ -3,6 +3,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display"; import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display"; import { createDisplay } from "./lib/display";
@@ -48,6 +49,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
try { try {
const response = await createDisplay(inputValidation.data); const response = await createDisplay(inputValidation.data);
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
return responses.successResponse(response, true); return responses.successResponse(response, true);
} catch (error) { } catch (error) {
if (error instanceof ResourceNotFoundError) { if (error instanceof ResourceNotFoundError) {

View File

@@ -8,8 +8,13 @@ import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
import { TResponse } from "@formbricks/types/responses"; import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response"; import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service"; import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContact } from "./contact"; import { getContact } from "./contact";
@@ -44,7 +49,9 @@ vi.mock("@/lib/constants", () => ({
})); }));
vi.mock("@/lib/organization/service"); vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/response/utils"); vi.mock("@/lib/response/utils");
vi.mock("@/lib/telemetry");
vi.mock("@/lib/utils/validate"); vi.mock("@/lib/utils/validate");
vi.mock("@/modules/ee/quotas/lib/evaluation-service"); vi.mock("@/modules/ee/quotas/lib/evaluation-service");
vi.mock("@formbricks/database", () => ({ vi.mock("@formbricks/database", () => ({
@@ -159,6 +166,9 @@ describe("createResponse V2", () => {
...ttc, ...ttc,
_total: Object.values(ttc).reduce((a, b) => a + b, 0), _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({ vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: false, shouldEndSurvey: false,
quotaFull: null, quotaFull: null,
@@ -169,6 +179,32 @@ describe("createResponse V2", () => {
mockIsFormbricksCloud = false; 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 () => { test("should throw ResourceNotFoundError if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null); vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(ResourceNotFoundError); await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(ResourceNotFoundError);
@@ -189,6 +225,20 @@ describe("createResponse V2", () => {
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError); 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 () => { test("should correctly map prisma tags to response tags", async () => {
const mockTag: TTag = { id: "tag1", name: "Tag 1", environmentId }; const mockTag: TTag = { id: "tag1", name: "Tag 1", environmentId };
const prismaResponseWithTags = { const prismaResponseWithTags = {
@@ -219,6 +269,7 @@ describe("createResponseWithQuotaEvaluation V2", () => {
...ttc, ...ttc,
_total: Object.values(ttc).reduce((a, b) => a + b, 0), _total: Object.values(ttc).reduce((a, b) => a + b, 0),
})); }));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
vi.mocked(evaluateResponseQuotas).mockResolvedValue({ vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: false, shouldEndSurvey: false,
quotaFull: null, quotaFull: null,

View File

@@ -6,10 +6,12 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota"; import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses"; import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags"; 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 { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response"; import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service"; import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContact } from "./contact"; import { getContact } from "./contact";
@@ -89,6 +91,7 @@ export const createResponse = async (
tx?: Prisma.TransactionClient tx?: Prisma.TransactionClient
): Promise<TResponse> => { ): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]); validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const { environmentId, contactId, finished, ttc: initialTtc } = responseInput; const { environmentId, contactId, finished, ttc: initialTtc } = responseInput;
@@ -126,6 +129,8 @@ export const createResponse = async (
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
}; };
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
return response; return response;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -8,9 +8,9 @@ import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/respons
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines"; import { sendToPipeline } from "@/app/lib/pipelines";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils"; import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers"; import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { createResponseWithQuotaEvaluation } from "./lib/response"; import { createResponseWithQuotaEvaluation } from "./lib/response";
@@ -91,7 +91,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
// Validate response data for "other" options exceeding character limit // Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({ const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: responseInputData.data, responseData: responseInputData.data,
surveyQuestions: getElementsFromBlocks(survey.blocks), surveyQuestions: survey.questions,
responseLanguage: responseInputData.language, responseLanguage: responseInputData.language,
}); });
@@ -148,6 +148,11 @@ 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 quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = { const responseDataWithQuota = {

File diff suppressed because it is too large Load Diff

View File

@@ -1,520 +0,0 @@
import { createId } from "@paralleldrive/cuid2";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { InvalidInputError } from "@formbricks/types/errors";
import {
type TSurveyBlock,
type TSurveyBlockLogic,
type 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[];
};
const transformBlockLogicToQuestionLogic = (
blockLogic: TSurveyBlockLogic[],
blockIdToQuestionId: Map<string, string>,
endingIds: Set<string>
): unknown[] => {
return blockLogic.map((item) => {
const updatedConditions = convertElementToQuestionType(item.conditions);
if (!updatedConditions || !isConditionGroup(updatedConditions)) {
return item;
}
return {
...item,
conditions: updatedConditions,
actions: reverseLogicActions(item.actions, blockIdToQuestionId, endingIds),
};
});
};
const applyBlockAttributesToElement = (
element: Record<string, unknown>,
block: TSurveyBlock,
blockIdToQuestionId: Map<string, string>,
endingIds: Set<string>
): void => {
if (element.type === "cta" && element.ctaButtonLabel) {
element.buttonLabel = element.ctaButtonLabel;
}
if (Array.isArray(block.logic) && block.logic.length > 0) {
element.logic = transformBlockLogicToQuestionLogic(block.logic, 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;
}
};
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] };
applyBlockAttributesToElement(element, block, blockIdToQuestionId, endingIds);
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 });
};

View File

@@ -1,308 +0,0 @@
import { createId } from "@paralleldrive/cuid2";
import type { TFunction } from "i18next";
import type { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import type {
TSurveyCTAElement,
TSurveyConsentElement,
TSurveyElement,
TSurveyMultipleChoiceElement,
TSurveyNPSElement,
TSurveyOpenTextElement,
TSurveyOpenTextElementInputType,
TSurveyRatingElement,
} from "@formbricks/types/surveys/elements";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import type { TShuffleOption } from "@formbricks/types/surveys/types";
import { createI18nString } from "@/lib/i18n/utils";
const getDefaultButtonLabel = (label: string | undefined, t: TFunction) =>
createI18nString(label || t("common.next"), []);
const getDefaultBackButtonLabel = (label: string | undefined, t: TFunction) =>
createI18nString(label || t("common.back"), []);
export const buildMultipleChoiceElement = ({
id,
headline,
type,
subheader,
choices,
choiceIds,
shuffleOption,
required,
containsOther = false,
}: {
id?: string;
headline: string;
type: TSurveyElementTypeEnum.MultipleChoiceMulti | TSurveyElementTypeEnum.MultipleChoiceSingle;
subheader?: string;
choices: string[];
choiceIds?: string[];
shuffleOption?: TShuffleOption;
required?: boolean;
containsOther?: boolean;
}): TSurveyMultipleChoiceElement => {
return {
id: id ?? createId(),
type,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
choices: choices.map((choice, index) => {
const isLastIndex = index === choices.length - 1;
let choiceId: string;
if (containsOther && isLastIndex) {
choiceId = "other";
} else if (choiceIds) {
choiceId = choiceIds[index];
} else {
choiceId = createId();
}
return { id: choiceId, label: createI18nString(choice, []) };
}),
shuffleOption: shuffleOption || "none",
required: required ?? false,
};
};
export const buildOpenTextElement = ({
id,
headline,
subheader,
placeholder,
inputType,
required,
longAnswer,
}: {
id?: string;
headline: string;
subheader?: string;
placeholder?: string;
required?: boolean;
inputType: TSurveyOpenTextElementInputType;
longAnswer?: boolean;
}): TSurveyOpenTextElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.OpenText,
inputType,
subheader: subheader ? createI18nString(subheader, []) : undefined,
placeholder: placeholder ? createI18nString(placeholder, []) : undefined,
headline: createI18nString(headline, []),
required: required ?? false,
longAnswer,
charLimit: {
enabled: false,
},
};
};
export const buildRatingElement = ({
id,
headline,
subheader,
scale,
range,
lowerLabel,
upperLabel,
required,
isColorCodingEnabled = false,
}: {
id?: string;
headline: string;
scale: TSurveyRatingElement["scale"];
range: TSurveyRatingElement["range"];
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): TSurveyRatingElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.Rating,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
scale,
range,
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
};
};
export const buildConsentElement = ({
id,
headline,
subheader,
label,
required,
}: {
id?: string;
headline: string;
subheader: string;
required?: boolean;
label: string;
}): TSurveyConsentElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.Consent,
subheader: createI18nString(subheader, []),
headline: createI18nString(headline, []),
required: required ?? false,
label: createI18nString(label, []),
};
};
export const buildCTAElement = ({
id,
headline,
subheader,
buttonExternal,
required,
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,
};
};

View File

@@ -1,6 +1,15 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { TShuffleOption, TSurveyLogic, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { import {
buildCTAQuestion,
buildConsentQuestion,
buildMultipleChoiceQuestion,
buildNPSQuestion,
buildOpenTextQuestion,
buildRatingQuestion,
buildSurvey, buildSurvey,
createChoiceJumpLogic,
createJumpLogic,
getDefaultEndingCard, getDefaultEndingCard,
getDefaultSurveyPreset, getDefaultSurveyPreset,
getDefaultWelcomeCard, getDefaultWelcomeCard,
@@ -10,81 +19,595 @@ import {
const mockT = (props: any): string => (typeof props === "string" ? props : props.key); const mockT = (props: any): string => (typeof props === "string" ? props : props.key);
describe("Survey Builder", () => { describe("Survey Builder", () => {
describe("Helper Functions", () => { describe("buildMultipleChoiceQuestion", () => {
test("getDefaultSurveyPreset returns expected default survey preset", () => { test("creates a single choice question with required fields", () => {
const preset = getDefaultSurveyPreset(mockT); const question = buildMultipleChoiceQuestion({
expect(preset.name).toBe("New Survey"); headline: "Test Question",
// test welcomeCard and endings type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
expect(preset.welcomeCard).toHaveProperty("headline"); choices: ["Option 1", "Option 2", "Option 3"],
expect(preset.endings).toHaveLength(1); t: mockT,
expect(preset.endings[0]).toHaveProperty("headline");
expect(preset.hiddenFields).toEqual(hiddenFieldsDefault);
expect(preset.blocks).toEqual([]);
});
test("getDefaultWelcomeCard returns expected welcome card", () => {
const welcomeCard = getDefaultWelcomeCard(mockT);
expect(welcomeCard).toMatchObject({
enabled: false,
headline: { default: "templates.default_welcome_card_headline" },
timeToFinish: false,
showResponseCount: false,
}); });
// Check that the welcome card is properly structured
expect(welcomeCard).toHaveProperty("enabled");
expect(welcomeCard).toHaveProperty("headline");
expect(welcomeCard).toHaveProperty("showResponseCount");
expect(welcomeCard).toHaveProperty("timeToFinish");
});
test("getDefaultEndingCard returns expected ending card", () => { expect(question).toMatchObject({
const languages: string[] = []; type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
const endingCard = getDefaultEndingCard(languages, mockT); headline: { default: "Test Question" },
expect(endingCard).toMatchObject({ choices: expect.arrayContaining([
type: "endScreen", expect.objectContaining({ label: { default: "Option 1" } }),
headline: { default: "templates.default_ending_card_headline" }, expect.objectContaining({ label: { default: "Option 2" } }),
subheader: { default: "templates.default_ending_card_subheader" }, expect.objectContaining({ label: { default: "Option 3" } }),
]),
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
shuffleOption: "none",
required: false,
}); });
expect(endingCard.id).toBeDefined(); expect(question.choices.length).toBe(3);
expect(endingCard).toHaveProperty("buttonLabel"); expect(question.id).toBeDefined();
expect(endingCard).toHaveProperty("buttonLink");
}); });
test("hiddenFieldsDefault has expected structure", () => { test("creates a multiple choice question with provided ID", () => {
expect(hiddenFieldsDefault).toMatchObject({ const customId = "custom-id-123";
enabled: true, const question = buildMultipleChoiceQuestion({
fieldIds: [], id: customId,
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
choices: ["Option 1", "Option 2"],
t: mockT,
}); });
expect(question.id).toBe(customId);
expect(question.type).toBe(TSurveyQuestionTypeEnum.MultipleChoiceMulti);
}); });
test("buildSurvey returns built survey with overridden preset properties", () => { test("handles 'other' option correctly", () => {
const config = { const choices = ["Option 1", "Option 2", "Other"];
name: "Custom Survey", const question = buildMultipleChoiceQuestion({
role: "productManager" as const, headline: "Test Question",
industries: ["saas" as const], type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
channels: ["link" as const], choices,
description: "A custom survey description", containsOther: true,
blocks: [], t: mockT,
endings: [getDefaultEndingCard([], mockT)], });
hiddenFields: hiddenFieldsDefault,
};
const survey = buildSurvey(config, mockT); expect(question.choices.length).toBe(3);
expect(question.choices[2].id).toBe("other");
});
// role, industries, channels, description test("uses provided choice IDs when available", () => {
expect(survey.role).toBe(config.role); const choiceIds = ["id1", "id2", "id3"];
expect(survey.industries).toEqual(config.industries); const question = buildMultipleChoiceQuestion({
expect(survey.channels).toEqual(config.channels); headline: "Test Question",
expect(survey.description).toBe(config.description); type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: ["Option 1", "Option 2", "Option 3"],
choiceIds,
t: mockT,
});
// preset overrides expect(question.choices[0].id).toBe(choiceIds[0]);
expect(survey.preset.name).toBe(config.name); expect(question.choices[1].id).toBe(choiceIds[1]);
expect(survey.preset.endings).toEqual(config.endings); expect(question.choices[2].id).toBe(choiceIds[2]);
expect(survey.preset.hiddenFields).toEqual(config.hiddenFields); });
expect(survey.preset.blocks).toEqual(config.blocks);
// default values from getDefaultSurveyPreset test("applies all optional parameters correctly", () => {
expect(survey.preset.welcomeCard).toHaveProperty("headline"); const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const shuffleOption: TShuffleOption = "all";
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
subheader: "This is a subheader",
choices: ["Option 1", "Option 2"],
buttonLabel: "Custom Next",
backButtonLabel: "Custom Back",
shuffleOption,
required: false,
logic,
t: mockT,
});
expect(question.subheader).toEqual({ default: "This is a subheader" });
expect(question.buttonLabel).toEqual({ default: "Custom Next" });
expect(question.backButtonLabel).toEqual({ default: "Custom Back" });
expect(question.shuffleOption).toBe("all");
expect(question.required).toBe(false);
expect(question.logic).toBe(logic);
});
});
describe("buildOpenTextQuestion", () => {
test("creates an open text question with required fields", () => {
const question = buildOpenTextQuestion({
headline: "Open Question",
inputType: "text",
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Open Question" },
inputType: "text",
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
charLimit: {
enabled: false,
},
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildOpenTextQuestion({
id: "custom-id",
headline: "Open Question",
subheader: "Answer this question",
placeholder: "Type here",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
longAnswer: true,
inputType: "email",
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "Answer this question" });
expect(question.placeholder).toEqual({ default: "Type here" });
expect(question.buttonLabel).toEqual({ default: "Submit" });
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.longAnswer).toBe(true);
expect(question.inputType).toBe("email");
expect(question.logic).toBe(logic);
});
});
describe("buildRatingQuestion", () => {
test("creates a rating question with required fields", () => {
const question = buildRatingQuestion({
headline: "Rating Question",
scale: "number",
range: 5,
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rating Question" },
scale: "number",
range: 5,
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
isColorCodingEnabled: false,
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildRatingQuestion({
id: "custom-id",
headline: "Rating Question",
subheader: "Rate us",
scale: "star",
range: 10,
lowerLabel: "Poor",
upperLabel: "Excellent",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
isColorCodingEnabled: true,
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "Rate us" });
expect(question.scale).toBe("star");
expect(question.range).toBe(10);
expect(question.lowerLabel).toEqual({ default: "Poor" });
expect(question.upperLabel).toEqual({ default: "Excellent" });
expect(question.buttonLabel).toEqual({ default: "Submit" });
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.isColorCodingEnabled).toBe(true);
expect(question.logic).toBe(logic);
});
});
describe("buildNPSQuestion", () => {
test("creates an NPS question with required fields", () => {
const question = buildNPSQuestion({
headline: "NPS Question",
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "NPS Question" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
isColorCodingEnabled: false,
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildNPSQuestion({
id: "custom-id",
headline: "NPS Question",
subheader: "How likely are you to recommend us?",
lowerLabel: "Not likely",
upperLabel: "Very likely",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
isColorCodingEnabled: true,
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "How likely are you to recommend us?" });
expect(question.lowerLabel).toEqual({ default: "Not likely" });
expect(question.upperLabel).toEqual({ default: "Very likely" });
expect(question.buttonLabel).toEqual({ default: "Submit" });
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.isColorCodingEnabled).toBe(true);
expect(question.logic).toBe(logic);
});
});
describe("buildConsentQuestion", () => {
test("creates a consent question with required fields", () => {
const question = buildConsentQuestion({
headline: "Consent Question",
subheader: "",
label: "I agree to terms",
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.Consent,
headline: { default: "Consent Question" },
subheader: { default: "" },
label: { default: "I agree to terms" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildConsentQuestion({
id: "custom-id",
headline: "Consent Question",
subheader: "Please read the terms",
label: "I agree to terms",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "Please read the terms" });
expect(question.label).toEqual({ default: "I agree to terms" });
expect(question.buttonLabel).toEqual({ default: "Submit" });
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.logic).toBe(logic);
});
});
describe("buildCTAQuestion", () => {
test("creates a CTA question with required fields", () => {
const question = buildCTAQuestion({
headline: "CTA Question",
subheader: "",
buttonExternal: false,
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA Question" },
subheader: { default: "" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
buttonExternal: false,
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildCTAQuestion({
id: "custom-id",
headline: "CTA Question",
subheader: "<p>Click the button</p>",
buttonLabel: "Click me",
buttonExternal: true,
buttonUrl: "https://example.com",
backButtonLabel: "Previous",
required: false,
dismissButtonLabel: "No thanks",
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "<p>Click the button</p>" });
expect(question.buttonLabel).toEqual({ default: "Click me" });
expect(question.buttonExternal).toBe(true);
expect(question.buttonUrl).toBe("https://example.com");
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.dismissButtonLabel).toEqual({ default: "No thanks" });
expect(question.logic).toBe(logic);
});
test("handles external button with URL", () => {
const question = buildCTAQuestion({
headline: "CTA Question",
subheader: "",
buttonExternal: true,
buttonUrl: "https://formbricks.com",
t: mockT,
});
expect(question.buttonExternal).toBe(true);
expect(question.buttonUrl).toBe("https://formbricks.com");
});
});
// Test combinations of parameters for edge cases
describe("Edge cases", () => {
test("multiple choice question with empty choices array", () => {
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [],
t: mockT,
});
expect(question.choices).toEqual([]);
});
test("open text question with all parameters", () => {
const question = buildOpenTextQuestion({
id: "custom-id",
headline: "Open Question",
subheader: "Answer this question",
placeholder: "Type here",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
longAnswer: true,
inputType: "email",
logic: [],
t: mockT,
});
expect(question).toMatchObject({
id: "custom-id",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Open Question" },
subheader: { default: "Answer this question" },
placeholder: { default: "Type here" },
buttonLabel: { default: "Submit" },
backButtonLabel: { default: "Previous" },
required: false,
longAnswer: true,
inputType: "email",
logic: [],
});
}); });
}); });
}); });
describe("Helper Functions", () => {
test("createJumpLogic returns valid jump logic", () => {
const sourceId = "q1";
const targetId = "q2";
const operator: "isClicked" = "isClicked";
const logic = createJumpLogic(sourceId, targetId, operator);
// Check structure
expect(logic).toHaveProperty("id");
expect(logic).toHaveProperty("conditions");
expect(logic.conditions).toHaveProperty("conditions");
expect(Array.isArray(logic.conditions.conditions)).toBe(true);
// Check one of the inner conditions
const condition = logic.conditions.conditions[0];
// Need to use type checking to ensure condition is a TSingleCondition not a TConditionGroup
if (!("connector" in condition)) {
expect(condition.leftOperand.value).toBe(sourceId);
expect(condition.operator).toBe(operator);
}
// Check actions
expect(Array.isArray(logic.actions)).toBe(true);
const action = logic.actions[0];
if (action.objective === "jumpToQuestion") {
expect(action.target).toBe(targetId);
}
});
test("createChoiceJumpLogic returns valid jump logic based on choice selection", () => {
const sourceId = "q1";
const choiceId = "choice1";
const targetId = "q2";
const logic = createChoiceJumpLogic(sourceId, choiceId, targetId);
expect(logic).toHaveProperty("id");
expect(logic.conditions).toHaveProperty("conditions");
const condition = logic.conditions.conditions[0];
if (!("connector" in condition)) {
expect(condition.leftOperand.value).toBe(sourceId);
expect(condition.operator).toBe("equals");
expect(condition.rightOperand?.value).toBe(choiceId);
}
const action = logic.actions[0];
if (action.objective === "jumpToQuestion") {
expect(action.target).toBe(targetId);
}
});
test("getDefaultWelcomeCard returns expected welcome card", () => {
const card = getDefaultWelcomeCard(mockT);
expect(card.enabled).toBe(false);
expect(card.headline).toEqual({ default: "templates.default_welcome_card_headline" });
expect(card.subheader).toEqual({ default: "templates.default_welcome_card_html" });
expect(card.buttonLabel).toEqual({ default: "templates.default_welcome_card_button_label" });
// boolean flags
expect(card.timeToFinish).toBe(false);
expect(card.showResponseCount).toBe(false);
});
test("getDefaultEndingCard returns expected end screen card", () => {
// Pass empty languages array to simulate no languages
const card = getDefaultEndingCard([], mockT);
expect(card).toHaveProperty("id");
expect(card.type).toBe("endScreen");
expect(card.headline).toEqual({ default: "templates.default_ending_card_headline" });
expect(card.subheader).toEqual({ default: "templates.default_ending_card_subheader" });
expect(card.buttonLabel).toEqual({ default: "templates.default_ending_card_button_label" });
expect(card.buttonLink).toBe("https://formbricks.com");
});
test("getDefaultSurveyPreset returns expected default survey preset", () => {
const preset = getDefaultSurveyPreset(mockT);
expect(preset.name).toBe("New Survey");
expect(preset.questions).toEqual([]);
// test welcomeCard and endings
expect(preset.welcomeCard).toHaveProperty("headline");
expect(Array.isArray(preset.endings)).toBe(true);
expect(preset.hiddenFields).toEqual(hiddenFieldsDefault);
});
test("buildSurvey returns built survey with overridden preset properties", () => {
const config = {
name: "Custom Survey",
industries: ["eCommerce"] as string[],
channels: ["link"],
description: "Test survey",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText, // changed from "OpenText"
headline: { default: "Question 1" },
inputType: "text",
buttonLabel: { default: "Next" },
backButtonLabel: { default: "Back" },
required: true,
},
],
endings: [
{
id: "end1",
type: "endScreen",
headline: { default: "End Screen" },
subheader: { default: "Thanks" },
buttonLabel: { default: "Finish" },
buttonLink: "https://formbricks.com",
},
],
hiddenFields: { enabled: false, fieldIds: ["f1"] },
};
const survey = buildSurvey(config as any, mockT);
expect(survey.name).toBe(config.name);
expect(survey.industries).toEqual(config.industries);
expect(survey.channels).toEqual(config.channels);
expect(survey.description).toBe(config.description);
// preset overrides
expect(survey.preset.name).toBe(config.name);
expect(survey.preset.questions).toEqual(config.questions);
expect(survey.preset.endings).toEqual(config.endings);
expect(survey.preset.hiddenFields).toEqual(config.hiddenFields);
});
test("hiddenFieldsDefault has expected default configuration", () => {
expect(hiddenFieldsDefault).toEqual({ enabled: true, fieldIds: [] });
});
});

View File

@@ -1,17 +1,284 @@
import { createId } from "@paralleldrive/cuid2"; import { createId } from "@paralleldrive/cuid2";
import type { TFunction } from "i18next"; import { TFunction } from "i18next";
import type { TSurveyBlock } from "@formbricks/types/surveys/blocks"; import {
import type { TShuffleOption,
TSurveyCTAQuestion,
TSurveyConsentQuestion,
TSurveyEndScreenCard, TSurveyEndScreenCard,
TSurveyEnding, TSurveyEnding,
TSurveyHiddenFields, TSurveyHiddenFields,
TSurveyLanguage, TSurveyLanguage,
TSurveyLogic, TSurveyLogic,
TSurveyMultipleChoiceQuestion,
TSurveyNPSQuestion,
TSurveyOpenTextQuestion,
TSurveyOpenTextQuestionInputType,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveyRatingQuestion,
TSurveyWelcomeCard, TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types"; } from "@formbricks/types/surveys/types";
import type { TTemplate, TTemplateRole } from "@formbricks/types/templates"; import { TTemplate, TTemplateRole } from "@formbricks/types/templates";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; 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 // Helper function to create standard jump logic based on operator
export const createJumpLogic = ( export const createJumpLogic = (
sourceQuestionId: string, sourceQuestionId: string,
@@ -27,7 +294,7 @@ export const createJumpLogic = (
id: createId(), id: createId(),
leftOperand: { leftOperand: {
value: sourceQuestionId, value: sourceQuestionId,
type: "element", type: "question",
}, },
operator: operator, operator: operator,
}, },
@@ -57,7 +324,7 @@ export const createChoiceJumpLogic = (
id: createId(), id: createId(),
leftOperand: { leftOperand: {
value: sourceQuestionId, value: sourceQuestionId,
type: "element", type: "question",
}, },
operator: "equals", operator: "equals",
rightOperand: { rightOperand: {
@@ -110,13 +377,13 @@ export const getDefaultSurveyPreset = (t: TFunction): TTemplate["preset"] => {
welcomeCard: getDefaultWelcomeCard(t), welcomeCard: getDefaultWelcomeCard(t),
endings: [getDefaultEndingCard([], t)], endings: [getDefaultEndingCard([], t)],
hiddenFields: hiddenFieldsDefault, hiddenFields: hiddenFieldsDefault,
blocks: [], questions: [],
}; };
}; };
/** /**
* Generic builder for survey. * Generic builder for survey.
* @param config - The configuration for survey settings and blocks. * @param config - The configuration for survey settings and questions.
* @param t - The translation function. * @param t - The translation function.
*/ */
export const buildSurvey = ( export const buildSurvey = (
@@ -126,9 +393,9 @@ export const buildSurvey = (
channels: ("link" | "app" | "website")[]; channels: ("link" | "app" | "website")[];
role: TTemplateRole; role: TTemplateRole;
description: string; description: string;
blocks: TSurveyBlock[]; questions: TSurveyQuestion[];
endings: TSurveyEnding[]; endings?: TSurveyEnding[];
hiddenFields: TSurveyHiddenFields; hiddenFields?: TSurveyHiddenFields;
}, },
t: TFunction t: TFunction
): TTemplate => { ): TTemplate => {
@@ -142,7 +409,7 @@ export const buildSurvey = (
preset: { preset: {
...localSurvey, ...localSurvey,
name: config.name, name: config.name,
blocks: config.blocks ?? [], questions: config.questions,
endings: config.endings ?? localSurvey.endings, endings: config.endings ?? localSurvey.endings,
hiddenFields: config.hiddenFields ?? hiddenFieldsDefault, hiddenFields: config.hiddenFields ?? hiddenFieldsDefault,
}, },

View File

@@ -2,15 +2,19 @@ import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react"; import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest"; import { afterEach, describe, expect, test } from "vitest";
import { TLanguage } from "@formbricks/types/project"; import { TLanguage } from "@formbricks/types/project";
import { type TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import {
import { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types"; TSurvey,
TSurveyLanguage,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { import {
DateRange, DateRange,
SelectedFilterValue, SelectedFilterValue,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context"; } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementsComboBox"; import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { generateElementAndFilterOptions, getFormattedFilters, getTodayDate } from "./surveys"; import { generateQuestionAndFilterOptions, getFormattedFilters, getTodayDate } from "./surveys";
describe("surveys", () => { describe("surveys", () => {
afterEach(() => { afterEach(() => {
@@ -22,42 +26,31 @@ describe("surveys", () => {
const survey = { const survey = {
id: "survey1", id: "survey1",
name: "Test Survey", name: "Test Survey",
blocks: [ questions: [
{ {
id: "block1", id: "q1",
name: "Block 1", type: TSurveyQuestionTypeEnum.OpenText,
elements: [ headline: { default: "Open Text Question" },
{ } as unknown as TSurveyQuestion,
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Open Text Question" },
required: false,
inputType: "text",
charLimit: { enabled: false },
} as TSurveyElement,
],
},
], ],
questions: [],
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
environmentId: "env1", environmentId: "env1",
status: "draft", status: "draft",
} as unknown as TSurvey; } as unknown as TSurvey;
const result = generateElementAndFilterOptions(survey, undefined, {}, {}, {}, []); const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {}, []);
expect(result.elementOptions.length).toBeGreaterThan(0); expect(result.questionOptions.length).toBeGreaterThan(0);
expect(result.elementOptions[0].header).toBe(OptionsType.ELEMENTS); expect(result.questionOptions[0].header).toBe(OptionsType.QUESTIONS);
expect(result.elementFilterOptions.length).toBe(1); expect(result.questionFilterOptions.length).toBe(1);
expect(result.elementFilterOptions[0].id).toBe("q1"); expect(result.questionFilterOptions[0].id).toBe("q1");
}); });
test("should include tags in options when provided", () => { test("should include tags in options when provided", () => {
const survey = { const survey = {
id: "survey1", id: "survey1",
name: "Test Survey", name: "Test Survey",
blocks: [],
questions: [], questions: [],
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
@@ -69,9 +62,9 @@ describe("surveys", () => {
{ id: "tag1", name: "Tag 1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() }, { id: "tag1", name: "Tag 1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() },
]; ];
const result = generateElementAndFilterOptions(survey, tags, {}, {}, {}, []); const result = generateQuestionAndFilterOptions(survey, tags, {}, {}, {}, []);
const tagsHeader = result.elementOptions.find((opt) => opt.header === OptionsType.TAGS); const tagsHeader = result.questionOptions.find((opt) => opt.header === OptionsType.TAGS);
expect(tagsHeader).toBeDefined(); expect(tagsHeader).toBeDefined();
expect(tagsHeader?.option.length).toBe(1); expect(tagsHeader?.option.length).toBe(1);
expect(tagsHeader?.option[0].label).toBe("Tag 1"); expect(tagsHeader?.option[0].label).toBe("Tag 1");
@@ -81,7 +74,6 @@ describe("surveys", () => {
const survey = { const survey = {
id: "survey1", id: "survey1",
name: "Test Survey", name: "Test Survey",
blocks: [],
questions: [], questions: [],
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
@@ -93,9 +85,9 @@ describe("surveys", () => {
role: ["admin", "user"], role: ["admin", "user"],
}; };
const result = generateElementAndFilterOptions(survey, undefined, attributes, {}, {}, []); const result = generateQuestionAndFilterOptions(survey, undefined, attributes, {}, {}, []);
const attributesHeader = result.elementOptions.find((opt) => opt.header === OptionsType.ATTRIBUTES); const attributesHeader = result.questionOptions.find((opt) => opt.header === OptionsType.ATTRIBUTES);
expect(attributesHeader).toBeDefined(); expect(attributesHeader).toBeDefined();
expect(attributesHeader?.option.length).toBe(1); expect(attributesHeader?.option.length).toBe(1);
expect(attributesHeader?.option[0].label).toBe("role"); expect(attributesHeader?.option[0].label).toBe("role");
@@ -105,7 +97,6 @@ describe("surveys", () => {
const survey = { const survey = {
id: "survey1", id: "survey1",
name: "Test Survey", name: "Test Survey",
blocks: [],
questions: [], questions: [],
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
@@ -117,9 +108,9 @@ describe("surveys", () => {
source: ["web", "mobile"], source: ["web", "mobile"],
}; };
const result = generateElementAndFilterOptions(survey, undefined, {}, meta, {}, []); const result = generateQuestionAndFilterOptions(survey, undefined, {}, meta, {}, []);
const metaHeader = result.elementOptions.find((opt) => opt.header === OptionsType.META); const metaHeader = result.questionOptions.find((opt) => opt.header === OptionsType.META);
expect(metaHeader).toBeDefined(); expect(metaHeader).toBeDefined();
expect(metaHeader?.option.length).toBe(1); expect(metaHeader?.option.length).toBe(1);
expect(metaHeader?.option[0].label).toBe("source"); expect(metaHeader?.option[0].label).toBe("source");
@@ -129,7 +120,6 @@ describe("surveys", () => {
const survey = { const survey = {
id: "survey1", id: "survey1",
name: "Test Survey", name: "Test Survey",
blocks: [],
questions: [], questions: [],
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
@@ -141,9 +131,9 @@ describe("surveys", () => {
segment: ["free", "paid"], segment: ["free", "paid"],
}; };
const result = generateElementAndFilterOptions(survey, undefined, {}, {}, hiddenFields, []); const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, hiddenFields, []);
const hiddenFieldsHeader = result.elementOptions.find( const hiddenFieldsHeader = result.questionOptions.find(
(opt) => opt.header === OptionsType.HIDDEN_FIELDS (opt) => opt.header === OptionsType.HIDDEN_FIELDS
); );
expect(hiddenFieldsHeader).toBeDefined(); expect(hiddenFieldsHeader).toBeDefined();
@@ -155,7 +145,6 @@ describe("surveys", () => {
const survey = { const survey = {
id: "survey1", id: "survey1",
name: "Test Survey", name: "Test Survey",
blocks: [],
questions: [], questions: [],
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
@@ -164,9 +153,9 @@ describe("surveys", () => {
languages: [{ language: { code: "en" } as unknown as TLanguage } as unknown as TSurveyLanguage], languages: [{ language: { code: "en" } as unknown as TLanguage } as unknown as TSurveyLanguage],
} as unknown as TSurvey; } as unknown as TSurvey;
const result = generateElementAndFilterOptions(survey, undefined, {}, {}, {}, []); const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {}, []);
const othersHeader = result.elementOptions.find((opt) => opt.header === OptionsType.OTHERS); const othersHeader = result.questionOptions.find((opt) => opt.header === OptionsType.OTHERS);
expect(othersHeader).toBeDefined(); expect(othersHeader).toBeDefined();
expect(othersHeader?.option.some((o) => o.label === "Language")).toBeTruthy(); expect(othersHeader?.option.some((o) => o.label === "Language")).toBeTruthy();
}); });
@@ -175,107 +164,78 @@ describe("surveys", () => {
const survey = { const survey = {
id: "survey1", id: "survey1",
name: "Test Survey", name: "Test Survey",
blocks: [ questions: [
{ {
id: "block1", id: "q1",
name: "Block 1", type: TSurveyQuestionTypeEnum.OpenText,
elements: [ headline: { default: "Open Text" },
{ } as unknown as TSurveyQuestion,
id: "q1", {
type: TSurveyElementTypeEnum.OpenText, id: "q2",
headline: { default: "Open Text" }, type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
required: false, headline: { default: "Multiple Choice Single" },
inputType: "text", choices: [{ id: "c1", label: "Choice 1" }],
charLimit: { enabled: false }, } as unknown as TSurveyQuestion,
}, {
{ id: "q3",
id: "q2", type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
type: TSurveyElementTypeEnum.MultipleChoiceSingle, headline: { default: "Multiple Choice Multi" },
headline: { default: "Multiple Choice Single" }, choices: [
required: false, { id: "c1", label: "Choice 1" },
choices: [{ id: "c1", label: { default: "Choice 1" } }], { id: "other", label: "Other" },
shuffleOption: "none", ],
}, } as unknown as TSurveyQuestion,
{ {
id: "q3", id: "q4",
type: TSurveyElementTypeEnum.MultipleChoiceMulti, type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "Multiple Choice Multi" }, headline: { default: "NPS" },
required: false, } as unknown as TSurveyQuestion,
choices: [ {
{ id: "c1", label: { default: "Choice 1" } }, id: "q5",
{ id: "other", label: { default: "Other" } }, type: TSurveyQuestionTypeEnum.Rating,
], headline: { default: "Rating" },
shuffleOption: "none", } as unknown as TSurveyQuestion,
}, {
{ id: "q6",
id: "q4", type: TSurveyQuestionTypeEnum.CTA,
type: TSurveyElementTypeEnum.NPS, headline: { default: "CTA" },
headline: { default: "NPS" }, } as unknown as TSurveyQuestion,
required: false, {
lowerLabel: { default: "Not likely" }, id: "q7",
upperLabel: { default: "Very likely" }, type: TSurveyQuestionTypeEnum.PictureSelection,
}, headline: { default: "Picture Selection" },
{ choices: [
id: "q5", { id: "p1", imageUrl: "url1" },
type: TSurveyElementTypeEnum.Rating, { id: "p2", imageUrl: "url2" },
headline: { default: "Rating" }, ],
required: false, } as unknown as TSurveyQuestion,
scale: "number", {
range: 5, id: "q8",
lowerLabel: { default: "Low" }, type: TSurveyQuestionTypeEnum.Matrix,
upperLabel: { default: "High" }, headline: { default: "Matrix" },
}, rows: [{ id: "r1", label: { default: "Row 1" } }],
{ columns: [{ id: "c1", label: { default: "Column 1" } }],
id: "q6", } as unknown as TSurveyQuestion,
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(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
environmentId: "env1", environmentId: "env1",
status: "draft", status: "draft",
} as unknown as TSurvey; } as unknown as TSurvey;
const result = generateElementAndFilterOptions(survey, undefined, {}, {}, {}, []); const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {}, []);
expect(result.elementFilterOptions.length).toBe(8); expect(result.questionFilterOptions.length).toBe(8);
expect(result.elementFilterOptions.some((o) => o.id === "q1")).toBeTruthy(); expect(result.questionFilterOptions.some((o) => o.id === "q1")).toBeTruthy();
expect(result.elementFilterOptions.some((o) => o.id === "q2")).toBeTruthy(); expect(result.questionFilterOptions.some((o) => o.id === "q2")).toBeTruthy();
expect(result.elementFilterOptions.some((o) => o.id === "q7")).toBeTruthy(); expect(result.questionFilterOptions.some((o) => o.id === "q7")).toBeTruthy();
expect(result.elementFilterOptions.some((o) => o.id === "q8")).toBeTruthy(); expect(result.questionFilterOptions.some((o) => o.id === "q8")).toBeTruthy();
}); });
test("should provide extended filter options for URL meta field", () => { test("should provide extended filter options for URL meta field", () => {
const survey = { const survey = {
id: "survey1", id: "survey1",
name: "Test Survey", name: "Test Survey",
blocks: [],
questions: [], questions: [],
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
@@ -288,10 +248,10 @@ describe("surveys", () => {
source: ["web", "mobile"], source: ["web", "mobile"],
}; };
const result = generateElementAndFilterOptions(survey, undefined, {}, meta, {}, []); const result = generateQuestionAndFilterOptions(survey, undefined, {}, meta, {}, []);
const urlFilterOption = result.elementFilterOptions.find((o) => o.id === "url"); const urlFilterOption = result.questionFilterOptions.find((o) => o.id === "url");
const sourceFilterOption = result.elementFilterOptions.find((o) => o.id === "source"); const sourceFilterOption = result.questionFilterOptions.find((o) => o.id === "source");
expect(urlFilterOption).toBeDefined(); expect(urlFilterOption).toBeDefined();
expect(urlFilterOption?.filterOptions).toEqual([ expect(urlFilterOption?.filterOptions).toEqual([
@@ -308,185 +268,82 @@ describe("surveys", () => {
expect(sourceFilterOption).toBeDefined(); expect(sourceFilterOption).toBeDefined();
expect(sourceFilterOption?.filterOptions).toEqual(["Equals", "Not equals"]); 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", () => { describe("getFormattedFilters", () => {
const survey = { const survey = {
id: "survey1", id: "survey1",
name: "Test Survey", name: "Test Survey",
blocks: [ questions: [
{ {
id: "block1", id: "openTextQ",
name: "Block 1", type: TSurveyQuestionTypeEnum.OpenText,
elements: [ headline: { default: "Open Text" },
{ } as unknown as TSurveyQuestion,
id: "openTextQ", {
type: TSurveyElementTypeEnum.OpenText, id: "mcSingleQ",
headline: { default: "Open Text" }, type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
required: false, headline: { default: "Multiple Choice Single" },
inputType: "text", choices: [{ id: "c1", label: "Choice 1" }],
charLimit: { enabled: false }, } as unknown as TSurveyQuestion,
}, {
{ id: "mcMultiQ",
id: "mcSingleQ", type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
type: TSurveyElementTypeEnum.MultipleChoiceSingle, headline: { default: "Multiple Choice Multi" },
headline: { default: "Multiple Choice Single" }, choices: [{ id: "c1", label: "Choice 1" }],
required: false, } as unknown as TSurveyQuestion,
choices: [{ id: "c1", label: { default: "Choice 1" } }], {
shuffleOption: "none", id: "npsQ",
}, type: TSurveyQuestionTypeEnum.NPS,
{ headline: { default: "NPS" },
id: "mcMultiQ", } as unknown as TSurveyQuestion,
type: TSurveyElementTypeEnum.MultipleChoiceMulti, {
headline: { default: "Multiple Choice Multi" }, id: "ratingQ",
required: false, type: TSurveyQuestionTypeEnum.Rating,
choices: [{ id: "c1", label: { default: "Choice 1" } }], headline: { default: "Rating" },
shuffleOption: "none", } as unknown as TSurveyQuestion,
}, {
{ id: "ctaQ",
id: "npsQ", type: TSurveyQuestionTypeEnum.CTA,
type: TSurveyElementTypeEnum.NPS, headline: { default: "CTA" },
headline: { default: "NPS" }, } as unknown as TSurveyQuestion,
required: false, {
lowerLabel: { default: "Not likely" }, id: "consentQ",
upperLabel: { default: "Very likely" }, type: TSurveyQuestionTypeEnum.Consent,
}, headline: { default: "Consent" },
{ } as unknown as TSurveyQuestion,
id: "ratingQ", {
type: TSurveyElementTypeEnum.Rating, id: "pictureQ",
headline: { default: "Rating" }, type: TSurveyQuestionTypeEnum.PictureSelection,
required: false, headline: { default: "Picture Selection" },
scale: "number", choices: [
range: 5, { id: "p1", imageUrl: "url1" },
lowerLabel: { default: "Low" }, { id: "p2", imageUrl: "url2" },
upperLabel: { default: "High" }, ],
}, } as unknown as TSurveyQuestion,
{ {
id: "ctaQ", id: "matrixQ",
type: TSurveyElementTypeEnum.CTA, type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "CTA" }, headline: { default: "Matrix" },
required: false, rows: [{ id: "r1", label: "Row 1" }],
buttonLabel: { default: "Click me" }, columns: [{ id: "c1", label: "Column 1" }],
buttonExternal: false, } as unknown as TSurveyQuestion,
}, {
{ id: "addressQ",
id: "consentQ", type: TSurveyQuestionTypeEnum.Address,
type: TSurveyElementTypeEnum.Consent, headline: { default: "Address" },
headline: { default: "Consent" }, } as unknown as TSurveyQuestion,
required: false, {
label: { default: "I agree" }, id: "contactQ",
}, type: TSurveyQuestionTypeEnum.ContactInfo,
{ headline: { default: "Contact Info" },
id: "pictureQ", } as unknown as TSurveyQuestion,
type: TSurveyElementTypeEnum.PictureSelection, {
headline: { default: "Picture Selection" }, id: "rankingQ",
required: false, type: TSurveyQuestionTypeEnum.Ranking,
allowMultiple: false, headline: { default: "Ranking" },
choices: [ } as unknown as TSurveyQuestion,
{ 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(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
environmentId: "env1", environmentId: "env1",
@@ -538,11 +395,11 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { type: "Tags", label: "Tag 1", id: "tag1" }, questionType: { type: "Tags", label: "Tag 1", id: "tag1" },
filterType: { filterComboBoxValue: "Applied" }, filterType: { filterComboBoxValue: "Applied" },
}, },
{ {
elementType: { type: "Tags", label: "Tag 2", id: "tag2" }, questionType: { type: "Tags", label: "Tag 2", id: "tag2" },
filterType: { filterComboBoxValue: "Not applied" }, filterType: { filterComboBoxValue: "Not applied" },
}, },
] as any, ] as any,
@@ -559,11 +416,11 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { questionType: {
type: "Elements", type: "Questions",
label: "Open Text", label: "Open Text",
id: "openTextQ", id: "openTextQ",
elementType: TSurveyElementTypeEnum.OpenText, questionType: TSurveyQuestionTypeEnum.OpenText,
}, },
filterType: { filterComboBoxValue: "Filled out" }, filterType: { filterComboBoxValue: "Filled out" },
}, },
@@ -580,11 +437,11 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { questionType: {
type: "Elements", type: "Questions",
label: "Address", label: "Address",
id: "addressQ", id: "addressQ",
elementType: TSurveyElementTypeEnum.Address, questionType: TSurveyQuestionTypeEnum.Address,
}, },
filterType: { filterComboBoxValue: "Skipped" }, filterType: { filterComboBoxValue: "Skipped" },
}, },
@@ -601,11 +458,11 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { questionType: {
type: "Elements", type: "Questions",
label: "Contact Info", label: "Contact Info",
id: "contactQ", id: "contactQ",
elementType: TSurveyElementTypeEnum.ContactInfo, questionType: TSurveyQuestionTypeEnum.ContactInfo,
}, },
filterType: { filterComboBoxValue: "Filled out" }, filterType: { filterComboBoxValue: "Filled out" },
}, },
@@ -622,11 +479,11 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { questionType: {
type: "Elements", type: "Questions",
label: "Ranking", label: "Ranking",
id: "rankingQ", id: "rankingQ",
elementType: TSurveyElementTypeEnum.Ranking, questionType: TSurveyQuestionTypeEnum.Ranking,
}, },
filterType: { filterComboBoxValue: "Filled out" }, filterType: { filterComboBoxValue: "Filled out" },
}, },
@@ -643,11 +500,11 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { questionType: {
type: "Elements", type: "Questions",
label: "MC Single", label: "MC Single",
id: "mcSingleQ", id: "mcSingleQ",
elementType: TSurveyElementTypeEnum.MultipleChoiceSingle, questionType: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
}, },
filterType: { filterValue: "Includes either", filterComboBoxValue: ["Choice 1"] }, filterType: { filterValue: "Includes either", filterComboBoxValue: ["Choice 1"] },
}, },
@@ -664,11 +521,11 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { questionType: {
type: "Elements", type: "Questions",
label: "MC Multi", label: "MC Multi",
id: "mcMultiQ", id: "mcMultiQ",
elementType: TSurveyElementTypeEnum.MultipleChoiceMulti, questionType: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
}, },
filterType: { filterValue: "Includes all", filterComboBoxValue: ["Choice 1", "Choice 2"] }, filterType: { filterValue: "Includes all", filterComboBoxValue: ["Choice 1", "Choice 2"] },
}, },
@@ -685,11 +542,11 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { questionType: {
type: "Elements", type: "Questions",
label: "NPS", label: "NPS",
id: "npsQ", id: "npsQ",
elementType: TSurveyElementTypeEnum.NPS, questionType: TSurveyQuestionTypeEnum.NPS,
}, },
filterType: { filterValue: "Is equal to", filterComboBoxValue: "7" }, filterType: { filterValue: "Is equal to", filterComboBoxValue: "7" },
}, },
@@ -706,11 +563,11 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { questionType: {
type: "Elements", type: "Questions",
label: "Rating", label: "Rating",
id: "ratingQ", id: "ratingQ",
elementType: TSurveyElementTypeEnum.Rating, questionType: TSurveyQuestionTypeEnum.Rating,
}, },
filterType: { filterValue: "Is less than", filterComboBoxValue: "4" }, filterType: { filterValue: "Is less than", filterComboBoxValue: "4" },
}, },
@@ -727,11 +584,11 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { questionType: {
type: "Elements", type: "Questions",
label: "CTA", label: "CTA",
id: "ctaQ", id: "ctaQ",
elementType: TSurveyElementTypeEnum.CTA, questionType: TSurveyQuestionTypeEnum.CTA,
}, },
filterType: { filterComboBoxValue: "Clicked" }, filterType: { filterComboBoxValue: "Clicked" },
}, },
@@ -748,11 +605,11 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { questionType: {
type: "Elements", type: "Questions",
label: "Consent", label: "Consent",
id: "consentQ", id: "consentQ",
elementType: TSurveyElementTypeEnum.Consent, questionType: TSurveyQuestionTypeEnum.Consent,
}, },
filterType: { filterComboBoxValue: "Accepted" }, filterType: { filterComboBoxValue: "Accepted" },
}, },
@@ -769,11 +626,11 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { questionType: {
type: "Elements", type: "Questions",
label: "Picture", label: "Picture",
id: "pictureQ", id: "pictureQ",
elementType: TSurveyElementTypeEnum.PictureSelection, questionType: TSurveyQuestionTypeEnum.PictureSelection,
}, },
filterType: { filterValue: "Includes either", filterComboBoxValue: ["Picture 1"] }, filterType: { filterValue: "Includes either", filterComboBoxValue: ["Picture 1"] },
}, },
@@ -790,11 +647,11 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { questionType: {
type: "Elements", type: "Questions",
label: "Matrix", label: "Matrix",
id: "matrixQ", id: "matrixQ",
elementType: TSurveyElementTypeEnum.Matrix, questionType: TSurveyQuestionTypeEnum.Matrix,
}, },
filterType: { filterValue: "Row 1", filterComboBoxValue: "Column 1" }, filterType: { filterValue: "Row 1", filterComboBoxValue: "Column 1" },
}, },
@@ -811,7 +668,7 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { type: "Hidden Fields", label: "plan", id: "plan" }, questionType: { type: "Hidden Fields", label: "plan", id: "plan" },
filterType: { filterValue: "Equals", filterComboBoxValue: "pro" }, filterType: { filterValue: "Equals", filterComboBoxValue: "pro" },
}, },
], ],
@@ -827,7 +684,7 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { type: "Attributes", label: "role", id: "role" }, questionType: { type: "Attributes", label: "role", id: "role" },
filterType: { filterValue: "Not equals", filterComboBoxValue: "admin" }, filterType: { filterValue: "Not equals", filterComboBoxValue: "admin" },
}, },
], ],
@@ -843,7 +700,7 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { type: "Other Filters", label: "Language", id: "language" }, questionType: { type: "Other Filters", label: "Language", id: "language" },
filterType: { filterValue: "Equals", filterComboBoxValue: "en" }, filterType: { filterValue: "Equals", filterComboBoxValue: "en" },
}, },
], ],
@@ -859,7 +716,7 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { type: "Meta", label: "source", id: "source" }, questionType: { type: "Meta", label: "source", id: "source" },
filterType: { filterValue: "Not equals", filterComboBoxValue: "web" }, filterType: { filterValue: "Not equals", filterComboBoxValue: "web" },
}, },
], ],
@@ -875,16 +732,16 @@ describe("surveys", () => {
responseStatus: "complete", responseStatus: "complete",
filter: [ filter: [
{ {
elementType: { questionType: {
type: "Elements", type: "Questions",
label: "NPS", label: "NPS",
id: "npsQ", id: "npsQ",
elementType: TSurveyElementTypeEnum.NPS, questionType: TSurveyQuestionTypeEnum.NPS,
}, },
filterType: { filterValue: "Is more than", filterComboBoxValue: "7" }, filterType: { filterValue: "Is more than", filterComboBoxValue: "7" },
}, },
{ {
elementType: { type: "Tags", label: "Tag 1", id: "tag1" }, questionType: { type: "Tags", label: "Tag 1", id: "tag1" },
filterType: { filterComboBoxValue: "Applied" }, filterType: { filterComboBoxValue: "Applied" },
}, },
], ],
@@ -903,7 +760,7 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { type: "Meta", label: "url", id: "url" }, questionType: { type: "Meta", label: "url", id: "url" },
filterType: { filterValue: "Contains", filterComboBoxValue: "example.com" }, filterType: { filterValue: "Contains", filterComboBoxValue: "example.com" },
}, },
], ],
@@ -931,7 +788,7 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { type: "Meta", label: "url", id: "url" }, questionType: { type: "Meta", label: "url", id: "url" },
filterType: { filterValue, filterComboBoxValue: expected.value }, filterType: { filterValue, filterComboBoxValue: expected.value },
}, },
], ],
@@ -947,7 +804,7 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { type: "Meta", label: "url", id: "url" }, questionType: { type: "Meta", label: "url", id: "url" },
filterType: { filterValue: "Contains", filterComboBoxValue: "" }, filterType: { filterValue: "Contains", filterComboBoxValue: "" },
}, },
], ],
@@ -963,7 +820,7 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { type: "Meta", label: "url", id: "url" }, questionType: { type: "Meta", label: "url", id: "url" },
filterType: { filterValue: "Contains", filterComboBoxValue: " " }, filterType: { filterValue: "Contains", filterComboBoxValue: " " },
}, },
], ],
@@ -979,7 +836,7 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { type: "Meta", label: "source", id: "source" }, questionType: { type: "Meta", label: "source", id: "source" },
filterType: { filterValue: "Equals", filterComboBoxValue: ["google"] }, filterType: { filterValue: "Equals", filterComboBoxValue: ["google"] },
}, },
], ],
@@ -995,11 +852,11 @@ describe("surveys", () => {
responseStatus: "all", responseStatus: "all",
filter: [ filter: [
{ {
elementType: { type: "Meta", label: "url", id: "url" }, questionType: { type: "Meta", label: "url", id: "url" },
filterType: { filterValue: "Contains", filterComboBoxValue: "formbricks.com" }, filterType: { filterValue: "Contains", filterComboBoxValue: "formbricks.com" },
}, },
{ {
elementType: { type: "Meta", label: "source", id: "source" }, questionType: { type: "Meta", label: "source", id: "source" },
filterType: { filterValue: "Equals", filterComboBoxValue: ["newsletter"] }, filterType: { filterValue: "Equals", filterComboBoxValue: ["newsletter"] },
}, },
], ],
@@ -1010,75 +867,6 @@ describe("surveys", () => {
expect(result.meta?.url).toEqual({ op: "contains", value: "formbricks.com" }); expect(result.meta?.url).toEqual({ op: "contains", value: "formbricks.com" });
expect(result.meta?.source).toEqual({ op: "equals", value: "newsletter" }); 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", () => { describe("getTodayDate", () => {

View File

@@ -5,26 +5,24 @@ import {
TSurveyContactAttributes, TSurveyContactAttributes,
TSurveyMetaFieldFilter, TSurveyMetaFieldFilter,
} from "@formbricks/types/responses"; } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; import { getTextContent } from "@formbricks/types/surveys/validation";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { import {
DateRange, DateRange,
FilterValue, FilterValue,
SelectedFilterValue, SelectedFilterValue,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context"; } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { import {
ElementOption,
ElementOptions,
OptionsType, OptionsType,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementsComboBox"; QuestionOption,
import { ElementFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter"; QuestionOptions,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { recallToHeadline } from "@/lib/utils/recall"; import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
const conditionOptions: Record<string, string[]> = { const conditionOptions = {
openText: ["is"], openText: ["is"],
multipleChoiceSingle: ["Includes either"], multipleChoiceSingle: ["Includes either"],
multipleChoiceMulti: ["Includes all", "Includes either"], multipleChoiceMulti: ["Includes all", "Includes either"],
@@ -41,7 +39,7 @@ const conditionOptions: Record<string, string[]> = {
contactInfo: ["is"], contactInfo: ["is"],
ranking: ["is"], ranking: ["is"],
}; };
const filterOptions: Record<string, string[]> = { const filterOptions = {
openText: ["Filled out", "Skipped"], openText: ["Filled out", "Skipped"],
rating: ["1", "2", "3", "4", "5"], rating: ["1", "2", "3", "4", "5"],
nps: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], nps: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
@@ -53,51 +51,6 @@ const filterOptions: Record<string, string[]> = {
ranking: ["Filled out", "Skipped"], 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 // URL/meta text operators mapping
const META_OP_MAP = { const META_OP_MAP = {
Equals: "equals", Equals: "equals",
@@ -110,7 +63,8 @@ const META_OP_MAP = {
"Does not end with": "doesNotEndWith", "Does not end with": "doesNotEndWith",
} as const; } as const;
export const generateElementAndFilterOptions = ( // creating the options for the filtering to be selected there are 4 types questions, attributes, tags and metadata
export const generateQuestionAndFilterOptions = (
survey: TSurvey, survey: TSurvey,
environmentTags: TTag[] | undefined, environmentTags: TTag[] | undefined,
attributes: TSurveyContactAttributes, attributes: TSurveyContactAttributes,
@@ -118,32 +72,67 @@ export const generateElementAndFilterOptions = (
hiddenFields: TResponseHiddenFieldsFilter, hiddenFields: TResponseHiddenFieldsFilter,
quotas: TSurveyQuota[] quotas: TSurveyQuota[]
): { ): {
elementOptions: ElementOptions[]; questionOptions: QuestionOptions[];
elementFilterOptions: ElementFilterOptions[]; questionFilterOptions: QuestionFilterOptions[];
} => { } => {
let elementOptions: ElementOptions[] = []; let questionOptions: QuestionOptions[] = [];
let elementFilterOptions: ElementFilterOptions[] = []; let questionFilterOptions: QuestionFilterOptions[] = [];
let elementsOptions: ElementOption[] = [];
const elements = getElementsFromBlocks(survey.blocks); let questionsOptions: QuestionOption[] = [];
elements.forEach((q) => { survey.questions.forEach((q) => {
if (Object.keys(conditionOptions).includes(q.type)) { if (Object.keys(conditionOptions).includes(q.type)) {
elementsOptions.push({ questionsOptions.push({
label: getTextContent( label: getTextContent(
getLocalizedValue(recallToHeadline(q.headline, survey, false, "default"), "default") getLocalizedValue(recallToHeadline(q.headline, survey, false, "default"), "default")
), ),
elementType: q.type, questionType: q.type,
type: OptionsType.ELEMENTS, type: OptionsType.QUESTIONS,
id: q.id, id: q.id,
}); });
} }
}); });
elementOptions = [...elementOptions, { header: OptionsType.ELEMENTS, option: elementsOptions }]; questionOptions = [...questionOptions, { header: OptionsType.QUESTIONS, option: questionsOptions }];
elements.forEach((q) => { survey.questions.forEach((q) => {
const filterOption = getElementFilterOption(q); if (Object.keys(conditionOptions).includes(q.type)) {
if (filterOption) { if (q.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle) {
elementFilterOptions.push(filterOption); 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.map((row) => getLocalizedValue(row.label, "default")),
filterComboBoxOptions: q.columns.map((column) => getLocalizedValue(column.label, "default")),
id: q.id,
});
} else {
questionFilterOptions.push({
type: q.type,
filterOptions: conditionOptions[q.type],
filterComboBoxOptions: filterOptions[q.type],
id: q.id,
});
}
} }
}); });
@@ -151,9 +140,9 @@ export const generateElementAndFilterOptions = (
return { label: t.name, type: OptionsType.TAGS, id: t.id }; return { label: t.name, type: OptionsType.TAGS, id: t.id };
}); });
if (tagsOptions && tagsOptions?.length > 0) { if (tagsOptions && tagsOptions?.length > 0) {
elementOptions = [...elementOptions, { header: OptionsType.TAGS, option: tagsOptions }]; questionOptions = [...questionOptions, { header: OptionsType.TAGS, option: tagsOptions }];
environmentTags?.forEach((t) => { environmentTags?.forEach((t) => {
elementFilterOptions.push({ questionFilterOptions.push({
type: "Tags", type: "Tags",
filterOptions: conditionOptions.tags, filterOptions: conditionOptions.tags,
filterComboBoxOptions: filterOptions.tags, filterComboBoxOptions: filterOptions.tags,
@@ -163,8 +152,8 @@ export const generateElementAndFilterOptions = (
} }
if (attributes) { if (attributes) {
elementOptions = [ questionOptions = [
...elementOptions, ...questionOptions,
{ {
header: OptionsType.ATTRIBUTES, header: OptionsType.ATTRIBUTES,
option: Object.keys(attributes).map((a) => { option: Object.keys(attributes).map((a) => {
@@ -173,7 +162,7 @@ export const generateElementAndFilterOptions = (
}, },
]; ];
Object.keys(attributes).forEach((a) => { Object.keys(attributes).forEach((a) => {
elementFilterOptions.push({ questionFilterOptions.push({
type: "Attributes", type: "Attributes",
filterOptions: conditionOptions.userAttributes, filterOptions: conditionOptions.userAttributes,
filterComboBoxOptions: attributes[a], filterComboBoxOptions: attributes[a],
@@ -183,8 +172,8 @@ export const generateElementAndFilterOptions = (
} }
if (meta) { if (meta) {
elementOptions = [ questionOptions = [
...elementOptions, ...questionOptions,
{ {
header: OptionsType.META, header: OptionsType.META,
option: Object.keys(meta).map((m) => { option: Object.keys(meta).map((m) => {
@@ -193,7 +182,7 @@ export const generateElementAndFilterOptions = (
}, },
]; ];
Object.keys(meta).forEach((m) => { Object.keys(meta).forEach((m) => {
elementFilterOptions.push({ questionFilterOptions.push({
type: "Meta", type: "Meta",
filterOptions: m === "url" ? Object.keys(META_OP_MAP) : ["Equals", "Not equals"], filterOptions: m === "url" ? Object.keys(META_OP_MAP) : ["Equals", "Not equals"],
filterComboBoxOptions: meta[m], filterComboBoxOptions: meta[m],
@@ -203,8 +192,8 @@ export const generateElementAndFilterOptions = (
} }
if (hiddenFields) { if (hiddenFields) {
elementOptions = [ questionOptions = [
...elementOptions, ...questionOptions,
{ {
header: OptionsType.HIDDEN_FIELDS, header: OptionsType.HIDDEN_FIELDS,
option: Object.keys(hiddenFields).map((hiddenField) => { option: Object.keys(hiddenFields).map((hiddenField) => {
@@ -213,7 +202,7 @@ export const generateElementAndFilterOptions = (
}, },
]; ];
Object.keys(hiddenFields).forEach((hiddenField) => { Object.keys(hiddenFields).forEach((hiddenField) => {
elementFilterOptions.push({ questionFilterOptions.push({
type: "Hidden Fields", type: "Hidden Fields",
filterOptions: ["Equals", "Not equals"], filterOptions: ["Equals", "Not equals"],
filterComboBoxOptions: hiddenFields[hiddenField], filterComboBoxOptions: hiddenFields[hiddenField],
@@ -222,326 +211,38 @@ export const generateElementAndFilterOptions = (
}); });
} }
let languageElement: ElementOption[] = []; let languageQuestion: QuestionOption[] = [];
//can be extended to include more properties //can be extended to include more properties
if (survey.languages?.length > 0) { if (survey.languages?.length > 0) {
languageElement.push({ label: "Language", type: OptionsType.OTHERS, id: "language" }); languageQuestion.push({ label: "Language", type: OptionsType.OTHERS, id: "language" });
const languageOptions = survey.languages.map((sl) => sl.language.code); const languageOptions = survey.languages.map((sl) => sl.language.code);
elementFilterOptions.push({ questionFilterOptions.push({
type: OptionsType.OTHERS, type: OptionsType.OTHERS,
filterOptions: conditionOptions.languages, filterOptions: conditionOptions.languages,
filterComboBoxOptions: languageOptions, filterComboBoxOptions: languageOptions,
id: "language", id: "language",
}); });
} }
elementOptions = [...elementOptions, { header: OptionsType.OTHERS, option: languageElement }]; questionOptions = [...questionOptions, { header: OptionsType.OTHERS, option: languageQuestion }];
if (quotas.length > 0) { if (quotas.length > 0) {
const quotaOptions = quotas.map((quota) => { const quotaOptions = quotas.map((quota) => {
return { label: quota.name, type: OptionsType.QUOTAS, id: quota.id }; return { label: quota.name, type: OptionsType.QUOTAS, id: quota.id };
}); });
elementOptions = [...elementOptions, { header: OptionsType.QUOTAS, option: quotaOptions }]; questionOptions = [...questionOptions, { header: OptionsType.QUOTAS, option: quotaOptions }];
quotas.forEach((quota) => { quotas.forEach((quota) => {
elementFilterOptions.push({ questionFilterOptions.push({
type: "Quotas", type: "Quotas",
filterOptions: ["Status"], filterOptions: ["Status"],
filterComboBoxOptions: ["Screened in", "Screened out (overquota)", "Not in quota"], filterComboBoxOptions: ["Screened in", "Screened out (overquota)", "Screened out (not in quota)"],
id: quota.id, id: quota.id,
}); });
}); });
} }
return { elementOptions: [...elementOptions], elementFilterOptions: [...elementFilterOptions] }; return { questionOptions: [...questionOptions], questionFilterOptions: [...questionFilterOptions] };
};
// 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;
})
.filter(Boolean);
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 // get the formatted filter expression to fetch filtered responses
@@ -552,7 +253,7 @@ export const getFormattedFilters = (
): TResponseFilterCriteria => { ): TResponseFilterCriteria => {
const filters: TResponseFilterCriteria = {}; const filters: TResponseFilterCriteria = {};
const elements: FilterValue[] = []; const questions: FilterValue[] = [];
const tags: FilterValue[] = []; const tags: FilterValue[] = [];
const attributes: FilterValue[] = []; const attributes: FilterValue[] = [];
const others: FilterValue[] = []; const others: FilterValue[] = [];
@@ -561,19 +262,19 @@ export const getFormattedFilters = (
const quotas: FilterValue[] = []; const quotas: FilterValue[] = [];
selectedFilter.filter.forEach((filter) => { selectedFilter.filter.forEach((filter) => {
if (filter.elementType?.type === "Elements") { if (filter.questionType?.type === "Questions") {
elements.push(filter); questions.push(filter);
} else if (filter.elementType?.type === "Tags") { } else if (filter.questionType?.type === "Tags") {
tags.push(filter); tags.push(filter);
} else if (filter.elementType?.type === "Attributes") { } else if (filter.questionType?.type === "Attributes") {
attributes.push(filter); attributes.push(filter);
} else if (filter.elementType?.type === "Other Filters") { } else if (filter.questionType?.type === "Other Filters") {
others.push(filter); others.push(filter);
} else if (filter.elementType?.type === "Meta") { } else if (filter.questionType?.type === "Meta") {
meta.push(filter); meta.push(filter);
} else if (filter.elementType?.type === "Hidden Fields") { } else if (filter.questionType?.type === "Hidden Fields") {
hiddenFields.push(filter); hiddenFields.push(filter);
} else if (filter.elementType?.type === "Quotas") { } else if (filter.questionType?.type === "Quotas") {
quotas.push(filter); quotas.push(filter);
} }
}); });
@@ -601,41 +302,259 @@ export const getFormattedFilters = (
}; };
tags.forEach((tag) => { tags.forEach((tag) => {
if (tag.filterType.filterComboBoxValue === "Applied") { if (tag.filterType.filterComboBoxValue === "Applied") {
filters.tags?.applied?.push(tag.elementType.label ?? ""); filters.tags?.applied?.push(tag.questionType.label ?? "");
} else { } else {
filters.tags?.notApplied?.push(tag.elementType.label ?? ""); filters.tags?.notApplied?.push(tag.questionType.label ?? "");
} }
}); });
} }
processElementFilters(elements, survey, filters); // 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;
}
}
});
}
// for hidden fields // for hidden fields
if (hiddenFields.length) { if (hiddenFields.length) {
filters.data = filters.data || {}; hiddenFields.forEach(({ filterType, questionType }) => {
hiddenFields.forEach(({ filterType, elementType }) => { if (!filters.data) filters.data = {};
processEqualsNotEqualsFilter(filterType, elementType.label, 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,
};
}
}); });
} }
// for attributes // for attributes
if (attributes.length) { if (attributes.length) {
filters.contactAttributes = filters.contactAttributes || {}; attributes.forEach(({ filterType, questionType }) => {
attributes.forEach(({ filterType, elementType }) => { if (!filters.contactAttributes) filters.contactAttributes = {};
processEqualsNotEqualsFilter(filterType, elementType.label, 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,
};
}
}); });
} }
// for others // for others
if (others.length) { if (others.length) {
filters.others = filters.others || {}; others.forEach(({ filterType, questionType }) => {
others.forEach(({ filterType, elementType }) => { if (!filters.others) filters.others = {};
processEqualsNotEqualsFilter(filterType, elementType.label, 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,
};
}
}); });
} }
processMetaFilters(meta, filters); // for meta
processQuotaFilters(quotas, filters); 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 };
});
}
return filters; return filters;
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -17,8 +17,7 @@
"zh-Hans-CN", "zh-Hans-CN",
"zh-Hant-TW", "zh-Hant-TW",
"nl-NL", "nl-NL",
"es-ES", "es-ES"
"sv-SE"
] ]
}, },
"version": 1.8 "version": 1.8

View File

@@ -234,7 +234,6 @@ checksums:
common/maximum: 4c07541dd1f093775bdc61b559cca6c8 common/maximum: 4c07541dd1f093775bdc61b559cca6c8
common/member: 1606dc30b369856b9dba1fe9aec425d2 common/member: 1606dc30b369856b9dba1fe9aec425d2
common/members: 0932e80cba1e3e0a7f52bb67ff31da32 common/members: 0932e80cba1e3e0a7f52bb67ff31da32
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4 common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
common/metadata: 695d4f7da261ba76e3be4de495491028 common/metadata: 695d4f7da261ba76e3be4de495491028
common/minimum: d9759235086d0169928b3c1401115e22 common/minimum: d9759235086d0169928b3c1401115e22
@@ -305,16 +304,15 @@ checksums:
common/project_not_found: be3b516c02b05553acb4ae338511f645 common/project_not_found: be3b516c02b05553acb4ae338511f645
common/project_permission_not_found: ace6b03f06bd14e884e4295c5022d61b common/project_permission_not_found: ace6b03f06bd14e884e4295c5022d61b
common/projects: fe8af5cfb3c95cb35534872a325b225e common/projects: fe8af5cfb3c95cb35534872a325b225e
common/question: 2a47e06b62410b16003c4979dee0099f common/question: 0576462ce60d4263d7c482463fcc9547
common/question_id: d0c3672976c281411bdccf749faf5ffd common/question_id: d0c3672976c281411bdccf749faf5ffd
common/questions: 38d08215fd7a8026077c7b64eea6bb59 common/questions: 38d08215fd7a8026077c7b64eea6bb59
common/quota: edd33b180b463ee7a70a64a5c4ad7f02 common/quota: edd33b180b463ee7a70a64a5c4ad7f02
common/quotas: e6afead11b5b8ae627885ce2b84a548f common/quotas: e6afead11b5b8ae627885ce2b84a548f
common/quotas_description: a2caa44fa74664b3b6007e813f31a754 common/quotas_description: a2caa44fa74664b3b6007e813f31a754
common/read_docs: d06513c266fdd9056e0500eab838ebac common/read_docs: 426ba960bfedf186a878b7467867f9d2
common/recipients: f90e7f266be3f5a724858f21a9fd855e common/recipients: f90e7f266be3f5a724858f21a9fd855e
common/remove: dba2fe5fe9f83f8078c687f28cba4b52 common/remove: dba2fe5fe9f83f8078c687f28cba4b52
common/remove_from_team: 69bcc7a1001c3017f9de578ee22cffd6
common/reorder_and_hide_columns: a5e3d7c0c7ef879211d05a37be1c5069 common/reorder_and_hide_columns: a5e3d7c0c7ef879211d05a37be1c5069
common/report_survey: 147dd05db52e35f5d1f837460fb720f5 common/report_survey: 147dd05db52e35f5d1f837460fb720f5
common/request_pricing: 58eb24af4f098632709cb7482b70a1cb common/request_pricing: 58eb24af4f098632709cb7482b70a1cb
@@ -324,10 +322,10 @@ checksums:
common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28 common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28
common/restart: bab6232e89f24e3129f8e48268739d5b common/restart: bab6232e89f24e3129f8e48268739d5b
common/role: 53743bbb6ca938f5b893552e839d067f common/role: 53743bbb6ca938f5b893552e839d067f
common/role_organization: e7dbf80450ceac1c6c22ba5602ea7e66
common/saas: f01686245bcfb35a3590ab56db677bdb common/saas: f01686245bcfb35a3590ab56db677bdb
common/sales: 38758eb50094cd8190a71fe67be4d647 common/sales: 38758eb50094cd8190a71fe67be4d647
common/save: f7a2929f33bc420195e59ac5a8bcd454 common/save: f7a2929f33bc420195e59ac5a8bcd454
common/save_as_draft: b1b38812110113627d141db981fb1b12
common/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a common/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
common/saving: 27ad05746d65e2f3f17d327eb181725d common/saving: 27ad05746d65e2f3f17d327eb181725d
common/search: 49dd6c21604b5e8d4153ff1aff2177e1 common/search: 49dd6c21604b5e8d4153ff1aff2177e1
@@ -382,8 +380,7 @@ checksums:
common/team_access: 45c6232c71b760eaa33b932dabab4c1c common/team_access: 45c6232c71b760eaa33b932dabab4c1c
common/team_id: 134e32d6f7184577a46b2fd83e85e532 common/team_id: 134e32d6f7184577a46b2fd83e85e532
common/team_name: 549d949de4b9adad4afd6427a60a329e common/team_name: 549d949de4b9adad4afd6427a60a329e
common/team_role: 66db395781aef64ef3791417b3b67c0b common/teams: a2fbdec69342366a2b6033d119aa279a
common/teams: b63448c05270497973ac4407047dae02
common/teams_not_found: 02f333a64a83c1c014d8900ec9666345 common/teams_not_found: 02f333a64a83c1c014d8900ec9666345
common/text: 4ddccc1974775ed7357f9beaf9361cec common/text: 4ddccc1974775ed7357f9beaf9361cec
common/time: b504a03d52e8001bfdc5cb6205364f42 common/time: b504a03d52e8001bfdc5cb6205364f42
@@ -398,7 +395,6 @@ checksums:
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4 common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
common/updated_at: 8fdb85248e591254973403755dcc3724 common/updated_at: 8fdb85248e591254973403755dcc3724
common/upload: 4a6c84aa16db0f4e5697f49b45257bc7 common/upload: 4a6c84aa16db0f4e5697f49b45257bc7
common/upload_failed: d4dd7b6ee4c1572e4136659f74d9632b
common/upload_input_description: 64f59bc339568d52b8464b82546b70ea common/upload_input_description: 64f59bc339568d52b8464b82546b70ea
common/url: ca97457614226960d41dd18c3c29c86b common/url: ca97457614226960d41dd18c3c29c86b
common/user: 61073457a5c3901084b557d065f876be common/user: 61073457a5c3901084b557d065f876be
@@ -444,7 +440,6 @@ checksums:
emails/forgot_password_email_link_valid_for_24_hours: 1616714e6bf36e4379b9868e98e82957 emails/forgot_password_email_link_valid_for_24_hours: 1616714e6bf36e4379b9868e98e82957
emails/forgot_password_email_subject: bd7a2b22e7b480c29f512532fd2b7e2b emails/forgot_password_email_subject: bd7a2b22e7b480c29f512532fd2b7e2b
emails/forgot_password_email_text: 5100fa2fe2180ded9cb2d89b4f77d2e0 emails/forgot_password_email_text: 5100fa2fe2180ded9cb2d89b4f77d2e0
emails/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
emails/imprint: c4e5f2a1994d3cc5896b200709cc499c emails/imprint: c4e5f2a1994d3cc5896b200709cc499c
emails/invite_accepted_email_heading: 6ff6dff269b0f1ac1b73912c9e344343 emails/invite_accepted_email_heading: 6ff6dff269b0f1ac1b73912c9e344343
emails/invite_accepted_email_subject: 4f5f2a68c98dd1dd01143fcae3be5562 emails/invite_accepted_email_subject: 4f5f2a68c98dd1dd01143fcae3be5562
@@ -456,14 +451,12 @@ checksums:
emails/invite_email_text_par2: 14da6da9fdbc21a1cb38988abac7932d emails/invite_email_text_par2: 14da6da9fdbc21a1cb38988abac7932d
emails/invite_member_email_subject: 295e329b1642339dc7cc2b49a687e1f8 emails/invite_member_email_subject: 295e329b1642339dc7cc2b49a687e1f8
emails/new_email_verification_text: b7f00f47d04afa9e872176d9933f2d93 emails/new_email_verification_text: b7f00f47d04afa9e872176d9933f2d93
emails/number_variable: d4f2bbb1965c791cf9921a5112914f3f
emails/password_changed_email_heading: 601f68fc8bef9c5ecf79f4ec4de5ad06 emails/password_changed_email_heading: 601f68fc8bef9c5ecf79f4ec4de5ad06
emails/password_changed_email_text: f9ed4db250ec1b2adf4cb4527ec72d78 emails/password_changed_email_text: f9ed4db250ec1b2adf4cb4527ec72d78
emails/password_reset_notify_email_subject: 0a6805fc27c5bb7999f0d311ef5981e1 emails/password_reset_notify_email_subject: 0a6805fc27c5bb7999f0d311ef5981e1
emails/privacy_policy: 7459744a63ef8af4e517a09024bd7c08 emails/privacy_policy: 7459744a63ef8af4e517a09024bd7c08
emails/reject: 417c19f66db70a0548bdeb398cdc46e0 emails/reject: 417c19f66db70a0548bdeb398cdc46e0
emails/render_email_response_value_file_upload_response_link_not_included: 56f400d68c00b06a2bd976389778df9f emails/render_email_response_value_file_upload_response_link_not_included: 56f400d68c00b06a2bd976389778df9f
emails/response_data: 26363c0d3a839c3b33c9e8c6dd3deca9
emails/response_finished_email_subject: 7e8b92b483242ddb31ba83e8fcf890f9 emails/response_finished_email_subject: 7e8b92b483242ddb31ba83e8fcf890f9
emails/response_finished_email_subject_with_email: 14798acfdaec4b2b2f33dc4a9f4f8ee5 emails/response_finished_email_subject_with_email: 14798acfdaec4b2b2f33dc4a9f4f8ee5
emails/schedule_your_meeting: 01683323bd7373560cd2cb2737dbaf06 emails/schedule_your_meeting: 01683323bd7373560cd2cb2737dbaf06
@@ -475,7 +468,6 @@ checksums:
emails/survey_response_finished_email_turn_off_notifications_for_this_form: 7b6a7074490ceaf3d1903a37169364d6 emails/survey_response_finished_email_turn_off_notifications_for_this_form: 7b6a7074490ceaf3d1903a37169364d6
emails/survey_response_finished_email_view_more_responses: fe053505f470cbbb5823ca15ceefcedd emails/survey_response_finished_email_view_more_responses: fe053505f470cbbb5823ca15ceefcedd
emails/survey_response_finished_email_view_survey_summary: c4e8b5207c0dc856a01011c8b91e0d94 emails/survey_response_finished_email_view_survey_summary: c4e8b5207c0dc856a01011c8b91e0d94
emails/text_variable: 5fdfcc48b8010a4f44e16b8051272a75
emails/verification_email_click_on_this_link: 3c9ad15bd2e3822d3ecd85a421311ebc emails/verification_email_click_on_this_link: 3c9ad15bd2e3822d3ecd85a421311ebc
emails/verification_email_heading: 0f86a46d434bb4595b8753d3cf2524e0 emails/verification_email_heading: 0f86a46d434bb4595b8753d3cf2524e0
emails/verification_email_hey: 20c5157a424f7d49ceeb27e6fb13d194 emails/verification_email_hey: 20c5157a424f7d49ceeb27e6fb13d194
@@ -604,7 +596,6 @@ checksums:
environments/contacts/upload_contacts_modal_pick_different_file: e748a6e81a425ef9aa33f96ca4edc157 environments/contacts/upload_contacts_modal_pick_different_file: e748a6e81a425ef9aa33f96ca4edc157
environments/contacts/upload_contacts_modal_preview: c4406f8d9a54f131abfff4e9928228bb environments/contacts/upload_contacts_modal_preview: c4406f8d9a54f131abfff4e9928228bb
environments/contacts/upload_contacts_modal_upload_btn: 47b7f3bcf478a7d8dc258d2efc80af37 environments/contacts/upload_contacts_modal_upload_btn: 47b7f3bcf478a7d8dc258d2efc80af37
environments/contacts/upload_contacts_success: cd5d6b6d587586dd4f944868c92835bc
environments/formbricks_logo: b7ee57de32c8b13463cc8ca8643eddd4 environments/formbricks_logo: b7ee57de32c8b13463cc8ca8643eddd4
environments/integrations/activepieces_integration_description: 62a8fbf86762bab01c7d2db2ba60fff4 environments/integrations/activepieces_integration_description: 62a8fbf86762bab01c7d2db2ba60fff4
environments/integrations/additional_settings: 20936205a75745fba2c4047375a04db3 environments/integrations/additional_settings: 20936205a75745fba2c4047375a04db3
@@ -757,11 +748,8 @@ checksums:
environments/project/app-connection/how_to_setup_description: 2ae5cd9456a8acd3986e3d3678e70ed2 environments/project/app-connection/how_to_setup_description: 2ae5cd9456a8acd3986e3d3678e70ed2
environments/project/app-connection/receiving_data: 9f2a48c0b0278861add70b526061264c environments/project/app-connection/receiving_data: 9f2a48c0b0278861add70b526061264c
environments/project/app-connection/recheck: f95f2bbe6990a123d60255c87bdd59f7 environments/project/app-connection/recheck: f95f2bbe6990a123d60255c87bdd59f7
environments/project/app-connection/sdk_connection_details: 89f2c169fd1604c1df5a834517f1eae1
environments/project/app-connection/sdk_connection_details_description: d9b5d06776a139aef6fc8ed53d71bf0a
environments/project/app-connection/setup_alert_description: 6d676044d01dc2147731ffab7df6c259 environments/project/app-connection/setup_alert_description: 6d676044d01dc2147731ffab7df6c259
environments/project/app-connection/setup_alert_title: 9561cca2b391e0df81e8a982921ff2bb environments/project/app-connection/setup_alert_title: 9561cca2b391e0df81e8a982921ff2bb
environments/project/app-connection/webapp_url: d64d8cc3c4c4ecce780d94755f7e4de9
environments/project/general/cannot_delete_only_project: 24751701a42d8b4d2ba6112a5f642bad environments/project/general/cannot_delete_only_project: 24751701a42d8b4d2ba6112a5f642bad
environments/project/general/delete_project: e4a2a227105c4ec71e561ab1f140eb26 environments/project/general/delete_project: e4a2a227105c4ec71e561ab1f140eb26
environments/project/general/delete_project_confirmation: 54a4ee78867537e0244c7170453cdb3f environments/project/general/delete_project_confirmation: 54a4ee78867537e0244c7170453cdb3f
@@ -847,6 +835,7 @@ checksums:
environments/project/tags/tags_merged: 544471de666f93fbb0ab600321d1e553 environments/project/tags/tags_merged: 544471de666f93fbb0ab600321d1e553
environments/project/teams/manage_teams: d7b5f26335cea450c333832adbe0b6ad environments/project/teams/manage_teams: d7b5f26335cea450c333832adbe0b6ad
environments/project/teams/no_teams_found: fb6680d4b5b73731697b100713afb50d environments/project/teams/no_teams_found: fb6680d4b5b73731697b100713afb50d
environments/project/teams/only_organization_owners_and_managers_can_manage_teams: 179056fade669d34f63fb1ee965b8024
environments/project/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b environments/project/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b
environments/project/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02 environments/project/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02
environments/project/teams/team_settings_description: da32d77993f5c5c7547cdf3e1d3fc7d5 environments/project/teams/team_settings_description: da32d77993f5c5c7547cdf3e1d3fc7d5
@@ -1092,17 +1081,13 @@ checksums:
environments/settings/teams/manage_team: 4c52e636cfd1451a08179fb7a68042ab environments/settings/teams/manage_team: 4c52e636cfd1451a08179fb7a68042ab
environments/settings/teams/manage_team_disabled: 2aaa0557b403a5bc657ec9e8b19ac5ac environments/settings/teams/manage_team_disabled: 2aaa0557b403a5bc657ec9e8b19ac5ac
environments/settings/teams/manager_role_description: 39846863fa85ff8b1c6e4f354eb5018f environments/settings/teams/manager_role_description: 39846863fa85ff8b1c6e4f354eb5018f
environments/settings/teams/member: 1606dc30b369856b9dba1fe9aec425d2
environments/settings/teams/member_role_description: 1c5deaece65798b74cc0d34525506c18 environments/settings/teams/member_role_description: 1c5deaece65798b74cc0d34525506c18
environments/settings/teams/member_role_info_message: 0a276eef3c3b907d6f396ebfdc693b12 environments/settings/teams/member_role_info_message: 0a276eef3c3b907d6f396ebfdc693b12
environments/settings/teams/organization_role: 979b75fcc3696952e5922d659c839c10
environments/settings/teams/owner_role_description: 8f577e6f9d1368fed4eba5a91ffc8cbf environments/settings/teams/owner_role_description: 8f577e6f9d1368fed4eba5a91ffc8cbf
environments/settings/teams/please_fill_all_member_fields: 60e38d9906ec9a02a44d16c736bd9fe9 environments/settings/teams/please_fill_all_member_fields: 60e38d9906ec9a02a44d16c736bd9fe9
environments/settings/teams/please_fill_all_project_fields: 6712059df63c432ecd31f3c52b8e4d87 environments/settings/teams/please_fill_all_project_fields: 6712059df63c432ecd31f3c52b8e4d87
environments/settings/teams/read: 2494ca23d10e5b6381eb271aceeb5270 environments/settings/teams/read: 2494ca23d10e5b6381eb271aceeb5270
environments/settings/teams/read_write: 278a90dade128198d4c93ac00c345320 environments/settings/teams/read_write: 278a90dade128198d4c93ac00c345320
environments/settings/teams/select_member: 7f4a38312aabbbe3fe92756b57bd5d75
environments/settings/teams/select_project: 6e4f4a24178660851d9ae0874706be9f
environments/settings/teams/team_admin: 5df68214685738029af678ae1d5912bb environments/settings/teams/team_admin: 5df68214685738029af678ae1d5912bb
environments/settings/teams/team_created_successfully: 45f83048fcabf466551144858a761eca environments/settings/teams/team_created_successfully: 45f83048fcabf466551144858a761eca
environments/settings/teams/team_deleted_successfully: 972c86b0abe87f229f7bf1a691c0a253 environments/settings/teams/team_deleted_successfully: 972c86b0abe87f229f7bf1a691c0a253
@@ -1132,9 +1117,9 @@ checksums:
environments/surveys/edit/add: 5196f5cd4ba3a6ac8edef91345e17f66 environments/surveys/edit/add: 5196f5cd4ba3a6ac8edef91345e17f66
environments/surveys/edit/add_a_delay_or_auto_close_the_survey: b5fa358bf3ff324014060eb0baf6dd2f environments/surveys/edit/add_a_delay_or_auto_close_the_survey: b5fa358bf3ff324014060eb0baf6dd2f
environments/surveys/edit/add_a_four_digit_pin: 953cb3673d2135923e3b4474d33ffb2c environments/surveys/edit/add_a_four_digit_pin: 953cb3673d2135923e3b4474d33ffb2c
environments/surveys/edit/add_a_new_question_to_your_survey: 65f3a4f0d5132eab7aeaed1ad28df56c
environments/surveys/edit/add_a_variable_to_calculate: c202b50c12fc6f71f06eaf6f1b61e961 environments/surveys/edit/add_a_variable_to_calculate: c202b50c12fc6f71f06eaf6f1b61e961
environments/surveys/edit/add_action_below: 46cdbf9a77391aa89593908e508f7af0 environments/surveys/edit/add_action_below: 46cdbf9a77391aa89593908e508f7af0
environments/surveys/edit/add_block: ae8fbf8fdb5c6be7e4951a6cdd486473
environments/surveys/edit/add_choice_below: abf0416f7a78df61869de63d9766683c environments/surveys/edit/add_choice_below: abf0416f7a78df61869de63d9766683c
environments/surveys/edit/add_color_coding: db738f7be21e08c5dc878c09fdf95e44 environments/surveys/edit/add_color_coding: db738f7be21e08c5dc878c09fdf95e44
environments/surveys/edit/add_color_coding_description: da15c619aa00084ad18f30766906527f environments/surveys/edit/add_color_coding_description: da15c619aa00084ad18f30766906527f
@@ -1155,8 +1140,8 @@ checksums:
environments/surveys/edit/add_other: de75bd3d40f3b5effdbe1c8d536f936b environments/surveys/edit/add_other: de75bd3d40f3b5effdbe1c8d536f936b
environments/surveys/edit/add_photo_or_video: 7fd213e807ad060e415d1d4195397473 environments/surveys/edit/add_photo_or_video: 7fd213e807ad060e415d1d4195397473
environments/surveys/edit/add_pin: 1bc282dd7eaea51301655d3e8dd3a9fb environments/surveys/edit/add_pin: 1bc282dd7eaea51301655d3e8dd3a9fb
environments/surveys/edit/add_question: 10336b52895385f7390540ad5bb4e208
environments/surveys/edit/add_question_below: 58e64eb2e013f1175ea0dcf79149109f environments/surveys/edit/add_question_below: 58e64eb2e013f1175ea0dcf79149109f
environments/surveys/edit/add_question_to_block: 8589b1042aa93531a836549d6036492c
environments/surveys/edit/add_row: a613cef4caf1f0e05697c8de5164e2a3 environments/surveys/edit/add_row: a613cef4caf1f0e05697c8de5164e2a3
environments/surveys/edit/add_variable: 23f97e23aba763cc58934df4fa13ffc1 environments/surveys/edit/add_variable: 23f97e23aba763cc58934df4fa13ffc1
environments/surveys/edit/address_fields: 9cabb97c3deaff4f6cb3afc3d5cfaf0a environments/surveys/edit/address_fields: 9cabb97c3deaff4f6cb3afc3d5cfaf0a
@@ -1181,18 +1166,13 @@ checksums:
environments/surveys/edit/automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds: 1be3819ffa1db67385357ae933d69a7b environments/surveys/edit/automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds: 1be3819ffa1db67385357ae933d69a7b
environments/surveys/edit/automatically_mark_the_survey_as_complete_after: c6ede2a5515a4ca72b36aec2583f43aa environments/surveys/edit/automatically_mark_the_survey_as_complete_after: c6ede2a5515a4ca72b36aec2583f43aa
environments/surveys/edit/back_button_label: 25af945e77336724b5276de291cc92d9 environments/surveys/edit/back_button_label: 25af945e77336724b5276de291cc92d9
environments/surveys/edit/background_styling: eb4a06cf54a7271b493fab625d930570 environments/surveys/edit/background_styling: 4e1e6fd2ec767bbff8767f6c0f68a731
environments/surveys/edit/block_duplicated: dc9e9fab2b1cd91f6c265324b34c6376
environments/surveys/edit/bold: 4d7306bc355ed2befd6a9237c5452ee6 environments/surveys/edit/bold: 4d7306bc355ed2befd6a9237c5452ee6
environments/surveys/edit/brand_color: 84ddb5736deb9f5c081ffe4962a6c63e environments/surveys/edit/brand_color: 84ddb5736deb9f5c081ffe4962a6c63e
environments/surveys/edit/brightness: 45425b6db1872225bfff71cf619d0e64 environments/surveys/edit/brightness: 45425b6db1872225bfff71cf619d0e64
environments/surveys/edit/bulk_edit: 59bd1a55587c8cbad716afbf2509e5bb
environments/surveys/edit/bulk_edit_description: 9b5b2c6183c6c51689e16d7ba02ec9bb
environments/surveys/edit/bulk_edit_options: 74ebec7c53be729f33e38d7605b25815
environments/surveys/edit/bulk_edit_options_for: 986af3a8286f34c9e4ad7c74d3c65ada
environments/surveys/edit/button_external: d2de24e06574622baf1c0cdd1b718b1a
environments/surveys/edit/button_external_description: cbd10d494a70b362bfee811e012c45b1
environments/surveys/edit/button_label: db3cd7c74f393187bd780c5c3d8b9b4f environments/surveys/edit/button_label: db3cd7c74f393187bd780c5c3d8b9b4f
environments/surveys/edit/button_to_continue_in_survey: 931d87aaf360ab7521f9dd75795a42d0
environments/surveys/edit/button_to_link_to_external_url: 7c7cf54e8dc86240b86964133e802888
environments/surveys/edit/button_url: 6f39f649a165a11873c11ea6403dba90 environments/surveys/edit/button_url: 6f39f649a165a11873c11ea6403dba90
environments/surveys/edit/cal_username: a4a9c739af909d975beb1bc4998feae9 environments/surveys/edit/cal_username: a4a9c739af909d975beb1bc4998feae9
environments/surveys/edit/calculate: c5fcf8d3a38706ae2071b6f78339ec68 environments/surveys/edit/calculate: c5fcf8d3a38706ae2071b6f78339ec68
@@ -1201,7 +1181,7 @@ checksums:
environments/surveys/edit/card_arrangement_for_survey_type_derived: c06b9aaebcc11bc16e57a445b62361fc environments/surveys/edit/card_arrangement_for_survey_type_derived: c06b9aaebcc11bc16e57a445b62361fc
environments/surveys/edit/card_background_color: acd5d023e1d1a4471b053dce504c7a83 environments/surveys/edit/card_background_color: acd5d023e1d1a4471b053dce504c7a83
environments/surveys/edit/card_border_color: 8d7c7f4cbd99f154ce892dfa258eb504 environments/surveys/edit/card_border_color: 8d7c7f4cbd99f154ce892dfa258eb504
environments/surveys/edit/card_styling: 47137a7e809b060ca94418202a8fd3c5 environments/surveys/edit/card_styling: 01e88d58219539fb831e79f0bb3ce88e
environments/surveys/edit/casual: 6534fe68718fade470a9031f7390409e environments/surveys/edit/casual: 6534fe68718fade470a9031f7390409e
environments/surveys/edit/caution_edit_duplicate: ee93bccb34fcd707e1ef4735f1c2fc31 environments/surveys/edit/caution_edit_duplicate: ee93bccb34fcd707e1ef4735f1c2fc31
environments/surveys/edit/caution_edit_published_survey: faf7fc57c776f2a9104d143e20044486 environments/surveys/edit/caution_edit_published_survey: faf7fc57c776f2a9104d143e20044486
@@ -1231,7 +1211,6 @@ checksums:
environments/surveys/edit/character_limit_toggle_title: fdc45bcc6335e5116aec895fecda0d87 environments/surveys/edit/character_limit_toggle_title: fdc45bcc6335e5116aec895fecda0d87
environments/surveys/edit/checkbox_label: 12a07d6bdf38e283a2e95892ec49b7f8 environments/surveys/edit/checkbox_label: 12a07d6bdf38e283a2e95892ec49b7f8
environments/surveys/edit/choose_the_actions_which_trigger_the_survey: 773b311a148a112243f3b139506b9987 environments/surveys/edit/choose_the_actions_which_trigger_the_survey: 773b311a148a112243f3b139506b9987
environments/surveys/edit/choose_the_first_question_on_your_block: bdece06ca04f89d0c445ba1554dd5b80
environments/surveys/edit/choose_where_to_run_the_survey: ad87bcae97c445f1fd9ac110ea24f117 environments/surveys/edit/choose_where_to_run_the_survey: ad87bcae97c445f1fd9ac110ea24f117
environments/surveys/edit/city: 1831f32e1babbb29af27fac3053504a2 environments/surveys/edit/city: 1831f32e1babbb29af27fac3053504a2
environments/surveys/edit/close_survey_on_response_limit: 256d0bccdbcbb3d20e39aabc5b376e5e environments/surveys/edit/close_survey_on_response_limit: 256d0bccdbcbb3d20e39aabc5b376e5e
@@ -1255,13 +1234,10 @@ checksums:
environments/surveys/edit/create_group: 4566e056e5217dc02a383105892fe18c environments/surveys/edit/create_group: 4566e056e5217dc02a383105892fe18c
environments/surveys/edit/create_your_own_survey: e3ddd53e0cfa409ca8dccfb3d77933e7 environments/surveys/edit/create_your_own_survey: e3ddd53e0cfa409ca8dccfb3d77933e7
environments/surveys/edit/css_selector: 615e9f1b74622df29de28a5b5614c6fe environments/surveys/edit/css_selector: 615e9f1b74622df29de28a5b5614c6fe
environments/surveys/edit/cta_button_label: ec070ffba38eae24751bb3a4c1e14c81
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429 environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
environments/surveys/edit/customize_survey_logo: 7f7e26026c88a727228f2d7a00d914e2
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a
environments/surveys/edit/days_before_showing_this_survey_again: 354fb28c5ff076f022d82a20c749ee46 environments/surveys/edit/days_before_showing_this_survey_again: 354fb28c5ff076f022d82a20c749ee46
environments/surveys/edit/delete_block: c00617cb0724557e486304276063807a
environments/surveys/edit/delete_choice: fd750208d414b9ad8c980c161a0199e1 environments/surveys/edit/delete_choice: fd750208d414b9ad8c980c161a0199e1
environments/surveys/edit/disable_the_visibility_of_survey_progress: 2af631010114307ac2a91612559c9618 environments/surveys/edit/disable_the_visibility_of_survey_progress: 2af631010114307ac2a91612559c9618
environments/surveys/edit/display_an_estimate_of_completion_time_for_survey: 03f0a816569399c1c61d08dbc913de06 environments/surveys/edit/display_an_estimate_of_completion_time_for_survey: 03f0a816569399c1c61d08dbc913de06
@@ -1273,12 +1249,9 @@ checksums:
environments/surveys/edit/does_not_include_all_of: c18c1a71e6d96c681a3e95c7bd6c9482 environments/surveys/edit/does_not_include_all_of: c18c1a71e6d96c681a3e95c7bd6c9482
environments/surveys/edit/does_not_include_one_of: 91090d2e0667faf654f6a81d9857440f environments/surveys/edit/does_not_include_one_of: 91090d2e0667faf654f6a81d9857440f
environments/surveys/edit/does_not_start_with: 9395869b54cdfb353a51a7e0864f4fd7 environments/surveys/edit/does_not_start_with: 9395869b54cdfb353a51a7e0864f4fd7
environments/surveys/edit/duplicate_block: d4ea4afb5fc5b18a81cbe0302fa05997
environments/surveys/edit/duplicate_question: 910751de01fdd327165968214717711b
environments/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54 environments/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54
environments/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318 environments/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318
environments/surveys/edit/edit_translations: 2b21bea4b53e88342559272701e9fbf3 environments/surveys/edit/edit_translations: 2b21bea4b53e88342559272701e9fbf3
environments/surveys/edit/element_not_found: 196777ff6811dd177971ffc8e27a72c1
environments/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: c70466147d49dcbb3686452f35c46428 environments/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: c70466147d49dcbb3686452f35c46428
environments/surveys/edit/enable_recaptcha_to_protect_your_survey_from_spam: 4483a5763718d201ac97caa1e1216e13 environments/surveys/edit/enable_recaptcha_to_protect_your_survey_from_spam: 4483a5763718d201ac97caa1e1216e13
environments/surveys/edit/enable_spam_protection: e1fb0dd0723044bf040b92d8fc58015d environments/surveys/edit/enable_spam_protection: e1fb0dd0723044bf040b92d8fc58015d
@@ -1294,7 +1267,7 @@ checksums:
environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7 environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113 environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113
environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413 environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413
environments/surveys/edit/external_urls_paywall_tooltip: a8860ff0a2ad5f283bc0becba374cd54 environments/surveys/edit/external_urls_paywall_tooltip: 0dbb62557e8a6fa817f0e74709eeb3d2
environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722 environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722
environments/surveys/edit/fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first: ad4afe2980e1dfeffb20aa78eb892350 environments/surveys/edit/fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first: ad4afe2980e1dfeffb20aa78eb892350
environments/surveys/edit/fieldId_is_used_in_quota_please_remove_it_from_quota_first: 374c563964fc805ab0b8974e781687d9 environments/surveys/edit/fieldId_is_used_in_quota_please_remove_it_from_quota_first: 374c563964fc805ab0b8974e781687d9
@@ -1309,13 +1282,11 @@ checksums:
environments/surveys/edit/follow_ups_ending_card_delete_modal_text: 71ac1865afe2b2f76836dcbebd1a813e environments/surveys/edit/follow_ups_ending_card_delete_modal_text: 71ac1865afe2b2f76836dcbebd1a813e
environments/surveys/edit/follow_ups_ending_card_delete_modal_title: 11d0b31535034e0a86c906557fb6f22e environments/surveys/edit/follow_ups_ending_card_delete_modal_title: 11d0b31535034e0a86c906557fb6f22e
environments/surveys/edit/follow_ups_hidden_field_error: 28aa017b194fb6d7d6c06a8a0bf843ff environments/surveys/edit/follow_ups_hidden_field_error: 28aa017b194fb6d7d6c06a8a0bf843ff
environments/surveys/edit/follow_ups_include_hidden_fields: 8f0c2f8ddd3b95a3e7456a42be9362bb
environments/surveys/edit/follow_ups_include_variables: 2604dd580ceafec167ff9136d800f31e
environments/surveys/edit/follow_ups_item_ending_tag: 159c4e3bc953aae9a9dba27f7917228b environments/surveys/edit/follow_ups_item_ending_tag: 159c4e3bc953aae9a9dba27f7917228b
environments/surveys/edit/follow_ups_item_issue_detected_tag: bfb6b1f7b9f0a0a76bac853f01f72ba8 environments/surveys/edit/follow_ups_item_issue_detected_tag: bfb6b1f7b9f0a0a76bac853f01f72ba8
environments/surveys/edit/follow_ups_item_response_tag: 4b63073494e2224e1333624c6cee4240 environments/surveys/edit/follow_ups_item_response_tag: 4b63073494e2224e1333624c6cee4240
environments/surveys/edit/follow_ups_item_send_email_tag: 0ef83c0bb40de25921a9ee7fa05babec environments/surveys/edit/follow_ups_item_send_email_tag: 0ef83c0bb40de25921a9ee7fa05babec
environments/surveys/edit/follow_ups_modal_action_attach_response_data_description: 901a493d60331420da61d0e76bf07eae environments/surveys/edit/follow_ups_modal_action_attach_response_data_description: d23abb5a7e610b1ec3273c60d36a81e7
environments/surveys/edit/follow_ups_modal_action_attach_response_data_label: 32eff1a88e1a044fc22b0bff54f3c683 environments/surveys/edit/follow_ups_modal_action_attach_response_data_label: 32eff1a88e1a044fc22b0bff54f3c683
environments/surveys/edit/follow_ups_modal_action_body_label: e88eb1ea71f5ef886aa43ea6ba292d87 environments/surveys/edit/follow_ups_modal_action_body_label: e88eb1ea71f5ef886aa43ea6ba292d87
environments/surveys/edit/follow_ups_modal_action_body_placeholder: 4a658fa2f0af640a07f956551043eb88 environments/surveys/edit/follow_ups_modal_action_body_placeholder: 4a658fa2f0af640a07f956551043eb88
@@ -1356,13 +1327,12 @@ checksums:
environments/surveys/edit/hidden_field_used_in_recall: 70dee46bae18209e8861b654ff9a04ae environments/surveys/edit/hidden_field_used_in_recall: 70dee46bae18209e8861b654ff9a04ae
environments/surveys/edit/hidden_field_used_in_recall_ending_card: a985d03d18e33d83521961c9c981d0ee environments/surveys/edit/hidden_field_used_in_recall_ending_card: a985d03d18e33d83521961c9c981d0ee
environments/surveys/edit/hidden_field_used_in_recall_welcome: 22fef7001d5e60edbf877e7b435c1991 environments/surveys/edit/hidden_field_used_in_recall_welcome: 22fef7001d5e60edbf877e7b435c1991
environments/surveys/edit/hide_advanced_settings: ffa251d7762030b72c12e92f3c69a9b4
environments/surveys/edit/hide_back_button: 9f355fb4a8e80485b9de521a952ffeb9 environments/surveys/edit/hide_back_button: 9f355fb4a8e80485b9de521a952ffeb9
environments/surveys/edit/hide_back_button_description: caaa30cf43c5611577933a1c9f44b9ee environments/surveys/edit/hide_back_button_description: caaa30cf43c5611577933a1c9f44b9ee
environments/surveys/edit/hide_block_settings: c24c3d3892c251792e297cdc036d2fde
environments/surveys/edit/hide_logo: eef4de2e3fffe8cbe32bff4f6f7250d8 environments/surveys/edit/hide_logo: eef4de2e3fffe8cbe32bff4f6f7250d8
environments/surveys/edit/hide_logo_from_survey: 9d44321539cc2b397376a35bb8b3d1cd
environments/surveys/edit/hide_progress_bar: 7eefe7db6a051105bded521d94204933 environments/surveys/edit/hide_progress_bar: 7eefe7db6a051105bded521d94204933
environments/surveys/edit/hide_question_settings: 99127cd016db2f7fc80333b36473c0ef environments/surveys/edit/hide_the_logo_in_this_specific_survey: 29d4c6c714886e57bc29ad292d0f5a00
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62 environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
environments/surveys/edit/how_funky_do_you_want_your_cards_in_survey_type_derived_surveys: 3cb16b37510c01af20a80f51b598346e environments/surveys/edit/how_funky_do_you_want_your_cards_in_survey_type_derived_surveys: 3cb16b37510c01af20a80f51b598346e
environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768 environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768
@@ -1388,7 +1358,6 @@ checksums:
environments/surveys/edit/is_clicked: 8977b8cc9ff07d2b8bdb81bb41bb55cf environments/surveys/edit/is_clicked: 8977b8cc9ff07d2b8bdb81bb41bb55cf
environments/surveys/edit/is_completely_submitted: 8c8f0c0a9cf81dac16e486b2f5cdbb3b environments/surveys/edit/is_completely_submitted: 8c8f0c0a9cf81dac16e486b2f5cdbb3b
environments/surveys/edit/is_empty: dca87bc415341b1cdf9523f3b795a313 environments/surveys/edit/is_empty: dca87bc415341b1cdf9523f3b795a313
environments/surveys/edit/is_not_clicked: 04ac5678998edbdf9f431af74bd480da
environments/surveys/edit/is_not_empty: 8e53d702b296f172386b1277a8699050 environments/surveys/edit/is_not_empty: 8e53d702b296f172386b1277a8699050
environments/surveys/edit/is_not_set: c1a6fd89387686d3a5426a768bb286e9 environments/surveys/edit/is_not_set: c1a6fd89387686d3a5426a768bb286e9
environments/surveys/edit/is_partially_submitted: f5acf840b87d0d42c69d49a5714a86f3 environments/surveys/edit/is_partially_submitted: f5acf840b87d0d42c69d49a5714a86f3
@@ -1396,7 +1365,7 @@ checksums:
environments/surveys/edit/is_skipped: 9fb90b6578f603cca37d4e6c912bb401 environments/surveys/edit/is_skipped: 9fb90b6578f603cca37d4e6c912bb401
environments/surveys/edit/is_submitted: 13e774a97ad5f5609555e6f99514e70f environments/surveys/edit/is_submitted: 13e774a97ad5f5609555e6f99514e70f
environments/surveys/edit/italic: 555c60fb1d12ae305136202afa6deb3d environments/surveys/edit/italic: 555c60fb1d12ae305136202afa6deb3d
environments/surveys/edit/jump_to_block: 2fc00bd725c44f98861051c57bb2c392 environments/surveys/edit/jump_to_question: 742aabed8845190825418aa429f01b2d
environments/surveys/edit/keep_current_order: a7c944ad6b3515f2c4f83a2c81f8fc26 environments/surveys/edit/keep_current_order: a7c944ad6b3515f2c4f83a2c81f8fc26
environments/surveys/edit/keep_showing_while_conditions_match: 2574802d87bd6da151c9145aacce7281 environments/surveys/edit/keep_showing_while_conditions_match: 2574802d87bd6da151c9145aacce7281
environments/surveys/edit/key: 3d1065ab98a1c2f1210507fd5c7bf515 environments/surveys/edit/key: 3d1065ab98a1c2f1210507fd5c7bf515
@@ -1409,20 +1378,17 @@ checksums:
environments/surveys/edit/load_segment: 5341d3de37ff10f7526152e38e25e3c5 environments/surveys/edit/load_segment: 5341d3de37ff10f7526152e38e25e3c5
environments/surveys/edit/logic_error_warning: 542fbb918ffdb29e6f9a4a6196ffb558 environments/surveys/edit/logic_error_warning: 542fbb918ffdb29e6f9a4a6196ffb558
environments/surveys/edit/logic_error_warning_text: f2afad8852a95ed169a39959efbf592c environments/surveys/edit/logic_error_warning_text: f2afad8852a95ed169a39959efbf592c
environments/surveys/edit/logo_settings: 9f54ca6684e989cc38bf177425b6d366
environments/surveys/edit/long_answer: 3a97f8d2e90aba6e679917a0c5670c53 environments/surveys/edit/long_answer: 3a97f8d2e90aba6e679917a0c5670c53
environments/surveys/edit/long_answer_toggle_description: 86bcdfeb74d9825c2f2d5a215e92d111
environments/surveys/edit/lower_label: 45985bca022d4370bd6e013af75d5160 environments/surveys/edit/lower_label: 45985bca022d4370bd6e013af75d5160
environments/surveys/edit/manage_languages: 9c56d5afee8a73dfc283a452470f3a10 environments/surveys/edit/manage_languages: 9c56d5afee8a73dfc283a452470f3a10
environments/surveys/edit/matrix_all_fields: 187240509163b2f52a400a565e57c67f environments/surveys/edit/matrix_all_fields: 187240509163b2f52a400a565e57c67f
environments/surveys/edit/matrix_rows: 8f41f34e6ca28221cf1ebd948af4c151 environments/surveys/edit/matrix_rows: 8f41f34e6ca28221cf1ebd948af4c151
environments/surveys/edit/max_file_size: 3d35a22048f4d22e24da698fb5fb77d7 environments/surveys/edit/max_file_size: 3d35a22048f4d22e24da698fb5fb77d7
environments/surveys/edit/max_file_size_limit_is: 78998639cde3587cecb272ba47e05f9e environments/surveys/edit/max_file_size_limit_is: 78998639cde3587cecb272ba47e05f9e
environments/surveys/edit/move_question_to_block: e8d7ef1e2f727921cb7f5788849492ad
environments/surveys/edit/multiply: 89a0bb629167f97750ae1645a46ced0d environments/surveys/edit/multiply: 89a0bb629167f97750ae1645a46ced0d
environments/surveys/edit/needed_for_self_hosted_cal_com_instance: d241e72f0332177d32ce6c35070757dc environments/surveys/edit/needed_for_self_hosted_cal_com_instance: d241e72f0332177d32ce6c35070757dc
environments/surveys/edit/next_block: 53eaa5b1c9333455ab1e99bedd222ba2
environments/surveys/edit/next_button_label: e23522dd38f3eabeeccd3f48f32b73a8 environments/surveys/edit/next_button_label: e23522dd38f3eabeeccd3f48f32b73a8
environments/surveys/edit/next_question: 2e0f1ea264fb4bfcb8378b2b0cf7c18f
environments/surveys/edit/no_hidden_fields_yet_add_first_one_below: 9cc6cab3a6a42dbf835215897b5b8516 environments/surveys/edit/no_hidden_fields_yet_add_first_one_below: 9cc6cab3a6a42dbf835215897b5b8516
environments/surveys/edit/no_images_found_for: 90f10f4611ed7b115a49595409b66ebe environments/surveys/edit/no_images_found_for: 90f10f4611ed7b115a49595409b66ebe
environments/surveys/edit/no_languages_found_add_first_one_to_get_started: 22d7782c8504daf693cab3cf7135d6e3 environments/surveys/edit/no_languages_found_add_first_one_to_get_started: 22d7782c8504daf693cab3cf7135d6e3
@@ -1438,12 +1404,10 @@ checksums:
environments/surveys/edit/option_used_in_logic_error: c682ac2cfd286c3cc07dd21ac863dd4c environments/surveys/edit/option_used_in_logic_error: c682ac2cfd286c3cc07dd21ac863dd4c
environments/surveys/edit/optional: 396fb9a0472daf401c392bdc3e248943 environments/surveys/edit/optional: 396fb9a0472daf401c392bdc3e248943
environments/surveys/edit/options: 59156082418d80acb211f973b1218f11 environments/surveys/edit/options: 59156082418d80acb211f973b1218f11
environments/surveys/edit/options_used_in_logic_bulk_error: 1720e7a01a0bcb67c152cfe6a68c5355
environments/surveys/edit/override_theme_with_individual_styles_for_this_survey: edffc97f5d3372419fe0444de0a5aa3f environments/surveys/edit/override_theme_with_individual_styles_for_this_survey: edffc97f5d3372419fe0444de0a5aa3f
environments/surveys/edit/overwrite_global_waiting_time: 7bc23bd502b6bd048356b67acd956d9d environments/surveys/edit/overwrite_global_waiting_time: 7bc23bd502b6bd048356b67acd956d9d
environments/surveys/edit/overwrite_global_waiting_time_description: 795cf6e93d4c01d2e43aa0ebab601c6e environments/surveys/edit/overwrite_global_waiting_time_description: 795cf6e93d4c01d2e43aa0ebab601c6e
environments/surveys/edit/overwrite_placement: d7278be243e52c5091974e0fc4a7c342 environments/surveys/edit/overwrite_placement: d7278be243e52c5091974e0fc4a7c342
environments/surveys/edit/overwrite_survey_logo: a89cb566dfcc1559446abd8b830c84ed
environments/surveys/edit/overwrite_the_global_placement_of_the_survey: 874075712254b1ce92e099d89f675a48 environments/surveys/edit/overwrite_the_global_placement_of_the_survey: 874075712254b1ce92e099d89f675a48
environments/surveys/edit/pick_a_background_from_our_library_or_upload_your_own: b83bcbdc8131fc9524d272ff5dede754 environments/surveys/edit/pick_a_background_from_our_library_or_upload_your_own: b83bcbdc8131fc9524d272ff5dede754
environments/surveys/edit/picture_idx: 55e053ad1ade5d17c582406706036028 environments/surveys/edit/picture_idx: 55e053ad1ade5d17c582406706036028
@@ -1534,17 +1498,17 @@ checksums:
environments/surveys/edit/set_the_global_placement_in_the_look_feel_settings: e34e579e778a918733702edb041ac929 environments/surveys/edit/set_the_global_placement_in_the_look_feel_settings: e34e579e778a918733702edb041ac929
environments/surveys/edit/settings_saved_successfully: eb109269bc59dd67ae09fd9eb53652d2 environments/surveys/edit/settings_saved_successfully: eb109269bc59dd67ae09fd9eb53652d2
environments/surveys/edit/seven_points: 4ead50fdfda45e8710767e1b1a84bf42 environments/surveys/edit/seven_points: 4ead50fdfda45e8710767e1b1a84bf42
environments/surveys/edit/show_block_settings: bad99d99c9908874e45f5c350a88cc79 environments/surveys/edit/show_advanced_settings: b6f5bbbb84f34e51cd72ccd332e9613e
environments/surveys/edit/show_button: 6b364aac9d7ac71f34a438607c9693bc environments/surveys/edit/show_button: 6b364aac9d7ac71f34a438607c9693bc
environments/surveys/edit/show_language_switch: b6915a7f26d7079f2d4d844d74440413 environments/surveys/edit/show_language_switch: b6915a7f26d7079f2d4d844d74440413
environments/surveys/edit/show_multiple_times: 05239c532c9c05ef5d2990ba6ce12f60 environments/surveys/edit/show_multiple_times: 05239c532c9c05ef5d2990ba6ce12f60
environments/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af environments/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af
environments/surveys/edit/show_question_settings: a84698a95df0833a35d653edcdbbe501
environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0 environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197 environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197
environments/surveys/edit/simple: 65575bd903091299bc4a94b7517a6288 environments/surveys/edit/simple: 65575bd903091299bc4a94b7517a6288
environments/surveys/edit/six_points: c6c09b3f07171dc388cb5a610ea79af7 environments/surveys/edit/six_points: c6c09b3f07171dc388cb5a610ea79af7
environments/surveys/edit/skip_button_label: bfc8993b0f13e6f4fc9ef0c570b808e3
environments/surveys/edit/smiley: e68e3b28fc3c04255e236c6a0feb662b environments/surveys/edit/smiley: e68e3b28fc3c04255e236c6a0feb662b
environments/surveys/edit/spam_protection_note: 94059310d07c30f6704e216297036d05 environments/surveys/edit/spam_protection_note: 94059310d07c30f6704e216297036d05
environments/surveys/edit/spam_protection_threshold_description: ed8b8c9c583077a88bf5dd3ec8b59e60 environments/surveys/edit/spam_protection_threshold_description: ed8b8c9c583077a88bf5dd3ec8b59e60
@@ -1565,7 +1529,6 @@ checksums:
environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579 environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
environments/surveys/edit/switch_multi_lanugage_on_to_get_started: d2ca06684af26bd6b5121a4656bb6458 environments/surveys/edit/switch_multi_lanugage_on_to_get_started: d2ca06684af26bd6b5121a4656bb6458
environments/surveys/edit/target_block_not_found: 0a0c401017ab32364fec2fcbf815d832
environments/surveys/edit/targeted: ca615f1fc3b490d5a2187b27fb4a2073 environments/surveys/edit/targeted: ca615f1fc3b490d5a2187b27fb4a2073
environments/surveys/edit/ten_points: a1317b82003859f77fb3138c55450d63 environments/surveys/edit/ten_points: a1317b82003859f77fb3138c55450d63
environments/surveys/edit/the_survey_will_be_shown_multiple_times_until_they_respond: 2d8d7d2351bd7533eb3788cce228c654 environments/surveys/edit/the_survey_will_be_shown_multiple_times_until_they_respond: 2d8d7d2351bd7533eb3788cce228c654
@@ -1585,8 +1548,6 @@ checksums:
environments/surveys/edit/unlock_targeting_title: 6098caf969cac64cd54e217471ae42d4 environments/surveys/edit/unlock_targeting_title: 6098caf969cac64cd54e217471ae42d4
environments/surveys/edit/unsaved_changes_warning: a164f276c9f7344022aa4640b32abcf9 environments/surveys/edit/unsaved_changes_warning: a164f276c9f7344022aa4640b32abcf9
environments/surveys/edit/until_they_submit_a_response: 2a0fd5dcc6cc40a72ed9b974f22eaf68 environments/surveys/edit/until_they_submit_a_response: 2a0fd5dcc6cc40a72ed9b974f22eaf68
environments/surveys/edit/untitled_block: fdaa045139deff5cc65fa027df0cc22e
environments/surveys/edit/update_options: 3499161b010acdefba2d878daa5fb6fa
environments/surveys/edit/upgrade_notice_description: 32b66a4f257ad8d38bc38dcc95fe23c4 environments/surveys/edit/upgrade_notice_description: 32b66a4f257ad8d38bc38dcc95fe23c4
environments/surveys/edit/upgrade_notice_title: 40866066ebc558ad0c92a4f19f12090c environments/surveys/edit/upgrade_notice_title: 40866066ebc558ad0c92a4f19f12090c
environments/surveys/edit/upload: 4a6c84aa16db0f4e5697f49b45257bc7 environments/surveys/edit/upload: 4a6c84aa16db0f4e5697f49b45257bc7
@@ -1958,6 +1919,7 @@ checksums:
templates/card_abandonment_survey: 705c3dfcc7f6de3a445aaefe0d68c43f templates/card_abandonment_survey: 705c3dfcc7f6de3a445aaefe0d68c43f
templates/card_abandonment_survey_description: a3db29212b51402a7659a76248299798 templates/card_abandonment_survey_description: a3db29212b51402a7659a76248299798
templates/card_abandonment_survey_question_1_button_label: 6208ac076107506686eb8eae42ac4450 templates/card_abandonment_survey_question_1_button_label: 6208ac076107506686eb8eae42ac4450
templates/card_abandonment_survey_question_1_dismiss_button_label: 17961ce57f78e2cbfded4590014e5e06
templates/card_abandonment_survey_question_1_headline: d19fc64f80ef192b124f4f9fb070bccc templates/card_abandonment_survey_question_1_headline: d19fc64f80ef192b124f4f9fb070bccc
templates/card_abandonment_survey_question_1_html: 2a4cbf4a5cc305109d23baa9896a9010 templates/card_abandonment_survey_question_1_html: 2a4cbf4a5cc305109d23baa9896a9010
templates/card_abandonment_survey_question_2_choice_1: 7723bcd15400a40303409716854f88f9 templates/card_abandonment_survey_question_2_choice_1: 7723bcd15400a40303409716854f88f9
@@ -2044,10 +2006,12 @@ checksums:
templates/churn_survey_question_2_button_label: 76a8497d7b546628b03bb81d5c1ce995 templates/churn_survey_question_2_button_label: 76a8497d7b546628b03bb81d5c1ce995
templates/churn_survey_question_2_headline: 17d3e7e2ce62af5ef9332c0d208f9172 templates/churn_survey_question_2_headline: 17d3e7e2ce62af5ef9332c0d208f9172
templates/churn_survey_question_3_button_label: 43834ccf20c1c7cd49382468abe2edce templates/churn_survey_question_3_button_label: 43834ccf20c1c7cd49382468abe2edce
templates/churn_survey_question_3_dismiss_button_label: b7f28dfa2f58b80b149bb82b392d0291
templates/churn_survey_question_3_headline: 76444078de5c30666ff65f453f60b420 templates/churn_survey_question_3_headline: 76444078de5c30666ff65f453f60b420
templates/churn_survey_question_3_html: 4f723d2aea95570d6fc4559519611b8e templates/churn_survey_question_3_html: 4f723d2aea95570d6fc4559519611b8e
templates/churn_survey_question_4_headline: c64605fecd9342dffe904d809e9e3762 templates/churn_survey_question_4_headline: c64605fecd9342dffe904d809e9e3762
templates/churn_survey_question_5_button_label: 03e28ea8c2c970cd1b532fee14b22e2b templates/churn_survey_question_5_button_label: 03e28ea8c2c970cd1b532fee14b22e2b
templates/churn_survey_question_5_dismiss_button_label: b7f28dfa2f58b80b149bb82b392d0291
templates/churn_survey_question_5_headline: bab9054d83ebc8c67a5bfe7edcb29c85 templates/churn_survey_question_5_headline: bab9054d83ebc8c67a5bfe7edcb29c85
templates/churn_survey_question_5_html: da3da01f91e3e922ea4d09c4bd836023 templates/churn_survey_question_5_html: da3da01f91e3e922ea4d09c4bd836023
templates/collect_feedback_description: 450c46ad8406e6ac92940a80ed24c000 templates/collect_feedback_description: 450c46ad8406e6ac92940a80ed24c000
@@ -2154,7 +2118,6 @@ checksums:
templates/csat_survey_question_3_headline: 25974b7f1692cad41908fe305830b6c0 templates/csat_survey_question_3_headline: 25974b7f1692cad41908fe305830b6c0
templates/csat_survey_question_3_placeholder: 3ec4d0b6bb1f26bb2c32e9e8bd377ae3 templates/csat_survey_question_3_placeholder: 3ec4d0b6bb1f26bb2c32e9e8bd377ae3
templates/cta_description: bc94a2ddc965b286a8677b0642696c7e templates/cta_description: bc94a2ddc965b286a8677b0642696c7e
templates/custom_survey_block_1_name: 5e1b4dce0cb70662441b663507a69454
templates/custom_survey_description: 0492afdea2ef1bd683eaf48a2bad2caa templates/custom_survey_description: 0492afdea2ef1bd683eaf48a2bad2caa
templates/custom_survey_name: 6fc756927ca9ea22c26368cccd64a67e templates/custom_survey_name: 6fc756927ca9ea22c26368cccd64a67e
templates/custom_survey_question_1_headline: 0abf9d41e0b5c5567c3833fd63048398 templates/custom_survey_question_1_headline: 0abf9d41e0b5c5567c3833fd63048398
@@ -2241,6 +2204,7 @@ checksums:
templates/evaluate_a_product_idea_description: 734295caa08aac718e9ee01a99c3debe templates/evaluate_a_product_idea_description: 734295caa08aac718e9ee01a99c3debe
templates/evaluate_a_product_idea_name: b0d8039556d686b83dfcd455092b9d9c templates/evaluate_a_product_idea_name: b0d8039556d686b83dfcd455092b9d9c
templates/evaluate_a_product_idea_question_1_button_label: 102449dc2f516eb6259c39fa4ed9c56a templates/evaluate_a_product_idea_question_1_button_label: 102449dc2f516eb6259c39fa4ed9c56a
templates/evaluate_a_product_idea_question_1_dismiss_button_label: b7f28dfa2f58b80b149bb82b392d0291
templates/evaluate_a_product_idea_question_1_headline: c94096ba66ad74fb3bbfaaa06bd709a0 templates/evaluate_a_product_idea_question_1_headline: c94096ba66ad74fb3bbfaaa06bd709a0
templates/evaluate_a_product_idea_question_1_html: bc0dcb887591e018dfeeb65a3a5c4bb9 templates/evaluate_a_product_idea_question_1_html: bc0dcb887591e018dfeeb65a3a5c4bb9
templates/evaluate_a_product_idea_question_2_headline: 10a50778c4559554336e7289a48d021c templates/evaluate_a_product_idea_question_2_headline: 10a50778c4559554336e7289a48d021c
@@ -2249,6 +2213,7 @@ checksums:
templates/evaluate_a_product_idea_question_3_headline: 69407cff7b3e2706bdc86cb425e88918 templates/evaluate_a_product_idea_question_3_headline: 69407cff7b3e2706bdc86cb425e88918
templates/evaluate_a_product_idea_question_3_placeholder: 3ec4d0b6bb1f26bb2c32e9e8bd377ae3 templates/evaluate_a_product_idea_question_3_placeholder: 3ec4d0b6bb1f26bb2c32e9e8bd377ae3
templates/evaluate_a_product_idea_question_4_button_label: 89ddbcf710eba274963494f312bdc8a9 templates/evaluate_a_product_idea_question_4_button_label: 89ddbcf710eba274963494f312bdc8a9
templates/evaluate_a_product_idea_question_4_dismiss_button_label: b7f28dfa2f58b80b149bb82b392d0291
templates/evaluate_a_product_idea_question_4_headline: e7e5b5234f617f38f09b2cac639a7ef8 templates/evaluate_a_product_idea_question_4_headline: e7e5b5234f617f38f09b2cac639a7ef8
templates/evaluate_a_product_idea_question_4_html: 8902a0d7738376818d2729644321438f templates/evaluate_a_product_idea_question_4_html: 8902a0d7738376818d2729644321438f
templates/evaluate_a_product_idea_question_5_headline: 1d573c2338e6ba5d3cccb09c785bd8c3 templates/evaluate_a_product_idea_question_5_headline: 1d573c2338e6ba5d3cccb09c785bd8c3
@@ -2298,6 +2263,7 @@ checksums:
templates/feedback_box_question_2_headline: 878b8f17dc18877bfbc07823113cd5d5 templates/feedback_box_question_2_headline: 878b8f17dc18877bfbc07823113cd5d5
templates/feedback_box_question_2_subheader: 476ff43369a72225b01633e1bce59b95 templates/feedback_box_question_2_subheader: 476ff43369a72225b01633e1bce59b95
templates/feedback_box_question_3_button_label: c631d5b3f14b581c303b782221582fe7 templates/feedback_box_question_3_button_label: c631d5b3f14b581c303b782221582fe7
templates/feedback_box_question_3_dismiss_button_label: 0d5962c08cdca1a2804dfc4abc308a8f
templates/feedback_box_question_3_headline: 5cfb173d156555227fbc2c97ad921e72 templates/feedback_box_question_3_headline: 5cfb173d156555227fbc2c97ad921e72
templates/feedback_box_question_3_html: 7e5877860eec80971969ae83c89b30f6 templates/feedback_box_question_3_html: 7e5877860eec80971969ae83c89b30f6
templates/feedback_box_question_4_button_label: 1050569a1ea31d070e0cee55bcab3494 templates/feedback_box_question_4_button_label: 1050569a1ea31d070e0cee55bcab3494
@@ -2322,6 +2288,7 @@ checksums:
templates/identify_sign_up_barriers_description: 5b2fbee8c425d7a4d0706ec3628cea11 templates/identify_sign_up_barriers_description: 5b2fbee8c425d7a4d0706ec3628cea11
templates/identify_sign_up_barriers_name: 3bbc5352dfa7a9c237bc2c6b21b608dd templates/identify_sign_up_barriers_name: 3bbc5352dfa7a9c237bc2c6b21b608dd
templates/identify_sign_up_barriers_question_1_button_label: 080fd22c580f56ffdcea6c3d60448b84 templates/identify_sign_up_barriers_question_1_button_label: 080fd22c580f56ffdcea6c3d60448b84
templates/identify_sign_up_barriers_question_1_dismiss_button_label: 0d5962c08cdca1a2804dfc4abc308a8f
templates/identify_sign_up_barriers_question_1_headline: c8c247363daf4697e1939aaf8dc5770c templates/identify_sign_up_barriers_question_1_headline: c8c247363daf4697e1939aaf8dc5770c
templates/identify_sign_up_barriers_question_1_html: 51029ae64c19101af608684b6f429eb8 templates/identify_sign_up_barriers_question_1_html: 51029ae64c19101af608684b6f429eb8
templates/identify_sign_up_barriers_question_2_headline: f768ea3053b07f6bbcba977f714ec3da templates/identify_sign_up_barriers_question_2_headline: f768ea3053b07f6bbcba977f714ec3da
@@ -2344,6 +2311,7 @@ checksums:
templates/identify_sign_up_barriers_question_8_headline: 1f4ee5675d0d84bf049052be26549037 templates/identify_sign_up_barriers_question_8_headline: 1f4ee5675d0d84bf049052be26549037
templates/identify_sign_up_barriers_question_8_placeholder: 3ec4d0b6bb1f26bb2c32e9e8bd377ae3 templates/identify_sign_up_barriers_question_8_placeholder: 3ec4d0b6bb1f26bb2c32e9e8bd377ae3
templates/identify_sign_up_barriers_question_9_button_label: 0dd2ae69be4618c1f9e615774a4509ca templates/identify_sign_up_barriers_question_9_button_label: 0dd2ae69be4618c1f9e615774a4509ca
templates/identify_sign_up_barriers_question_9_dismiss_button_label: b8bf7f2b6e67a523dc4ff5ce009cdb72
templates/identify_sign_up_barriers_question_9_headline: 54d02e5c8eeb10fed40e2e82f7399f8c templates/identify_sign_up_barriers_question_9_headline: 54d02e5c8eeb10fed40e2e82f7399f8c
templates/identify_sign_up_barriers_question_9_html: ed87aa8d325b6063d4150431e9f80ef0 templates/identify_sign_up_barriers_question_9_html: ed87aa8d325b6063d4150431e9f80ef0
templates/identify_upsell_opportunities_description: ed6b8dcb162076a380955d7c98482b06 templates/identify_upsell_opportunities_description: ed6b8dcb162076a380955d7c98482b06
@@ -2380,6 +2348,7 @@ checksums:
templates/improve_newsletter_content_question_2_headline: abbea0e97841b617a878f1de2c968d0e templates/improve_newsletter_content_question_2_headline: abbea0e97841b617a878f1de2c968d0e
templates/improve_newsletter_content_question_2_placeholder: 3ec4d0b6bb1f26bb2c32e9e8bd377ae3 templates/improve_newsletter_content_question_2_placeholder: 3ec4d0b6bb1f26bb2c32e9e8bd377ae3
templates/improve_newsletter_content_question_3_button_label: 5d5352aba5272de9b1337909d49d4a4c templates/improve_newsletter_content_question_3_button_label: 5d5352aba5272de9b1337909d49d4a4c
templates/improve_newsletter_content_question_3_dismiss_button_label: 6a6d6f71da4a44cca4fe5ad09f83a9d2
templates/improve_newsletter_content_question_3_headline: fcd056a1581f5a538aad57641cd0abad templates/improve_newsletter_content_question_3_headline: fcd056a1581f5a538aad57641cd0abad
templates/improve_newsletter_content_question_3_html: 102e73f836fe99b6c333c88c730fa25b templates/improve_newsletter_content_question_3_html: 102e73f836fe99b6c333c88c730fa25b
templates/improve_trial_conversion_description: 3187c4ac1de993326a988c6665d3d4ae templates/improve_trial_conversion_description: 3187c4ac1de993326a988c6665d3d4ae
@@ -2394,6 +2363,7 @@ checksums:
templates/improve_trial_conversion_question_2_button_label: 89ddbcf710eba274963494f312bdc8a9 templates/improve_trial_conversion_question_2_button_label: 89ddbcf710eba274963494f312bdc8a9
templates/improve_trial_conversion_question_2_headline: 05dd4820f60b9d267a9affc7e662f029 templates/improve_trial_conversion_question_2_headline: 05dd4820f60b9d267a9affc7e662f029
templates/improve_trial_conversion_question_4_button_label: d94a6a11cfdf4ebde4c5332e585e2e96 templates/improve_trial_conversion_question_4_button_label: d94a6a11cfdf4ebde4c5332e585e2e96
templates/improve_trial_conversion_question_4_dismiss_button_label: b7f28dfa2f58b80b149bb82b392d0291
templates/improve_trial_conversion_question_4_headline: 9b07341f65574c4165086ec107cebb45 templates/improve_trial_conversion_question_4_headline: 9b07341f65574c4165086ec107cebb45
templates/improve_trial_conversion_question_4_html: 95d13979f92aa0e6c5bce6613ad3b417 templates/improve_trial_conversion_question_4_html: 95d13979f92aa0e6c5bce6613ad3b417
templates/improve_trial_conversion_question_5_button_label: 89ddbcf710eba274963494f312bdc8a9 templates/improve_trial_conversion_question_5_button_label: 89ddbcf710eba274963494f312bdc8a9
@@ -2557,6 +2527,7 @@ checksums:
templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e
templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72 templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72
templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00 templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00
templates/preview_survey_welcome_card_html: 5fc24f7cfeba1af9a3fc3ddb6fb67de4
templates/prioritize_features_description: 1eae41fad0e3947f803d8539081e59ec templates/prioritize_features_description: 1eae41fad0e3947f803d8539081e59ec
templates/prioritize_features_name: 4ca59ff1f9c319aaa68c3106d820fd6a templates/prioritize_features_name: 4ca59ff1f9c319aaa68c3106d820fd6a
templates/prioritize_features_question_1_choice_1: 7c0b2da44eacc271073d4f15caaa86c8 templates/prioritize_features_question_1_choice_1: 7c0b2da44eacc271073d4f15caaa86c8
@@ -2582,6 +2553,7 @@ checksums:
templates/product_market_fit_superhuman: 48b1b2db74562dea0d00483b29942346 templates/product_market_fit_superhuman: 48b1b2db74562dea0d00483b29942346
templates/product_market_fit_superhuman_description: d14c8e7f4eb7c98919de171457d10a31 templates/product_market_fit_superhuman_description: d14c8e7f4eb7c98919de171457d10a31
templates/product_market_fit_superhuman_question_1_button_label: 5d5352aba5272de9b1337909d49d4a4c templates/product_market_fit_superhuman_question_1_button_label: 5d5352aba5272de9b1337909d49d4a4c
templates/product_market_fit_superhuman_question_1_dismiss_button_label: 17961ce57f78e2cbfded4590014e5e06
templates/product_market_fit_superhuman_question_1_headline: 21a16bc7bc801fdd743ad37354eedbfb templates/product_market_fit_superhuman_question_1_headline: 21a16bc7bc801fdd743ad37354eedbfb
templates/product_market_fit_superhuman_question_1_html: fa12924d03a014c4a81e770c3eb2175a templates/product_market_fit_superhuman_question_1_html: fa12924d03a014c4a81e770c3eb2175a
templates/product_market_fit_superhuman_question_2_choice_1: 074b2a608d4bba5706b5c55dae249edf templates/product_market_fit_superhuman_question_2_choice_1: 074b2a608d4bba5706b5c55dae249edf
@@ -2687,6 +2659,7 @@ checksums:
templates/site_abandonment_survey_description: 46581a9b056f3cbf8c1dc9e630e716b5 templates/site_abandonment_survey_description: 46581a9b056f3cbf8c1dc9e630e716b5
templates/site_abandonment_survey_question_1_html: eec37cddb0c530c72544067712e95670 templates/site_abandonment_survey_question_1_html: eec37cddb0c530c72544067712e95670
templates/site_abandonment_survey_question_2_button_label: 6208ac076107506686eb8eae42ac4450 templates/site_abandonment_survey_question_2_button_label: 6208ac076107506686eb8eae42ac4450
templates/site_abandonment_survey_question_2_dismiss_button_label: 17961ce57f78e2cbfded4590014e5e06
templates/site_abandonment_survey_question_2_headline: e11a5c95e6a4ba0a3fe9bb0ad1da0b46 templates/site_abandonment_survey_question_2_headline: e11a5c95e6a4ba0a3fe9bb0ad1da0b46
templates/site_abandonment_survey_question_3_choice_1: c86306eb379a1b5f4039e27a0a12caca templates/site_abandonment_survey_question_3_choice_1: c86306eb379a1b5f4039e27a0a12caca
templates/site_abandonment_survey_question_3_choice_2: fee51e29951105d7650c3da72282db6d templates/site_abandonment_survey_question_3_choice_2: fee51e29951105d7650c3da72282db6d
@@ -2711,6 +2684,7 @@ checksums:
templates/site_abandonment_survey_question_7_label: c0d4407cabb5811192c17cbbb8c1a71e templates/site_abandonment_survey_question_7_label: c0d4407cabb5811192c17cbbb8c1a71e
templates/site_abandonment_survey_question_8_headline: 9e82d6f51788351c7e2c8f73be66d005 templates/site_abandonment_survey_question_8_headline: 9e82d6f51788351c7e2c8f73be66d005
templates/site_abandonment_survey_question_9_headline: ef1289130df46b80d43119380095b579 templates/site_abandonment_survey_question_9_headline: ef1289130df46b80d43119380095b579
templates/skip: b7f28dfa2f58b80b149bb82b392d0291
templates/smileys_survey_name: 6ef64e8182e7820efa53a2d1c81eb912 templates/smileys_survey_name: 6ef64e8182e7820efa53a2d1c81eb912
templates/smileys_survey_question_1_headline: 6b15d118037b729138c2214cfef49a68 templates/smileys_survey_question_1_headline: 6b15d118037b729138c2214cfef49a68
templates/smileys_survey_question_1_lower_label: ff4681be0a94185111459994fe58478c templates/smileys_survey_question_1_lower_label: ff4681be0a94185111459994fe58478c

View File

@@ -1,15 +1,10 @@
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
import { IS_PRODUCTION, PROMETHEUS_ENABLED, SENTRY_DSN } from "@/lib/constants"; import { IS_PRODUCTION, PROMETHEUS_ENABLED, SENTRY_DSN } from "@/lib/constants";
import { setupGlobalAgentProxy } from "@/lib/setupGlobalAgentProxy";
export const onRequestError = Sentry.captureRequestError; export const onRequestError = Sentry.captureRequestError;
export const register = async () => { export const register = async () => {
if (process.env.NEXT_RUNTIME === "nodejs") { if (process.env.NEXT_RUNTIME === "nodejs") {
// Initialize global-agent proxy support (opt-in via USE_GLOBAL_AGENT_PROXY=1)
// Must run before any outbound HTTP requests to ensure proxy settings are applied
setupGlobalAgentProxy();
if (PROMETHEUS_ENABLED) { if (PROMETHEUS_ENABLED) {
await import("./instrumentation-node"); await import("./instrumentation-node");
} }

View File

@@ -206,19 +206,15 @@ const getExistingFields = async (key: TIntegrationAirtableCredential, baseId: st
export const writeData = async ( export const writeData = async (
key: TIntegrationAirtableCredential, key: TIntegrationAirtableCredential,
configData: TIntegrationAirtableConfigData, configData: TIntegrationAirtableConfigData,
responses: string[], values: string[][]
elements: string[]
) => { ) => {
if (responses.length !== elements.length) { const responses = values[0];
throw new Error( const questions = values[1];
`Array length mismatch: responses (${responses.length}) and elements (${elements.length}) must be equal`
);
}
// 1) Build the record payload // 1) Build the record payload
const data: Record<string, string> = {}; const data: Record<string, string> = {};
for (let i = 0; i < elements.length; i++) { for (let i = 0; i < questions.length; i++) {
data[elements[i]] = data[questions[i]] =
responses[i].length > AIRTABLE_MESSAGE_LIMIT responses[i].length > AIRTABLE_MESSAGE_LIMIT
? truncateText(responses[i], AIRTABLE_MESSAGE_LIMIT) ? truncateText(responses[i], AIRTABLE_MESSAGE_LIMIT)
: responses[i]; : responses[i];
@@ -226,7 +222,7 @@ export const writeData = async (
// 2) Figure out which fields need creating // 2) Figure out which fields need creating
const existingFields = await getExistingFields(key, configData.baseId, configData.tableId); const existingFields = await getExistingFields(key, configData.baseId, configData.tableId);
const fieldsToCreate = elements.filter((q) => !existingFields.has(q)); const fieldsToCreate = questions.filter((q) => !existingFields.has(q));
// 3) Create any missing fields with throttling to respect Airtable's 5 req/sec per base limit // 3) Create any missing fields with throttling to respect Airtable's 5 req/sec per base limit
if (fieldsToCreate.length > 0) { if (fieldsToCreate.length > 0) {

View File

@@ -176,7 +176,6 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
"ja-JP", "ja-JP",
"zh-Hans-CN", "zh-Hans-CN",
"es-ES", "es-ES",
"sv-SE",
]; ];
// Billing constants // Billing constants
@@ -219,6 +218,10 @@ export const INTERCOM_SECRET_KEY = env.INTERCOM_SECRET_KEY;
export const INTERCOM_APP_ID = env.INTERCOM_APP_ID; export const INTERCOM_APP_ID = env.INTERCOM_APP_ID;
export const IS_INTERCOM_CONFIGURED = Boolean(env.INTERCOM_APP_ID && INTERCOM_SECRET_KEY); export const IS_INTERCOM_CONFIGURED = Boolean(env.INTERCOM_APP_ID && INTERCOM_SECRET_KEY);
export const POSTHOG_API_KEY = env.POSTHOG_API_KEY;
export const POSTHOG_API_HOST = env.POSTHOG_API_HOST;
export const IS_POSTHOG_CONFIGURED = Boolean(POSTHOG_API_KEY && POSTHOG_API_HOST);
export const TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY; export const TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY;
export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY; export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY;
export const IS_TURNSTILE_CONFIGURED = Boolean(env.TURNSTILE_SITE_KEY && TURNSTILE_SECRET_KEY); export const IS_TURNSTILE_CONFIGURED = Boolean(env.TURNSTILE_SITE_KEY && TURNSTILE_SECRET_KEY);

View File

@@ -32,9 +32,6 @@ export const env = createEnv({
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(), GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
HTTP_PROXY: z.string().url().optional(), HTTP_PROXY: z.string().url().optional(),
HTTPS_PROXY: z.string().url().optional(), HTTPS_PROXY: z.string().url().optional(),
GLOBAL_AGENT_NO_PROXY: z.string().optional(),
NO_PROXY: z.string().optional(),
USE_GLOBAL_AGENT_PROXY: z.enum(["1", "0"]).optional(),
IMPRINT_URL: z IMPRINT_URL: z
.string() .string()
.url() .url()
@@ -62,6 +59,8 @@ export const env = createEnv({
? z.string().optional() ? z.string().optional()
: z.string().url("REDIS_URL is required for caching, rate limiting, and audit logging"), : z.string().url("REDIS_URL is required for caching, rate limiting, and audit logging"),
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(), PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
POSTHOG_API_HOST: z.string().optional(),
POSTHOG_API_KEY: z.string().optional(),
PRIVACY_URL: z PRIVACY_URL: z
.string() .string()
.url() .url()
@@ -104,6 +103,7 @@ export const env = createEnv({
} }
) )
.optional(), .optional(),
TELEMETRY_DISABLED: z.enum(["1", "0"]).optional(),
TERMS_URL: z TERMS_URL: z
.string() .string()
.url() .url()
@@ -162,9 +162,6 @@ export const env = createEnv({
GOOGLE_SHEETS_REDIRECT_URL: process.env.GOOGLE_SHEETS_REDIRECT_URL, GOOGLE_SHEETS_REDIRECT_URL: process.env.GOOGLE_SHEETS_REDIRECT_URL,
HTTP_PROXY: process.env.HTTP_PROXY, HTTP_PROXY: process.env.HTTP_PROXY,
HTTPS_PROXY: process.env.HTTPS_PROXY, HTTPS_PROXY: process.env.HTTPS_PROXY,
GLOBAL_AGENT_NO_PROXY: process.env.GLOBAL_AGENT_NO_PROXY,
NO_PROXY: process.env.NO_PROXY,
USE_GLOBAL_AGENT_PROXY: process.env.USE_GLOBAL_AGENT_PROXY,
IMPRINT_URL: process.env.IMPRINT_URL, IMPRINT_URL: process.env.IMPRINT_URL,
IMPRINT_ADDRESS: process.env.IMPRINT_ADDRESS, IMPRINT_ADDRESS: process.env.IMPRINT_ADDRESS,
INVITE_DISABLED: process.env.INVITE_DISABLED, INVITE_DISABLED: process.env.INVITE_DISABLED,
@@ -175,6 +172,8 @@ export const env = createEnv({
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME, MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
SENTRY_DSN: process.env.SENTRY_DSN, SENTRY_DSN: process.env.SENTRY_DSN,
POSTHOG_API_KEY: process.env.POSTHOG_API_KEY,
POSTHOG_API_HOST: process.env.POSTHOG_API_HOST,
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL, OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
INTERCOM_APP_ID: process.env.INTERCOM_APP_ID, INTERCOM_APP_ID: process.env.INTERCOM_APP_ID,
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID, NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
@@ -207,6 +206,7 @@ export const env = createEnv({
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
PUBLIC_URL: process.env.PUBLIC_URL, PUBLIC_URL: process.env.PUBLIC_URL,
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY, TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY, TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,
RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY, RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY,

View File

@@ -17,6 +17,7 @@ import {
} from "@formbricks/types/environment"; } from "@formbricks/types/environment";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors"; import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { getOrganizationsByUserId } from "../organization/service"; import { getOrganizationsByUserId } from "../organization/service";
import { capturePosthogEnvironmentEvent } from "../posthogServer";
import { getUserProjects } from "../project/service"; import { getUserProjects } from "../project/service";
import { validateInputs } from "../utils/validate"; import { validateInputs } from "../utils/validate";
@@ -172,6 +173,10 @@ export const createEnvironment = async (
}, },
}); });
await capturePosthogEnvironmentEvent(environment.id, "environment created", {
environmentType: environment.type,
});
return environment; return environment;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -22,36 +22,31 @@ const { google } = require("googleapis");
export const writeData = async ( export const writeData = async (
integrationData: TIntegrationGoogleSheets, integrationData: TIntegrationGoogleSheets,
spreadsheetId: string, spreadsheetId: string,
responses: string[], values: string[][]
elements: string[]
) => { ) => {
validateInputs( validateInputs(
[integrationData, ZIntegrationGoogleSheets], [integrationData, ZIntegrationGoogleSheets],
[spreadsheetId, ZString], [spreadsheetId, ZString],
[responses, z.array(ZString)], [values, z.array(z.array(ZString))]
[elements, z.array(ZString)]
); );
try { try {
const authClient = await authorize(integrationData); const authClient = await authorize(integrationData);
const sheets = google.sheets({ version: "v4", auth: authClient }); const sheets = google.sheets({ version: "v4", auth: authClient });
const responsesMapped = { const responses = {
values: [ values: [
responses.map((response) => values[0].map((value) =>
response.length > GOOGLE_SHEET_MESSAGE_LIMIT value.length > GOOGLE_SHEET_MESSAGE_LIMIT ? truncateText(value, GOOGLE_SHEET_MESSAGE_LIMIT) : value
? truncateText(response, GOOGLE_SHEET_MESSAGE_LIMIT)
: response
), ),
], ],
}; };
const question = { values: [values[1]] };
const element = { values: [elements] };
sheets.spreadsheets.values.update( sheets.spreadsheets.values.update(
{ {
spreadsheetId: spreadsheetId, spreadsheetId: spreadsheetId,
range: "A1", range: "A1",
valueInputOption: "RAW", valueInputOption: "RAW",
resource: element, resource: question,
}, },
(err: Error) => { (err: Error) => {
if (err) { if (err) {
@@ -65,7 +60,7 @@ export const writeData = async (
spreadsheetId: spreadsheetId, spreadsheetId: spreadsheetId,
range: "A2", range: "A2",
valueInputOption: "RAW", valueInputOption: "RAW",
resource: responsesMapped, resource: responses,
}, },
(err: Error) => { (err: Error) => {
if (err) { if (err) {

View File

@@ -1,6 +1,6 @@
import { TSurveyCTAElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { import {
TSurvey, TSurvey,
TSurveyCTAQuestion,
TSurveyCalQuestion, TSurveyCalQuestion,
TSurveyConsentQuestion, TSurveyConsentQuestion,
TSurveyDateQuestion, TSurveyDateQuestion,
@@ -173,17 +173,20 @@ export const mockNpsQuestion: TSurveyNPSQuestion = {
isColorCodingEnabled: false, isColorCodingEnabled: false,
}; };
export const mockCtaQuestion: TSurveyCTAElement = { export const mockCtaQuestion: TSurveyCTAQuestion = {
required: true, required: true,
headline: { headline: {
default: "You are one of our power users!", default: "You are one of our power users!",
}, },
ctaButtonLabel: { buttonLabel: {
default: "Book interview", default: "Book interview",
}, },
buttonExternal: true, buttonExternal: false,
dismissButtonLabel: {
default: "Skip",
},
id: "gwn15urom4ffnhfimwbz3vgc", id: "gwn15urom4ffnhfimwbz3vgc",
type: TSurveyElementTypeEnum.CTA, type: TSurveyQuestionTypeEnum.CTA,
isDraft: true, isDraft: true,
}; };
@@ -442,13 +445,15 @@ export const mockLegacyNpsQuestion = {
export const mockTranslatedCtaQuestion = { export const mockTranslatedCtaQuestion = {
...mockCtaQuestion, ...mockCtaQuestion,
headline: { default: "You are one of our power users!", de: "" }, headline: { default: "You are one of our power users!", de: "" },
ctaButtonLabel: { default: "Book interview", de: "" }, buttonLabel: { default: "Book interview", de: "" },
dismissButtonLabel: { default: "Skip", de: "" },
}; };
export const mockLegacyCtaQuestion = { export const mockLegacyCtaQuestion = {
...mockCtaQuestion, ...mockCtaQuestion,
headline: "You are one of our power users!", headline: "You are one of our power users!",
ctaButtonLabel: "Book interview", buttonLabel: "Book interview",
dismissButtonLabel: "Skip",
}; };
export const mockTranslatedConsentQuestion = { export const mockTranslatedConsentQuestion = {

View File

@@ -1,7 +1,6 @@
import { iso639Languages } from "@formbricks/i18n-utils/src/utils"; import { iso639Languages } from "@formbricks/i18n-utils/src/utils";
import { TI18nString } from "@formbricks/types/i18n";
import { TLanguage } from "@formbricks/types/project"; import { TLanguage } from "@formbricks/types/project";
import { TSurveyLanguage } from "@formbricks/types/surveys/types"; import { TI18nString, TSurveyLanguage } from "@formbricks/types/surveys/types";
import { structuredClone } from "@/lib/pollyfills/structuredClone"; import { structuredClone } from "@/lib/pollyfills/structuredClone";
// Helper function to create an i18nString from a regular string. // Helper function to create an i18nString from a regular string.
@@ -30,7 +29,7 @@ export const createI18nString = (
return i18nString; return i18nString;
} else { } else {
// It's a regular string, so create a new i18n object // It's a regular string, so create a new i18n object
const i18nString = { const i18nString: any = {
[targetLanguageCode ?? "default"]: text, [targetLanguageCode ?? "default"]: text,
}; };
@@ -46,7 +45,7 @@ export const createI18nString = (
}; };
// Type guard to check if an object is an I18nString // Type guard to check if an object is an I18nString
export const isI18nObject = (obj: unknown): obj is TI18nString => { export const isI18nObject = (obj: any): obj is TI18nString => {
return typeof obj === "object" && obj !== null && Object.keys(obj).includes("default"); return typeof obj === "object" && obj !== null && Object.keys(obj).includes("default");
}; };
@@ -92,7 +91,7 @@ export const iso639Identifiers = iso639Languages.map((language) => language.alph
// Helper function to add language keys to a multi-language object (e.g. survey or question) // Helper function to add language keys to a multi-language object (e.g. survey or question)
// Iterates over the object recursively and adds empty strings for new language keys // Iterates over the object recursively and adds empty strings for new language keys
export const addMultiLanguageLabels = (object: unknown, languageSymbols: string[]): any => { export const addMultiLanguageLabels = (object: any, languageSymbols: string[]): any => {
// Helper function to add language keys to a multi-language object // Helper function to add language keys to a multi-language object
function addLanguageKeys(obj: { default: string; [key: string]: string }) { function addLanguageKeys(obj: { default: string; [key: string]: string }) {
languageSymbols.forEach((lang) => { languageSymbols.forEach((lang) => {
@@ -103,14 +102,14 @@ export const addMultiLanguageLabels = (object: unknown, languageSymbols: string[
} }
// Recursive function to process an object or array // Recursive function to process an object or array
function processObject(obj: unknown) { function processObject(obj: any) {
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
obj.forEach((item) => processObject(item)); obj.forEach((item) => processObject(item));
} else if (obj && typeof obj === "object") { } else if (obj && typeof obj === "object") {
for (const key in obj) { for (const key in obj) {
if (obj.hasOwnProperty(key)) { if (obj.hasOwnProperty(key)) {
if (key === "default" && typeof obj[key] === "string") { if (key === "default" && typeof obj[key] === "string") {
addLanguageKeys(obj as { default: string; [key: string]: string }); addLanguageKeys(obj);
} else { } else {
processObject(obj[key]); processObject(obj[key]);
} }
@@ -140,7 +139,6 @@ export const appLanguages = [
"zh-Hans-CN": "英语(美国)", "zh-Hans-CN": "英语(美国)",
"nl-NL": "Engels (VS)", "nl-NL": "Engels (VS)",
"es-ES": "Inglés (EE.UU.)", "es-ES": "Inglés (EE.UU.)",
"sv-SE": "Engelska (USA)",
}, },
}, },
{ {
@@ -157,7 +155,6 @@ export const appLanguages = [
"zh-Hans-CN": "德语", "zh-Hans-CN": "德语",
"nl-NL": "Duits", "nl-NL": "Duits",
"es-ES": "Alemán", "es-ES": "Alemán",
"sv-SE": "Tyska",
}, },
}, },
{ {
@@ -174,7 +171,6 @@ export const appLanguages = [
"zh-Hans-CN": "葡萄牙语(巴西)", "zh-Hans-CN": "葡萄牙语(巴西)",
"nl-NL": "Portugees (Brazilië)", "nl-NL": "Portugees (Brazilië)",
"es-ES": "Portugués (Brasil)", "es-ES": "Portugués (Brasil)",
"sv-SE": "Portugisiska (Brasilien)",
}, },
}, },
{ {
@@ -191,7 +187,6 @@ export const appLanguages = [
"zh-Hans-CN": "法语", "zh-Hans-CN": "法语",
"nl-NL": "Frans", "nl-NL": "Frans",
"es-ES": "Francés", "es-ES": "Francés",
"sv-SE": "Franska",
}, },
}, },
{ {
@@ -203,12 +198,11 @@ export const appLanguages = [
"fr-FR": "Chinois (Traditionnel)", "fr-FR": "Chinois (Traditionnel)",
"zh-Hant-TW": "繁體中文", "zh-Hant-TW": "繁體中文",
"pt-PT": "Chinês (Tradicional)", "pt-PT": "Chinês (Tradicional)",
"ro-RO": "Chineza (Tradițională)", "ro-RO": "Chineză (Tradicională)",
"ja-JP": "中国語(繁体字)", "ja-JP": "中国語(繁体字)",
"zh-Hans-CN": "繁体中文", "zh-Hans-CN": "繁体中文",
"nl-NL": "Chinees (Traditioneel)", "nl-NL": "Chinees (Traditioneel)",
"es-ES": "Chino (Tradicional)", "es-ES": "Chino (Tradicional)",
"sv-SE": "Kinesiska (traditionell)",
}, },
}, },
{ {
@@ -225,7 +219,6 @@ export const appLanguages = [
"zh-Hans-CN": "葡萄牙语(葡萄牙)", "zh-Hans-CN": "葡萄牙语(葡萄牙)",
"nl-NL": "Portugees (Portugal)", "nl-NL": "Portugees (Portugal)",
"es-ES": "Portugués (Portugal)", "es-ES": "Portugués (Portugal)",
"sv-SE": "Portugisiska (Portugal)",
}, },
}, },
{ {
@@ -242,7 +235,6 @@ export const appLanguages = [
"zh-Hans-CN": "罗马尼亚语", "zh-Hans-CN": "罗马尼亚语",
"nl-NL": "Roemeens", "nl-NL": "Roemeens",
"es-ES": "Rumano", "es-ES": "Rumano",
"sv-SE": "Rumänska",
}, },
}, },
{ {
@@ -259,7 +251,6 @@ export const appLanguages = [
"zh-Hans-CN": "日语", "zh-Hans-CN": "日语",
"nl-NL": "Japans", "nl-NL": "Japans",
"es-ES": "Japonés", "es-ES": "Japonés",
"sv-SE": "Japanska",
}, },
}, },
{ {
@@ -271,12 +262,11 @@ export const appLanguages = [
"fr-FR": "Chinois (Simplifié)", "fr-FR": "Chinois (Simplifié)",
"zh-Hant-TW": "簡體中文", "zh-Hant-TW": "簡體中文",
"pt-PT": "Chinês (Simplificado)", "pt-PT": "Chinês (Simplificado)",
"ro-RO": "Chineza (Simplificată)", "ro-RO": "Chineză (Simplificată)",
"ja-JP": "中国語(簡体字)", "ja-JP": "中国語(簡体字)",
"zh-Hans-CN": "简体中文", "zh-Hans-CN": "简体中文",
"nl-NL": "Chinees (Vereenvoudigd)", "nl-NL": "Chinees (Vereenvoudigd)",
"es-ES": "Chino (Simplificado)", "es-ES": "Chino (Simplificado)",
"sv-SE": "Kinesiska (förenklad)",
}, },
}, },
{ {
@@ -288,12 +278,11 @@ export const appLanguages = [
"fr-FR": "Néerlandais", "fr-FR": "Néerlandais",
"zh-Hant-TW": "荷蘭語", "zh-Hant-TW": "荷蘭語",
"pt-PT": "Holandês", "pt-PT": "Holandês",
"ro-RO": "Olandeza", "ro-RO": "Olandeză",
"ja-JP": "オランダ語", "ja-JP": "オランダ語",
"zh-Hans-CN": "荷兰语", "zh-Hans-CN": "荷兰语",
"nl-NL": "Nederlands", "nl-NL": "Nederlands",
"es-ES": "Neerlandés", "es-ES": "Neerlandés",
"sv-SE": "Nederländska",
}, },
}, },
{ {
@@ -310,24 +299,6 @@ export const appLanguages = [
"zh-Hans-CN": "西班牙语", "zh-Hans-CN": "西班牙语",
"nl-NL": "Spaans", "nl-NL": "Spaans",
"es-ES": "Español", "es-ES": "Español",
"sv-SE": "Spanska",
},
},
{
code: "sv-SE",
label: {
"en-US": "Swedish",
"de-DE": "Schwedisch",
"pt-BR": "Sueco",
"fr-FR": "Suédois",
"zh-Hant-TW": "瑞典語",
"pt-PT": "Sueco",
"ro-RO": "Suedeză",
"ja-JP": "スウェーデン語",
"zh-Hans-CN": "瑞典语",
"nl-NL": "Zweeds",
"es-ES": "Sueco",
"sv-SE": "Svenska",
}, },
}, },
]; ];

View File

@@ -1,50 +0,0 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { createHash } from "node:crypto";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
export type TInstanceInfo = {
instanceId: string;
createdAt: Date;
};
/**
* Returns instance info including the anonymized instance ID and creation date.
*
* The instance ID is a SHA-256 hash of the oldest organization's ID, ensuring
* it remains stable over time. Used for telemetry and license checks.
*
* @returns Instance info with hashed ID and creation date, or `null` if no organizations exist
*/
export const getInstanceInfo = reactCache(async (): Promise<TInstanceInfo | null> => {
try {
const oldestOrg = await prisma.organization.findFirst({
orderBy: { createdAt: "asc" },
select: { id: true, createdAt: true },
});
if (!oldestOrg) return null;
return {
instanceId: createHash("sha256").update(oldestOrg.id).digest("hex"),
createdAt: oldestOrg.createdAt,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
});
/**
* Convenience function that returns just the instance ID.
*
* @returns Hashed instance ID, or `null` if no organizations exist
*/
export const getInstanceId = async (): Promise<string | null> => {
const info = await getInstanceInfo();
return info?.instanceId ?? null;
};

Some files were not shown because too many files have changed in this diff Show More