Compare commits

...

100 Commits

Author SHA1 Message Date
pandeymangg
5729630678 remove unnecessary files 2025-12-22 14:15:10 +05:30
pandeymangg
3216b0177a remove unnecessary files 2025-12-22 13:52:12 +05:30
pandeymangg
14d031610f chore: merge with main 2025-12-22 13:23:25 +05:30
pandeymangg
944da1a16c Merge branch 'feat/custom-html-script' of https://github.com/formbricks/formbricks into feat/custom-html-script 2025-12-22 12:31:52 +05:30
pandeymangg
d27ce64bde restricts pretty url for cloud 2025-12-22 12:31:28 +05:30
Johannes
b70b2eef95 fix: vimeo + loom embed (#7018)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-12-20 08:08:48 +00:00
Harsh Bhat
392a95834b docs: Best practices Panel Management (#7011) 2025-12-20 06:32:57 +00:00
Anshuman Pandey
66d9cc8eac chore: adds docs for min browser version support (#7014) 2025-12-19 10:02:01 +00:00
Johannes
befdc078f1 fix: replace isomorphic-dompurify with sanitize-html in server component (#7002) 2025-12-19 07:34:56 +00:00
Dhruwang Jariwala
13b983b3b2 fix: missing question media (#6997)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-19 07:29:06 +00:00
Harsh Bhat
1e285ebe4e docs: Remove references of delay removal with debug mode (#7009) 2025-12-19 07:03:02 +00:00
Dhruwang Jariwala
a7c4971952 fix: replaced bg-white with survey-bg color in surveys package (#7004)
Co-authored-by: Luis Gustavo S. Barreto <gustavo@ossystems.com.br>
2025-12-19 06:50:33 +00:00
Johannes
b1da5ffe38 fixes and tweaks 2025-12-18 23:10:25 +01:00
Johannes
adf8686846 fix sonarqube issues 2025-12-18 22:48:13 +01:00
Johannes
a48734da4c Merge branch 'main' of https://github.com/formbricks/formbricks into feat/custom-html-script 2025-12-18 22:47:58 +01:00
Dhruwang Jariwala
c8689d91d5 fix: empty button in cta question (#6995) 2025-12-18 21:18:48 +00:00
Dhruwang Jariwala
73a2ff7421 fix: border radius for inputs (#6996) 2025-12-18 20:56:47 +00:00
Dhruwang Jariwala
0c28e89b41 fix: missing required question warning (#6998) 2025-12-18 19:12:47 +00:00
pandeymangg
3b56b21af9 feat: backend for pretty urls 2025-12-18 22:15:07 +05:30
Anshuman Pandey
a736436e29 chore: fixes typo (#6993) 2025-12-18 09:25:12 +00:00
Johannes
7dbb0300d3 fix: Pass the isExternalUrlAllowed prop to welcome card (#6992) 2025-12-18 08:51:21 +00:00
Matti Nannt
e71f3f412c feat: Add base path support for Formbricks (#6853) 2025-12-17 17:13:32 +00:00
Anshuman Pandey
07ed926225 fix: updates the patch to fix the next-auth no proxy issue (#6987)
Co-authored-by: Matti Nannt <matti@formbricks.com>
2025-12-17 17:11:40 +00:00
Dhruwang Jariwala
15dc83a4eb feat: improved survey UI (#6988)
Co-authored-by: Matti Nannt <matti@formbricks.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-17 16:13:28 +01:00
Johannes
3ce07edf43 chore: replacing intercom with chatwoot (#6980)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-16 16:16:09 +00:00
Johannes
0f34d9cc5f feat: standardize URL prefilling with option ID support and MQB support (#6970)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-16 10:09:47 +00:00
Matti Nannt
e9f800f017 fix: prepare pnpm in runner stage for airgapped deployments (#6925) 2025-12-15 13:30:55 +00:00
Johannes
ba2070b638 feat: add vars & hidden fields + send to verified email to followups (#6874)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-14 09:09:43 +00:00
Johannes
75cdb25d27 fix: improve survey response queue robustness to prevent data loss (#6959)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-14 08:18:11 +00:00
Johannes
6bc7db852c feat: Save draft without validation (Duplicate of #6847) (#6966)
Co-authored-by: Mahadeva Peruka <97960828+mahadevaperuka@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-12 21:52:00 +00:00
Matti Nannt
ffb4eac1a4 chore: upgrade azure-playwright (#6949) 2025-12-12 18:14:21 +00:00
Bhagya Amarasinghe
56da3b5725 chore: remove docker compose version pinning and update Traefik image version to v2.11.31 in docker-compose and documentation (#6967)
Co-authored-by: Matti Nannt <matti@formbricks.com>
2025-12-12 11:29:26 +01:00
dependabot[bot]
c189af5482 chore(deps): bump the npm_and_yarn group across 2 directories with 1 update (#6971)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2025-12-12 11:25:57 +01:00
Johannes
5dbf42fd6a feat: add bulk edit for single-select and multi-select options (#6951)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-12 06:49:49 +00:00
Anshuman Pandey
42525a86a8 fix: close the survey on formbricks.logout (#6955) 2025-12-12 06:03:35 +00:00
Anshuman Pandey
b96f0e67c5 fix: preserve attribute key casing during CSV contact upload (#6958)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-12-12 05:22:48 +00:00
Johannes
2d7b99ba26 feat: allow team admins to invite members to their own teams (#6891)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-12 05:01:48 +00:00
Matti Nannt
666a79044f fix: skip instance ID in license check during E2E tests (#6968)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-12 04:05:25 +00:00
Johannes
36d0a81dec feat: add pretty URL UI components for surveys
Added UI-only implementation for custom survey URLs (backend integration pending):

**Survey Share Modal:**
- New "Pretty URL" tab with inline URL builder (domain prefix + slug input)
- Edit/view mode toggle with form validation
- Uses React Hook Form for consistency with other tabs
- Delete confirmation dialog using standard DeleteDialog component

**Organization Settings:**
- Domain settings page showing all surveys with pretty URLs
- Table view with survey name, project, URL, and environment
- Navigation added to org settings sidebar

**Implementation:**
- Form components aligned with existing patterns (link-settings-tab)
- All text internationalized (11 new translation keys)
- TypeScript type extension for prettyUrl field (temporary)
- Input sanitization (lowercase, alphanumeric + hyphens only)

**What's Next (Backend Work):**
1. Add prettyUrl field to Survey Prisma model (@unique)
2. Create database migration
3. Update ZSurvey schema in types package
4. Implement save/update/delete API endpoints
5. Add slug availability check endpoint
6. Create /p/[slug] route handler for redirects
7. Remove TODO comments and temporary type extension

Files: 9 changed, 346 insertions(+)
2025-12-11 22:26:06 +01:00
Johannes
c3d97c2932 fix: docs links (#6960) 2025-12-10 10:59:25 +00:00
Anshuman Pandey
cc5d630a05 chore: adds docs for min ios and android versions (#6956) 2025-12-09 10:11:00 +00:00
Anshuman Pandey
be38d76ccf fix: removes empty imageUrl and videoUrl keys from elements (#6950) 2025-12-09 09:52:01 +00:00
Joel Ekström Svensson
a8eea306e5 feat: Add Swedish sv-SE translation (#6913)
Co-authored-by: Matti Nannt <matti@formbricks.com>
2025-12-08 14:49:44 +00:00
Matti Nannt
4fd53ac115 refactor: centralize instance ID generation (#6952) 2025-12-08 13:42:54 +00:00
Matti Nannt
eb92392ed1 fix: add node-forge security override to resolve Dependabot #230 (#6948) 2025-12-08 12:34:36 +00:00
dependabot[bot]
7412b32526 chore(deps): bump the npm_and_yarn group across 2 directories with 1 update (#6928)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2025-12-04 13:40:52 +00:00
Matti Nannt
193346a70d fix: upgrade Next.js to 15.5.7 and React to 19.1.2 to fix CVE-2025-66478 and CVE-2025-55182 (#6943) 2025-12-04 10:50:04 +00:00
Johannes
a1d4754b04 feat: allow survey-level logo override in styling tab (#6887)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-04 08:51:56 +00:00
Johannes
f4b918a4b6 feat: add survey metadata to webhook payload (#6939)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-04 07:08:42 +00:00
Dhruwang Jariwala
fb9a0b197a fix: disable keyboard navigation for 'other' option in multiple-choice component (#6941) 2025-12-04 06:59:13 +00:00
Dhruwang Jariwala
95b6c16dd1 fix: truncate language switch text #6910 (#6934)
Co-authored-by: Mahadeva Peruka <97960828+mahadevaperuka@users.noreply.github.com>
2025-12-03 13:40:26 +00:00
Johannes
cfdf09650f fix: error message in rating Question (#6909)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2025-12-03 09:15:34 +00:00
Anshuman Pandey
4c94fc25ae fix: fixes pnpm i18n script to generate surveys package translations as well (#6930) 2025-12-02 09:56:35 +00:00
Johannes
ccf501d925 fix: keyboard nav for MQP with multiple questions (#6926)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-02 06:40:30 +00:00
Dhruwang Jariwala
04dfbe0777 fix: removed unused t wrapper (#6923) 2025-12-01 16:35:13 +00:00
Matti Nannt
cbf255ab0d docs: add custom subpath deployment guide (#6922) 2025-12-01 15:33:51 +01:00
Dhruwang Jariwala
942366956c fix: missing finish label on last card (#6915) 2025-12-01 13:50:49 +00:00
Dhruwang Jariwala
a6ee796cef fix: back button label validation (#6916) 2025-12-01 12:09:50 +00:00
Dhruwang Jariwala
a535529bd3 fix: border around language select dropdown (#6914) 2025-12-01 08:57:36 +00:00
Dhruwang Jariwala
018cef61a6 feat: telemetry setup (#6888)
Co-authored-by: Matti Nannt <matti@formbricks.com>
2025-11-29 11:57:14 +00:00
Matti Nannt
c53e4f54cb feat: migrate integration configs from questions to elements (#6906)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-11-28 17:07:58 +00:00
Anshuman Pandey
e2fd71abfd fix: fixes the blocks deletion issue (#6907) 2025-11-28 14:04:37 +00:00
Anshuman Pandey
f888aa8a19 feat: MQP (#6901)
Co-authored-by: Matti Nannt <matti@formbricks.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-28 12:36:17 +00:00
Dhruwang Jariwala
2698817adb fix: language select UI (#6890)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-11-27 20:10:03 +00:00
Matti Nannt
2c18912f2f fix: use correct permission check for remove branding feature (#6895) 2025-11-27 15:56:43 +00:00
Johannes
f57497d8b3 fix: improve Contacts and Segments UX and functionality (#6855)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-26 07:49:23 +00:00
Johannes
aab6798b29 chore: Remove old telemetry & usage tracking (#6844)
Co-authored-by: Matti Nannt <matti@formbricks.com>
2025-11-25 12:57:43 +00:00
Johannes
f07092595f feat: UI improvements to survey editor and summary cards (#6857) 2025-11-25 09:49:59 +00:00
Johannes
c03c7ec1ed fix: Clarify wording around custom links against phishing (#6875) 2025-11-25 08:57:10 +00:00
Johannes
628de8e6ae fix: add missing filter option (#6879) 2025-11-25 08:55:34 +00:00
Matti Nannt
be4b54a827 docs: add S3 CORS configuration to file uploads documentation (#6877) 2025-11-24 13:00:28 +00:00
Harsh Bhat
e03df83e88 docs: Add GTM docs (#6830)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-11-24 10:59:27 +00:00
Dhruwang Jariwala
ed26427302 feat: add CSP nonce support for inline styles (#6796) (#6801) 2025-11-21 15:17:39 +00:00
Matti Nannt
554809742b fix: release pipeline boolean comparison for is_latest output (#6870) 2025-11-21 09:10:55 +00:00
Johannes
28adfb905c fix: Matrix filter (#6864)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-21 07:13:21 +00:00
Johannes
05c455ed62 fix: Link metadata (#6865) 2025-11-21 06:56:43 +00:00
Matti Nannt
f7687bc0ea fix: pin Prisma CLI to version 6 in Dockerfile (#6868) 2025-11-21 06:36:12 +00:00
Dhruwang Jariwala
af34391309 fix: filters not persisting in response page (#6862) 2025-11-20 15:14:44 +00:00
Dhruwang Jariwala
70978fbbdf fix: update preview when props change (#6860) 2025-11-20 13:26:55 +00:00
Matti Nannt
f6683d1165 fix: optimize survey list performance with client-side filtering (#6812)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-19 06:36:07 +00:00
Matti Nannt
13be7a8970 perf: Optimize link survey with server/client component architecture (#6764)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-19 06:31:41 +00:00
Dhruwang Jariwala
0472d5e8f0 fix: language switch tweak and docs feedback template (#6811) 2025-11-18 17:00:23 +00:00
Dhruwang Jariwala
00a61f7abe chore: response page optimization (#6843)
Co-authored-by: igor-srdoc <igor@srdoc.si>
2025-11-18 16:50:48 +00:00
Matti Nannt
6999abba3b fix: add typeorm security override (Dependabot #223) (#6842) 2025-11-18 10:35:34 +00:00
Matti Nannt
9ae66f44ae feat: add filterDateField parameter to enable filtering by updated-at in responses endpoint (#6833)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-18 10:14:45 +00:00
dependabot[bot]
7933d0077a chore(deps): bump glob from 11.0.2 to 11.1.0 in the npm_and_yarn group across 1 directory (#6838)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-18 11:13:41 +01:00
Johannes
cc8289fa33 feat: improve rating and NPS summary UI with aggregated view (#6834)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-18 08:38:11 +00:00
Matti Nannt
c458051839 chore: upgrade playwright to fix dependabot warnings (#6840) 2025-11-18 08:33:52 +00:00
Johannes
718a199d5b feat: add Personal Link generation UI (#6819)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-18 05:37:23 +00:00
Matti Nannt
5ab9fdf1e3 feat: reduce environment cache TTL to 1 minute for CDN and Redis (#6825)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-18 05:20:38 +00:00
Johannes
5741209aa9 fix: resolve metadata in hover confusion + other UI tweaks (#6821)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-17 11:51:49 +00:00
Johannes
35d0d8ed54 feat: add AND relationship support for URL filters in No Code Actions (#6822) 2025-11-17 11:06:32 +00:00
Johannes
5bce5c0a3b perf: Duplicate of Parallelize responses page data fetching v2 (#6831)
Co-authored-by: igor-srdoc <igor@srdoc.si>
2025-11-17 09:39:40 +00:00
Igor Srdoc
c61212964c perf: Parallelize independent data fetching in responses page (#6762)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-11-17 09:39:40 +00:00
Johannes
b8d41a6e9b perf: optimize survey editor drag and drop performance (#6823) 2025-11-17 09:36:13 +00:00
Johannes
eedd5200a4 fix: allow 1 option + other in select question (#6824)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-17 08:39:40 +00:00
Matti Nannt
71a85c7126 feat: add CUID v1 validation for environment ID endpoints (#6827)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-17 07:33:52 +00:00
Dhruwang Jariwala
341e2639e1 feat: spanish translations (#6817)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-11-13 14:48:37 +00:00
Dhruwang Jariwala
056470e6f0 fix: added variable key id mapping UI (#6814) 2025-11-13 09:56:42 +00:00
Dhruwang Jariwala
e965ad4b97 fix: raw html issues (#6813) 2025-11-13 09:12:39 +00:00
693 changed files with 59211 additions and 22200 deletions

View File

@@ -0,0 +1,352 @@
# Create New Question Element
Use this command to scaffold a new question element component in `packages/survey-ui/src/elements/`.
## Usage
When creating a new question type (e.g., `single-select`, `rating`, `nps`), follow these steps:
1. **Create the component file** `{question-type}.tsx` with this structure:
```typescript
import * as React from "react";
import { ElementHeader } from "../components/element-header";
import { useTextDirection } from "../hooks/use-text-direction";
import { cn } from "../lib/utils";
interface {QuestionType}Props {
/** Unique identifier for the element container */
elementId: string;
/** The main question or prompt text displayed as the headline */
headline: string;
/** Optional descriptive text displayed below the headline */
description?: string;
/** Unique identifier for the input/control group */
inputId: string;
/** Current value */
value?: {ValueType};
/** Callback function called when the value changes */
onChange: (value: {ValueType}) => void;
/** Whether the field is required (shows asterisk indicator) */
required?: boolean;
/** Error message to display */
errorMessage?: string;
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
dir?: "ltr" | "rtl" | "auto";
/** Whether the controls are disabled */
disabled?: boolean;
// Add question-specific props here
}
function {QuestionType}({
elementId,
headline,
description,
inputId,
value,
onChange,
required = false,
errorMessage,
dir = "auto",
disabled = false,
// ... question-specific props
}: {QuestionType}Props): React.JSX.Element {
// Ensure value is always the correct type (handle undefined/null)
const currentValue = value ?? {defaultValue};
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
textContent: [headline, description ?? "", /* add other text content from question */],
});
return (
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
/>
{/* Question-specific controls */}
{/* TODO: Add your question-specific UI here */}
{/* Error message */}
{errorMessage && (
<div className="text-destructive flex items-center gap-1 text-sm" dir={detectedDir}>
<span>{errorMessage}</span>
</div>
)}
</div>
);
}
export { {QuestionType} };
export type { {QuestionType}Props };
```
2. **Create the Storybook file** `{question-type}.stories.tsx`:
```typescript
import type { Decorator, Meta, StoryObj } from "@storybook/react";
import React from "react";
import { {QuestionType}, type {QuestionType}Props } from "./{question-type}";
// Styling options for the StylingPlayground story
interface StylingOptions {
// Question styling
questionHeadlineFontFamily: string;
questionHeadlineFontSize: string;
questionHeadlineFontWeight: string;
questionHeadlineColor: string;
questionDescriptionFontFamily: string;
questionDescriptionFontWeight: string;
questionDescriptionFontSize: string;
questionDescriptionColor: string;
// Add component-specific styling options here
}
type StoryProps = {QuestionType}Props & Partial<StylingOptions>;
const meta: Meta<StoryProps> = {
title: "UI-package/Elements/{QuestionType}",
component: {QuestionType},
parameters: {
layout: "centered",
docs: {
description: {
component: "A complete {question type} question element...",
},
},
},
tags: ["autodocs"],
argTypes: {
headline: {
control: "text",
description: "The main question text",
table: { category: "Content" },
},
description: {
control: "text",
description: "Optional description or subheader text",
table: { category: "Content" },
},
value: {
control: "object",
description: "Current value",
table: { category: "State" },
},
required: {
control: "boolean",
description: "Whether the field is required",
table: { category: "Validation" },
},
errorMessage: {
control: "text",
description: "Error message to display",
table: { category: "Validation" },
},
dir: {
control: { type: "select" },
options: ["ltr", "rtl", "auto"],
description: "Text direction for RTL support",
table: { category: "Layout" },
},
disabled: {
control: "boolean",
description: "Whether the controls are disabled",
table: { category: "State" },
},
onChange: {
action: "changed",
table: { category: "Events" },
},
// Add question-specific argTypes here
},
};
export default meta;
type Story = StoryObj<StoryProps>;
// Decorator to apply CSS variables from story args
const withCSSVariables: Decorator<StoryProps> = (Story, context) => {
const args = context.args as StoryProps;
const {
questionHeadlineFontFamily,
questionHeadlineFontSize,
questionHeadlineFontWeight,
questionHeadlineColor,
questionDescriptionFontFamily,
questionDescriptionFontSize,
questionDescriptionFontWeight,
questionDescriptionColor,
// Extract component-specific styling options
} = args;
const cssVarStyle: React.CSSProperties & Record<string, string | undefined> = {
"--fb-question-headline-font-family": questionHeadlineFontFamily,
"--fb-question-headline-font-size": questionHeadlineFontSize,
"--fb-question-headline-font-weight": questionHeadlineFontWeight,
"--fb-question-headline-color": questionHeadlineColor,
"--fb-question-description-font-family": questionDescriptionFontFamily,
"--fb-question-description-font-size": questionDescriptionFontSize,
"--fb-question-description-font-weight": questionDescriptionFontWeight,
"--fb-question-description-color": questionDescriptionColor,
// Add component-specific CSS variables
};
return (
<div style={cssVarStyle} className="w-[600px]">
<Story />
</div>
);
};
export const StylingPlayground: Story = {
args: {
headline: "Example question?",
description: "Example description",
// Default styling values
questionHeadlineFontFamily: "system-ui, sans-serif",
questionHeadlineFontSize: "1.125rem",
questionHeadlineFontWeight: "600",
questionHeadlineColor: "#1e293b",
questionDescriptionFontFamily: "system-ui, sans-serif",
questionDescriptionFontSize: "0.875rem",
questionDescriptionFontWeight: "400",
questionDescriptionColor: "#64748b",
// Add component-specific default values
},
argTypes: {
// Question styling argTypes
questionHeadlineFontFamily: {
control: "text",
table: { category: "Question Styling" },
},
questionHeadlineFontSize: {
control: "text",
table: { category: "Question Styling" },
},
questionHeadlineFontWeight: {
control: "text",
table: { category: "Question Styling" },
},
questionHeadlineColor: {
control: "color",
table: { category: "Question Styling" },
},
questionDescriptionFontFamily: {
control: "text",
table: { category: "Question Styling" },
},
questionDescriptionFontSize: {
control: "text",
table: { category: "Question Styling" },
},
questionDescriptionFontWeight: {
control: "text",
table: { category: "Question Styling" },
},
questionDescriptionColor: {
control: "color",
table: { category: "Question Styling" },
},
// Add component-specific argTypes
},
decorators: [withCSSVariables],
};
export const Default: Story = {
args: {
headline: "Example question?",
// Add default props
},
};
export const WithDescription: Story = {
args: {
headline: "Example question?",
description: "Example description text",
},
};
export const Required: Story = {
args: {
headline: "Example question?",
required: true,
},
};
export const WithError: Story = {
args: {
headline: "Example question?",
errorMessage: "This field is required",
required: true,
},
};
export const Disabled: Story = {
args: {
headline: "Example question?",
disabled: true,
},
};
export const RTL: Story = {
args: {
headline: "مثال على السؤال؟",
description: "مثال على الوصف",
// Add RTL-specific props
},
};
```
3. **Add CSS variables** to `packages/survey-ui/src/styles/globals.css` if needed:
```css
/* Component-specific CSS variables */
--fb-{component}-{property}: {default-value};
```
4. **Export from** `packages/survey-ui/src/index.ts`:
```typescript
export { {QuestionType}, type {QuestionType}Props } from "./elements/{question-type}";
```
## Key Requirements
- ✅ Always use `ElementHeader` component for headline/description
- ✅ Always use `useTextDirection` hook for RTL support
- ✅ Always handle undefined/null values safely (e.g., `Array.isArray(value) ? value : []`)
- ✅ Always include error message display if applicable
- ✅ Always support disabled state if applicable
- ✅ Always add JSDoc comments to props interface
- ✅ Always create Storybook stories with styling playground
- ✅ Always export types from component file
- ✅ Always add to index.ts exports
## Examples
- `open-text.tsx` - Text input/textarea question (string value)
- `multi-select.tsx` - Multiple checkbox selection (string[] value)
## Checklist
When creating a new question element, verify:
- [ ] Component file created with proper structure
- [ ] Props interface with JSDoc comments for all props
- [ ] Uses `ElementHeader` component (don't duplicate header logic)
- [ ] Uses `useTextDirection` hook for RTL support
- [ ] Handles undefined/null values safely
- [ ] Storybook file created with styling playground
- [ ] Includes common stories: Default, WithDescription, Required, WithError, Disabled, RTL
- [ ] CSS variables added to `globals.css` if component needs custom styling
- [ ] Exported from `index.ts` with types
- [ ] TypeScript types properly exported
- [ ] Error message display included if applicable
- [ ] Disabled state supported if applicable

View File

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

View File

@@ -1,13 +1,8 @@
--- ---
description: > description: >
This rule provides comprehensive knowledge about the Formbricks database structure, relationships, globs: schema.prisma
and data patterns. It should be used **only when the agent explicitly requests database schema-level alwaysApply: false
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
globs: []
alwaysApply: agent-requested
--- ---
# Formbricks Database Schema Reference # Formbricks Database Schema Reference
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly. This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.

View File

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

View File

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

View File

@@ -9,8 +9,12 @@
WEBAPP_URL=http://localhost:3000 WEBAPP_URL=http://localhost:3000
# Required for next-auth. Should be the same as WEBAPP_URL # Required for next-auth. Should be the same as WEBAPP_URL
# If your pplication uses a custom base path, specify the route to the API endpoint in full, e.g. NEXTAUTH_URL=https://example.com/custom-route/api/auth
NEXTAUTH_URL=http://localhost:3000 NEXTAUTH_URL=http://localhost:3000
# Can be used to deploy the application under a sub-path of a domain. This can only be set at build time
# BASE_PATH=
# Encryption keys # Encryption keys
# Please set both for now, we will change this in the future # Please set both for now, we will change this in the future
@@ -189,8 +193,9 @@ REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this) # The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL: # REDIS_HTTP_URL:
# INTERCOM_APP_ID= # Chatwoot
# INTERCOM_SECRET_KEY= # CHATWOOT_BASE_URL=
# CHATWOOT_WEBSITE_TOKEN=
# Enable Prometheus metrics # Enable Prometheus metrics
# PROMETHEUS_ENABLED= # PROMETHEUS_ENABLED=

View File

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

View File

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

View File

@@ -1,8 +1,11 @@
import type { StorybookConfig } from "@storybook/react-vite"; import type { StorybookConfig } from "@storybook/react-vite";
import { createRequire } from "module"; import { createRequire } from "module";
import { dirname, join } from "path"; import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/** /**
* This function is used to resolve the absolute path of a package. * This function is used to resolve the absolute path of a package.
@@ -13,7 +16,7 @@ function getAbsolutePath(value: string): any {
} }
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"], stories: ["../src/**/*.mdx", "../../../packages/survey-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [ addons: [
getAbsolutePath("@storybook/addon-onboarding"), getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-links"), getAbsolutePath("@storybook/addon-links"),
@@ -25,5 +28,25 @@ const config: StorybookConfig = {
name: getAbsolutePath("@storybook/react-vite"), name: getAbsolutePath("@storybook/react-vite"),
options: {}, options: {},
}, },
async viteFinal(config) {
const surveyUiPath = resolve(__dirname, "../../../packages/survey-ui/src");
const rootPath = resolve(__dirname, "../../../");
// Configure server to allow files from outside the storybook directory
config.server = config.server || {};
config.server.fs = {
...config.server.fs,
allow: [...(config.server.fs?.allow || []), rootPath],
};
// Configure simple alias resolution
config.resolve = config.resolve || {};
config.resolve.alias = {
...config.resolve.alias,
"@": surveyUiPath,
};
return config;
},
}; };
export default config; export default config;

View File

@@ -1,19 +1,6 @@
import type { Preview } from "@storybook/react-vite"; import type { Preview } from "@storybook/react-vite";
import React from "react"; import React from "react";
import { I18nProvider } from "../../web/lingodotdev/client"; import "../../../packages/survey-ui/src/styles/globals.css";
import "../../web/modules/ui/globals.css";
// Create a Storybook-specific Lingodot Dev decorator
const withLingodotDev = (Story: any) => {
return React.createElement(
I18nProvider,
{
language: "en-US",
defaultLanguage: "en-US",
} as any,
React.createElement(Story)
);
};
const preview: Preview = { const preview: Preview = {
parameters: { parameters: {
@@ -22,9 +9,23 @@ const preview: Preview = {
color: /(background|color)$/i, color: /(background|color)$/i,
date: /Date$/i, date: /Date$/i,
}, },
expanded: true,
},
backgrounds: {
default: "light",
}, },
}, },
decorators: [withLingodotDev], decorators: [
(Story) =>
React.createElement(
"div",
{
id: "fbjs",
className: "w-full h-full min-h-screen p-4 bg-background font-sans antialiased text-foreground",
},
React.createElement(Story)
),
],
}; };
export default preview; export default preview;

View File

@@ -11,22 +11,24 @@
"clean": "rimraf .turbo node_modules dist storybook-static" "clean": "rimraf .turbo node_modules dist storybook-static"
}, },
"dependencies": { "dependencies": {
"eslint-plugin-react-refresh": "0.4.20" "@formbricks/survey-ui": "workspace:*",
"eslint-plugin-react-refresh": "0.4.24"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "^4.0.1", "@chromatic-com/storybook": "^4.1.3",
"@storybook/addon-a11y": "9.0.15", "@storybook/addon-a11y": "10.0.8",
"@storybook/addon-links": "9.0.15", "@storybook/addon-links": "10.0.8",
"@storybook/addon-onboarding": "9.0.15", "@storybook/addon-onboarding": "10.0.8",
"@storybook/react-vite": "9.0.15", "@storybook/react-vite": "10.0.8",
"@typescript-eslint/eslint-plugin": "8.32.0", "@typescript-eslint/eslint-plugin": "8.48.0",
"@typescript-eslint/parser": "8.32.0", "@tailwindcss/vite": "4.1.17",
"@vitejs/plugin-react": "4.4.1", "@typescript-eslint/parser": "8.48.0",
"esbuild": "0.25.4", "@vitejs/plugin-react": "5.1.1",
"eslint-plugin-storybook": "9.0.15", "esbuild": "0.27.0",
"eslint-plugin-storybook": "10.0.8",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"storybook": "9.0.15", "storybook": "10.0.8",
"vite": "6.4.1", "vite": "7.2.4",
"@storybook/addon-docs": "9.0.15" "@storybook/addon-docs": "10.0.8"
} }
} }

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,7 +1,15 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
import base from "../web/tailwind.config"; import surveyUi from "../../packages/survey-ui/tailwind.config";
export default { export default {
...base, content: [
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}", "../web/modules/ui/**/*.{js,ts,jsx,tsx}"], "./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
"../../packages/survey-ui/src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
...surveyUi.theme?.extend,
},
},
}; };

View File

@@ -1,16 +1,17 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import path from "path"; import path from "path";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react(), tailwindcss()],
define: { define: {
"process.env": {}, "process.env": {},
}, },
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "../web"), "@formbricks/survey-ui": path.resolve(__dirname, "../../packages/survey-ui/src"),
}, },
}, },
}); });

View File

@@ -37,6 +37,10 @@ ENV NODE_OPTIONS=${NODE_OPTIONS}
# but needs explicit declaration for some build systems (like Depot) # but needs explicit declaration for some build systems (like Depot)
ARG TARGETARCH ARG TARGETARCH
# Base path for the application (optional)
ARG BASE_PATH=""
ENV BASE_PATH=${BASE_PATH}
# Set the working directory # Set the working directory
WORKDIR /app WORKDIR /app
@@ -73,8 +77,8 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
# #
FROM base AS runner FROM base AS runner
RUN npm install --ignore-scripts -g corepack@latest RUN npm install --ignore-scripts -g corepack@latest && \
RUN corepack enable corepack enable
RUN apk add --no-cache curl \ RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \ && apk add --no-cache supercronic \
@@ -124,7 +128,7 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod RUN chmod -R 755 ./node_modules/zod
RUN npm install -g prisma RUN npm install -g prisma@6
# Create a startup script to handle the conditional logic # Create a startup script to handle the conditional logic
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
@@ -134,12 +138,13 @@ EXPOSE 3000
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
USER nextjs USER nextjs
# Prepare volume for uploads # Prepare pnpm as the nextjs user to ensure it's available at runtime
RUN mkdir -p /home/nextjs/apps/web/uploads/ # Prepare volumes for uploads and SAML connections
VOLUME /home/nextjs/apps/web/uploads/ RUN corepack prepare pnpm@9.15.9 --activate && \
mkdir -p /home/nextjs/apps/web/uploads/ && \
mkdir -p /home/nextjs/apps/web/saml-connection
# Prepare volume for SAML preloaded connection VOLUME /home/nextjs/apps/web/uploads/
RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/saml-connection VOLUME /home/nextjs/apps/web/saml-connection
CMD ["/home/nextjs/start.sh"] CMD ["/home/nextjs/start.sh"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,6 +44,7 @@ interface ProjectSettingsProps {
organizationTeams: TOrganizationTeam[]; organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean; isAccessControlAllowed: boolean;
userProjectsCount: number; userProjectsCount: number;
publicDomain: string;
} }
export const ProjectSettings = ({ export const ProjectSettings = ({
@@ -55,6 +56,7 @@ export const ProjectSettings = ({
organizationTeams, organizationTeams,
isAccessControlAllowed = false, isAccessControlAllowed = false,
userProjectsCount, userProjectsCount,
publicDomain,
}: ProjectSettingsProps) => { }: ProjectSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false); const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
@@ -231,6 +233,7 @@ export const ProjectSettings = ({
<p className="text-sm text-slate-400">{t("common.preview")}</p> <p className="text-sm text-slate-400">{t("common.preview")}</p>
<div className="z-0 h-3/4 w-3/4"> <div className="z-0 h-3/4 w-3/4">
<SurveyInline <SurveyInline
appUrl={publicDomain}
isPreviewMode={true} isPreviewMode={true}
survey={previewSurvey(projectName || "my Product", t)} survey={previewSurvey(projectName || "my Product", t)}
styling={{ brandColor: { light: brandColor } }} styling={{ brandColor: { light: brandColor } }}

View File

@@ -5,6 +5,7 @@ import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@fo
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding"; import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings"; import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants"; import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getUserProjects } from "@/lib/project/service"; import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils"; import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
@@ -47,6 +48,8 @@ const Page = async (props: ProjectSettingsPageProps) => {
throw new Error(t("common.organization_teams_not_found")); throw new Error(t("common.organization_teams_not_found"));
} }
const publicDomain = getPublicDomain();
return ( return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12"> <div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header <Header
@@ -62,6 +65,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
organizationTeams={organizationTeams} organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed} isAccessControlAllowed={isAccessControlAllowed}
userProjectsCount={projects.length} userProjectsCount={projects.length}
publicDomain={publicDomain}
/> />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button

View File

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

@@ -1,6 +1,7 @@
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation"; import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar"; import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils"; import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
@@ -15,6 +16,7 @@ interface EnvironmentLayoutProps {
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => { export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
const t = await getTranslate(); const t = await getTranslate();
const publicDomain = getPublicDomain();
// Destructure all data from props (NO database queries) // Destructure all data from props (NO database queries)
const { const {
@@ -72,6 +74,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
isFormbricksCloud={IS_FORMBRICKS_CLOUD} isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT} isDevelopment={IS_DEVELOPMENT}
membershipRole={membership.role} membershipRole={membership.role}
publicDomain={publicDomain}
/> />
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50"> <div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar <TopControlBar

View File

@@ -46,6 +46,7 @@ interface NavigationProps {
isFormbricksCloud: boolean; isFormbricksCloud: boolean;
isDevelopment: boolean; isDevelopment: boolean;
membershipRole?: TOrganizationRole; membershipRole?: TOrganizationRole;
publicDomain: string;
} }
export const MainNavigation = ({ export const MainNavigation = ({
@@ -56,6 +57,7 @@ export const MainNavigation = ({
membershipRole, membershipRole,
isFormbricksCloud, isFormbricksCloud,
isDevelopment, isDevelopment,
publicDomain,
}: NavigationProps) => { }: NavigationProps) => {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
@@ -286,15 +288,16 @@ export const MainNavigation = ({
{/* Logout */} {/* Logout */}
<DropdownMenuItem <DropdownMenuItem
onClick={async () => { onClick={async () => {
const loginUrl = `${publicDomain}/auth/login`;
const route = await signOutWithAudit({ const route = await signOutWithAudit({
reason: "user_initiated", reason: "user_initiated",
redirectUrl: "/auth/login", redirectUrl: loginUrl,
organizationId: organization.id, organizationId: organization.id,
redirect: false, redirect: false,
callbackUrl: "/auth/login", callbackUrl: loginUrl,
clearEnvironmentId: true, clearEnvironmentId: true,
}); });
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings router.push(route?.url || loginUrl); // NOSONAR // We want to check for empty strings
}} }}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}> icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")} {t("common.logout")}

View File

@@ -1,61 +0,0 @@
"use client";
import type { Session } from "next-auth";
import { usePostHog } from "posthog-js/react";
import { useEffect } from "react";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
interface PosthogIdentifyProps {
session: Session;
user: TUser;
environmentId?: string;
organizationId?: string;
organizationName?: string;
organizationBilling?: TOrganizationBilling;
isPosthogEnabled: boolean;
}
export const PosthogIdentify = ({
session,
user,
environmentId,
organizationId,
organizationName,
organizationBilling,
isPosthogEnabled,
}: PosthogIdentifyProps) => {
const posthog = usePostHog();
useEffect(() => {
if (isPosthogEnabled && session.user && posthog) {
posthog.identify(session.user.id, {
name: user.name,
email: user.email,
});
if (environmentId) {
posthog.group("environment", environmentId, { name: environmentId });
}
if (organizationId) {
posthog.group("organization", organizationId, {
name: organizationName,
plan: organizationBilling?.plan,
responseLimit: organizationBilling?.limits.monthly.responses,
miuLimit: organizationBilling?.limits.monthly.miu,
});
}
}
}, [
posthog,
session.user,
environmentId,
organizationId,
organizationName,
organizationBilling,
user.name,
user.email,
isPosthogEnabled,
]);
return null;
};

View File

@@ -135,7 +135,7 @@ export const OrganizationBreadcrumb = ({
}, },
{ {
id: "teams", id: "teams",
label: t("common.teams"), label: t("common.members_and_teams"),
href: `/environments/${currentEnvironmentId}/settings/teams`, href: `/environments/${currentEnvironmentId}/settings/teams`,
}, },
{ {
@@ -144,6 +144,11 @@ export const OrganizationBreadcrumb = ({
href: `/environments/${currentEnvironmentId}/settings/api-keys`, href: `/environments/${currentEnvironmentId}/settings/api-keys`,
hidden: !isOwnerOrManager, hidden: !isOwnerOrManager,
}, },
{
id: "domain",
label: t("common.domain"),
href: `/environments/${currentEnvironmentId}/settings/domain`,
},
{ {
id: "billing", id: "billing",
label: t("common.billing"), label: t("common.billing"),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,7 +36,7 @@ export const OrganizationSettingsNavbar = ({
}, },
{ {
id: "teams", id: "teams",
label: t("common.teams"), label: t("common.members_and_teams"),
href: `/environments/${environmentId}/settings/teams`, href: `/environments/${environmentId}/settings/teams`,
current: pathname?.includes("/teams"), current: pathname?.includes("/teams"),
}, },
@@ -47,6 +47,13 @@ export const OrganizationSettingsNavbar = ({
current: pathname?.includes("/api-keys"), current: pathname?.includes("/api-keys"),
hidden: !isOwner, hidden: !isOwner,
}, },
{
id: "domain",
label: t("common.domain"),
href: `/environments/${environmentId}/settings/domain`,
current: pathname?.includes("/domain"),
hidden: isFormbricksCloud,
},
{ {
id: "billing", id: "billing",
label: t("common.billing"), label: t("common.billing"),

View File

@@ -0,0 +1,88 @@
"use client";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TSurveyStatus } from "@formbricks/types/surveys/types";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
interface SurveyWithSlug {
id: string;
name: string;
slug: string | null;
status: TSurveyStatus;
environment: {
id: string;
type: "production" | "development";
project: {
id: string;
name: string;
};
};
createdAt: Date;
}
interface PrettyUrlsTableProps {
surveys?: SurveyWithSlug[];
}
export const PrettyUrlsTable = ({ surveys = [] }: PrettyUrlsTableProps) => {
const { t } = useTranslation();
const getEnvironmentBadgeColor = (type: string) => {
return type === "production" ? "bg-green-100 text-green-800" : "bg-blue-100 text-blue-800";
};
return (
<div className="overflow-hidden rounded-lg">
<Table>
<TableHeader>
<TableRow className="bg-slate-100">
<TableHead className="font-medium text-slate-500">
{t("environments.settings.domain.survey_name")}
</TableHead>
<TableHead className="font-medium text-slate-500">
{t("environments.settings.domain.project")}
</TableHead>
<TableHead className="font-medium text-slate-500">
{t("environments.settings.domain.pretty_url")}
</TableHead>
<TableHead className="font-medium text-slate-500">{t("common.environment")}</TableHead>
</TableRow>
</TableHeader>
<TableBody className="[&_tr:last-child]:border-b">
{surveys.length === 0 && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={4} className="text-center text-slate-500">
{t("environments.settings.domain.no_pretty_urls")}
</TableCell>
</TableRow>
)}
{surveys.map((survey) => (
<TableRow key={survey.id} className="border-slate-200 hover:bg-transparent">
<TableCell className="font-medium">
<Link
href={`/environments/${survey.environment.id}/surveys/${survey.id}/summary`}
className="text-slate-900 hover:text-slate-700 hover:underline">
{survey.name}
</Link>
</TableCell>
<TableCell>{survey.environment.project.name}</TableCell>
<TableCell>
<IdBadge id={survey.slug ?? ""} />
</TableCell>
<TableCell>
<span
className={`rounded px-2 py-1 text-xs font-medium ${getEnvironmentBadgeColor(survey.environment.type)}`}>
{survey.environment.type === "production"
? t("common.production")
: t("common.development")}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};

View File

@@ -0,0 +1,53 @@
import { notFound } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getSurveysWithSlugsByOrganization } from "@/modules/survey/lib/slug";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsCard } from "../../components/SettingsCard";
import { OrganizationSettingsNavbar } from "../components/OrganizationSettingsNavbar";
import { PrettyUrlsTable } from "./components/pretty-urls-table";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { session, currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
if (!session) {
throw new Error(t("common.session_not_found"));
}
const result = await getSurveysWithSlugsByOrganization(organization.id);
if (!result.ok) {
throw new Error(t("common.something_went_wrong"));
}
const surveys = result.data;
return (
<PageContentWrapper>
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
<OrganizationSettingsNavbar
environmentId={params.environmentId}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
membershipRole={currentUserMembership?.role}
activeId="domain"
/>
</PageHeader>
<SettingsCard
title={t("environments.settings.domain.title")}
description={t("environments.settings.domain.description")}>
<PrettyUrlsTable surveys={surveys} />
</SettingsCard>
</PageContentWrapper>
);
};
export default Page;

View File

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

View File

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

View File

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

View File

@@ -8,8 +8,8 @@ import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user"; import { TUser, TUserLocale } from "@formbricks/types/user";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions"; import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView"; import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter"; import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { getFormattedFilters } from "@/app/lib/surveys/surveys"; import { getFormattedFilters } from "@/app/lib/surveys/surveys";
@@ -26,6 +26,7 @@ interface ResponsePageProps {
isReadOnly: boolean; isReadOnly: boolean;
isQuotasAllowed: boolean; isQuotasAllowed: boolean;
quotas: TSurveyQuota[]; quotas: TSurveyQuota[];
initialResponses?: TResponseWithQuotas[];
} }
export const ResponsePage = ({ export const ResponsePage = ({
@@ -39,11 +40,12 @@ export const ResponsePage = ({
isReadOnly, isReadOnly,
isQuotasAllowed, isQuotasAllowed,
quotas, quotas,
initialResponses = [],
}: ResponsePageProps) => { }: ResponsePageProps) => {
const [responses, setResponses] = useState<TResponseWithQuotas[]>([]); const [responses, setResponses] = useState<TResponseWithQuotas[]>(initialResponses);
const [page, setPage] = useState<number>(1); const [page, setPage] = useState<number | null>(null);
const [hasMore, setHasMore] = useState<boolean>(true); const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
const [isFetchingFirstPage, setFetchingFirstPage] = useState<boolean>(true); const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
const { selectedFilter, dateRange, resetState } = useResponseFilter(); const { selectedFilter, dateRange, resetState } = useResponseFilter();
const filters = useMemo( const filters = useMemo(
@@ -56,6 +58,7 @@ export const ResponsePage = ({
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const fetchNextPage = useCallback(async () => { const fetchNextPage = useCallback(async () => {
if (page === null) return;
const newPage = page + 1; const newPage = page + 1;
let newResponses: TResponseWithQuotas[] = []; let newResponses: TResponseWithQuotas[] = [];
@@ -93,10 +96,22 @@ export const ResponsePage = ({
} }
}, [searchParams, resetState]); }, [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(() => { useEffect(() => {
const fetchInitialResponses = async () => { const fetchFilteredResponses = async () => {
try { try {
setFetchingFirstPage(true); // skip call for initial mount
if (page === null && !hasFilters) {
setPage(1);
return;
}
setPage(1);
setIsFetchingFirstPage(true);
let responses: TResponseWithQuotas[] = []; let responses: TResponseWithQuotas[] = [];
const getResponsesActionResponse = await getResponsesAction({ const getResponsesActionResponse = await getResponsesAction({
@@ -110,19 +125,16 @@ export const ResponsePage = ({
if (responses.length < responsesPerPage) { if (responses.length < responsesPerPage) {
setHasMore(false); setHasMore(false);
} else {
setHasMore(true);
} }
setResponses(responses); setResponses(responses);
} finally { } finally {
setFetchingFirstPage(false); setIsFetchingFirstPage(false);
} }
}; };
fetchInitialResponses(); fetchFilteredResponses();
}, [surveyId, filters, responsesPerPage]); }, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
useEffect(() => {
setPage(1);
setHasMore(true);
}, [filters]);
return ( return (
<> <>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,104 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
interface DateElementSummary {
elementSummary: TSurveyElementSummaryDate;
environmentId: string;
survey: TSurvey;
locale: TUserLocale;
}
export const DateElementSummary = ({ elementSummary, environmentId, survey, locale }: DateElementSummary) => {
const { t } = useTranslation();
const [visibleResponses, setVisibleResponses] = useState(10);
const handleLoadMore = () => {
// Increase the number of visible responses by 10, not exceeding the total number of responses
setVisibleResponses((prevVisibleResponses) =>
Math.min(prevVisibleResponses + 10, elementSummary.samples.length)
);
};
const renderResponseValue = (value: string) => {
const parsedDate = new Date(value);
const formattedDate = isNaN(parsedDate.getTime())
? `${t("common.invalid_date")}(${value})`
: formatDateWithOrdinal(parsedDate);
return formattedDate;
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
<div className="">
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
<div className="pl-4 md:pl-6">{t("common.user")}</div>
<div className="col-span-2 pl-4 md:pl-6">{t("common.response")}</div>
<div className="px-4 md:px-6">{t("common.time")}</div>
</div>
<div className="max-h-[62vh] w-full overflow-y-auto">
{elementSummary.samples.length === 0 ? (
<div className="p-8">
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
</div>
) : (
elementSummary.samples.slice(0, visibleResponses).map((response) => (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{renderResponseValue(response.value)}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div>
))
)}
</div>
{elementSummary.samples.length > 0 && visibleResponses < elementSummary.samples.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</div>
</div>
);
};

View File

@@ -1,102 +0,0 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyQuestionSummaryDate } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface DateQuestionSummary {
questionSummary: TSurveyQuestionSummaryDate;
environmentId: string;
survey: TSurvey;
locale: TUserLocale;
}
export const DateQuestionSummary = ({
questionSummary,
environmentId,
survey,
locale,
}: DateQuestionSummary) => {
const { t } = useTranslation();
const [visibleResponses, setVisibleResponses] = useState(10);
const handleLoadMore = () => {
// Increase the number of visible responses by 10, not exceeding the total number of responses
setVisibleResponses((prevVisibleResponses) =>
Math.min(prevVisibleResponses + 10, questionSummary.samples.length)
);
};
const renderResponseValue = (value: string) => {
const parsedDate = new Date(value);
const formattedDate = isNaN(parsedDate.getTime())
? `${t("common.invalid_date")}(${value})`
: formatDateWithOrdinal(parsedDate);
return formattedDate;
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="">
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
<div className="pl-4 md:pl-6">{t("common.user")}</div>
<div className="col-span-2 pl-4 md:pl-6">{t("common.response")}</div>
<div className="px-4 md:px-6">{t("common.time")}</div>
</div>
<div className="max-h-[62vh] w-full overflow-y-auto">
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{renderResponseValue(response.value)}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div>
))}
</div>
{visibleResponses < questionSummary.samples.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</div>
</div>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,7 +29,6 @@ interface SurveyAnalysisCTAProps {
user: TUser; user: TUser;
publicDomain: string; publicDomain: string;
responseCount: number; responseCount: number;
displayCount: number;
segments: TSegment[]; segments: TSegment[];
isContactsEnabled: boolean; isContactsEnabled: boolean;
isFormbricksCloud: boolean; isFormbricksCloud: boolean;
@@ -48,7 +47,6 @@ export const SurveyAnalysisCTA = ({
user, user,
publicDomain, publicDomain,
responseCount, responseCount,
displayCount,
segments, segments,
isContactsEnabled, isContactsEnabled,
isFormbricksCloud, isFormbricksCloud,
@@ -169,7 +167,7 @@ export const SurveyAnalysisCTA = ({
icon: ListRestart, icon: ListRestart,
tooltip: t("environments.surveys.summary.reset_survey"), tooltip: t("environments.surveys.summary.reset_survey"),
onClick: () => setIsResetModalOpen(true), onClick: () => setIsResetModalOpen(true),
isVisible: !isReadOnly && (responseCount > 0 || displayCount > 0), isVisible: !isReadOnly,
}, },
{ {
icon: SquarePenIcon, icon: SquarePenIcon,

View File

@@ -3,7 +3,7 @@
import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { import {
Code2Icon, Code2Icon,
LinkIcon, Link2Icon,
MailIcon, MailIcon,
QrCodeIcon, QrCodeIcon,
Settings, Settings,
@@ -22,6 +22,7 @@ import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/survey
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab"; import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
import { LinkSettingsTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab"; import { LinkSettingsTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab";
import { PersonalLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab"; import { PersonalLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab";
import { PrettyUrlTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/pretty-url-tab";
import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab"; import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab";
import { SocialMediaTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab"; import { SocialMediaTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab";
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container"; import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
@@ -80,13 +81,13 @@ export const ShareSurveyModal = ({
componentType: React.ComponentType<unknown>; componentType: React.ComponentType<unknown>;
componentProps: unknown; componentProps: unknown;
disabled?: boolean; disabled?: boolean;
}[] = useMemo( }[] = useMemo(() => {
() => [ const tabs = [
{ {
id: ShareViaType.ANON_LINKS, id: ShareViaType.ANON_LINKS,
type: LinkTabsType.SHARE_VIA, type: LinkTabsType.SHARE_VIA,
label: t("environments.surveys.share.anonymous_links.nav_title"), label: t("environments.surveys.share.anonymous_links.nav_title"),
icon: LinkIcon, icon: Link2Icon,
title: t("environments.surveys.share.anonymous_links.nav_title"), title: t("environments.surveys.share.anonymous_links.nav_title"),
description: t("environments.surveys.share.anonymous_links.description"), description: t("environments.surveys.share.anonymous_links.description"),
componentType: AnonymousLinksTab, componentType: AnonymousLinksTab,
@@ -180,22 +181,33 @@ export const ShareSurveyModal = ({
componentType: LinkSettingsTab, componentType: LinkSettingsTab,
componentProps: { isReadOnly, locale: user.locale, isStorageConfigured }, componentProps: { isReadOnly, locale: user.locale, isStorageConfigured },
}, },
], {
[ id: ShareSettingsType.PRETTY_URL,
t, type: LinkTabsType.SHARE_SETTING,
survey, label: t("environments.surveys.share.pretty_url.title"),
publicDomain, icon: Link2Icon,
user.locale, title: t("environments.surveys.share.pretty_url.title"),
surveyUrl, description: t("environments.surveys.share.pretty_url.description"),
isReadOnly, componentType: PrettyUrlTab,
environmentId, componentProps: { publicDomain, isReadOnly },
segments, },
isContactsEnabled, ];
isFormbricksCloud,
email, return isFormbricksCloud ? tabs.filter((tab) => tab.id !== ShareSettingsType.PRETTY_URL) : tabs;
isStorageConfigured, }, [
] t,
); survey,
publicDomain,
user.locale,
surveyUrl,
isReadOnly,
environmentId,
segments,
isContactsEnabled,
isFormbricksCloud,
email,
isStorageConfigured,
]);
const getDefaultActiveId = useCallback(() => { const getDefaultActiveId = useCallback(() => {
if (survey.type !== "link") { if (survey.type !== "link") {

View File

@@ -0,0 +1,31 @@
"use client";
import { useTranslation } from "react-i18next";
import { Input } from "@/modules/ui/components/input";
interface PrettyUrlInputProps {
value: string;
onChange: (value: string) => void;
publicDomain: string;
disabled?: boolean;
}
export const PrettyUrlInput = ({ value, onChange, publicDomain, disabled = false }: PrettyUrlInputProps) => {
const { t } = useTranslation();
return (
<div className="flex items-center overflow-hidden rounded-md border border-slate-300 bg-white">
<span className="flex-shrink-0 border-r border-slate-300 bg-slate-50 px-3 py-2 text-sm text-slate-600">
{publicDomain}/p/
</span>
<Input
type="text"
value={value}
onChange={(e) => onChange(e.target.value.toLowerCase().replaceAll(/[^a-z0-9-]/g, ""))}
placeholder={t("environments.surveys.share.pretty_url.slug_placeholder")}
disabled={disabled}
className="border-0 bg-white focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
);
};

View File

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

View File

@@ -0,0 +1,203 @@
"use client";
import { Copy, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { removeSurveySlugAction, updateSurveySlugAction } from "@/modules/survey/slug/actions";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { PrettyUrlInput } from "./components/pretty-url-input";
interface PrettyUrlTabProps {
publicDomain: string;
isReadOnly?: boolean;
}
interface PrettyUrlFormData {
slug: string;
}
export const PrettyUrlTab = ({ publicDomain, isReadOnly = false }: PrettyUrlTabProps) => {
const { t } = useTranslation();
const router = useRouter();
const { survey } = useSurvey();
const [isEditing, setIsEditing] = useState(!survey.slug);
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// Initialize form with current values - memoize to prevent re-initialization
const initialFormData = useMemo(() => {
return {
slug: survey.slug || "",
};
}, [survey.slug]);
const form = useForm<PrettyUrlFormData>({
defaultValues: initialFormData,
});
const { handleSubmit, reset } = form;
// Sync isEditing state and form with survey.slug changes
useEffect(() => {
setIsEditing(!survey.slug);
reset({ slug: survey.slug || "" });
}, [survey.slug, reset]);
const onSubmit = async (data: PrettyUrlFormData) => {
if (!data.slug.trim()) {
toast.error(t("environments.surveys.share.pretty_url.slug_required"));
return;
}
setIsSubmitting(true);
try {
const result = await updateSurveySlugAction({
surveyId: survey.id,
slug: data.slug,
});
if (result?.data) {
if (result.data.ok) {
toast.success(t("environments.surveys.share.pretty_url.save_success"));
router.refresh();
setIsEditing(false);
} else {
toast.error(result.data.error.message);
}
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage || "Failed to update slug");
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to update slug");
} finally {
setIsSubmitting(false);
}
};
const handleEdit = () => {
setIsEditing(true);
};
const handleCancel = () => {
reset({ slug: survey.slug || "" });
setIsEditing(false);
};
const handleRemove = async () => {
setIsSubmitting(true);
try {
const result = await removeSurveySlugAction({ surveyId: survey.id });
if (result?.data) {
if (result.data.ok) {
setShowRemoveDialog(false);
reset({ slug: "" });
router.refresh();
setIsEditing(true);
toast.success(t("environments.surveys.share.pretty_url.remove_success"));
} else {
toast.error(result.data.error.message);
}
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage || "Failed to remove slug");
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to remove slug");
} finally {
setIsSubmitting(false);
}
};
const handleCopyUrl = () => {
if (!survey.slug) return;
const prettyUrl = `${publicDomain}/p/${survey.slug}`;
navigator.clipboard.writeText(prettyUrl);
toast.success(t("common.copied_to_clipboard"));
};
return (
<div className="px-1">
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>{t("environments.surveys.share.pretty_url.slug_label")}</FormLabel>
<FormControl>
<PrettyUrlInput
value={field.value}
onChange={field.onChange}
publicDomain={publicDomain}
disabled={isReadOnly || !isEditing}
/>
</FormControl>
<FormDescription>{t("environments.surveys.share.pretty_url.slug_help")}</FormDescription>
</FormItem>
)}
/>
<div className="flex gap-2">
{isEditing ? (
<>
<Button type="submit" disabled={isReadOnly || isSubmitting}>
{t("common.save")}
</Button>
{survey.slug && (
<Button type="button" variant="secondary" onClick={handleCancel} disabled={isSubmitting}>
{t("common.cancel")}
</Button>
)}
</>
) : (
<Button type="button" variant="secondary" onClick={handleEdit} disabled={isReadOnly}>
{t("common.edit")}
</Button>
)}
{survey.slug && !isEditing && (
<>
<Button type="button" variant="default" onClick={handleCopyUrl} disabled={isReadOnly}>
<Copy className="mr-2 h-4 w-4" />
{t("common.copy")} URL
</Button>
<Button
type="button"
variant="destructive"
onClick={() => setShowRemoveDialog(true)}
disabled={isReadOnly}>
<Trash2 className="mr-2 h-4 w-4" />
{t("common.remove")}
</Button>
</>
)}
</div>
</form>
</FormProvider>
<DeleteDialog
open={showRemoveDialog}
setOpen={setShowRemoveDialog}
deleteWhat={t("environments.surveys.share.pretty_url.title")}
onDelete={handleRemove}
text={t("environments.surveys.share.pretty_url.remove_description")}></DeleteDialog>
</div>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ export enum ShareViaType {
export enum ShareSettingsType { export enum ShareSettingsType {
LINK_SETTINGS = "link-settings", LINK_SETTINGS = "link-settings",
PRETTY_URL = "pretty-url",
} }
export enum LinkTabsType { export enum LinkTabsType {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,10 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { Suspense } from "react"; import { ChatwootWidget } from "@/app/chatwoot/ChatwootWidget";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper"; import { CHATWOOT_BASE_URL, CHATWOOT_WEBSITE_TOKEN, IS_CHATWOOT_CONFIGURED } from "@/lib/constants";
import { IS_POSTHOG_CONFIGURED, POSTHOG_API_HOST, POSTHOG_API_KEY } from "@/lib/constants";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout"; import { ClientLogout } from "@/modules/ui/components/client-logout";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay"; import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client";
import { ToasterClient } from "@/modules/ui/components/toaster-client"; import { ToasterClient } from "@/modules/ui/components/toaster-client";
const AppLayout = async ({ children }) => { const AppLayout = async ({ children }) => {
@@ -21,20 +19,17 @@ const AppLayout = async ({ children }) => {
return ( return (
<> <>
<NoMobileOverlay /> <NoMobileOverlay />
<Suspense> {IS_CHATWOOT_CONFIGURED && (
<PostHogPageview <ChatwootWidget
posthogEnabled={IS_POSTHOG_CONFIGURED} userEmail={user?.email}
postHogApiHost={POSTHOG_API_HOST} userName={user?.name}
postHogApiKey={POSTHOG_API_KEY} userId={user?.id}
chatwootWebsiteToken={CHATWOOT_WEBSITE_TOKEN}
chatwootBaseUrl={CHATWOOT_BASE_URL}
/> />
</Suspense> )}
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}> <ToasterClient />
<> {children}
<IntercomClientWrapper user={user} />
<ToasterClient />
{children}
</>
</PHProvider>
</> </>
); );
}; };

View File

@@ -1,11 +1,9 @@
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay"; import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
const AppLayout = async ({ children }) => { const AppLayout = async ({ children }) => {
return ( return (
<> <>
<NoMobileOverlay /> <NoMobileOverlay />
<IntercomClientWrapper />
{children} {children}
</> </>
); );

View File

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

View File

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

View File

@@ -0,0 +1,272 @@
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

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

View File

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

View File

@@ -1,34 +0,0 @@
import { Organization } from "@prisma/client";
import { logger } from "@formbricks/logger";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
export const handleBillingLimitsCheck = async (
environmentId: string,
organizationId: string,
organizationBilling: Organization["billing"]
): Promise<void> => {
if (!IS_FORMBRICKS_CLOUD) return;
const responsesCount = await getMonthlyOrganizationResponseCount(organizationId);
const responsesLimit = organizationBilling.limits.monthly.responses;
if (responsesLimit && responsesCount >= responsesLimit) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organizationBilling.plan,
limits: {
projects: null,
monthly: {
responses: responsesLimit,
miu: null,
},
},
});
} catch (err) {
// Log error but do not throw
logger.error(err, "Error sending plan limits reached event to Posthog");
}
}
};

View File

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

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