Compare commits

..

2 Commits

562 changed files with 23692 additions and 49231 deletions

View File

@@ -179,14 +179,14 @@ For endpoints serving client SDKs, coordinate TTLs across layers:
```typescript
// Client SDK cache (expiresAt) - longest TTL for fewer requests
const CLIENT_TTL = 60; // 1 minute (seconds for client)
const CLIENT_TTL = 60 * 60; // 1 hour (seconds for client)
// Server Redis cache - shorter TTL ensures fresh data for clients
const SERVER_TTL = 60 * 1000; // 1 minutes in milliseconds
const SERVER_TTL = 60 * 30 * 1000; // 30 minutes in milliseconds
// HTTP cache headers (seconds)
const BROWSER_TTL = 60; // 1 minute (max-age)
const CDN_TTL = 60; // 1 minute (s-maxage)
const BROWSER_TTL = 60 * 60; // 1 hour (max-age)
const CDN_TTL = 60 * 30; // 30 minutes (s-maxage)
const CORS_TTL = 60 * 60; // 1 hour (balanced approach)
```

View File

@@ -1,8 +1,13 @@
---
description: >
globs: schema.prisma
alwaysApply: false
This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
and data patterns. It should be used **only when the agent explicitly requests database schema-level
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
globs: []
alwaysApply: agent-requested
---
# 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.

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

View File

@@ -89,7 +89,7 @@ jobs:
- check-latest-release
with:
IS_PRERELEASE: ${{ github.event.release.prerelease }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
docker-build-cloud:
name: Build & push Formbricks Cloud to ECR
@@ -101,7 +101,7 @@ jobs:
with:
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
IS_PRERELEASE: ${{ github.event.release.prerelease }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
needs:
- check-latest-release
- docker-build-community
@@ -154,4 +154,4 @@ jobs:
release_tag: ${{ github.event.release.tag_name }}
commit_sha: ${{ github.sha }}
is_prerelease: ${{ github.event.release.prerelease }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest }}

View File

@@ -73,8 +73,8 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
#
FROM base AS runner
RUN npm install --ignore-scripts -g corepack@latest && \
corepack enable
RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable
RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \
@@ -124,7 +124,7 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
RUN npm install -g prisma@6
RUN npm install -g prisma
# Create a startup script to handle the conditional logic
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
@@ -134,13 +134,12 @@ EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
USER nextjs
# Prepare pnpm as the nextjs user to ensure it's available at runtime
# Prepare volumes for uploads and SAML connections
RUN corepack prepare pnpm@9.15.9 --activate && \
mkdir -p /home/nextjs/apps/web/uploads/ && \
mkdir -p /home/nextjs/apps/web/saml-connection
# Prepare volume for uploads
RUN mkdir -p /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/uploads/
# Prepare volume for SAML preloaded connection
RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/saml-connection
CMD ["/home/nextjs/start.sh"]

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { AuthorizationError } from "@formbricks/types/errors";
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
import { IS_POSTHOG_CONFIGURED } from "@/lib/constants";
import { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
@@ -38,6 +40,14 @@ const ProjectOnboardingLayout = async (props) => {
return (
<div className="flex-1 bg-slate-50">
<PosthogIdentify
session={session}
user={user}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<ToasterClient />
{children}
</div>

View File

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

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

View File

@@ -135,7 +135,7 @@ export const OrganizationBreadcrumb = ({
},
{
id: "teams",
label: t("common.members_and_teams"),
label: t("common.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 { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
const EnvLayout = async (props: {
@@ -23,7 +24,11 @@ const EnvLayout = async (props: {
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
return (
<>
<EnvironmentIdBaseLayout
environmentId={params.environmentId}
session={layoutData.session}
user={layoutData.user}
organization={layoutData.organization}>
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentContextWrapper
environment={layoutData.environment}
@@ -31,7 +36,7 @@ const EnvLayout = async (props: {
organization={layoutData.organization}>
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
</EnvironmentContextWrapper>
</>
</EnvironmentIdBaseLayout>
);
};

View File

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

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -15,6 +16,7 @@ interface AirtableWrapperProps {
airtableArray: TIntegrationItem[];
airtableIntegration?: TIntegrationAirtable;
surveys: TSurvey[];
environment: TEnvironment;
isEnabled: boolean;
webAppUrl: string;
locale: TUserLocale;
@@ -25,6 +27,7 @@ export const AirtableWrapper = ({
airtableArray,
airtableIntegration,
surveys,
environment,
isEnabled,
webAppUrl,
locale,
@@ -45,6 +48,7 @@ export const AirtableWrapper = ({
<ManageIntegration
airtableArray={airtableArray}
environmentId={environmentId}
environment={environment}
airtableIntegration={airtableIntegration}
setIsConnected={setIsConnected}
surveys={surveys}

View File

@@ -4,6 +4,7 @@ import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -14,11 +15,12 @@ import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { IntegrationModalInputs } from "../lib/types";
interface ManageIntegrationProps {
airtableIntegration: TIntegrationAirtable;
environment: TEnvironment;
environmentId: string;
setIsConnected: (data: boolean) => void;
surveys: TSurvey[];
@@ -27,7 +29,7 @@ interface ManageIntegrationProps {
}
export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
const { t } = useTranslation();
const tableHeaders = [
@@ -108,7 +110,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
onClick={() => {
setDefaultValues({
base: data.baseId,
elements: data.elementIds,
questions: data.questionIds,
survey: data.surveyId,
table: data.tableId,
includeVariables: !!data.includeVariables,
@@ -121,7 +123,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
}}>
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.tableName}</div>
<div className="col-span-2 text-center">{data.elements}</div>
<div className="col-span-2 text-center">{data.questions}</div>
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), props.locale)}
</div>
@@ -130,7 +132,12 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
</div>
) : (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.airtable.no_integrations_yet")} />
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage={t("environments.integrations.airtable.no_integrations_yet")}
/>
</div>
)}

View File

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

View File

@@ -51,6 +51,7 @@ const Page = async (props) => {
airtableArray={airtableArray}
environmentId={environment.id}
surveys={surveys}
environment={environment}
webAppUrl={WEBAPP_URL}
locale={locale}
/>

View File

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

View File

@@ -60,6 +60,7 @@ export const GoogleSheetWrapper = ({
selectedIntegration={selectedIntegration}
/>
<ManageIntegration
environment={environment}
googleSheetIntegration={googleSheetIntegration}
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}

View File

@@ -4,6 +4,7 @@ import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsConfigData,
@@ -14,9 +15,10 @@ import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
interface ManageIntegrationProps {
environment: TEnvironment;
googleSheetIntegration: TIntegrationGoogleSheets;
setOpenAddIntegrationModal: (v: boolean) => void;
setIsConnected: (v: boolean) => void;
@@ -25,6 +27,7 @@ interface ManageIntegrationProps {
}
export const ManageIntegration = ({
environment,
googleSheetIntegration,
setOpenAddIntegrationModal,
setIsConnected,
@@ -87,7 +90,12 @@ export const ManageIntegration = ({
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.google_sheets.no_integrations_yet")} />
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage={t("environments.integrations.google_sheets.no_integrations_yet")}
/>
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
@@ -110,7 +118,7 @@ export const ManageIntegration = ({
}}>
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.spreadsheetName}</div>
<div className="col-span-2 text-center">{data.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>
</button>
);

View File

@@ -12,9 +12,7 @@ import {
TIntegrationNotionConfigData,
TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import {
ERRORS,
@@ -22,10 +20,10 @@ import {
UNSUPPORTED_TYPES_BY_NOTION,
} from "@/app/(app)/environments/[environmentId]/project/integrations/notion/constants";
import NotionLogo from "@/images/notion.png";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { getElementTypes } from "@/modules/survey/lib/elements";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
@@ -39,59 +37,6 @@ import {
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label";
const MappingErrorMessage = ({
error,
col,
elem,
t,
}: {
error: { type: string; msg?: React.ReactNode | string } | null | undefined;
col: { id: string; name: string; type: string };
elem: { id: string; name: string; type: string };
t: ReturnType<typeof useTranslation>["t"];
}) => {
const showErrorMsg = useMemo(() => {
switch (error?.type) {
case ERRORS.UNSUPPORTED_TYPE:
return (
<>
-{" "}
{t("environments.integrations.notion.col_name_of_type_is_not_supported", {
col_name: col.name,
type: col.type,
})}
</>
);
case ERRORS.MAPPING:
const element = getElementTypes(t).find((et) => et.id === elem.type);
if (!element) return null;
return (
<>
{t("environments.integrations.notion.que_name_of_type_cant_be_mapped_to", {
que_name: elem.name,
question_label: element.label,
col_name: col.name,
col_type: col.type,
mapped_type: TYPE_MAPPING[element.id].join(" ,"),
})}
</>
);
default:
return null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error, col, elem, t]);
if (!error) return null;
return (
<div className="my-4 w-full rounded-lg bg-red-100 p-4 text-sm text-red-800">
<span className="mb-2 block">{error.type}</span>
{showErrorMsg}
</div>
);
};
interface AddIntegrationModalProps {
environmentId: string;
surveys: TSurvey[];
@@ -118,7 +63,7 @@ export const AddIntegrationModal = ({
const [mapping, setMapping] = useState<
{
column: { id: string; name: string; type: string };
element: { id: string; name: string; type: string };
question: { id: string; name: string; type: string };
error?: {
type: string;
msg: React.ReactNode | string;
@@ -127,7 +72,7 @@ export const AddIntegrationModal = ({
>([
{
column: { id: "", name: "", type: "" },
element: { id: "", name: "", type: "" },
question: { id: "", name: "", type: "" },
},
]);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
@@ -140,17 +85,12 @@ export const AddIntegrationModal = ({
mapping: [
{
column: { id: "", name: "", type: "" },
element: { id: "", name: "", type: "" },
question: { id: "", name: "", type: "" },
},
],
createdAt: new Date(),
};
const elements = useMemo(
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
[selectedSurvey]
);
const notionIntegrationData: TIntegrationInput = {
type: "notion",
config: {
@@ -178,12 +118,12 @@ export const AddIntegrationModal = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDatabase?.id]);
const elementItems = useMemo(() => {
const mappedElements = selectedSurvey
? elements.map((el) => ({
id: el.id,
name: getTextContent(recallToHeadline(el.headline, selectedSurvey, false, "default")["default"]),
type: el.type,
const questionItems = useMemo(() => {
const questions = selectedSurvey
? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
id: q.id,
name: getLocalizedValue(q.headline, "default"),
type: q.type,
}))
: [];
@@ -191,31 +131,31 @@ export const AddIntegrationModal = ({
selectedSurvey?.variables.map((variable) => ({
id: variable.id,
name: variable.name,
type: TSurveyElementTypeEnum.OpenText,
type: TSurveyQuestionTypeEnum.OpenText,
})) || [];
const hiddenFields =
selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
id: fId,
name: `${t("common.hidden_field")} : ${fId}`,
type: TSurveyElementTypeEnum.OpenText,
type: TSurveyQuestionTypeEnum.OpenText,
})) || [];
const Metadata = [
{
id: "metadata",
name: t("common.metadata"),
type: TSurveyElementTypeEnum.OpenText,
type: TSurveyQuestionTypeEnum.OpenText,
},
];
const createdAt = [
{
id: "createdAt",
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
}, [selectedSurvey?.id]);
@@ -249,7 +189,7 @@ export const AddIntegrationModal = ({
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"));
}
@@ -258,8 +198,8 @@ export const AddIntegrationModal = ({
}
if (
mapping.filter((m) => m.column.id && !m.element.id).length >= 1 ||
mapping.filter((m) => m.element.id && !m.column.id).length >= 1
mapping.filter((m) => m.column.id && !m.question.id).length >= 1 ||
mapping.filter((m) => m.question.id && !m.column.id).length >= 1
) {
throw new Error(
t("environments.integrations.notion.please_complete_mapping_fields_with_notion_property")
@@ -320,23 +260,23 @@ export const AddIntegrationModal = ({
setSelectedDatabase(null);
setSelectedSurvey(null);
};
const getFilteredElementItems = (selectedIdx) => {
const selectedElementIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.element.id);
const getFilteredQuestionItems = (selectedIdx) => {
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 MappingRow = ({ idx }: { idx: number }) => {
const filteredElementItems = getFilteredElementItems(idx);
const filteredQuestionItems = getFilteredQuestionItems(idx);
const addRow = () => {
setMapping((prev) => [
...prev,
{
column: { id: "", name: "", type: "" },
element: { id: "", name: "", type: "" },
question: { id: "", name: "", type: "" },
},
]);
};
@@ -347,6 +287,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 colMapping = mapping.map((m) => m.column.id);
return dbItems.filter((item) => !colMapping.includes(item.id));
@@ -354,20 +337,19 @@ export const AddIntegrationModal = ({
return (
<div className="w-full">
<MappingErrorMessage
<ErrorMsg
key={idx}
error={mapping[idx]?.error}
col={mapping[idx].column}
elem={mapping[idx].element}
t={t}
ques={mapping[idx].question}
/>
<div className="flex w-full items-center space-x-2">
<div className="flex w-full items-center">
<div className="max-w-full flex-1">
<DropdownSelector
placeholder={t("environments.integrations.notion.select_a_survey_question")}
items={filteredElementItems}
selectedItem={mapping?.[idx]?.element}
items={filteredQuestionItems}
selectedItem={mapping?.[idx]?.question}
setSelectedItem={(item) => {
setMapping((prev) => {
const copy = createCopy(prev);
@@ -379,7 +361,7 @@ export const AddIntegrationModal = ({
error: {
type: ERRORS.UNSUPPORTED_TYPE,
},
element: item,
question: item,
};
return copy;
}
@@ -391,7 +373,7 @@ export const AddIntegrationModal = ({
error: {
type: ERRORS.MAPPING,
},
element: item,
question: item,
};
return copy;
}
@@ -399,13 +381,13 @@ export const AddIntegrationModal = ({
copy[idx] = {
...copy[idx],
element: item,
question: item,
error: null,
};
return copy;
});
}}
disabled={elementItems.length === 0}
disabled={questionItems.length === 0}
/>
</div>
<div className="h-px w-4 border-t border-t-slate-300" />
@@ -417,9 +399,9 @@ export const AddIntegrationModal = ({
setSelectedItem={(item) => {
setMapping((prev) => {
const copy = createCopy(prev);
const elem = copy[idx].element;
if (elem.id) {
const isValidElemType = TYPE_MAPPING[elem.type].includes(item.type);
const ques = copy[idx].question;
if (ques.id) {
const isValidQuesType = TYPE_MAPPING[ques.type].includes(item.type);
if (UNSUPPORTED_TYPES_BY_NOTION.includes(item.type)) {
copy[idx] = {
@@ -432,7 +414,7 @@ export const AddIntegrationModal = ({
return copy;
}
if (!isValidElemType) {
if (!isValidQuesType) {
copy[idx] = {
...copy[idx],
error: {

View File

@@ -4,6 +4,7 @@ import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import React, { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
@@ -11,10 +12,11 @@ import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface ManageIntegrationProps {
environment: TEnvironment;
notionIntegration: TIntegrationNotion;
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
@@ -26,6 +28,7 @@ interface ManageIntegrationProps {
}
export const ManageIntegration = ({
environment,
notionIntegration,
setOpenAddIntegrationModal,
setIsConnected,
@@ -98,7 +101,12 @@ export const ManageIntegration = ({
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.notion.no_databases_found")} />
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage={t("environments.integrations.notion.no_databases_found")}
/>
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">

View File

@@ -64,6 +64,7 @@ export const NotionWrapper = ({
selectedIntegration={selectedIntegration}
/>
<ManageIntegration
environment={environment}
notionIntegration={notionIntegration}
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}

View File

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

View File

@@ -4,6 +4,7 @@ import { Trash2Icon } from "lucide-react";
import React, { useState } from "react";
import toast from "react-hot-toast";
import { Trans, useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
@@ -11,9 +12,10 @@ import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
interface ManageIntegrationProps {
environment: TEnvironment;
slackIntegration: TIntegrationSlack;
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
@@ -27,6 +29,7 @@ interface ManageIntegrationProps {
}
export const ManageIntegration = ({
environment,
slackIntegration,
setOpenAddIntegrationModal,
setIsConnected,
@@ -103,7 +106,12 @@ export const ManageIntegration = ({
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.slack.connect_your_first_slack_channel")} />
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage={t("environments.integrations.slack.connect_your_first_slack_channel")}
/>
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
@@ -126,7 +134,7 @@ export const ManageIntegration = ({
}}>
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.channelName}</div>
<div className="col-span-2 text-center">{data.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>
</button>
);

View File

@@ -78,6 +78,7 @@ export const SlackWrapper = ({
selectedIntegration={selectedIntegration}
/>
<ManageIntegration
environment={environment}
slackIntegration={slackIntegration}
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}

View File

@@ -215,7 +215,7 @@ export const EditProfileDetailsForm = ({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-slate-50 text-slate-700"
align="start">
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
{appLanguages.map((lang) => (

View File

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

View File

@@ -1,6 +1,5 @@
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -26,7 +25,7 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
};
const SurveyLayout = async ({ children }) => {
return <ResponseFilterProvider>{children}</ResponseFilterProvider>;
return <>{children}</>;
};
export default SurveyLayout;

View File

@@ -10,7 +10,6 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
interface ResponseDataViewProps {
survey: TSurvey;
@@ -56,11 +55,9 @@ export const formatContactInfoData = (responseValue: TResponseDataValue): Record
export const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => {
const responseData: Record<string, any> = {};
const elements = getElementsFromBlocks(survey.blocks);
for (const element of elements) {
const responseValue = response.data[element.id];
switch (element.type) {
for (const question of survey.questions) {
const responseValue = response.data[question.id];
switch (question.type) {
case "matrix":
if (typeof responseValue === "object") {
Object.assign(responseData, responseValue);
@@ -73,7 +70,7 @@ export const extractResponseData = (response: TResponseWithQuotas, survey: TSurv
Object.assign(responseData, formatContactInfoData(responseValue));
break;
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 { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
@@ -26,7 +26,6 @@ interface ResponsePageProps {
isReadOnly: boolean;
isQuotasAllowed: boolean;
quotas: TSurveyQuota[];
initialResponses?: TResponseWithQuotas[];
}
export const ResponsePage = ({
@@ -40,12 +39,11 @@ export const ResponsePage = ({
isReadOnly,
isQuotasAllowed,
quotas,
initialResponses = [],
}: ResponsePageProps) => {
const [responses, setResponses] = useState<TResponseWithQuotas[]>(initialResponses);
const [page, setPage] = useState<number | null>(null);
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
const [responses, setResponses] = useState<TResponseWithQuotas[]>([]);
const [page, setPage] = useState<number>(1);
const [hasMore, setHasMore] = useState<boolean>(true);
const [isFetchingFirstPage, setFetchingFirstPage] = useState<boolean>(true);
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const filters = useMemo(
@@ -58,7 +56,6 @@ export const ResponsePage = ({
const searchParams = useSearchParams();
const fetchNextPage = useCallback(async () => {
if (page === null) return;
const newPage = page + 1;
let newResponses: TResponseWithQuotas[] = [];
@@ -96,22 +93,10 @@ export const ResponsePage = ({
}
}, [searchParams, resetState]);
// Only fetch if filters are applied (not on initial mount with no filters)
const hasFilters =
selectedFilter?.responseStatus !== "all" ||
(selectedFilter?.filter && selectedFilter.filter.length > 0) ||
(dateRange.from && dateRange.to);
useEffect(() => {
const fetchFilteredResponses = async () => {
const fetchInitialResponses = async () => {
try {
// skip call for initial mount
if (page === null && !hasFilters) {
setPage(1);
return;
}
setPage(1);
setIsFetchingFirstPage(true);
setFetchingFirstPage(true);
let responses: TResponseWithQuotas[] = [];
const getResponsesActionResponse = await getResponsesAction({
@@ -125,16 +110,19 @@ export const ResponsePage = ({
if (responses.length < responsesPerPage) {
setHasMore(false);
} else {
setHasMore(true);
}
setResponses(responses);
} finally {
setIsFetchingFirstPage(false);
setFetchingFirstPage(false);
}
};
fetchFilteredResponses();
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
fetchInitialResponses();
}, [surveyId, filters, responsesPerPage]);
useEffect(() => {
setPage(1);
setHasMore(true);
}, [filters]);
return (
<>

View File

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

View File

@@ -2,8 +2,9 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
@@ -13,6 +14,7 @@ import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -21,43 +23,44 @@ const Page = async (props) => {
const params = await props.params;
const t = await getTranslate();
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const { session, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
getSurvey(params.surveyId),
getUser(session.user.id),
getTagsByEnvironmentId(params.environmentId),
getIsContactsEnabled(),
getResponseCountBySurveyId(params.surveyId),
findMatchingLocale(),
]);
const survey = await getSurvey(params.surveyId);
if (!survey) {
throw new Error(t("common.survey_not_found"));
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const tags = await getTagsByEnvironmentId(params.environmentId);
const isContactsEnabled = await getIsContactsEnabled();
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
// Get response count for the CTA component
const responseCount = await getResponseCountBySurveyId(params.surveyId);
const displayCount = await getDisplayCountBySurveyId(params.surveyId);
const locale = await findMatchingLocale();
const publicDomain = getPublicDomain();
const organizationBilling = await getOrganizationBilling(organization.id);
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
if (!organizationId) {
throw new Error(t("common.organization_not_found"));
}
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new Error(t("common.organization_not_found"));
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
// Fetch initial responses on the server to prevent duplicate client-side fetch
const initialResponses = await getResponses(params.surveyId, RESPONSES_PER_PAGE, 0);
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
return (
<PageContentWrapper>
@@ -71,6 +74,7 @@ const Page = async (props) => {
user={user}
publicDomain={publicDomain}
responseCount={responseCount}
displayCount={displayCount}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
@@ -90,7 +94,6 @@ const Page = async (props) => {
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
initialResponses={initialResponses}
/>
</PageContentWrapper>
);

View File

@@ -3,13 +3,8 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { TResponseInput } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { createResponseWithQuotaEvaluation } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
@@ -22,29 +17,6 @@ import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customizat
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
import { deleteResponsesAndDisplaysForSurvey } from "./lib/survey";
const loremIpsumSentences = [
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
"Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
"Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.",
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum.",
"Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit.",
"Nisi ut aliquip ex ea commodo consequat.",
"Pellentesque habitant morbi tristique senectus et netus et malesuada fames.",
"Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante.",
"Donec eu libero sit amet quam egestas semper.",
"Aenean ultricies mi vitae est. Mauris placerat eleifend leo.",
];
function generateLoremIpsum(): string {
const sentenceCount = Math.floor(Math.random() * 3) + 1;
const selectedSentences: string[] = [];
for (let i = 0; i < sentenceCount; i++) {
const randomIndex = Math.floor(Math.random() * loremIpsumSentences.length);
selectedSentences.push(loremIpsumSentences[randomIndex]);
}
return selectedSentences.join(" ");
}
const ZSendEmbedSurveyPreviewEmailAction = z.object({
surveyId: ZId,
});
@@ -288,169 +260,3 @@ export const updateSingleUseLinksAction = authenticatedActionClient
return updatedSurvey;
});
const ZGenerateTestResponsesAction = z.object({
surveyId: ZId,
environmentId: ZId,
});
export const generateTestResponsesAction = authenticatedActionClient
.schema(ZGenerateTestResponsesAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
if (survey.environmentId !== parsedInput.environmentId) {
throw new OperationNotAllowedError("Survey does not belong to the specified environment");
}
const supportedElementTypes = [
TSurveyElementTypeEnum.OpenText,
TSurveyElementTypeEnum.NPS,
TSurveyElementTypeEnum.Rating,
TSurveyElementTypeEnum.MultipleChoiceSingle,
TSurveyElementTypeEnum.MultipleChoiceMulti,
TSurveyElementTypeEnum.PictureSelection,
TSurveyElementTypeEnum.Ranking,
TSurveyElementTypeEnum.Matrix,
];
// Extract elements from blocks
const elements = getElementsFromBlocks(survey.blocks);
const supportedElements = elements.filter((element) => supportedElementTypes.includes(element.type));
if (supportedElements.length === 0) {
throw new OperationNotAllowedError(
"Survey does not contain any supported question types (OpenText, NPS, Rating, Multiple Choice, Picture Selection, Ranking, or Matrix)"
);
}
const responsesToCreate = 5;
const createdResponses: string[] = [];
for (let i = 0; i < responsesToCreate; i++) {
const responseData: Record<string, string | number | string[] | Record<string, string>> = {};
for (const element of supportedElements) {
if (element.type === TSurveyElementTypeEnum.OpenText) {
responseData[element.id] = generateLoremIpsum();
} else if (element.type === TSurveyElementTypeEnum.NPS) {
responseData[element.id] = Math.floor(Math.random() * 11);
} else if (element.type === TSurveyElementTypeEnum.Rating) {
const range = "range" in element && typeof element.range === "number" ? element.range : 5;
responseData[element.id] = Math.floor(Math.random() * range) + 1;
} else if (element.type === TSurveyElementTypeEnum.MultipleChoiceSingle) {
// Single choice: pick one random option, store the label
if ("choices" in element && Array.isArray(element.choices) && element.choices.length > 0) {
const randomIndex = Math.floor(Math.random() * element.choices.length);
const selectedChoice = element.choices[randomIndex];
// For "other" option, generate custom text; otherwise use the choice label
responseData[element.id] =
selectedChoice.id === "other"
? generateLoremIpsum()
: getLocalizedValue(selectedChoice.label, "default");
}
} else if (element.type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
// Multi choice: pick 1-3 random options, store the labels
if ("choices" in element && Array.isArray(element.choices) && element.choices.length > 0) {
const numSelections = Math.min(Math.floor(Math.random() * 3) + 1, element.choices.length);
const shuffled = [...element.choices].sort(() => Math.random() - 0.5);
responseData[element.id] = shuffled.slice(0, numSelections).map((choice) => {
// For "other" option, generate custom text; otherwise use the choice label
return choice.id === "other"
? generateLoremIpsum()
: getLocalizedValue(choice.label, "default");
});
}
} else if (element.type === TSurveyElementTypeEnum.PictureSelection) {
// Picture selection: single or multi based on allowMulti
if ("choices" in element && Array.isArray(element.choices) && element.choices.length > 0) {
const allowMulti = "allowMulti" in element ? element.allowMulti : false;
if (allowMulti) {
const numSelections = Math.min(Math.floor(Math.random() * 3) + 1, element.choices.length);
const shuffled = [...element.choices].sort(() => Math.random() - 0.5);
responseData[element.id] = shuffled.slice(0, numSelections).map((choice) => choice.id);
} else {
const randomIndex = Math.floor(Math.random() * element.choices.length);
responseData[element.id] = element.choices[randomIndex].id;
}
}
} else if (element.type === TSurveyElementTypeEnum.Ranking) {
// Ranking: all options in random order, store the labels
if ("choices" in element && Array.isArray(element.choices) && element.choices.length > 0) {
const shuffled = [...element.choices].sort(() => Math.random() - 0.5);
responseData[element.id] = shuffled.map((choice) => {
// For "other" option, generate custom text; otherwise use the choice label
return choice.id === "other"
? generateLoremIpsum()
: getLocalizedValue(choice.label, "default");
});
}
} else if (element.type === TSurveyElementTypeEnum.Matrix) {
// Matrix: for each row, pick a random column
if (
"rows" in element &&
"columns" in element &&
Array.isArray(element.rows) &&
Array.isArray(element.columns) &&
element.rows.length > 0 &&
element.columns.length > 0
) {
const matrixData: Record<string, string> = {};
for (const row of element.rows) {
const randomColumnIndex = Math.floor(Math.random() * element.columns.length);
matrixData[row.id] = element.columns[randomColumnIndex].id;
}
responseData[element.id] = matrixData;
}
}
}
const responseInput: TResponseInput = {
environmentId: parsedInput.environmentId,
surveyId: parsedInput.surveyId,
finished: true,
data: responseData,
meta: {
source: "test",
userAgent: {
browser: "Test Generator",
device: "desktop",
os: "Test OS",
},
},
};
try {
const response = await createResponseWithQuotaEvaluation(responseInput);
createdResponses.push(response.id);
} catch (error) {
throw new UnknownError(
`Failed to create response: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
return {
success: true,
createdCount: createdResponses.length,
};
});

View File

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

View File

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

View File

@@ -1,23 +1,23 @@
"use client";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummaryCal } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestionSummaryCal } from "@formbricks/types/surveys/types";
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface CalSummaryProps {
elementSummary: TSurveyElementSummaryCal;
questionSummary: TSurveyQuestionSummaryCal;
environmentId: string;
survey: TSurvey;
}
export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
export const CalSummary = ({ questionSummary, survey }: CalSummaryProps) => {
const { t } = useTranslation();
return (
<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>
<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>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(elementSummary.booked.percentage, 2)}%
{convertFloatToNDecimal(questionSummary.booked.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary.booked.count}{" "}
{elementSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
{questionSummary.booked.count}{" "}
{questionSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} />
<ProgressBar barColor="bg-brand-dark" progress={questionSummary.booked.percentage / 100} />
</div>
<div>
<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>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(elementSummary.skipped.percentage, 2)}%
{convertFloatToNDecimal(questionSummary.skipped.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary.skipped.count}{" "}
{elementSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
{questionSummary.skipped.count}{" "}
{questionSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} />
<ProgressBar barColor="bg-brand-dark" progress={questionSummary.skipped.percentage / 100} />
</div>
</div>
</div>

View File

@@ -1,32 +0,0 @@
"use client";
import { CSSProperties, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface ClickableBarSegmentProps {
children: ReactNode;
onClick: () => void;
className?: string;
style?: CSSProperties;
}
export const ClickableBarSegment = ({
children,
onClick,
className = "",
style,
}: ClickableBarSegmentProps) => {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<button className={className} style={style} onClick={onClick}>
{children}
</button>
</TooltipTrigger>
<TooltipContent>{t("common.click_to_filter")}</TooltipContent>
</Tooltip>
);
};

View File

@@ -1,42 +1,46 @@
"use client";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryConsent } from "@formbricks/types/surveys/types";
import {
TI18nString,
TSurvey,
TSurveyQuestionId,
TSurveyQuestionSummaryConsent,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { convertFloatToNDecimal } from "../lib/utils";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface ConsentSummaryProps {
elementSummary: TSurveyElementSummaryConsent;
questionSummary: TSurveyQuestionSummaryConsent;
survey: TSurvey;
setFilter: (
elementId: string,
questionId: TSurveyQuestionId,
label: TI18nString,
elementType: TSurveyElementTypeEnum,
questionType: TSurveyQuestionTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => void;
}
export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSummaryProps) => {
export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSummaryProps) => {
const { t } = useTranslation();
const summaryItems = [
{
title: t("common.accepted"),
percentage: elementSummary.accepted.percentage,
count: elementSummary.accepted.count,
percentage: questionSummary.accepted.percentage,
count: questionSummary.accepted.count,
},
{
title: t("common.dismissed"),
percentage: elementSummary.dismissed.percentage,
count: elementSummary.dismissed.count,
percentage: questionSummary.dismissed.percentage,
count: questionSummary.dismissed.count,
},
];
return (
<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">
{summaryItems.map((summaryItem) => {
return (
@@ -45,9 +49,9 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
key={summaryItem.title}
onClick={() =>
setFilter(
elementSummary.element.id,
elementSummary.element.headline,
elementSummary.element.type,
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
"is",
summaryItem.title
)

View File

@@ -2,24 +2,23 @@
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummaryContactInfo } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestionSummaryContactInfo } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { ArrayResponse } from "@/modules/ui/components/array-response";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface ContactInfoSummaryProps {
elementSummary: TSurveyElementSummaryContactInfo;
questionSummary: TSurveyQuestionSummaryContactInfo;
environmentId: string;
survey: TSurvey;
locale: TUserLocale;
}
export const ContactInfoSummary = ({
elementSummary,
questionSummary,
environmentId,
survey,
locale,
@@ -27,7 +26,7 @@ export const ContactInfoSummary = ({
const { t } = useTranslation();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div>
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
<div className="pl-4 md:pl-6">{t("common.user")}</div>
@@ -35,48 +34,42 @@ export const ContactInfoSummary = ({
<div className="px-4 md:px-6">{t("common.time")}</div>
</div>
<div className="max-h-[62vh] w-full overflow-y-auto">
{elementSummary.samples.length === 0 ? (
<div className="p-8">
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
</div>
) : (
elementSummary.samples.map((response) => {
return (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
{questionSummary.samples.map((response) => {
return (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</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>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</div>
);
})
)}
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
<ArrayResponse value={response.value} />
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</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 { useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummaryFileUpload } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestionSummaryFileUpload } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
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";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface FileUploadSummaryProps {
elementSummary: TSurveyElementSummaryFileUpload;
questionSummary: TSurveyQuestionSummaryFileUpload;
environmentId: string;
survey: TSurvey;
locale: TUserLocale;
}
export const FileUploadSummary = ({
elementSummary,
questionSummary,
environmentId,
survey,
locale,
@@ -32,13 +31,13 @@ export const FileUploadSummary = ({
const handleLoadMore = () => {
// Increase the number of visible responses by 10, not exceeding the total number of responses
setVisibleResponses((prevVisibleResponses) =>
Math.min(prevVisibleResponses + 10, elementSummary.files.length)
Math.min(prevVisibleResponses + 10, questionSummary.files.length)
);
};
return (
<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="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>
@@ -46,77 +45,71 @@ export const FileUploadSummary = ({
<div className="px-4 md:px-6">{t("common.time")}</div>
</div>
<div className="max-h-[62vh] w-full overflow-y-auto">
{elementSummary.files.length === 0 ? (
<div className="p-8">
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
</div>
) : (
elementSummary.files.slice(0, visibleResponses).map((response) => (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
{questionSummary.files.slice(0, visibleResponses).map((response) => (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
)}
</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>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</div>
))
)}
<div className="col-span-2 grid">
{Array.isArray(response.value) &&
(response.value.length > 0 ? (
response.value.map((fileUrl) => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
return (
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
<div className="absolute right-0 top-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<DownloadIcon className="h-6 text-slate-500" />
</div>
</div>
</a>
<div className="flex flex-col items-center justify-center p-2">
<FileIcon className="h-6 text-slate-500" />
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{fileName}</p>
</div>
</div>
);
})
) : (
<div className="flex w-full flex-col items-center justify-center p-2">
<p className="mt-2 text-sm font-semibold text-slate-500 dark:text-slate-400">
{t("common.skipped")}
</p>
</div>
))}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div>
))}
</div>
{elementSummary.files.length > 0 && visibleResponses < elementSummary.files.length && (
{visibleResponses < questionSummary.files.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}

View File

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

View File

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

View File

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

View File

@@ -1,45 +1,31 @@
"use client";
import { BarChart, BarChartHorizontal } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryNps } from "@formbricks/types/surveys/types";
import {
TI18nString,
TSurvey,
TSurveyQuestionId,
TSurveyQuestionSummaryNps,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { HalfCircle, ProgressBar } from "@/modules/ui/components/progress-bar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { TooltipProvider } from "@/modules/ui/components/tooltip";
import { convertFloatToNDecimal } from "../lib/utils";
import { ClickableBarSegment } from "./ClickableBarSegment";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
import { SatisfactionIndicator } from "./SatisfactionIndicator";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface NPSSummaryProps {
elementSummary: TSurveyElementSummaryNps;
questionSummary: TSurveyQuestionSummaryNps;
survey: TSurvey;
setFilter: (
elementId: string,
questionId: TSurveyQuestionId,
label: TI18nString,
elementType: TSurveyElementTypeEnum,
questionType: TSurveyQuestionTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => void;
}
const calculateNPSOpacity = (rating: number): number => {
if (rating <= 6) {
return 0.3 + (rating / 6) * 0.3;
}
if (rating <= 8) {
return 0.6 + ((rating - 6) / 2) * 0.2;
}
return 0.8 + ((rating - 8) / 2) * 0.2;
};
export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProps) => {
export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
const applyFilter = (group: string) => {
const filters = {
promoters: {
@@ -64,9 +50,9 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
if (filter) {
setFilter(
elementSummary.element.id,
elementSummary.element.headline,
elementSummary.element.type,
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
filter.comparison,
filter.values
);
@@ -75,115 +61,41 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader
elementSummary={elementSummary}
survey={survey}
additionalInfo={
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.promoters.percentage} />
<div>
{t("environments.surveys.summary.promoters")}:{" "}
{convertFloatToNDecimal(elementSummary.promoters.percentage, 2)}%
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={group}
onClick={() => applyFilter(group)}>
<div
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1">
<p
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary[group]?.count}{" "}
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
</div>
}
/>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("environments.surveys.summary.aggregated")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("environments.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={group}
onClick={() => applyFilter(group)}>
<div
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1">
<p
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(elementSummary[group]?.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary[group]?.count}{" "}
{elementSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<ProgressBar
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={elementSummary[group]?.percentage / 100}
/>
</button>
))}
</div>
</div>
</TabsContent>
<TabsContent value="individual" className="mt-4">
<TooltipProvider delayDuration={200}>
<div className="grid grid-cols-11 gap-2 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{elementSummary.choices.map((choice) => {
const opacity = calculateNPSOpacity(choice.rating);
return (
<ClickableBarSegment
key={choice.rating}
className="group flex cursor-pointer flex-col items-center"
onClick={() =>
setFilter(
elementSummary.element.id,
elementSummary.element.headline,
elementSummary.element.type,
t("environments.surveys.summary.is_equal_to"),
choice.rating.toString()
)
}>
<div className="flex h-32 w-full flex-col items-center justify-end">
<div
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
style={{
height: `${Math.max(choice.percentage, 2)}%`,
opacity,
}}
/>
</div>
<div className="flex w-full flex-col items-center rounded-b-lg border border-t-0 border-slate-200 bg-slate-50 px-1 py-2">
<div className="mb-1.5 text-xs font-medium text-slate-500">{choice.rating}</div>
<div className="mb-1 flex items-center space-x-1">
<div className="text-base font-semibold text-slate-700">{choice.count}</div>
<div className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600">
{convertFloatToNDecimal(choice.percentage, 1)}%
</div>
</div>
</div>
</ClickableBarSegment>
);
})}
</div>
</TooltipProvider>
</TabsContent>
</Tabs>
<ProgressBar
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={questionSummary[group]?.percentage / 100}
/>
</button>
))}
</div>
<div className="flex justify-center pb-4 pt-4">
<HalfCircle value={elementSummary.score} />
<HalfCircle value={questionSummary.score} />
</div>
</div>
);

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,28 @@
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummaryRanking } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestionSummaryRanking } from "@formbricks/types/surveys/types";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { convertFloatToNDecimal } from "../lib/utils";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface RankingSummaryProps {
elementSummary: TSurveyElementSummaryRanking;
questionSummary: TSurveyQuestionSummaryRanking;
survey: TSurvey;
}
export const RankingSummary = ({ elementSummary, survey }: RankingSummaryProps) => {
export const RankingSummary = ({ questionSummary, survey }: RankingSummaryProps) => {
// sort by count and transform to array
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 (
<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">
{results.map((result, resultsIdx) => {
const choiceId = getChoiceIdByValue(result.value, elementSummary.element);
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
return (
<div key={result.value} className="group cursor-pointer">
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">

View File

@@ -1,24 +0,0 @@
"use client";
import { TSurveyRatingQuestion } from "@formbricks/types/surveys/types";
import { RatingResponse } from "@/modules/ui/components/rating-response";
interface RatingScaleLegendProps {
scale: TSurveyRatingQuestion["scale"];
range: number;
}
export const RatingScaleLegend = ({ scale, range }: RatingScaleLegendProps) => {
return (
<div className="mt-3 flex w-full items-start justify-between px-1">
<div className="flex items-center space-x-1">
<RatingResponse scale={scale} answer={1} range={range} addColors={false} variant="scale" />
<span className="text-xs text-slate-500">1</span>
</div>
<div className="flex items-center space-x-1">
<span className="text-xs text-slate-500">{range}</span>
<RatingResponse scale={scale} answer={range} range={range} addColors={false} variant="scale" />
</div>
</div>
);
};

View File

@@ -1,222 +1,101 @@
"use client";
import { BarChart, BarChartHorizontal, CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryRating } from "@formbricks/types/surveys/types";
import {
TI18nString,
TSurvey,
TSurveyQuestionId,
TSurveyQuestionSummaryRating,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { RatingResponse } from "@/modules/ui/components/rating-response";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { TooltipProvider } from "@/modules/ui/components/tooltip";
import { ClickableBarSegment } from "./ClickableBarSegment";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
import { RatingScaleLegend } from "./RatingScaleLegend";
import { SatisfactionIndicator } from "./SatisfactionIndicator";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface RatingSummaryProps {
elementSummary: TSurveyElementSummaryRating;
questionSummary: TSurveyQuestionSummaryRating;
survey: TSurvey;
setFilter: (
elementId: string,
questionId: TSurveyQuestionId,
label: TI18nString,
elementType: TSurveyElementTypeEnum,
questionType: TSurveyQuestionTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => void;
}
export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSummaryProps) => {
export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
const getIconBasedOnScale = useMemo(() => {
const scale = elementSummary.element.scale;
const scale = questionSummary.question.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
}, [elementSummary]);
}, [questionSummary]);
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader
elementSummary={elementSummary}
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
additionalInfo={
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("environments.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
</div>
</div>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.csat.satisfiedPercentage} />
<div>
CSAT: {elementSummary.csat.satisfiedPercentage}% {t("environments.surveys.summary.satisfied")}
</div>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)}
</div>
</div>
}
/>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("environments.surveys.summary.aggregated")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("environments.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
{elementSummary.responseCount === 0 ? (
<>
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
<RatingScaleLegend
scale={elementSummary.element.scale}
range={elementSummary.element.range}
/>
</>
) : (
<>
<TooltipProvider delayDuration={200}>
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
{elementSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
const range = elementSummary.element.range;
const opacity = 0.3 + (result.rating / range) * 0.8;
const isFirst = index === 0;
const isLast = index === elementSummary.choices.length - 1;
return (
<ClickableBarSegment
key={result.rating}
className="relative h-full cursor-pointer transition-opacity hover:brightness-110"
style={{
width: `${result.percentage}%`,
borderRight: isLast ? "none" : "1px solid rgb(226, 232, 240)",
}}
onClick={() =>
setFilter(
elementSummary.element.id,
elementSummary.element.headline,
elementSummary.element.type,
t("environments.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div
className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
style={{ opacity }}
/>
</ClickableBarSegment>
);
})}
</div>
</TooltipProvider>
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50">
{elementSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
return (
<div
key={result.rating}
className="flex flex-col items-center justify-center py-2"
style={{
width: `${result.percentage}%`,
borderRight:
index < elementSummary.choices.length - 1
? "1px solid rgb(226, 232, 240)"
: "none",
}}>
<div className="mb-1 flex items-center justify-center">
<RatingResponse
scale={elementSummary.element.scale}
answer={result.rating}
range={elementSummary.element.range}
addColors={false}
variant="aggregated"
/>
</div>
<div className="text-xs font-medium text-slate-600">
{convertFloatToNDecimal(result.percentage, 1)}%
</div>
</div>
);
})}
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((result) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={result.rating}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex items-center space-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={questionSummary.question.scale}
answer={result.rating}
range={questionSummary.question.range}
addColors={questionSummary.question.isColorCodingEnabled}
/>
</div>
<RatingScaleLegend
scale={elementSummary.element.scale}
range={elementSummary.element.range}
/>
</>
)}
</div>
</TabsContent>
<TabsContent value="individual" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base">
{elementSummary.choices.map((result) => (
<div key={result.rating}>
<button
className="w-full cursor-pointer hover:opacity-80"
onClick={() =>
setFilter(
elementSummary.element.id,
elementSummary.element.headline,
elementSummary.element.type,
t("environments.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex items-center space-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={elementSummary.element.scale}
answer={result.rating}
range={elementSummary.element.range}
addColors={elementSummary.element.isColorCodingEnabled}
variant="individual"
/>
</div>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
))}
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
</div>
</TabsContent>
</Tabs>
{elementSummary.dismissed && elementSummary.dismissed.count > 0 && (
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
))}
</div>
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
<div className="rounded-b-lg border-t bg-white px-6 py-4">
<div key="dismissed">
<div className="text flex justify-between px-2">
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
<p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary.dismissed.count}{" "}
{elementSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
{questionSummary.dismissed.count}{" "}
{questionSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
</div>

View File

@@ -1,17 +0,0 @@
interface SatisfactionIndicatorProps {
percentage: number;
}
export const SatisfactionIndicator = ({ percentage }: SatisfactionIndicatorProps) => {
let colorClass = "";
if (percentage > 80) {
colorClass = "bg-emerald-500";
} else if (percentage >= 55) {
colorClass = "bg-orange-500";
} else {
colorClass = "bg-rose-500";
}
return <div className={`h-3 w-3 rounded-full ${colorClass}`} />;
};

View File

@@ -2,11 +2,10 @@
import { TimerIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestionType, TSurveySummary } from "@formbricks/types/surveys/types";
import { recallToHeadline } from "@/lib/utils/recall";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { getElementIcon } from "@/modules/survey/lib/elements";
import { getQuestionIcon } from "@/modules/survey/lib/questions";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface SummaryDropOffsProps {
@@ -16,8 +15,8 @@ interface SummaryDropOffsProps {
export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
const { t } = useTranslation();
const getIcon = (elementType: TSurveyElementTypeEnum) => {
const Icon = getElementIcon(elementType, t);
const getIcon = (questionType: TSurveyQuestionType) => {
const Icon = getQuestionIcon(questionType, t);
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>
{dropOff.map((quesDropOff) => (
<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">
<div className="col-span-3 flex gap-3 px-4 py-2 md:px-6">
{getIcon(quesDropOff.elementType)}
{getIcon(quesDropOff.questionType)}
<p>
{formatTextWithSlashes(
recallToHeadline(

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
"use client";
import { BellRing, Eye, ListRestart, Sparkles, SquarePenIcon } from "lucide-react";
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
@@ -20,7 +20,7 @@ import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/action
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { IconBar } from "@/modules/ui/components/iconbar";
import { generateTestResponsesAction, resetSurveyAction } from "../actions";
import { resetSurveyAction } from "../actions";
interface SurveyAnalysisCTAProps {
survey: TSurvey;
@@ -29,6 +29,7 @@ interface SurveyAnalysisCTAProps {
user: TUser;
publicDomain: string;
responseCount: number;
displayCount: number;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
@@ -47,6 +48,7 @@ export const SurveyAnalysisCTA = ({
user,
publicDomain,
responseCount,
displayCount,
segments,
isContactsEnabled,
isFormbricksCloud,
@@ -63,7 +65,6 @@ export const SurveyAnalysisCTA = ({
});
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [isGeneratingResponses, setIsGeneratingResponses] = useState(false);
const { organizationId, project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
@@ -95,6 +96,7 @@ export const SurveyAnalysisCTA = ({
const duplicateSurveyAndRoute = async (surveyId: string) => {
setLoading(true);
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
environmentId: environment.id,
surveyId: surveyId,
targetEnvironmentId: environment.id,
});
@@ -148,23 +150,6 @@ export const SurveyAnalysisCTA = ({
setIsResetModalOpen(false);
};
const handleGenerateTestResponses = async () => {
if (isGeneratingResponses) return;
setIsGeneratingResponses(true);
const result = await generateTestResponsesAction({
surveyId: survey.id,
environmentId: environment.id,
});
if (result?.data?.success) {
toast.success(`Successfully generated ${result.data.createdCount} test responses`);
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
setIsGeneratingResponses(false);
};
const iconActions = [
{
icon: BellRing,
@@ -181,17 +166,11 @@ export const SurveyAnalysisCTA = ({
},
isVisible: survey.type === "link",
},
{
icon: Sparkles,
tooltip: isGeneratingResponses ? "Generating responses..." : "Generate test responses",
onClick: handleGenerateTestResponses,
isVisible: !isReadOnly,
},
{
icon: ListRestart,
tooltip: t("environments.surveys.summary.reset_survey"),
onClick: () => setIsResetModalOpen(true),
isVisible: !isReadOnly,
isVisible: !isReadOnly && (responseCount > 0 || displayCount > 0),
},
{
icon: SquarePenIcon,

View File

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

View File

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

View File

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

View File

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

View File

@@ -70,6 +70,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
user={user}
publicDomain={publicDomain}
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
displayCount={initialSurveySummary?.meta.displayCount ?? 0}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}

View File

@@ -25,7 +25,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import {
DateRange,
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 { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys";
@@ -164,12 +164,12 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
const datePickerRef = useRef<HTMLDivElement>(null);
const extractMetadataKeys = useCallback((obj, parentKey = "") => {
const extracMetadataKeys = useCallback((obj, parentKey = "") => {
let keys: string[] = [];
for (let key in obj) {
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 {
keys.push(parentKey + key);
}

View File

@@ -4,9 +4,8 @@ import clsx from "clsx";
import { ChevronDown, ChevronUp, X } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementsComboBox";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
import { Button } from "@/modules/ui/components/button";
@@ -26,52 +25,20 @@ import {
} from "@/modules/ui/components/dropdown-menu";
import { Input } from "@/modules/ui/components/input";
const DEFAULT_LANGUAGE_CODE = "default";
// Helper to get localized option value
const getOptionValue = (option: string | TI18nString): string => {
return typeof option === "object" && option !== null
? getLocalizedValue(option, DEFAULT_LANGUAGE_CODE)
: option;
};
type ElementFilterComboBoxProps = {
filterOptions: (string | TI18nString)[] | undefined;
filterComboBoxOptions: (string | TI18nString)[] | undefined;
type QuestionFilterComboBoxProps = {
filterOptions: string[] | undefined;
filterComboBoxOptions: string[] | undefined;
filterValue: string | undefined;
filterComboBoxValue: string | string[] | undefined;
onChangeFilterValue: (o: string) => void;
onChangeFilterComboBoxValue: (o: string | string[]) => void;
type?: TSurveyElementTypeEnum | Omit<OptionsType, OptionsType.ELEMENTS>;
type?: TSurveyQuestionTypeEnum | Omit<OptionsType, OptionsType.QUESTIONS>;
handleRemoveMultiSelect: (value: string[]) => void;
disabled?: boolean;
fieldId?: string;
};
// Helper function to check if multiple selection is allowed
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 = ({
export const QuestionFilterComboBox = ({
filterComboBoxOptions,
filterComboBoxValue,
filterOptions,
@@ -82,7 +49,7 @@ export const ElementFilterComboBox = ({
handleRemoveMultiSelect,
disabled = false,
fieldId,
}: ElementFilterComboBoxProps) => {
}: QuestionFilterComboBoxProps) => {
const [open, setOpen] = useState(false);
const commandRef = useRef(null);
const [searchQuery, setSearchQuery] = useState("");
@@ -90,19 +57,32 @@ export const ElementFilterComboBox = ({
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
const options = useMemo(() => {
if (!isMultiple) return filterComboBoxOptions;
return filterComboBoxOptions?.filter((o) => {
const optionValue = getOptionValue(o);
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
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)
const isTextInputField = type === OptionsType.META && fieldId === "url";
@@ -111,14 +91,14 @@ export const ElementFilterComboBox = ({
const filteredOptions = useMemo(
() =>
options?.filter((o) => {
const optionValue = getOptionValue(o);
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
return optionValue.toLowerCase().includes(searchQuery.toLowerCase());
}),
[options, searchQuery]
[options, searchQuery, defaultLanguageCode]
);
const handleCommandItemSelect = (o: string | TI18nString) => {
const value = getOptionValue(o);
const handleCommandItemSelect = (o: string) => {
const value = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
if (isMultiple) {
const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value];
@@ -131,56 +111,12 @@ export const ElementFilterComboBox = ({
};
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 = () => {
if (isComboBoxDisabled) return;
setOpen(true);
};
const ChevronIcon = open ? ChevronUp : ChevronDown;
// Helper to filter out a specific value from the array
const getFilteredValues = (valueToRemove: string): string[] => {
@@ -239,7 +175,42 @@ export const ElementFilterComboBox = ({
return (
<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) => (
<DropdownMenuItem
key={`${o}-${index}`}
className="cursor-pointer"
onClick={() => onChangeFilterValue(o)}>
{o}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{isTextInputField ? (
<Input
@@ -298,7 +269,7 @@ export const ElementFilterComboBox = ({
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
<CommandGroup>
{filteredOptions?.map((o) => {
const optionValue = getOptionValue(o);
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
return (
<CommandItem
key={optionValue}

View File

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

View File

@@ -4,18 +4,15 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
SelectedFilterValue,
TResponseStatus,
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 { ElementFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementFilterComboBox";
import { generateElementAndFilterOptions } from "@/app/lib/surveys/surveys";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox";
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
import { Button } from "@/modules/ui/components/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import {
@@ -25,20 +22,12 @@ import {
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { ElementOption, ElementsComboBox, OptionsType } from "./ElementsComboBox";
import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
export type ElementFilterOptions = {
type:
| TSurveyElementTypeEnum
| "Attributes"
| "Tags"
| "Languages"
| "Quotas"
| "Hidden Fields"
| "Meta"
| OptionsType.OTHERS;
filterOptions: (string | TI18nString)[];
filterComboBoxOptions: (string | TI18nString)[];
export type QuestionFilterOptions = {
type: TSurveyQuestionTypeEnum | "Attributes" | "Tags" | "Languages" | "Quotas";
filterOptions: string[];
filterComboBoxOptions: string[];
id: string;
};
@@ -80,12 +69,6 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [filterValue, setFilterValue] = useState<SelectedFilterValue>(selectedFilter);
const getDefaultFilterValue = (option?: ElementFilterOptions): string | undefined => {
if (!option || option.filterOptions.length === 0) return undefined;
const firstOption = option.filterOptions[0];
return typeof firstOption === "object" ? getLocalizedValue(firstOption, "default") : firstOption;
};
useEffect(() => {
// Fetch the initial data for the filter and load it into the state
const handleInitialData = async () => {
@@ -95,7 +78,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
if (!surveyFilterData?.data) return;
const { attributes, meta, environmentTags, hiddenFields, quotas } = surveyFilterData.data;
const { elementFilterOptions, elementOptions } = generateElementAndFilterOptions(
const { questionFilterOptions, questionOptions } = generateQuestionAndFilterOptions(
survey,
environmentTags,
attributes,
@@ -103,35 +86,34 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
hiddenFields,
quotas
);
setSelectedOptions({ elementFilterOptions: elementFilterOptions, elementOptions: elementOptions });
setSelectedOptions({ questionFilterOptions, questionOptions });
}
};
handleInitialData();
}, [isOpen, setSelectedOptions, survey]);
const handleOnChangeElementComboBoxValue = (value: ElementOption, index: number) => {
const matchingFilterOption = selectedOptions.elementFilterOptions.find(
(q) => q.type === value.type || q.type === value.elementType
);
const defaultFilterValue = getDefaultFilterValue(matchingFilterOption);
if (filterValue.filter[index].elementType) {
const handleOnChangeQuestionComboBoxValue = (value: QuestionOption, index: number) => {
if (filterValue.filter[index].questionType) {
// Create a new array and copy existing values from SelectedFilter
filterValue.filter[index] = {
elementType: value,
questionType: value,
filterType: {
filterComboBoxValue: undefined,
filterValue: defaultFilterValue,
filterValue: selectedOptions.questionFilterOptions.find(
(q) => q.type === value.type || q.type === value.questionType
)?.filterOptions[0],
},
};
setFilterValue({ filter: [...filterValue.filter], responseStatus: filterValue.responseStatus });
} else {
// Update the existing value at the specified index
filterValue.filter[index].elementType = value;
filterValue.filter[index].questionType = value;
filterValue.filter[index].filterType = {
filterComboBoxValue: undefined,
filterValue: defaultFilterValue,
filterValue: selectedOptions.questionFilterOptions.find(
(q) => q.type === value.type || q.type === value.questionType
)?.filterOptions[0],
};
setFilterValue({ ...filterValue });
}
@@ -141,8 +123,8 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
const clearItem = () => {
setFilterValue({
filter: filterValue.filter.filter((s) => {
// keep the filter if elementType is selected and filterComboBoxValue is selected
return s.elementType.hasOwnProperty("label") && s.filterType.filterComboBoxValue?.length;
// keep the filter if questionType is selected and filterComboBoxValue is selected
return s.questionType.hasOwnProperty("label") && s.filterType.filterComboBoxValue?.length;
}),
responseStatus: filterValue.responseStatus,
});
@@ -162,7 +144,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
filter: [
...filterValue.filter,
{
elementType: {},
questionType: {},
filterType: { filterComboBoxValue: undefined, filterValue: undefined },
},
],
@@ -214,10 +196,10 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
};
// remove the filter which has already been selected
const elementComboBoxOptions = selectedOptions.elementOptions.map((q) => {
const questionComboBoxOptions = selectedOptions.questionOptions.map((q) => {
return {
...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)),
};
});
@@ -235,13 +217,11 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
setFilterValue(selectedFilter);
}, [selectedFilter]);
const activeFilterCount = filterValue.filter.length + (filterValue.responseStatus === "all" ? 0 : 1);
return (
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<PopoverTriggerButton isOpen={isOpen}>
Filter <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
Filter <b>{filterValue.filter.length > 0 && `(${filterValue.filter.length})`}</b>
</PopoverTriggerButton>
</PopoverTrigger>
<PopoverContent
@@ -280,41 +260,41 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
<div className="flex w-full flex-wrap gap-3 md:flex-nowrap">
<div
className="grid w-full grid-cols-1 items-center gap-3 md:grid-cols-2"
key={`${s.elementType.id}-${i}-${s.elementType.label}`}>
<ElementsComboBox
key={`${s.elementType.label}-${i}-${s.elementType.id}`}
options={elementComboBoxOptions}
selected={s.elementType}
onChangeValue={(value) => handleOnChangeElementComboBoxValue(value, i)}
key={`${s.questionType.id}-${i}-${s.questionType.label}`}>
<QuestionsComboBox
key={`${s.questionType.label}-${i}-${s.questionType.id}`}
options={questionComboBoxOptions}
selected={s.questionType}
onChangeValue={(value) => handleOnChangeQuestionComboBoxValue(value, i)}
/>
<ElementFilterComboBox
key={`${s.elementType.id}-${i}`}
<QuestionFilterComboBox
key={`${s.questionType.id}-${i}`}
filterOptions={
selectedOptions.elementFilterOptions.find(
selectedOptions.questionFilterOptions.find(
(q) =>
(q.type === s.elementType.elementType || q.type === s.elementType.type) &&
q.id === s.elementType.id
(q.type === s.questionType.questionType || q.type === s.questionType.type) &&
q.id === s.questionType.id
)?.filterOptions
}
filterComboBoxOptions={
selectedOptions.elementFilterOptions.find(
selectedOptions.questionFilterOptions.find(
(q) =>
(q.type === s.elementType.elementType || q.type === s.elementType.type) &&
q.id === s.elementType.id
(q.type === s.questionType.questionType || q.type === s.questionType.type) &&
q.id === s.questionType.id
)?.filterComboBoxOptions
}
filterValue={filterValue.filter[i].filterType.filterValue}
filterComboBoxValue={filterValue.filter[i].filterType.filterComboBoxValue}
type={
s?.elementType?.type === OptionsType.ELEMENTS
? s?.elementType?.elementType
: s?.elementType?.type
s?.questionType?.type === OptionsType.QUESTIONS
? s?.questionType?.questionType
: s?.questionType?.type
}
fieldId={s?.elementType?.id}
fieldId={s?.questionType?.id}
handleRemoveMultiSelect={(value) => handleRemoveMultiSelect(value, i)}
onChangeFilterComboBoxValue={(value) => handleOnChangeFilterComboBoxValue(value, i)}
onChangeFilterValue={(value) => handleOnChangeFilterValue(value, i)}
disabled={!s?.elementType?.label}
disabled={!s?.questionType?.label}
/>
</div>
<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 { Suspense } from "react";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { IS_POSTHOG_CONFIGURED, POSTHOG_API_HOST, POSTHOG_API_KEY } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
const AppLayout = async ({ children }) => {
@@ -18,9 +21,20 @@ const AppLayout = async ({ children }) => {
return (
<>
<NoMobileOverlay />
<IntercomClientWrapper user={user} />
<ToasterClient />
{children}
<Suspense>
<PostHogPageview
posthogEnabled={IS_POSTHOG_CONFIGURED}
postHogApiHost={POSTHOG_API_HOST}
postHogApiKey={POSTHOG_API_KEY}
/>
</Suspense>
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
<>
<IntercomClientWrapper user={user} />
<ToasterClient />
{children}
</>
</PHProvider>
</>
);
};

View File

@@ -23,8 +23,12 @@ import {
TIntegrationSlackCredential,
} from "@formbricks/types/integration/slack";
import { TResponse, TResponseMeta } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
TSurvey,
TSurveyOpenTextQuestion,
TSurveyPictureSelectionQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { writeData as airtableWriteData } from "@/lib/airtable/service";
import { writeData as googleSheetWriteData } from "@/lib/googleSheet/service";
@@ -97,47 +101,33 @@ const mockPipelineInput = {
const mockSurvey = {
id: surveyId,
name: "Test Survey",
blocks: [
questions: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: questionId1,
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Question 1 {{recall:q2}}" },
required: true,
inputType: "text",
charLimit: 1000,
subheader: { default: "" },
placeholder: { default: "" },
},
{
id: questionId2,
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Question 2" },
required: true,
choices: [
{ id: "choice1", label: { default: "Choice 1" } },
{ id: "choice2", label: { default: "Choice 2" } },
],
shuffleOption: "none",
subheader: { default: "" },
},
{
id: questionId3,
type: TSurveyElementTypeEnum.PictureSelection,
headline: { default: "Question 3" },
required: true,
choices: [
{ id: "picChoice1", imageUrl: "http://image.com/1" },
{ id: "picChoice2", imageUrl: "http://image.com/2" },
],
allowMultiple: false,
subheader: { default: "" },
},
id: questionId1,
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1 {{recall:q2}}" },
required: true,
} as unknown as TSurveyOpenTextQuestion,
{
id: questionId2,
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Question 2" },
required: true,
choices: [
{ id: "choice1", label: { default: "Choice 1" } },
{ id: "choice2", label: { default: "Choice 2" } },
],
},
{
id: questionId3,
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: { default: "Question 3" },
required: true,
choices: [
{ id: "picChoice1", imageUrl: "http://image.com/1" },
{ id: "picChoice2", imageUrl: "http://image.com/2" },
],
} as unknown as TSurveyPictureSelectionQuestion,
],
hiddenFields: {
enabled: true,
@@ -172,7 +162,7 @@ const mockAirtableIntegration: TIntegrationAirtable = {
data: [
{
surveyId: surveyId,
elementIds: [questionId1, questionId2],
questionIds: [questionId1, questionId2],
baseId: "base1",
tableId: "table1",
createdAt: new Date(),
@@ -196,8 +186,8 @@ const mockGoogleSheetsIntegration: TIntegrationGoogleSheets = {
surveyId: surveyId,
spreadsheetId: "sheet1",
spreadsheetName: "Sheet Name",
elementIds: [questionId1],
elements: "What is Q1?",
questionIds: [questionId1],
questions: "What is Q1?",
createdAt: new Date("2024-01-01T00:00:00.000Z"),
includeHiddenFields: false,
includeMetadata: false,
@@ -219,8 +209,8 @@ const mockSlackIntegration: TIntegrationSlack = {
surveyId: surveyId,
channelId: "channel1",
channelName: "Channel 1",
elementIds: [questionId1, questionId2, questionId3],
elements: "Q1, Q2, Q3",
questionIds: [questionId1, questionId2, questionId3],
questions: "Q1, Q2, Q3",
createdAt: new Date(),
includeHiddenFields: true,
includeMetadata: true,
@@ -249,19 +239,19 @@ const mockNotionIntegration: TIntegrationNotion = {
databaseName: "DB 1",
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" },
},
{
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" },
},
{
element: { id: "metadata", name: "Metadata", type: "metadata" },
question: { id: "metadata", name: "Metadata", type: "metadata" },
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" },
},
],
@@ -351,14 +341,16 @@ describe("handleIntegrations", () => {
mockAirtableIntegration.config.key,
mockAirtableIntegration.config.data[0],
[
"Answer 1",
"Choice 1, Choice 2",
"Hidden Value",
expectedMetadataString,
"Variable Value",
"2024-01-01 12:00",
], // responses + hidden + meta + var + created
["Question 1 {{recall:q2}}", "Question 2", hiddenFieldId, "Metadata", "Variable 1", "Created At"] // elements (raw headline for Airtable) + hidden + meta + var + created
[
"Answer 1",
"Choice 1, Choice 2",
"Hidden Value",
expectedMetadataString,
"Variable Value",
"2024-01-01 12:00",
], // responses + hidden + meta + var + created
["Question 1 {{recall:q2}}", "Question 2", hiddenFieldId, "Metadata", "Variable 1", "Created At"], // questions (raw headline for Airtable) + hidden + meta + var + created
]
);
});
@@ -393,8 +385,10 @@ describe("handleIntegrations", () => {
expect(googleSheetWriteData).toHaveBeenCalledWith(
expectedIntegrationData,
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 { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import { TResponseDataValue, TResponseMeta } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TResponseMeta } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { writeData as airtableWriteData } from "@/lib/airtable/service";
@@ -17,7 +16,6 @@ import { getLocalizedValue } from "@/lib/i18n/utils";
import { writeData as writeNotionData } from "@/lib/notion/service";
import { processResponseData } from "@/lib/responses";
import { writeDataToSlack } from "@/lib/slack/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { parseRecallInfo } from "@/lib/utils/recall";
import { truncateText } from "@/lib/utils/strings";
@@ -44,40 +42,33 @@ const processDataForIntegration = async (
includeMetadata: boolean,
includeHiddenFields: boolean,
includeCreatedAt: boolean,
elementIds: string[]
): Promise<{
responses: string[];
elements: string[];
}> => {
questionIds: string[]
): Promise<string[][]> => {
const ids =
includeHiddenFields && survey.hiddenFields.fieldIds
? [...elementIds, ...survey.hiddenFields.fieldIds]
: elementIds;
const { responses, elements } = await extractResponses(integrationType, data, ids, survey);
? [...questionIds, ...survey.hiddenFields.fieldIds]
: questionIds;
const values = await extractResponses(integrationType, data, ids, survey);
if (includeMetadata) {
responses.push(convertMetaObjectToString(data.response.meta));
elements.push("Metadata");
values[0].push(convertMetaObjectToString(data.response.meta));
values[1].push("Metadata");
}
if (includeVariables) {
survey.variables?.forEach((variable) => {
survey.variables.forEach((variable) => {
const value = data.response.variables[variable.id];
if (value !== undefined) {
responses.push(String(data.response.variables[variable.id]));
elements.push(variable.name);
values[0].push(String(data.response.variables[variable.id]));
values[1].push(variable.name);
}
});
}
if (includeCreatedAt) {
const date = new Date(data.response.createdAt);
responses.push(`${getFormattedDateTimeString(date)}`);
elements.push("Created At");
values[0].push(`${getFormattedDateTimeString(date)}`);
values[1].push("Created At");
}
return {
responses,
elements,
};
return values;
};
export const handleIntegrations = async (
@@ -140,9 +131,9 @@ const handleAirtableIntegration = async (
!!element.includeMetadata,
!!element.includeHiddenFields,
!!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.includeHiddenFields,
!!element.includeCreatedAt,
element.elementIds
element.questionIds
);
const integrationData = structuredClone(integration);
integrationData.config.data.forEach((data) => {
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.includeHiddenFields,
!!element.includeCreatedAt,
element.elementIds
);
await writeDataToSlack(
integration.config.key,
element.channelId,
values.responses,
values.elements,
survey?.name
element.questionIds
);
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 (
integrationType: TIntegrationType,
pipelineData: TPipelineInput,
elementIds: string[],
questionIds: string[],
survey: TSurvey
): Promise<{
responses: string[];
elements: string[];
}> => {
): Promise<string[][]> => {
const responses: string[] = [];
const elements: string[] = [];
const surveyElements = getElementsFromBlocks(survey.blocks);
const emptyResponseObject = createEmptyResponseObject(pipelineData.response.data);
const questions: string[] = [];
for (const elementId of elementIds) {
// Check for hidden field Ids
if (survey.hiddenFields.fieldIds?.includes(elementId)) {
responses.push(processResponseData(pipelineData.response.data[elementId]));
elements.push(elementId);
for (const questionId of questionIds) {
//check for hidden field Ids
if (survey.hiddenFields.fieldIds?.includes(questionId)) {
responses.push(processResponseData(pipelineData.response.data[questionId]));
questions.push(questionId);
continue;
}
const question = survey?.questions.find((q) => q.id === questionId);
if (!question) {
continue;
}
const element = surveyElements.find((q) => q.id === elementId);
if (!element) {
continue;
const responseValue = pipelineData.response.data[questionId];
if (responseValue !== undefined) {
let answer: typeof responseValue;
if (question.type === TSurveyQuestionTypeEnum.PictureSelection) {
const selectedChoiceIds = responseValue as string[];
answer = question?.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => choice.imageUrl)
.join("\n");
} else {
answer = responseValue;
}
responses.push(processResponseData(answer));
} else {
responses.push("");
}
const responseValue = pipelineData.response.data[elementId];
responses.push(processElementResponse(element, responseValue));
const responseDataForRecall =
integrationType === "slack" ? pipelineData.response.data : emptyResponseObject;
const variablesForRecall = integrationType === "slack" ? pipelineData.response.variables : {};
elements.push(
// Create emptyResponseObject with same keys but empty string values
const emptyResponseObject = Object.keys(pipelineData.response.data).reduce(
(acc, key) => {
acc[key] = "";
return acc;
},
{} as Record<string, string>
);
questions.push(
parseRecallInfo(
getTextContent(getLocalizedValue(element.headline, "default")),
responseDataForRecall,
variablesForRecall
getTextContent(getLocalizedValue(question?.headline, "default")),
integrationType === "slack" ? pipelineData.response.data : emptyResponseObject,
integrationType === "slack" ? pipelineData.response.variables : {}
) || ""
);
}
return { responses, elements };
return [responses, questions];
};
const handleNotionIntegration = async (
@@ -354,34 +321,32 @@ const buildNotionPayloadProperties = (
const properties: any = {};
const responses = data.response.data;
const surveyElements = getElementsFromBlocks(surveyData.blocks);
const mappingElementIds = mapping
.filter((m) => m.element.type === TSurveyElementTypeEnum.PictureSelection)
.map((m) => m.element.id);
const mappingQIds = mapping
.filter((m) => m.question.type === TSurveyQuestionTypeEnum.PictureSelection)
.map((m) => m.question.id);
Object.keys(responses).forEach((resp) => {
if (mappingElementIds.find((elementId) => elementId === resp)) {
if (mappingQIds.find((qId) => qId === resp)) {
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))
.map((choice) => choice.imageUrl);
}
});
mapping.forEach((map) => {
if (map.element.id === "metadata") {
if (map.question.id === "metadata") {
properties[map.column.name] = {
[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] = {
[map.column.type]: getValue(map.column.type, data.response.createdAt) || null,
};
} else {
const value = responses[map.element.id];
const value = responses[map.question.id];
properties[map.column.name] = {
[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 { logger } from "@formbricks/logger";
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 { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
@@ -51,22 +50,6 @@ export const POST = async (request: Request) => {
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
const getWebhooksForPipeline = async (environmentId: string, event: PipelineTriggers, surveyId: string) => {
const webhooks = await prisma.webhook.findMany({
@@ -97,16 +80,7 @@ export const POST = async (request: Request) => {
body: JSON.stringify({
webhookId: webhook.id,
event,
data: {
...response,
survey: {
title: survey.name,
type: survey.type,
status: survey.status,
createdAt: survey.createdAt,
updatedAt: survey.updatedAt,
},
},
data: response,
}),
}).catch((error) => {
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") {
// Fetch integrations and responseCount in parallel
const [integrations, responseCount] = await Promise.all([
// Fetch integrations, survey, and responseCount in parallel
const [integrations, survey, responseCount] = await Promise.all([
getIntegrations(environmentId),
getSurvey(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) {
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: {} });
};

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,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { getProjectByEnvironmentId } from "@/lib/project/service";
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 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;
};
@@ -93,7 +111,10 @@ export const GET = withV1ApiWrapper({
}
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

View File

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

View File

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

View File

@@ -8,11 +8,16 @@ import { TOrganization } from "@formbricks/types/organizations";
import { TSurvey } from "@formbricks/types/surveys/types";
import { cache } from "@/lib/cache";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
import { getEnvironmentState } from "./environmentState";
// Mock dependencies
vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/cache", () => ({
cache: {
withCache: vi.fn(),
@@ -38,6 +43,7 @@ vi.mock("@/lib/constants", () => ({
RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key",
IS_RECAPTCHA_CONFIGURED: true,
IS_PRODUCTION: true,
IS_POSTHOG_CONFIGURED: false,
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
}));
@@ -182,7 +188,9 @@ describe("getEnvironmentState", () => {
expect(result.data).toEqual(expectedData);
expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId);
expect(prisma.environment.update).not.toHaveBeenCalled();
expect(capturePosthogEnvironmentEvent).not.toHaveBeenCalled();
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should throw ResourceNotFoundError if environment not found", async () => {
@@ -218,6 +226,7 @@ describe("getEnvironmentState", () => {
where: { id: environmentId },
data: { appSetupCompleted: true },
});
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(environmentId, "app setup completed");
expect(result.data).toBeDefined();
});
@@ -228,6 +237,16 @@ describe("getEnvironmentState", () => {
expect(result.data.surveys).toEqual([]);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
plan: mockOrganization.billing.plan,
limits: {
projects: null,
monthly: {
miu: null,
responses: mockOrganization.billing.limits.monthly.responses,
},
},
});
});
test("should return surveys if monthly response limit not reached (Cloud)", async () => {
@@ -237,6 +256,21 @@ describe("getEnvironmentState", () => {
expect(result.data.surveys).toEqual(mockSurveys);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should handle error when sending Posthog limit reached event", async () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
const posthogError = new Error("Posthog failed");
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
const result = await getEnvironmentState(environmentId);
expect(result.data.surveys).toEqual([]);
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
});
test("should include recaptchaSiteKey if recaptcha variables are set", async () => {
@@ -279,6 +313,7 @@ describe("getEnvironmentState", () => {
// Should return surveys even with high count since limit is null (unlimited)
expect(result.data.surveys).toEqual(mockSurveys);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should propagate database update errors", async () => {
@@ -296,6 +331,21 @@ describe("getEnvironmentState", () => {
await expect(getEnvironmentState(environmentId)).rejects.toThrow("Database error");
});
test("should propagate PostHog event capture errors", async () => {
const incompleteEnvironmentData = {
...mockEnvironmentStateData,
environment: {
...mockEnvironmentStateData.environment,
appSetupCompleted: false,
},
};
vi.mocked(getEnvironmentStateData).mockResolvedValue(incompleteEnvironmentData);
vi.mocked(capturePosthogEnvironmentEvent).mockRejectedValue(new Error("PostHog error"));
// Should throw error since Promise.all will fail if PostHog event capture fails
await expect(getEnvironmentState(environmentId)).rejects.toThrow("PostHog error");
});
test("should include recaptchaSiteKey when IS_RECAPTCHA_CONFIGURED is true", async () => {
const result = await getEnvironmentState(environmentId);

View File

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

View File

@@ -1,6 +1,5 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState";
import { responses } from "@/app/lib/api/response";
@@ -29,38 +28,15 @@ export const GET = withV1ApiWrapper({
const params = await props.params;
try {
// Basic type check for environmentId
// Simple validation for environmentId (faster than Zod for high-frequency endpoint)
if (typeof params.environmentId !== "string") {
return {
response: responses.badRequestResponse("Environment ID is required", undefined, true),
};
}
const environmentId = params.environmentId.trim();
// Validate CUID v1 format using Zod (matches Prisma schema @default(cuid()))
// This catches all invalid formats including:
// - null/undefined passed as string "null" or "undefined"
// - HTML-encoded placeholders like <environmentId> or %3C...%3E
// - Empty or whitespace-only IDs
// - Any other invalid CUID v1 format
const cuidValidation = ZEnvironmentId.safeParse(environmentId);
if (!cuidValidation.success) {
logger.warn(
{
environmentId: params.environmentId,
url: req.url,
validationError: cuidValidation.error.errors[0]?.message,
},
"Invalid CUID v1 format detected"
);
return {
response: responses.badRequestResponse("Invalid environment ID format", undefined, true),
};
}
// Use optimized environment state fetcher with new caching approach
const environmentState = await getEnvironmentState(environmentId);
const environmentState = await getEnvironmentState(params.environmentId);
const { data } = environmentState;
return {
@@ -70,12 +46,12 @@ export const GET = withV1ApiWrapper({
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck
},
true,
// Cache headers aligned with Redis cache TTL (1 minute)
// max-age=60: 1min browser cache
// s-maxage=60: 1min Cloudflare CDN cache
// stale-while-revalidate=60: 1min stale serving during revalidation
// stale-if-error=60: 1min stale serving on origin errors
"public, s-maxage=60, max-age=60, stale-while-revalidate=60, stale-if-error=60"
// Optimized cache headers for Cloudflare CDN and browser caching
// max-age=3600: 1hr browser cache (per guidelines)
// s-maxage=1800: 30min Cloudflare cache (per guidelines)
// stale-while-revalidate=1800: 30min stale serving during revalidation
// stale-if-error=3600: 1hr stale serving on origin errors
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
),
};
} catch (err) {

View File

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

View File

@@ -1,10 +1,15 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseInput } from "@formbricks/types/responses";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
@@ -19,13 +24,22 @@ vi.mock("@/lib/constants", () => ({
}));
vi.mock("@/lib/organization/service", () => ({
getMonthlyOrganizationResponseCount: vi.fn(),
getOrganizationByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/posthogServer", () => ({
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn(),
}));
vi.mock("@/lib/response/utils", () => ({
calculateTtcTotal: vi.fn((ttc) => ttc),
}));
vi.mock("@/lib/telemetry", () => ({
captureTelemetry: vi.fn(),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
@@ -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 () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(ResourceNotFoundError);
@@ -143,6 +186,20 @@ describe("createResponse", () => {
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
});
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
const posthogError = new Error("PostHog error");
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
await createResponse(mockResponseInput);
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
});
});
describe("createResponseWithQuotaEvaluation", () => {

View File

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

View File

@@ -2,7 +2,7 @@ import { headers } from "next/headers";
import { NextRequest } from "next/server";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { ZId } from "@formbricks/types/common";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
@@ -10,6 +10,7 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
@@ -50,7 +51,7 @@ export const POST = withV1ApiWrapper({
}
const { environmentId } = params;
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
const environmentIdValidation = ZId.safeParse(environmentId);
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {
@@ -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 responseDataWithQuota = {

View File

@@ -4,7 +4,11 @@ import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseInput } from "@formbricks/types/responses";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { getResponseContact } from "@/lib/response/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { validateInputs } from "@/lib/utils/validate";
@@ -92,6 +96,9 @@ const mockTransformedResponses = [mockResponse, { ...mockResponse, id: "response
// Mock dependencies
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
@@ -111,8 +118,10 @@ vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mock-sentry-dsn",
}));
vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/response/service");
vi.mock("@/lib/response/utils");
vi.mock("@/lib/telemetry");
vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -153,6 +162,7 @@ describe("Response Lib Tests", () => {
vi.mocked(mockTx.response.create).mockResolvedValue({
...mockResponsePrisma,
});
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
const response = await createResponse(mockResponseInputWithUserId, mockTx);
@@ -207,6 +217,68 @@ describe("Response Lib Tests", () => {
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
});
describe("Cloud specific tests", () => {
test("should check response limit and send event if limit reached", async () => {
// IS_FORMBRICKS_CLOUD is true by default from the top-level mock
const limit = 100;
const mockOrgWithBilling = {
...mockOrganization,
billing: { limits: { monthly: { responses: limit } } },
} as any;
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
});
test("should check response limit and not send event if limit not reached", async () => {
const limit = 100;
const mockOrgWithBilling = {
...mockOrganization,
billing: { limits: { monthly: { responses: limit } } },
} as any;
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit - 1); // Limit not reached
await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should log error if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
const limit = 100;
const mockOrgWithBilling = {
...mockOrganization,
billing: { limits: { monthly: { responses: limit } } },
} as any;
const posthogError = new Error("Posthog error");
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
// Expecting successful response creation despite PostHog error
const response = await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
expect(response).toEqual(mockResponse); // Should still return the created response
});
});
});
describe("getResponsesByEnvironmentIds", () => {

View File

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

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

View File

@@ -4,11 +4,6 @@ import { DatabaseError } from "@formbricks/types/errors";
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
import { responses } from "@/app/lib/api/response";
import {
transformBlocksToQuestions,
transformQuestionsToBlocks,
validateSurveyInput,
} from "@/app/lib/api/survey-transformation";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
@@ -32,30 +27,10 @@ export const GET = withV1ApiWrapper({
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const surveys = await getSurveys(environmentIds, limit, offset);
const surveysWithQuestions = surveys.map((survey) => {
// If the survey has blocks and each block has ONLY ONE element, we can transform the blocks to questions
// This is only for backwards compatibility with the older surveys
const shouldTransformToQuestions =
survey.blocks &&
survey.blocks.length > 0 &&
survey.blocks.every((block) => block.elements.length === 1);
if (shouldTransformToQuestions) {
return {
...survey,
questions: transformBlocksToQuestions(survey.blocks, survey.endings),
blocks: [],
};
}
return survey;
});
return {
response: responses.successResponse(surveysWithQuestions),
response: responses.successResponse(surveys),
};
} catch (error) {
if (error instanceof DatabaseError) {
@@ -88,7 +63,6 @@ export const POST = withV1ApiWrapper({
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
if (!inputValidation.success) {
@@ -118,20 +92,6 @@ export const POST = withV1ApiWrapper({
const surveyData = { ...inputValidation.data, environmentId };
const validateResult = validateSurveyInput(surveyData);
if (!validateResult.ok) {
return {
response: responses.badRequestResponse(validateResult.error.message),
};
}
const { hasQuestions } = validateResult.data;
if (hasQuestions) {
surveyData.blocks = transformQuestionsToBlocks(surveyData.questions, surveyData.endings || []);
surveyData.questions = [];
}
const featureCheckResult = await checkFeaturePermissions(surveyData, organization);
if (featureCheckResult) {
return {
@@ -143,18 +103,6 @@ export const POST = withV1ApiWrapper({
auditLog.targetId = survey.id;
auditLog.newObject = survey;
if (hasQuestions) {
const surveyWithQuestions = {
...survey,
questions: transformBlocksToQuestions(survey.blocks, survey.endings),
blocks: [],
};
return {
response: responses.successResponse(surveyWithQuestions),
};
}
return {
response: responses.successResponse(survey),
};

View File

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

View File

@@ -8,8 +8,13 @@ import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContact } from "./contact";
@@ -44,7 +49,9 @@ vi.mock("@/lib/constants", () => ({
}));
vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/response/utils");
vi.mock("@/lib/telemetry");
vi.mock("@/lib/utils/validate");
vi.mock("@/modules/ee/quotas/lib/evaluation-service");
vi.mock("@formbricks/database", () => ({
@@ -159,6 +166,9 @@ describe("createResponse V2", () => {
...ttc,
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
}));
vi.mocked(captureTelemetry).mockResolvedValue(undefined);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockResolvedValue(undefined);
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: false,
quotaFull: null,
@@ -169,6 +179,32 @@ describe("createResponse V2", () => {
mockIsFormbricksCloud = false;
});
test("should check response limits if IS_FORMBRICKS_CLOUD is true", async () => {
mockIsFormbricksCloud = true;
await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
plan: "free",
limits: {
projects: null,
monthly: {
responses: 100,
miu: null,
},
},
});
});
test("should throw ResourceNotFoundError if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(ResourceNotFoundError);
@@ -189,6 +225,20 @@ describe("createResponse V2", () => {
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
});
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
const posthogError = new Error("PostHog error");
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
await createResponse(mockResponseInput, mockTx); // Should not throw
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
});
test("should correctly map prisma tags to response tags", async () => {
const mockTag: TTag = { id: "tag1", name: "Tag 1", environmentId };
const prismaResponseWithTags = {
@@ -219,6 +269,7 @@ describe("createResponseWithQuotaEvaluation V2", () => {
...ttc,
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
}));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: false,
quotaFull: null,

View File

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

View File

@@ -1,16 +1,16 @@
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { ZId } from "@formbricks/types/common";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { createResponseWithQuotaEvaluation } from "./lib/response";
@@ -43,7 +43,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
}
const { environmentId } = params;
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
const environmentIdValidation = ZId.safeParse(environmentId);
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {
@@ -91,7 +91,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: responseInputData.data,
surveyQuestions: getElementsFromBlocks(survey.blocks),
surveyQuestions: survey.questions,
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 responseDataWithQuota = {

File diff suppressed because it is too large Load Diff

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