Compare commits

..

1 Commits

Author SHA1 Message Date
hmacr
b39c3a877a feat: Show SingleUse ID data in survey responses table 2025-10-28 11:50:20 +05:30
533 changed files with 23685 additions and 45010 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

@@ -17,6 +17,7 @@ on:
workflow_dispatch:
env:
TELEMETRY_DISABLED: 1
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}

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

@@ -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

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

@@ -4,6 +4,7 @@ import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/co
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -23,6 +24,8 @@ const Page = async (props) => {
const user = await getUser(session.user.id);
if (!user) return notFound();
const organizations = await getOrganizationsByUserId(session.user.id);
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
@@ -34,10 +37,11 @@ const Page = async (props) => {
<div className="flex-1">
<div className="flex h-full flex-col">
<div className="p-6">
{/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */}
{/* we only need to render organization breadcrumb on this page, so we pass some default value without actually calculating them to ProjectAndOrgSwitch component */}
<ProjectAndOrgSwitch
currentOrganizationId={organization.id}
currentOrganizationName={organization.name}
organizations={organizations}
projects={[]}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={0}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}

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

@@ -4,7 +4,6 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
@@ -17,8 +16,6 @@ import {
getOrganizationProjectsLimit,
} from "@/modules/ee/license-check/lib/utils";
import { createProject } from "@/modules/projects/settings/lib/project";
import { getOrganizationsByUserId } from "./lib/organization";
import { getProjectsByUserId } from "./lib/project";
const ZCreateProjectAction = z.object({
organizationId: ZId,
@@ -87,59 +84,3 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
}
)
);
const ZGetOrganizationsForSwitcherAction = z.object({
organizationId: ZId, // Changed from environmentId to avoid extra query
});
/**
* Fetches organizations list for switcher dropdown.
* Called on-demand when user opens the organization switcher.
*/
export const getOrganizationsForSwitcherAction = authenticatedActionClient
.schema(ZGetOrganizationsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "member", "billing"],
},
],
});
return await getOrganizationsByUserId(ctx.user.id);
});
const ZGetProjectsForSwitcherAction = z.object({
organizationId: ZId, // Changed from environmentId to avoid extra query
});
/**
* Fetches projects list for switcher dropdown.
* Called on-demand when user opens the project switcher.
*/
export const getProjectsForSwitcherAction = authenticatedActionClient
.schema(ZGetProjectsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "member", "billing"],
},
],
});
// Need membership for getProjectsByUserId (1 DB query)
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
if (!membership) {
throw new Error("Membership not found");
}
return await getProjectsByUserId(ctx.user.id, membership);
});

View File

@@ -1,49 +1,104 @@
import type { Session } from "next-auth";
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { getOrganizationsByUserId } from "@/app/(app)/environments/[environmentId]/lib/organization";
import { getProjectsByUserId } from "@/app/(app)/environments/[environmentId]/lib/project";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import {
getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import {
getAccessControlPermission,
getOrganizationProjectsLimit,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
interface EnvironmentLayoutProps {
layoutData: TEnvironmentLayoutData;
environmentId: string;
session: Session;
children?: React.ReactNode;
}
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
export const EnvironmentLayout = async ({ environmentId, session, children }: EnvironmentLayoutProps) => {
const t = await getTranslate();
const [user, environment, organizations, organization] = await Promise.all([
getUser(session.user.id),
getEnvironment(environmentId),
getOrganizationsByUserId(session.user.id),
getOrganizationByEnvironmentId(environmentId),
]);
// Destructure all data from props (NO database queries)
const {
user,
environment,
organization,
membership,
project, // Current project details
environments, // All project environments (for environment switcher)
isAccessControlAllowed,
projectPermission,
license,
peopleCount,
responseCount,
} = layoutData;
if (!user) {
throw new Error(t("common.user_not_found"));
}
// Calculate derived values (no queries)
const { isMember, isOwner, isManager } = getAccessFlags(membership.role);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const { features, lastChecked, isPendingDowngrade, active } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const isOwnerOrManager = isOwner || isManager;
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
if (!currentUserMembership) {
throw new Error(t("common.membership_not_found"));
}
const membershipRole = currentUserMembership?.role;
const [projects, environments, isAccessControlAllowed] = await Promise.all([
getProjectsByUserId(user.id, currentUserMembership),
getEnvironments(environment.projectId),
getAccessControlPermission(organization.billing.plan),
]);
if (!projects || !environments || !organizations) {
throw new Error(t("environments.projects_environments_organizations_not_found"));
}
const { isMember } = getAccessFlags(membershipRole);
const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense();
const projectPermission = await getProjectPermissionByUserId(session.user.id, environment.projectId);
// Validate that project permission exists for members
if (isMember && !projectPermission) {
throw new Error(t("common.project_permission_not_found"));
}
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
let peopleCount = 0;
let responseCount = 0;
if (IS_FORMBRICKS_CLOUD) {
[peopleCount, responseCount] = await Promise.all([
getMonthlyActiveOrganizationPeopleCount(organization.id),
getMonthlyOrganizationResponseCount(organization.id),
]);
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
// Find the current project from the projects array
const project = projects.find((p) => p.id === environment.projectId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
const { isManager, isOwner } = getAccessFlags(membershipRole);
const isOwnerOrManager = isManager || isOwner;
return (
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
{IS_FORMBRICKS_CLOUD && (
@@ -67,24 +122,26 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
<MainNavigation
environment={environment}
organization={organization}
projects={projects}
user={user}
project={{ id: project.id, name: project.name }}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membership.role}
membershipRole={membershipRole}
/>
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar
environments={environments}
currentOrganizationId={organization.id}
organizations={organizations}
currentProjectId={project.id}
projects={projects}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={active}
isOwnerOrManager={isOwnerOrManager}
isAccessControlAllowed={isAccessControlAllowed}
membershipRole={membership.role}
membershipRole={membershipRole}
/>
<div className="flex-1 overflow-y-auto">{children}</div>
</div>

View File

@@ -42,7 +42,7 @@ interface NavigationProps {
environment: TEnvironment;
user: TUser;
organization: TOrganization;
project: { id: string; name: string };
projects: { id: string; name: string }[];
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
@@ -52,7 +52,7 @@ export const MainNavigation = ({
environment,
organization,
user,
project,
projects,
membershipRole,
isFormbricksCloud,
isDevelopment,
@@ -65,6 +65,7 @@ export const MainNavigation = ({
const [latestVersion, setLatestVersion] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const project = projects.find((project) => project.id === environment.projectId);
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
const isOwnerOrManager = isManager || isOwner;

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

@@ -9,7 +9,9 @@ import { getAccessFlags } from "@/lib/membership/utils";
interface TopControlBarProps {
environments: TEnvironment[];
currentOrganizationId: string;
organizations: { id: string; name: string }[];
currentProjectId: string;
projects: { id: string; name: string }[];
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
isFormbricksCloud: boolean;
@@ -22,7 +24,9 @@ interface TopControlBarProps {
export const TopControlBar = ({
environments,
currentOrganizationId,
organizations,
currentProjectId,
projects,
isMultiOrgEnabled,
organizationProjectsLimit,
isFormbricksCloud,
@@ -42,7 +46,9 @@ export const TopControlBar = ({
currentEnvironmentId={environment.id}
environments={environments}
currentOrganizationId={currentOrganizationId}
organizations={organizations}
currentProjectId={currentProjectId}
projects={projects}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={isFormbricksCloud}

View File

@@ -10,11 +10,9 @@ import {
SettingsIcon,
} from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { getOrganizationsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
import {
@@ -25,11 +23,10 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { useOrganization } from "../context/environment-context";
interface OrganizationBreadcrumbProps {
currentOrganizationId: string;
currentOrganizationName?: string; // Optional: pass directly if context not available
organizations: { id: string; name: string }[];
isMultiOrgEnabled: boolean;
currentEnvironmentId?: string;
isFormbricksCloud: boolean;
@@ -50,7 +47,7 @@ const isActiveOrganizationSetting = (pathname: string, settingId: string): boole
export const OrganizationBreadcrumb = ({
currentOrganizationId,
currentOrganizationName,
organizations,
isMultiOrgEnabled,
currentEnvironmentId,
isFormbricksCloud,
@@ -63,45 +60,7 @@ export const OrganizationBreadcrumb = ({
const pathname = usePathname();
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
// Get current organization name from context OR prop
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
const { organization: currentOrganization } = useOrganization();
const organizationName = currentOrganization?.name || currentOrganizationName || "";
// Lazy-load organizations when dropdown opens
useEffect(() => {
// Only fetch when dropdown opened for first time (and no error state)
if (isOrganizationDropdownOpen && organizations.length === 0 && !isLoadingOrganizations && !loadError) {
setIsLoadingOrganizations(true);
setLoadError(null); // Clear any previous errors
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort organizations by name
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted);
} else {
// Handle server errors or validation errors
const errorMessage = getFormattedErrorMessage(result);
const error = new Error(errorMessage);
logger.error(error, "Failed to load organizations");
Sentry.captureException(error);
setLoadError(errorMessage || t("common.failed_to_load_organizations"));
}
setIsLoadingOrganizations(false);
});
}
}, [
isOrganizationDropdownOpen,
currentOrganizationId,
organizations.length,
isLoadingOrganizations,
loadError,
t,
]);
const currentOrganization = organizations.find((org) => org.id === currentOrganizationId);
if (!currentOrganization) {
const errorMessage = `Organization not found for organization id: ${currentOrganizationId}`;
@@ -167,7 +126,7 @@ export const OrganizationBreadcrumb = ({
asChild>
<div className="flex items-center gap-1">
<BuildingIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{organizationName}</span>
<span>{currentOrganization.name}</span>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isOrganizationDropdownOpen ? (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
@@ -183,52 +142,30 @@ export const OrganizationBreadcrumb = ({
<BuildingIcon className="mr-2 inline h-4 w-4" />
{t("common.choose_organization")}
</div>
{isLoadingOrganizations && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingOrganizations && loadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{loadError}</p>
<button
onClick={() => {
setLoadError(null);
setOrganizations([]);
}}
className="text-xs text-slate-600 underline hover:text-slate-800">
{t("common.try_again")}
</button>
</div>
)}
{!isLoadingOrganizations && !loadError && (
<>
<DropdownMenuGroup>
{organizations.map((org) => (
<DropdownMenuCheckboxItem
key={org.id}
checked={org.id === currentOrganizationId}
onClick={() => handleOrganizationChange(org.id)}
className="cursor-pointer">
{org.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMultiOrgEnabled && (
<DropdownMenuCheckboxItem
onClick={() => setOpenCreateOrganizationModal(true)}
className="cursor-pointer">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 h-4 w-4" />
</DropdownMenuCheckboxItem>
)}
</>
<DropdownMenuGroup>
{organizations.map((org) => (
<DropdownMenuCheckboxItem
key={org.id}
checked={org.id === currentOrganization.id}
onClick={() => handleOrganizationChange(org.id)}
className="cursor-pointer">
{org.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMultiOrgEnabled && (
<DropdownMenuCheckboxItem
onClick={() => setOpenCreateOrganizationModal(true)}
className="cursor-pointer">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 h-4 w-4" />
</DropdownMenuCheckboxItem>
)}
</>
)}
{currentEnvironmentId && (
<div>
{showOrganizationDropdown && <DropdownMenuSeparator />}
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<SettingsIcon className="mr-2 inline h-4 w-4" />
{t("common.organization_settings")}

View File

@@ -1,5 +1,6 @@
"use client";
import { useMemo } from "react";
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
@@ -7,9 +8,9 @@ import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
interface ProjectAndOrgSwitchProps {
currentOrganizationId: string;
currentOrganizationName?: string; // Optional: for pages without context
organizations: { id: string; name: string }[];
currentProjectId?: string;
currentProjectName?: string; // Optional: for pages without context
projects: { id: string; name: string }[];
currentEnvironmentId?: string;
environments: { id: string; type: string }[];
isMultiOrgEnabled: boolean;
@@ -17,15 +18,15 @@ interface ProjectAndOrgSwitchProps {
isFormbricksCloud: boolean;
isLicenseActive: boolean;
isOwnerOrManager: boolean;
isMember: boolean;
isAccessControlAllowed: boolean;
isMember: boolean;
}
export const ProjectAndOrgSwitch = ({
currentOrganizationId,
currentOrganizationName,
organizations,
currentProjectId,
currentProjectName,
projects,
currentEnvironmentId,
environments,
isMultiOrgEnabled,
@@ -36,6 +37,11 @@ export const ProjectAndOrgSwitch = ({
isAccessControlAllowed,
isMember,
}: ProjectAndOrgSwitchProps) => {
const sortedProjects = useMemo(() => projects.toSorted((a, b) => a.name.localeCompare(b.name)), [projects]);
const sortedOrganizations = useMemo(
() => organizations.toSorted((a, b) => a.name.localeCompare(b.name)),
[organizations]
);
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
@@ -44,9 +50,9 @@ export const ProjectAndOrgSwitch = ({
<BreadcrumbList className="gap-0">
<OrganizationBreadcrumb
currentOrganizationId={currentOrganizationId}
currentOrganizationName={currentOrganizationName}
currentEnvironmentId={currentEnvironmentId}
organizations={sortedOrganizations}
isMultiOrgEnabled={isMultiOrgEnabled}
currentEnvironmentId={currentEnvironmentId}
isFormbricksCloud={isFormbricksCloud}
isMember={isMember}
isOwnerOrManager={isOwnerOrManager}
@@ -54,9 +60,9 @@ export const ProjectAndOrgSwitch = ({
{currentProjectId && currentEnvironmentId && (
<ProjectBreadcrumb
currentProjectId={currentProjectId}
currentProjectName={currentProjectName}
currentOrganizationId={currentOrganizationId}
currentEnvironmentId={currentEnvironmentId}
projects={sortedProjects}
isOwnerOrManager={isOwnerOrManager}
organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={isFormbricksCloud}

View File

@@ -3,11 +3,9 @@
import * as Sentry from "@sentry/nextjs";
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { getProjectsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
@@ -20,11 +18,10 @@ import {
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { useProject } from "../context/environment-context";
interface ProjectBreadcrumbProps {
currentProjectId: string;
currentProjectName?: string; // Optional: pass directly if context not available
projects: { id: string; name: string }[];
isOwnerOrManager: boolean;
organizationProjectsLimit: number;
isFormbricksCloud: boolean;
@@ -47,7 +44,7 @@ const isActiveProjectSetting = (pathname: string, settingId: string): boolean =>
export const ProjectBreadcrumb = ({
currentProjectId,
currentProjectName,
projects,
isOwnerOrManager,
organizationProjectsLimit,
isFormbricksCloud,
@@ -62,41 +59,9 @@ export const ProjectBreadcrumb = ({
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
const [openLimitModal, setOpenLimitModal] = useState(false);
const router = useRouter();
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const pathname = usePathname();
// Get current project name from context OR prop
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
const { project: currentProject } = useProject();
const projectName = currentProject?.name || currentProjectName || "";
// Lazy-load projects when dropdown opens
useEffect(() => {
// Only fetch when dropdown opened for first time (and no error state)
if (isProjectDropdownOpen && projects.length === 0 && !isLoadingProjects && !loadError) {
setIsLoadingProjects(true);
setLoadError(null); // Clear any previous errors
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort projects by name
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
setProjects(sorted);
} else {
// Handle server errors or validation errors
const errorMessage = getFormattedErrorMessage(result);
const error = new Error(errorMessage);
logger.error(error, "Failed to load projects");
Sentry.captureException(error);
setLoadError(errorMessage || t("common.failed_to_load_projects"));
}
setIsLoadingProjects(false);
});
}
}, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
const projectSettings = [
{
id: "general",
@@ -135,6 +100,8 @@ export const ProjectBreadcrumb = ({
},
];
const currentProject = projects.find((project) => project.id === currentProjectId);
if (!currentProject) {
const errorMessage = `Project not found for project id: ${currentProjectId}`;
logger.error(errorMessage);
@@ -199,7 +166,7 @@ export const ProjectBreadcrumb = ({
asChild>
<div className="flex items-center gap-1">
<FolderOpenIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{projectName}</span>
<span>{currentProject.name}</span>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isProjectDropdownOpen ? (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
@@ -214,48 +181,26 @@ export const ProjectBreadcrumb = ({
<FolderOpenIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.choose_project")}
</div>
{isLoadingProjects && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingProjects && loadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{loadError}</p>
<button
onClick={() => {
setLoadError(null);
setProjects([]);
}}
className="text-xs text-slate-600 underline hover:text-slate-800">
{t("common.try_again")}
</button>
</div>
)}
{!isLoadingProjects && !loadError && (
<>
<DropdownMenuGroup>
{projects.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === currentProjectId}
onClick={() => handleProjectChange(proj.id)}
className="cursor-pointer">
<div className="flex items-center gap-2">
<span>{proj.name}</span>
</div>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleAddProject}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_project")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
<DropdownMenuGroup>
{projects.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === currentProject.id}
onClick={() => handleProjectChange(proj.id)}
className="cursor-pointer">
<div className="flex items-center gap-2">
<span>{proj.name}</span>
</div>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleAddProject}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_project")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
<DropdownMenuGroup>
<DropdownMenuSeparator />

View File

@@ -2,13 +2,11 @@
import { createContext, useContext, useMemo } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
export interface EnvironmentContextType {
environment: TEnvironment;
project: TProject;
organization: TOrganization;
organizationId: string;
}
@@ -22,44 +20,25 @@ export const useEnvironment = () => {
return context;
};
export const useProject = () => {
const context = useContext(EnvironmentContext);
if (!context) {
return { project: null };
}
return { project: context.project };
};
export const useOrganization = () => {
const context = useContext(EnvironmentContext);
if (!context) {
return { organization: null };
}
return { organization: context.organization };
};
// Client wrapper component to be used in server components
interface EnvironmentContextWrapperProps {
environment: TEnvironment;
project: TProject;
organization: TOrganization;
children: React.ReactNode;
}
export const EnvironmentContextWrapper = ({
environment,
project,
organization,
children,
}: EnvironmentContextWrapperProps) => {
const environmentContextValue = useMemo(
() => ({
environment,
project,
organization,
organizationId: project.organizationId,
}),
[environment, project, organization]
[environment, project]
);
return (

View File

@@ -1,9 +1,11 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
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 { getEnvironment } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
const EnvLayout = async (props: {
@@ -13,25 +15,48 @@ const EnvLayout = async (props: {
const params = await props.params;
const { children } = props;
// Check session first (required for userId)
const session = await getServerSession(authOptions);
if (!session?.user) {
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
if (!session) {
return redirect(`/auth/login`);
}
// Single consolidated data fetch (replaces ~12 individual fetches)
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
}
const [project, environment] = await Promise.all([
getProjectByEnvironmentId(params.environmentId),
getEnvironment(params.environmentId),
]);
if (!project) {
throw new Error(t("common.project_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
if (!membership) {
throw new Error(t("common.membership_not_found"));
}
return (
<>
<EnvironmentIdBaseLayout
environmentId={params.environmentId}
session={session}
user={user}
organization={organization}>
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentContextWrapper
environment={layoutData.environment}
project={layoutData.project}
organization={layoutData.organization}>
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
<EnvironmentContextWrapper environment={environment} project={project}>
<EnvironmentLayout environmentId={params.environmentId} session={session}>
{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

@@ -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

@@ -1,6 +1,6 @@
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -8,14 +8,7 @@ import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Dialog, DialogBody, DialogContent, DialogFooter, DialogTitle } from "@/modules/ui/components/dialog";
interface ResponseCardModalProps {
responses: TResponse[];
@@ -49,37 +42,25 @@ export const ResponseCardModal = ({
locale,
}: ResponseCardModalProps) => {
const [currentIndex, setCurrentIndex] = useState<number | null>(null);
const [isNavigating, setIsNavigating] = useState(false);
const idToIndexMap = useMemo(() => {
const map = new Map<string, number>();
for (let i = 0; i < responses.length; i++) {
map.set(responses[i].id, i);
}
return map;
}, [responses]);
useEffect(() => {
if (selectedResponseId) {
setOpen(true);
const index = idToIndexMap.get(selectedResponseId) ?? -1;
const index = responses.findIndex((response) => response.id === selectedResponseId);
setCurrentIndex(index);
setIsNavigating(false);
} else {
setOpen(false);
}
}, [selectedResponseId, idToIndexMap, setOpen]);
}, [selectedResponseId, responses, setOpen]);
const handleNext = () => {
if (currentIndex !== null && currentIndex < responses.length - 1) {
setIsNavigating(true);
setSelectedResponseId(responses[currentIndex + 1].id);
}
};
const handleBack = () => {
if (currentIndex !== null && currentIndex > 0) {
setIsNavigating(true);
setSelectedResponseId(responses[currentIndex - 1].id);
}
};
@@ -91,8 +72,8 @@ export const ResponseCardModal = ({
}
};
// If no response is selected or currentIndex is null or invalid, do not render the modal
if (selectedResponseId === null || currentIndex === null || currentIndex === -1) return null;
// If no response is selected or currentIndex is null, do not render the modal
if (selectedResponseId === null || currentIndex === null) return null;
return (
<Dialog open={open} onOpenChange={handleClose}>
@@ -100,11 +81,6 @@ export const ResponseCardModal = ({
<VisuallyHidden asChild>
<DialogTitle>Survey Response Details</DialogTitle>
</VisuallyHidden>
<VisuallyHidden asChild>
<DialogDescription>
Response {currentIndex + 1} of {responses.length}
</DialogDescription>
</VisuallyHidden>
<DialogBody>
<SingleResponseCard
survey={survey}
@@ -120,16 +96,12 @@ export const ResponseCardModal = ({
/>
</DialogBody>
<DialogFooter>
<Button
onClick={handleBack}
disabled={currentIndex === 0 || isNavigating}
variant="outline"
size="icon">
<Button onClick={handleBack} disabled={currentIndex === 0} variant="outline" size="icon">
<ChevronLeft />
</Button>
<Button
onClick={handleNext}
disabled={currentIndex === responses.length - 1 || isNavigating}
disabled={currentIndex === responses.length - 1}
variant="outline"
size="icon">
<ChevronRight />

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;
@@ -29,65 +28,60 @@ interface ResponseDataViewProps {
quotas: TSurveyQuota[];
}
// Helper function to format array values to record with specified keys
const formatArrayToRecord = (responseValue: TResponseDataValue, keys: string[]): Record<string, string> => {
if (!Array.isArray(responseValue)) return {};
const result: Record<string, string> = {};
for (let index = 0; index < responseValue.length; index++) {
const curr = responseValue[index];
result[keys[index]] = curr || "";
}
return result;
};
// Export for testing
export const formatAddressData = (responseValue: TResponseDataValue): Record<string, string> => {
const addressKeys = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
return formatArrayToRecord(responseValue, addressKeys);
return Array.isArray(responseValue)
? responseValue.reduce((acc, curr, index) => {
acc[addressKeys[index]] = curr || ""; // Fallback to empty string if undefined
return acc;
}, {})
: {};
};
// Export for testing
export const formatContactInfoData = (responseValue: TResponseDataValue): Record<string, string> => {
const contactInfoKeys = ["firstName", "lastName", "email", "phone", "company"];
return formatArrayToRecord(responseValue, contactInfoKeys);
const addressKeys = ["firstName", "lastName", "email", "phone", "company"];
return Array.isArray(responseValue)
? responseValue.reduce((acc, curr, index) => {
acc[addressKeys[index]] = curr || ""; // Fallback to empty string if undefined
return acc;
}, {})
: {};
};
// Export for testing
export const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => {
const responseData: Record<string, any> = {};
let responseData: Record<string, any> = {};
const elements = getElementsFromBlocks(survey.blocks);
for (const element of elements) {
const responseValue = response.data[element.id];
switch (element.type) {
survey.questions.forEach((question) => {
const responseValue = response.data[question.id];
switch (question.type) {
case "matrix":
if (typeof responseValue === "object") {
Object.assign(responseData, responseValue);
responseData = { ...responseData, ...responseValue };
}
break;
case "address":
Object.assign(responseData, formatAddressData(responseValue));
responseData = { ...responseData, ...formatAddressData(responseValue) };
break;
case "contactInfo":
Object.assign(responseData, formatContactInfoData(responseValue));
responseData = { ...responseData, ...formatContactInfoData(responseValue) };
break;
default:
responseData[element.id] = responseValue;
responseData[question.id] = responseValue;
}
}
});
if (survey.hiddenFields.fieldIds) {
for (const fieldId of survey.hiddenFields.fieldIds) {
responseData[fieldId] = response.data[fieldId];
}
}
survey.hiddenFields.fieldIds?.forEach((fieldId) => {
responseData[fieldId] = response.data[fieldId];
});
return responseData;
};
// Export for testing
const mapResponsesToTableData = (
export const mapResponsesToTableData = (
responses: TResponseWithQuotas[],
survey: TSurvey,
t: TFunction
@@ -133,10 +127,6 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
quotas,
}) => {
const { t } = useTranslation();
const [selectedResponseId, setSelectedResponseId] = React.useState<string | null>(null);
const setSelectedResponseIdTransition = React.useCallback((id: string | null) => {
React.startTransition(() => setSelectedResponseId(id));
}, []);
const data = mapResponsesToTableData(responses, survey, t);
return (
@@ -157,8 +147,6 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
locale={locale}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
selectedResponseId={selectedResponseId}
setSelectedResponseId={setSelectedResponseIdTransition}
/>
</div>
);

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,20 +110,24 @@ 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);
setResponses([]);
}, [filters]);
return (
<>
<div className="flex h-9 gap-1.5">
<div className="flex gap-1.5">
<CustomFilter survey={surveyMemoized} />
</div>
<ResponseDataView

View File

@@ -39,12 +39,6 @@ import {
import { Skeleton } from "@/modules/ui/components/skeleton";
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/modules/ui/components/table";
const SkeletonCell = () => (
<Skeleton className="w-full">
<div className="h-6"></div>
</Skeleton>
);
interface ResponseTableProps {
data: TResponseTableData[];
survey: TSurvey;
@@ -61,8 +55,6 @@ interface ResponseTableProps {
locale: TUserLocale;
isQuotasAllowed: boolean;
quotas: TSurveyQuota[];
selectedResponseId: string | null;
setSelectedResponseId: (id: string | null) => void;
}
export const ResponseTable = ({
@@ -81,13 +73,12 @@ export const ResponseTable = ({
locale,
isQuotasAllowed,
quotas,
selectedResponseId,
setSelectedResponseId,
}: ResponseTableProps) => {
const { t } = useTranslation();
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false);
const [selectedResponseId, setSelectedResponseId] = useState<string | null>(null);
const selectedResponse = responses?.find((response) => response.id === selectedResponseId) ?? null;
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
const [columnOrder, setColumnOrder] = useState<string[]>([]);
@@ -95,10 +86,7 @@ export const ResponseTable = ({
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
// Generate columns
const columns = useMemo(
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
);
const columns = generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn);
// Save settings to localStorage when they change
useEffect(() => {
@@ -122,13 +110,7 @@ export const ResponseTable = ({
// Memoize table data and columns
const tableData: TResponseTableData[] = useMemo(
() =>
isFetchingFirstPage
? Array.from(
{ length: 10 },
(_, index) => ({ responseId: `skeleton-${index}` }) as TResponseTableData
)
: data,
() => (isFetchingFirstPage ? Array(10).fill({}) : data),
[data, isFetchingFirstPage]
);
@@ -137,7 +119,11 @@ export const ResponseTable = ({
isFetchingFirstPage
? columns.map((column) => ({
...column,
cell: SkeletonCell,
cell: () => (
<Skeleton className="w-full">
<div className="h-6"></div>
</Skeleton>
),
}))
: columns,
[columns, isFetchingFirstPage]
@@ -261,8 +247,8 @@ export const ResponseTable = ({
</TableRow>
))}
</TableHeader>
{/* disable auto animation if there are more than 200 responses for performance optimizations */}
<TableBody ref={responses && responses.length > 200 ? undefined : parent}>
<TableBody ref={parent}>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
@@ -275,6 +261,7 @@ export const ResponseTable = ({
row={row}
isExpanded={isExpanded ?? false}
setSelectedResponseId={setSelectedResponseId}
responses={responses}
/>
))}
</TableRow>

View File

@@ -1,7 +1,6 @@
import { Cell, Row, flexRender } from "@tanstack/react-table";
import { Maximize2Icon } from "lucide-react";
import React from "react";
import { TResponseTableData } from "@formbricks/types/responses";
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
import { cn } from "@/lib/cn";
import { getCommonPinningStyles } from "@/modules/ui/components/data-table/lib/utils";
import { TableCell } from "@/modules/ui/components/table";
@@ -11,18 +10,21 @@ interface ResponseTableCellProps {
row: Row<TResponseTableData>;
isExpanded: boolean;
setSelectedResponseId: (responseId: string | null) => void;
responses: TResponse[] | null;
}
const ResponseTableCellComponent = ({
export const ResponseTableCell = ({
cell,
row,
isExpanded,
setSelectedResponseId,
responses,
}: ResponseTableCellProps) => {
// Function to handle cell click
const handleCellClick = () => {
if (cell.column.id !== "select") {
setSelectedResponseId(row.id);
const response = responses?.find((response) => response.id === row.id);
if (response) setSelectedResponseId(response.id);
}
};
@@ -64,5 +66,3 @@ const ResponseTableCellComponent = ({
</TableCell>
);
};
export const ResponseTableCell = React.memo(ResponseTableCellComponent);

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",
@@ -310,7 +314,7 @@ export const generateResponseTableColumns = (
const singleUseIdColumn: ColumnDef<TResponseTableData> = {
accessorKey: "singleUseId",
header: () => <div className="gap-x-1.5">{t("environments.surveys.responses.single_use_id")}</div>,
header: () => t("environments.surveys.responses.single_use_id"),
cell: ({ row }) => {
return <p className="truncate text-slate-900">{row.original.singleUseId}</p>;
},
@@ -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

@@ -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, resultsIdx) => {
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">
{results.length - resultsIdx} - {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

@@ -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,
@@ -94,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,
});
@@ -167,7 +170,7 @@ export const SurveyAnalysisCTA = ({
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

@@ -17,7 +17,7 @@ import {
subYears,
} from "date-fns";
import { TFunction } from "i18next";
import { Loader2 } from "lucide-react";
import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon, Loader2Icon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -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";
@@ -37,7 +37,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { PopoverTriggerButton, ResponseFilter } from "./ResponseFilter";
import { cn } from "@/modules/ui/lib/utils";
import { ResponseFilter } from "./ResponseFilter";
enum DateSelected {
FROM = "common.from",
@@ -136,7 +137,6 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
const [selectingDate, setSelectingDate] = useState<DateSelected>(DateSelected.FROM);
const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false);
const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState<boolean>(false);
const [isDownloadDropDownOpen, setIsDownloadDropDownOpen] = useState<boolean>(false);
const [hoveredRange, setHoveredRange] = useState<DateRange | null>(null);
const [isDownloading, setIsDownloading] = useState<boolean>(false);
@@ -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);
}
@@ -270,179 +270,201 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
useClickOutside(datePickerRef, () => handleDatePickerClose());
return (
<div className="relative flex justify-between">
<div className="flex justify-stretch gap-x-1.5">
<ResponseFilter survey={survey} />
<DropdownMenu
onOpenChange={(value) => {
value && handleDatePickerClose();
setIsFilterDropDownOpen(value);
}}>
<DropdownMenuTrigger asChild>
<PopoverTriggerButton isOpen={isFilterDropDownOpen}>
{filterRange === getFilterDropDownLabels(t).CUSTOM_RANGE
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
}`
: filterRange}
</PopoverTriggerButton>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
setDateRange({ from: undefined, to: getTodayDate() });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_MONTH);
setDateRange({
from: startOfMonth(subMonths(new Date(), 1)),
to: endOfMonth(subMonths(getTodayDate(), 1)),
});
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_QUARTER);
setDateRange({
from: startOfQuarter(subQuarters(new Date(), 1)),
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
});
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_6_MONTHS);
setDateRange({
from: startOfMonth(subMonths(new Date(), 6)),
to: endOfMonth(getTodayDate()),
});
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_YEAR);
setDateRange({
from: startOfYear(subYears(new Date(), 1)),
to: endOfYear(subYears(getTodayDate(), 1)),
});
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setIsDatePickerOpen(true);
setFilterRange(getFilterDropDownLabels(t).CUSTOM_RANGE);
setSelectingDate(DateSelected.FROM);
}}>
<p className="text-sm text-slate-700 hover:ring-0">{getFilterDropDownLabels(t).CUSTOM_RANGE}</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu
onOpenChange={(value) => {
value && handleDatePickerClose();
setIsDownloadDropDownOpen(value);
}}>
<DropdownMenuTrigger asChild>
<PopoverTriggerButton isOpen={isDownloadDropDownOpen} disabled={isDownloading}>
<span className="flex items-center gap-2">
{t("common.download")}
{isDownloading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
</span>
</PopoverTriggerButton>
</DropdownMenuTrigger>
<>
<div className="relative flex justify-between">
<div className="flex justify-stretch gap-x-1.5">
<ResponseFilter survey={survey} />
<DropdownMenu
onOpenChange={(value) => {
value && handleDatePickerClose();
setIsFilterDropDownOpen(value);
}}>
<DropdownMenuTrigger>
<div className="flex min-w-[8rem] items-center justify-between rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
<span className="text-sm text-slate-700">
{filterRange === getFilterDropDownLabels(t).CUSTOM_RANGE
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
}`
: filterRange}
</span>
{isFilterDropDownOpen ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
setDateRange({ from: undefined, to: getTodayDate() });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_MONTH);
setDateRange({
from: startOfMonth(subMonths(new Date(), 1)),
to: endOfMonth(subMonths(getTodayDate(), 1)),
});
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_QUARTER);
setDateRange({
from: startOfQuarter(subQuarters(new Date(), 1)),
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
});
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_6_MONTHS);
setDateRange({
from: startOfMonth(subMonths(new Date(), 6)),
to: endOfMonth(getTodayDate()),
});
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_YEAR);
setDateRange({
from: startOfYear(subYears(new Date(), 1)),
to: endOfYear(subYears(getTodayDate(), 1)),
});
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setIsDatePickerOpen(true);
setFilterRange(getFilterDropDownLabels(t).CUSTOM_RANGE);
setSelectingDate(DateSelected.FROM);
}}>
<p className="text-sm text-slate-700 hover:ring-0">
{getFilterDropDownLabels(t).CUSTOM_RANGE}
</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu
onOpenChange={(value) => {
value && handleDatePickerClose();
}}>
<DropdownMenuTrigger
asChild
className={cn(
"focus:bg-muted cursor-pointer outline-none",
isDownloading && "cursor-not-allowed opacity-50"
)}
disabled={isDownloading}
data-testid="fb__custom-filter-download-responses-button">
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">{t("common.download")}</span>
{isDownloading ? (
<Loader2Icon className="ml-2 h-4 w-4 animate-spin" />
) : (
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
)}
</div>
<DownloadIcon className="block h-4 sm:hidden" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
data-testid="fb__custom-filter-download-all-csv"
onClick={async () => {
await handleDownloadResponses(FilterDownload.ALL, "csv");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="fb__custom-filter-download-all-xlsx"
onClick={async () => {
await handleDownloadResponses(FilterDownload.ALL, "xlsx");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="fb__custom-filter-download-filtered-csv"
onClick={async () => {
await handleDownloadResponses(FilterDownload.FILTER, "csv");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="fb__custom-filter-download-filtered-xlsx"
onClick={async () => {
await handleDownloadResponses(FilterDownload.FILTER, "xlsx");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{isDatePickerOpen && (
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
<Calendar
autoFocus
mode="range"
defaultMonth={dateRange?.from}
selected={hoveredRange || dateRange}
numberOfMonths={2}
onDayClick={(date) => handleDateChange(date)}
onDayMouseEnter={handleDateHoveredChange}
onDayMouseLeave={() => setHoveredRange(null)}
classNames={{
day_today: "hover:bg-slate-200 bg-white",
}}
/>
<DropdownMenuContent align="start">
<DropdownMenuItem
data-testid="fb__custom-filter-download-all-csv"
onClick={async () => {
await handleDownloadResponses(FilterDownload.ALL, "csv");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="fb__custom-filter-download-all-xlsx"
onClick={async () => {
await handleDownloadResponses(FilterDownload.ALL, "xlsx");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="fb__custom-filter-download-filtered-csv"
onClick={async () => {
await handleDownloadResponses(FilterDownload.FILTER, "csv");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="fb__custom-filter-download-filtered-xlsx"
onClick={async () => {
await handleDownloadResponses(FilterDownload.FILTER, "xlsx");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
{isDatePickerOpen && (
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
<Calendar
autoFocus
mode="range"
defaultMonth={dateRange?.from}
selected={hoveredRange ? hoveredRange : dateRange}
numberOfMonths={2}
onDayClick={(date) => handleDateChange(date)}
onDayMouseEnter={handleDateHoveredChange}
onDayMouseLeave={() => setHoveredRange(null)}
classNames={{
day_today: "hover:bg-slate-200 bg-white",
}}
/>
</div>
)}
</div>
</>
);
};

View File

@@ -1,319 +0,0 @@
"use client";
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 { getLocalizedValue } from "@/lib/i18n/utils";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
import { Button } from "@/modules/ui/components/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/modules/ui/components/command";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} 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;
filterValue: string | undefined;
filterComboBoxValue: string | string[] | undefined;
onChangeFilterValue: (o: string) => void;
onChangeFilterComboBoxValue: (o: string | string[]) => void;
type?: TSurveyElementTypeEnum | Omit<OptionsType, OptionsType.ELEMENTS>;
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 = ({
filterComboBoxOptions,
filterComboBoxValue,
filterOptions,
filterValue,
onChangeFilterComboBoxValue,
onChangeFilterValue,
type,
handleRemoveMultiSelect,
disabled = false,
fieldId,
}: ElementFilterComboBoxProps) => {
const [open, setOpen] = useState(false);
const commandRef = useRef(null);
const [searchQuery, setSearchQuery] = useState("");
const { t } = useTranslation();
useClickOutside(commandRef, () => setOpen(false));
const isMultiple = checkIsMultiple(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);
return !filterComboBoxValue?.includes(optionValue);
});
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue]);
const isDisabledComboBox = checkIsDisabledComboBox(type, filterValue);
// Check if this is a text input field (URL meta field)
const isTextInputField = type === OptionsType.META && fieldId === "url";
// Filter options based on search query
const filteredOptions = useMemo(
() =>
options?.filter((o) => {
const optionValue = getOptionValue(o);
return optionValue.toLowerCase().includes(searchQuery.toLowerCase());
}),
[options, searchQuery]
);
const handleCommandItemSelect = (o: string | TI18nString) => {
const value = getOptionValue(o);
if (isMultiple) {
const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value];
onChangeFilterComboBoxValue(newValue);
return;
}
onChangeFilterComboBoxValue(value);
setOpen(false);
};
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);
};
// Helper to filter out a specific value from the array
const getFilteredValues = (valueToRemove: string): string[] => {
if (!Array.isArray(filterComboBoxValue)) return [];
return filterComboBoxValue.filter((i) => i !== valueToRemove);
};
// Handle removal of a multi-select tag
const handleRemoveTag = (e: React.MouseEvent, valueToRemove: string) => {
e.stopPropagation();
const filteredValues = getFilteredValues(valueToRemove);
handleRemoveMultiSelect(filteredValues);
};
// Render a single multi-select tag
const renderTag = (value: string, index: number) => (
<button
key={`${value}-${index}`}
type="button"
onClick={(e) => handleRemoveTag(e, value)}
className="flex items-center gap-1 whitespace-nowrap rounded bg-slate-100 px-2 py-1 text-sm text-slate-600 hover:bg-slate-200">
{value}
<X className="h-3 w-3" />
</button>
);
// Render multi-select tags
const renderMultiSelectTags = () => {
if (!Array.isArray(filterComboBoxValue) || filterComboBoxValue.length === 0) {
return null;
}
return (
<div className="no-scrollbar flex grow gap-2 overflow-auto">
{filterComboBoxValue.map((value, index) => renderTag(value, index))}
</div>
);
};
// Render the appropriate content based on filterComboBoxValue state
const renderComboBoxContent = () => {
if (!filterComboBoxValue || filterComboBoxValue.length === 0) {
return (
<p className={clsx("text-sm", isComboBoxDisabled ? "text-slate-300" : "text-slate-400")}>
{t("common.select")}...
</p>
);
}
if (Array.isArray(filterComboBoxValue)) {
return renderMultiSelectTags();
}
return <p className="truncate text-sm text-slate-600">{filterComboBoxValue}</p>;
};
return (
<div className="inline-flex h-fit w-full flex-row rounded-md border border-slate-300 hover:border-slate-400">
{renderFilterOptionsDropdown()}
{isTextInputField ? (
<Input
type="text"
value={typeof filterComboBoxValue === "string" ? filterComboBoxValue : ""}
onChange={(e) => onChangeFilterComboBoxValue(e.target.value)}
disabled={isComboBoxDisabled}
placeholder={t("common.enter_url")}
className="h-9 rounded-l-none border-none bg-white text-sm focus:ring-offset-0"
/>
) : (
<Command ref={commandRef} className="relative h-fit w-full min-w-0 overflow-visible bg-transparent">
{/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role */}
<div
role="button"
tabIndex={isComboBoxDisabled ? -1 : 0}
className={clsx(
"flex min-w-0 items-center gap-2 rounded-md rounded-l-none bg-white pl-2",
isComboBoxDisabled ? "opacity-50" : "cursor-pointer hover:bg-slate-50"
)}
onClick={handleOpenDropdown}
onKeyDown={(e) => {
const isActivationKey = e.key === "Enter" || e.key === " ";
if (isActivationKey && !isComboBoxDisabled) {
e.preventDefault();
handleOpenDropdown();
}
}}>
<div className="min-w-0 flex-1">{renderComboBoxContent()}</div>
<Button
onClick={(e) => {
e.stopPropagation();
if (isComboBoxDisabled) return;
setOpen(!open);
}}
disabled={isComboBoxDisabled}
variant="secondary"
size="icon"
className="flex-shrink-0"
aria-expanded={open}
aria-label={t("common.select")}>
<ChevronIcon />
</Button>
</div>
{open && (
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md bg-white shadow-md outline-none">
<CommandList className="max-h-52">
<CommandInput
value={searchQuery}
onValueChange={setSearchQuery}
placeholder={`${t("common.search")}...`}
className="border-none"
/>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
<CommandGroup>
{filteredOptions?.map((o) => {
const optionValue = getOptionValue(o);
return (
<CommandItem
key={optionValue}
onSelect={() => handleCommandItemSelect(o)}
className="cursor-pointer">
{optionValue}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</div>
)}
</Command>
)}
</div>
);
};

View File

@@ -1,238 +0,0 @@
"use client";
import clsx from "clsx";
import {
AirplayIcon,
ArrowUpFromDotIcon,
CheckIcon,
ChevronDown,
ChevronUp,
ContactIcon,
EyeOff,
FlagIcon,
GlobeIcon,
GridIcon,
HashIcon,
HomeIcon,
ImageIcon,
LanguagesIcon,
LinkIcon,
ListIcon,
ListOrderedIcon,
MessageSquareTextIcon,
MousePointerClickIcon,
PieChartIcon,
Rows3Icon,
SmartphoneIcon,
StarIcon,
User,
} from "lucide-react";
import { Fragment, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
import { Button } from "@/modules/ui/components/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/modules/ui/components/command";
import { NetPromoterScoreIcon } from "@/modules/ui/components/icons";
export enum OptionsType {
ELEMENTS = "Elements",
TAGS = "Tags",
ATTRIBUTES = "Attributes",
OTHERS = "Other Filters",
META = "Meta",
HIDDEN_FIELDS = "Hidden Fields",
QUOTAS = "Quotas",
}
export type ElementOption = {
label: string;
elementType?: TSurveyElementTypeEnum;
type: OptionsType;
id: string;
};
export type ElementOptions = {
header: OptionsType;
option: ElementOption[];
};
interface ElementComboBoxProps {
options: ElementOptions[];
selected: Partial<ElementOption>;
onChangeValue: (option: ElementOption) => 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,
// attributes
[OptionsType.ATTRIBUTES]: User,
// hidden fields
[OptionsType.HIDDEN_FIELDS]: EyeOff,
// meta
device: SmartphoneIcon,
os: AirplayIcon,
browser: GlobeIcon,
source: ArrowUpFromDotIcon,
action: MousePointerClickIcon,
country: FlagIcon,
url: LinkIcon,
// others
Language: LanguagesIcon,
// tags
[OptionsType.TAGS]: HashIcon,
// quotas
[OptionsType.QUOTAS]: PieChartIcon,
};
const getIcon = (type: string) => {
const IconComponent = elementIcons[type];
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
};
const getIconBackground = (type: OptionsType | string): string => {
const backgroundMap: Record<string, string> = {
[OptionsType.ATTRIBUTES]: "bg-indigo-500",
[OptionsType.ELEMENTS]: "bg-brand-dark",
[OptionsType.TAGS]: "bg-indigo-500",
[OptionsType.QUOTAS]: "bg-slate-500",
};
return backgroundMap[type] ?? "bg-amber-500";
};
const getLabelClassName = (type: OptionsType | string, label?: string): string => {
if (type !== OptionsType.META) return "";
return label === "os" || label === "url" ? "uppercase" : "capitalize";
};
export const SelectedCommandItem = ({ label, elementType, type }: Partial<ElementOption>) => {
const getDisplayIcon = () => {
if (!type) return null;
if (type === OptionsType.ELEMENTS && elementType) return getIcon(elementType);
if (type === OptionsType.ATTRIBUTES) return getIcon(OptionsType.ATTRIBUTES);
if (type === OptionsType.HIDDEN_FIELDS) return getIcon(OptionsType.HIDDEN_FIELDS);
if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) return getIcon(label);
if (type === OptionsType.TAGS) return getIcon(OptionsType.TAGS);
if (type === OptionsType.QUOTAS) return getIcon(OptionsType.QUOTAS);
return null;
};
return (
<div className="flex h-full min-w-0 items-center gap-2">
<span
className={clsx(
"flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-white",
getIconBackground(type ?? "")
)}>
{getDisplayIcon()}
</span>
<p className={clsx("truncate text-sm text-slate-600", getLabelClassName(type ?? "", label))}>
{typeof label === "string" ? label : getLocalizedValue(label, "default")}
</p>
</div>
);
};
export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementComboBoxProps) => {
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const commandRef = useRef(null);
const [inputValue, setInputValue] = useState("");
useClickOutside(commandRef, () => setOpen(false));
const hasSelection = selected.hasOwnProperty("label");
const ChevronIcon = open ? ChevronUp : ChevronDown;
return (
<Command
ref={commandRef}
className="relative h-fit w-full overflow-visible rounded-md border border-slate-300 bg-white hover:border-slate-400">
{/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role */}
<div
role="button"
tabIndex={0}
className="flex cursor-pointer items-center justify-between"
onClick={() => !open && setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
!open && setOpen(true);
}
}}>
{!open && hasSelection && <SelectedCommandItem {...selected} />}
{(open || !hasSelection) && (
<CommandInput
value={inputValue}
onValueChange={setInputValue}
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
/>
)}
<Button
onClick={(e) => {
e.stopPropagation();
setOpen(!open);
}}
variant="secondary"
size="icon"
className="flex-shrink-0"
aria-expanded={open}
aria-label={t("common.select")}>
<ChevronIcon className="h-4 w-4 opacity-50" />
</Button>
</div>
{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]">
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
{options?.map((data) => (
<Fragment key={data.header}>
{data?.option.length > 0 && (
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
{data?.option?.map((o) => (
<CommandItem
key={o.id}
onSelect={() => {
setInputValue("");
onChangeValue(o);
setOpen(false);
}}>
<SelectedCommandItem {...o} />
</CommandItem>
))}
</CommandGroup>
)}
</Fragment>
))}
</CommandList>
</div>
)}
</Command>
);
};

View File

@@ -0,0 +1,245 @@
"use client";
import clsx from "clsx";
import { ChevronDown, ChevronUp, X } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "@/modules/ui/components/command";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Input } from "@/modules/ui/components/input";
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?: TSurveyQuestionTypeEnum | Omit<OptionsType, OptionsType.QUESTIONS>;
handleRemoveMultiSelect: (value: string[]) => void;
disabled?: boolean;
fieldId?: string;
};
export const QuestionFilterComboBox = ({
filterComboBoxOptions,
filterComboBoxValue,
filterOptions,
filterValue,
onChangeFilterComboBoxValue,
onChangeFilterValue,
type,
handleRemoveMultiSelect,
disabled = false,
fieldId,
}: QuestionFilterComboBoxProps) => {
const [open, setOpen] = React.useState(false);
const [openFilterValue, setOpenFilterValue] = React.useState<boolean>(false);
const commandRef = React.useRef(null);
const [searchQuery, setSearchQuery] = React.useState<string>("");
const defaultLanguageCode = "default";
useClickOutside(commandRef, () => setOpen(false));
const { t } = useTranslation();
// multiple when question type is multi selection
const isMultiple =
type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
type === TSurveyQuestionTypeEnum.PictureSelection ||
(type === TSurveyQuestionTypeEnum.NPS && filterValue === "Includes either");
// when question type is multi selection so we remove the option from the options which has been already selected
const options = isMultiple
? filterComboBoxOptions?.filter(
(o) =>
!filterComboBoxValue?.includes(
typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o
)
)
: filterComboBoxOptions;
// disable the combo box for selection of value when question type is nps or rating and selected value is submitted or skipped
const isDisabledComboBox =
(type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) &&
(filterValue === "Submitted" || filterValue === "Skipped");
// Check if this is a URL field with string comparison operations that require text input
const isTextInputField = type === OptionsType.META && fieldId === "url";
const filteredOptions = options?.filter((o) =>
(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o)
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
const filterComboBoxItem = !Array.isArray(filterComboBoxValue) ? (
<p className="text-slate-600">{filterComboBoxValue}</p>
) : (
<div className="no-scrollbar flex w-[7rem] gap-3 overflow-auto md:w-[10rem] lg:w-[18rem]">
{typeof filterComboBoxValue !== "string" &&
filterComboBoxValue?.map((o, index) => (
<button
key={`${o}-${index}`}
type="button"
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
className="w-30 flex items-center whitespace-nowrap bg-slate-100 px-2 text-slate-600">
{o}
<X width={14} height={14} className="ml-2" />
</button>
))}
</div>
);
const commandItemOnSelect = (o: string) => {
if (!isMultiple) {
onChangeFilterComboBoxValue(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o);
} else {
onChangeFilterComboBoxValue(
Array.isArray(filterComboBoxValue)
? [...filterComboBoxValue, typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
: [typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
);
}
if (!isMultiple) {
setOpen(false);
}
};
return (
<div className="inline-flex w-full flex-row">
{filterOptions && filterOptions?.length <= 1 ? (
<div className="h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600">
<p className="mr-1 max-w-[50px] truncate text-slate-600 sm:max-w-[100px]">{filterValue}</p>
</div>
) : (
<DropdownMenu
onOpenChange={(value) => {
value && setOpen(false);
setOpenFilterValue(value);
}}>
<DropdownMenuTrigger
disabled={disabled}
className={clsx(
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
!disabled ? "cursor-pointer" : "opacity-50"
)}>
<div className="flex items-center justify-between">
{!filterValue ? (
<p className="text-slate-400">{t("common.select")}...</p>
) : (
<p className="mr-1 max-w-[50px] truncate text-slate-600 sm:max-w-[80px]">{filterValue}</p>
)}
{filterOptions && filterOptions.length > 1 && (
<>
{openFilterValue ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-white p-2">
{filterOptions?.map((o, index) => (
<DropdownMenuItem
key={`${o}-${index}`}
className="px-0.5 py-1 dark:bg-slate-700 dark:text-slate-300 dark:ring-slate-700"
onClick={() => onChangeFilterValue(o)}>
{o}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{isTextInputField ? (
<Input
type="text"
value={typeof filterComboBoxValue === "string" ? filterComboBoxValue : ""}
onChange={(e) => onChangeFilterComboBoxValue(e.target.value)}
disabled={disabled || !filterValue}
className="h-9 rounded-l-none border-none bg-white text-sm focus:ring-offset-0"
/>
) : (
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
<div
className={clsx(
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
)}>
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
filterComboBoxItem
) : (
<button
type="button"
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
disabled={disabled || isDisabledComboBox || !filterValue}
className={clsx(
"flex-1 text-left text-slate-400",
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
)}>
{t("common.select")}...
</button>
)}
<button
type="button"
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
disabled={disabled || isDisabledComboBox || !filterValue}
className={clsx(
"ml-2 flex items-center justify-center",
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
)}>
{open ? (
<ChevronUp className="h-4 w-4 opacity-50" />
) : (
<ChevronDown className="h-4 w-4 opacity-50" />
)}
</button>
</div>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<CommandList>
<div className="p-2">
<Input
type="text"
autoFocus
placeholder={t("common.search") + "..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
/>
</div>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
<CommandGroup>
{filteredOptions?.map((o, index) => (
<CommandItem
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
onSelect={() => commandItemOnSelect(o)}
className="cursor-pointer">
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</div>
)}
</div>
</Command>
)}
</div>
);
};

View File

@@ -0,0 +1,232 @@
"use client";
import clsx from "clsx";
import {
AirplayIcon,
ArrowUpFromDotIcon,
CheckIcon,
ChevronDown,
ChevronUp,
ContactIcon,
EyeOff,
FlagIcon,
GlobeIcon,
GridIcon,
HashIcon,
HomeIcon,
ImageIcon,
LanguagesIcon,
LinkIcon,
ListIcon,
ListOrderedIcon,
MessageSquareTextIcon,
MousePointerClickIcon,
PieChartIcon,
Rows3Icon,
SmartphoneIcon,
StarIcon,
User,
} from "lucide-react";
import { Fragment, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/modules/ui/components/command";
import { NetPromoterScoreIcon } from "@/modules/ui/components/icons";
export enum OptionsType {
QUESTIONS = "Questions",
TAGS = "Tags",
ATTRIBUTES = "Attributes",
OTHERS = "Other Filters",
META = "Meta",
HIDDEN_FIELDS = "Hidden Fields",
QUOTAS = "Quotas",
}
export type QuestionOption = {
label: string;
questionType?: TSurveyQuestionTypeEnum;
type: OptionsType;
id: string;
};
export type QuestionOptions = {
header: OptionsType;
option: QuestionOption[];
};
interface QuestionComboBoxProps {
options: QuestionOptions[];
selected: Partial<QuestionOption>;
onChangeValue: (option: QuestionOption) => void;
}
const questionIcons = {
// questions
[TSurveyQuestionTypeEnum.OpenText]: MessageSquareTextIcon,
[TSurveyQuestionTypeEnum.Rating]: StarIcon,
[TSurveyQuestionTypeEnum.CTA]: MousePointerClickIcon,
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: ListIcon,
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: Rows3Icon,
[TSurveyQuestionTypeEnum.NPS]: NetPromoterScoreIcon,
[TSurveyQuestionTypeEnum.Consent]: CheckIcon,
[TSurveyQuestionTypeEnum.PictureSelection]: ImageIcon,
[TSurveyQuestionTypeEnum.Matrix]: GridIcon,
[TSurveyQuestionTypeEnum.Ranking]: ListOrderedIcon,
[TSurveyQuestionTypeEnum.Address]: HomeIcon,
[TSurveyQuestionTypeEnum.ContactInfo]: ContactIcon,
// attributes
[OptionsType.ATTRIBUTES]: User,
// hidden fields
[OptionsType.HIDDEN_FIELDS]: EyeOff,
// meta
device: SmartphoneIcon,
os: AirplayIcon,
browser: GlobeIcon,
source: ArrowUpFromDotIcon,
action: MousePointerClickIcon,
country: FlagIcon,
url: LinkIcon,
// others
Language: LanguagesIcon,
// tags
[OptionsType.TAGS]: HashIcon,
// quotas
[OptionsType.QUOTAS]: PieChartIcon,
};
const getIcon = (type: string) => {
const IconComponent = questionIcons[type];
return IconComponent ? <IconComponent width={18} height={18} className="text-white" /> : null;
};
export const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
const getIconType = () => {
if (type) {
if (type === OptionsType.QUESTIONS && questionType) {
return getIcon(questionType);
} else if (type === OptionsType.ATTRIBUTES) {
return getIcon(OptionsType.ATTRIBUTES);
} else if (type === OptionsType.HIDDEN_FIELDS) {
return getIcon(OptionsType.HIDDEN_FIELDS);
} else if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) {
return getIcon(label);
} else if (type === OptionsType.TAGS) {
return getIcon(OptionsType.TAGS);
} else if (type === OptionsType.QUOTAS) {
return getIcon(OptionsType.QUOTAS);
}
}
};
const getColor = () => {
if (type === OptionsType.ATTRIBUTES) {
return "bg-indigo-500";
} else if (type === OptionsType.QUESTIONS) {
return "bg-brand-dark";
} else if (type === OptionsType.TAGS) {
return "bg-indigo-500";
} else if (type === OptionsType.QUOTAS) {
return "bg-slate-500";
} else {
return "bg-amber-500";
}
};
const getLabelStyle = (): string | undefined => {
if (type !== OptionsType.META) return undefined;
return label === "os" || label === "url" ? "uppercase" : "capitalize";
};
return (
<div className="flex h-5 w-[12rem] items-center sm:w-4/5">
<span className={clsx("rounded-md p-1", getColor())}>{getIconType()}</span>
<p className={clsx("ml-3 truncate text-sm text-slate-600", getLabelStyle())}>
{typeof label === "string" ? label : getLocalizedValue(label, "default")}
</p>
</div>
);
};
export const QuestionsComboBox = ({ options, selected, onChangeValue }: QuestionComboBoxProps) => {
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const commandRef = useRef(null);
const [inputValue, setInputValue] = useState("");
useClickOutside(commandRef, () => setOpen(false));
return (
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent hover:bg-slate-50">
<button
onClick={() => setOpen(true)}
className="group flex cursor-pointer items-center justify-between rounded-md bg-white px-3 py-2 text-sm">
{!open && selected.hasOwnProperty("label") && (
<SelectedCommandItem
label={selected?.label}
type={selected?.type}
questionType={selected?.questionType}
/>
)}
{(open || !selected.hasOwnProperty("label")) && (
<CommandInput
value={inputValue}
onValueChange={setInputValue}
placeholder={t("common.search") + "..."}
className="h-5 border-none border-transparent p-0 shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
/>
)}
<div>
{open ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</div>
</button>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in absolute top-0 z-50 w-full overflow-auto rounded-md bg-white outline-none">
<CommandList>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
{options?.map((data) => (
<Fragment key={data.header}>
{data?.option.length > 0 && (
<CommandGroup
heading={<p className="text-sm font-normal text-slate-600">{data.header}</p>}>
{data?.option?.map((o, i) => (
<CommandItem
key={`${o.label}-${i}`}
onSelect={() => {
setInputValue("");
onChangeValue(o);
setOpen(false);
}}
className="cursor-pointer">
<SelectedCommandItem label={o.label} type={o.type} questionType={o.questionType} />
</CommandItem>
))}
</CommandGroup>
)}
</Fragment>
))}
</CommandList>
</div>
)}
</div>
</Command>
);
};

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,49 +22,15 @@ 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;
};
interface PopoverTriggerButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
isOpen: boolean;
children: React.ReactNode;
}
export const PopoverTriggerButton = React.forwardRef<HTMLButtonElement, PopoverTriggerButtonProps>(
({ isOpen, children, ...props }, ref) => (
<button
ref={ref}
type="button"
{...props}
className="flex min-w-[8rem] cursor-pointer items-center justify-between rounded-md border border-slate-300 bg-white p-2 hover:border-slate-400">
<span className="text-sm text-slate-700">{children}</span>
<div className="ml-3">
{isOpen ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</div>
</button>
)
);
PopoverTriggerButton.displayName = "PopoverTriggerButton";
interface ResponseFilterProps {
survey: TSurvey;
}
@@ -80,12 +43,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 +52,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 +60,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 +97,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,
});
@@ -152,6 +108,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
useEffect(() => {
if (!isOpen) {
clearItem();
handleApplyFilters();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
@@ -162,7 +119,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
filter: [
...filterValue.filter,
{
elementType: {},
questionType: {},
filterType: { filterComboBoxValue: undefined, filterValue: undefined },
},
],
@@ -170,9 +127,8 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
};
const handleClearAllFilters = () => {
const clearedFilters = { filter: [], responseStatus: "all" as const };
setFilterValue(clearedFilters);
setSelectedFilter(clearedFilters);
setFilterValue((filterValue) => ({ ...filterValue, filter: [], responseStatus: "all" }));
setSelectedFilter((selectedFilters) => ({ ...selectedFilters, filter: [], responseStatus: "all" }));
setIsOpen(false);
};
@@ -214,10 +170,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)),
};
});
@@ -228,6 +184,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
};
const handleOpenChange = (open: boolean) => {
if (!open) {
handleApplyFilters();
}
setIsOpen(open);
};
@@ -235,30 +194,38 @@ 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>
</PopoverTriggerButton>
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded border border-slate-200 bg-white p-3 text-sm text-slate-600 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
<span>
Filter <b>{filterValue.filter.length > 0 && `(${filterValue.filter.length})`}</b>
</span>
<div className="ml-3">
{isOpen ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</div>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[300px] rounded-lg border-slate-200 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]"
className="w-[300px] border-slate-200 bg-slate-100 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]"
onOpenAutoFocus={(event) => event.preventDefault()}>
<div className="mb-6 flex flex-wrap items-start justify-between gap-2">
<p className="font-semibold text-slate-800">
<div className="mb-8 flex flex-wrap items-start justify-between gap-2">
<p className="text-slate800 hidden text-lg font-semibold sm:block">
{t("environments.surveys.summary.show_all_responses_that_match")}
</p>
<p className="block text-base text-slate-500 sm:hidden">
{t("environments.surveys.summary.show_all_responses_where")}
</p>
<div className="flex items-center space-x-2">
<Select
value={filterValue.responseStatus ?? "all"}
onValueChange={(val) => {
handleResponseStatusChange(val as TResponseStatus);
}}>
<SelectTrigger className="w-full bg-white text-slate-700">
}}
defaultValue={filterValue.responseStatus}>
<SelectTrigger className="w-full bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
@@ -280,76 +247,73 @@ 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">
<Button
variant="secondary"
size="icon"
<p className="block font-light text-slate-500 md:hidden">Delete</p>
<TrashIcon
className="w-4 cursor-pointer text-slate-500 md:text-black"
onClick={() => handleDeleteFilter(i)}
aria-label={t("common.delete")}>
<TrashIcon />
</Button>
/>
</div>
</div>
{i !== filterValue.filter.length - 1 && (
<div className="my-4 flex items-center">
<p className="mr-4 font-semibold text-slate-800">and</p>
<div className="my-6 flex items-center">
<p className="mr-6 text-base text-slate-600">And</p>
<hr className="w-full text-slate-600" />
</div>
)}
</React.Fragment>
))}
</div>
<div className="mt-6 flex items-center justify-between">
<div className="mt-8 flex items-center justify-between">
<Button size="sm" variant="secondary" onClick={handleAddNewFilter}>
{t("common.add_filter")}
<Plus width={18} height={18} className="ml-2" />
</Button>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={handleAddNewFilter}>
{t("common.add_filter")}
<Plus />
</Button>
<Button size="sm" onClick={handleApplyFilters}>
{t("common.apply_filters")}
</Button>
<Button size="sm" variant="ghost" onClick={handleClearAllFilters}>
{t("common.clear_all")}
</Button>
</div>
<Button size="sm" variant="destructive" onClick={handleClearAllFilters}>
{t("common.clear_all")}
<TrashIcon />
</Button>
</div>
</PopoverContent>
</Popover>

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,273 +0,0 @@
import { IntegrationType } from "@prisma/client";
import { createHash } from "node:crypto";
import { type CacheKey, getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { env } from "@/lib/env";
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 oldest organization to generate a stable, anonymized instance ID.
// Using the oldest org ensures the ID doesn't change over time.
const oldestOrg = await prisma.organization.findFirst({
orderBy: { createdAt: "asc" },
select: { id: true, createdAt: true },
});
if (!oldestOrg) return; // No organization exists, nothing to report
const instanceId = createHash("sha256").update(oldestOrg.id).digest("hex");
// 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: oldestOrg.createdAt.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";
@@ -227,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", () => {

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