mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-29 09:50:10 -06:00
Compare commits
68 Commits
release/4.
...
4.4.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d12ef3ef4e | ||
|
|
7260a1a3a4 | ||
|
|
5b308d10dc | ||
|
|
68f1f42f81 | ||
|
|
086d8177dc | ||
|
|
efbe27fa95 | ||
|
|
ca1a0053b8 | ||
|
|
035093e702 | ||
|
|
75d33a1716 | ||
|
|
97ab194107 | ||
|
|
e9cc636510 | ||
|
|
e71f3f412c | ||
|
|
07ed926225 | ||
|
|
15dc83a4eb | ||
|
|
3ce07edf43 | ||
|
|
0f34d9cc5f | ||
|
|
e9f800f017 | ||
|
|
ba2070b638 | ||
|
|
75cdb25d27 | ||
|
|
6bc7db852c | ||
|
|
ffb4eac1a4 | ||
|
|
56da3b5725 | ||
|
|
c189af5482 | ||
|
|
5dbf42fd6a | ||
|
|
42525a86a8 | ||
|
|
b96f0e67c5 | ||
|
|
2d7b99ba26 | ||
|
|
666a79044f | ||
|
|
c3d97c2932 | ||
|
|
cc5d630a05 | ||
|
|
be38d76ccf | ||
|
|
a8eea306e5 | ||
|
|
4fd53ac115 | ||
|
|
eb92392ed1 | ||
|
|
7412b32526 | ||
|
|
193346a70d | ||
|
|
a1d4754b04 | ||
|
|
f4b918a4b6 | ||
|
|
fb9a0b197a | ||
|
|
95b6c16dd1 | ||
|
|
cfdf09650f | ||
|
|
4c94fc25ae | ||
|
|
ccf501d925 | ||
|
|
04dfbe0777 | ||
|
|
cbf255ab0d | ||
|
|
942366956c | ||
|
|
a6ee796cef | ||
|
|
a535529bd3 | ||
|
|
018cef61a6 | ||
|
|
c53e4f54cb | ||
|
|
e2fd71abfd | ||
|
|
f888aa8a19 | ||
|
|
2698817adb | ||
|
|
2c18912f2f | ||
|
|
f57497d8b3 | ||
|
|
aab6798b29 | ||
|
|
f07092595f | ||
|
|
c03c7ec1ed | ||
|
|
628de8e6ae | ||
|
|
be4b54a827 | ||
|
|
e03df83e88 | ||
|
|
ed26427302 | ||
|
|
554809742b | ||
|
|
28adfb905c | ||
|
|
05c455ed62 | ||
|
|
f7687bc0ea | ||
|
|
af34391309 | ||
|
|
70978fbbdf |
352
.cursor/commands/create-question.md
Normal file
352
.cursor/commands/create-question.md
Normal 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
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
description:
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
description:
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
@@ -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=
|
||||||
|
|||||||
54
.github/workflows/e2e.yml
vendored
54
.github/workflows/e2e.yml
vendored
@@ -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: |
|
||||||
|
|||||||
6
.github/workflows/formbricks-release.yml
vendored
6
.github/workflows/formbricks-release.yml
vendored
@@ -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' }}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 } }}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -108,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,
|
||||||
@@ -121,7 +121,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
|||||||
}}>
|
}}>
|
||||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||||
<div className="col-span-2 text-center">{data.tableName}</div>
|
<div className="col-span-2 text-center">{data.tableName}</div>
|
||||||
<div className="col-span-2 text-center">{data.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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export const ManageIntegration = ({
|
|||||||
}}>
|
}}>
|
||||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||||
<div className="col-span-2 text-center">{data.spreadsheetName}</div>
|
<div className="col-span-2 text-center">{data.spreadsheetName}</div>
|
||||||
<div className="col-span-2 text-center">{data.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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ 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 { 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 {
|
||||||
@@ -21,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,
|
||||||
@@ -38,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[];
|
||||||
@@ -64,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;
|
||||||
@@ -73,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);
|
||||||
@@ -86,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: {
|
||||||
@@ -119,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: getTextContent(getLocalizedValue(q.headline, "default")),
|
name: getTextContent(recallToHeadline(el.headline, selectedSurvey, false, "default")["default"]),
|
||||||
type: q.type,
|
type: el.type,
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
@@ -132,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]);
|
||||||
|
|
||||||
@@ -190,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"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,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")
|
||||||
@@ -261,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: "" },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
@@ -288,49 +347,6 @@ export const AddIntegrationModal = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const ErrorMsg = ({ error, col, ques }) => {
|
|
||||||
const showErrorMsg = useMemo(() => {
|
|
||||||
switch (error?.type) {
|
|
||||||
case ERRORS.UNSUPPORTED_TYPE:
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
-{" "}
|
|
||||||
{t("environments.integrations.notion.col_name_of_type_is_not_supported", {
|
|
||||||
col_name: col.name,
|
|
||||||
type: col.type,
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
case ERRORS.MAPPING:
|
|
||||||
const question = getQuestionTypes(t).find((qt) => qt.id === ques.type);
|
|
||||||
if (!question) return null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{t("environments.integrations.notion.que_name_of_type_cant_be_mapped_to", {
|
|
||||||
que_name: ques.name,
|
|
||||||
question_label: question.label,
|
|
||||||
col_name: col.name,
|
|
||||||
col_type: col.type,
|
|
||||||
mapped_type: TYPE_MAPPING[question.id].join(" ,"),
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [error]);
|
|
||||||
|
|
||||||
if (!error) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="my-4 w-full rounded-lg bg-red-100 p-4 text-sm text-red-800">
|
|
||||||
<span className="mb-2 block">{error.type}</span>
|
|
||||||
{showErrorMsg}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFilteredDbItems = () => {
|
const 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));
|
||||||
@@ -338,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);
|
||||||
@@ -362,7 +379,7 @@ export const AddIntegrationModal = ({
|
|||||||
error: {
|
error: {
|
||||||
type: ERRORS.UNSUPPORTED_TYPE,
|
type: ERRORS.UNSUPPORTED_TYPE,
|
||||||
},
|
},
|
||||||
question: item,
|
element: item,
|
||||||
};
|
};
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
@@ -374,7 +391,7 @@ export const AddIntegrationModal = ({
|
|||||||
error: {
|
error: {
|
||||||
type: ERRORS.MAPPING,
|
type: ERRORS.MAPPING,
|
||||||
},
|
},
|
||||||
question: item,
|
element: item,
|
||||||
};
|
};
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
@@ -382,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" />
|
||||||
@@ -400,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] = {
|
||||||
@@ -415,7 +432,7 @@ export const AddIntegrationModal = ({
|
|||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidQuesType) {
|
if (!isValidElemType) {
|
||||||
copy[idx] = {
|
copy[idx] = {
|
||||||
...copy[idx],
|
...copy[idx],
|
||||||
error: {
|
error: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export const ManageIntegration = ({
|
|||||||
}}>
|
}}>
|
||||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||||
<div className="col-span-2 text-center">{data.channelName}</div>
|
<div className="col-span-2 text-center">{data.channelName}</div>
|
||||||
<div className="col-span-2 text-center">{data.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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>({
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -96,14 +96,21 @@ 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 fetchFilteredResponses = async () => {
|
const fetchFilteredResponses = async () => {
|
||||||
try {
|
try {
|
||||||
// skip call for initial mount
|
// skip call for initial mount
|
||||||
if (page === null) {
|
if (page === null && !hasFilters) {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setPage(1);
|
||||||
setIsFetchingFirstPage(true);
|
setIsFetchingFirstPage(true);
|
||||||
let responses: TResponseWithQuotas[] = [];
|
let responses: TResponseWithQuotas[] = [];
|
||||||
|
|
||||||
@@ -126,15 +133,7 @@ export const ResponsePage = ({
|
|||||||
setIsFetchingFirstPage(false);
|
setIsFetchingFirstPage(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
fetchFilteredResponses();
|
||||||
// Only fetch if filters are applied (not on initial mount with no filters)
|
|
||||||
const hasFilters =
|
|
||||||
(selectedFilter && Object.keys(selectedFilter).length > 0) ||
|
|
||||||
(dateRange && (dateRange.from || dateRange.to));
|
|
||||||
|
|
||||||
if (hasFilters) {
|
|
||||||
fetchFilteredResponses();
|
|
||||||
}
|
|
||||||
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
|
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/types";
|
||||||
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
|
import { timeSince } from "@/lib/time";
|
||||||
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
|
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
|
||||||
|
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||||
|
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||||
|
|
||||||
|
interface DateElementSummary {
|
||||||
|
elementSummary: TSurveyElementSummaryDate;
|
||||||
|
environmentId: string;
|
||||||
|
survey: TSurvey;
|
||||||
|
locale: TUserLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DateElementSummary = ({ elementSummary, environmentId, survey, locale }: DateElementSummary) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [visibleResponses, setVisibleResponses] = useState(10);
|
||||||
|
|
||||||
|
const handleLoadMore = () => {
|
||||||
|
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||||
|
setVisibleResponses((prevVisibleResponses) =>
|
||||||
|
Math.min(prevVisibleResponses + 10, elementSummary.samples.length)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderResponseValue = (value: string) => {
|
||||||
|
const parsedDate = new Date(value);
|
||||||
|
|
||||||
|
const formattedDate = isNaN(parsedDate.getTime())
|
||||||
|
? `${t("common.invalid_date")}(${value})`
|
||||||
|
: formatDateWithOrdinal(parsedDate);
|
||||||
|
|
||||||
|
return formattedDate;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
||||||
|
<div className="">
|
||||||
|
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||||
|
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
||||||
|
<div className="col-span-2 pl-4 md:pl-6">{t("common.response")}</div>
|
||||||
|
<div className="px-4 md:px-6">{t("common.time")}</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[62vh] w-full overflow-y-auto">
|
||||||
|
{elementSummary.samples.length === 0 ? (
|
||||||
|
<div className="p-8">
|
||||||
|
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
elementSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||||
|
<div
|
||||||
|
key={response.id}
|
||||||
|
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
||||||
|
<div className="pl-4 md:pl-6">
|
||||||
|
{response.contact ? (
|
||||||
|
<Link
|
||||||
|
className="ph-no-capture group flex items-center"
|
||||||
|
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||||
|
<div className="hidden md:flex">
|
||||||
|
<PersonAvatar personId={response.contact.id} />
|
||||||
|
</div>
|
||||||
|
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||||
|
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="group flex items-center">
|
||||||
|
<div className="hidden md:flex">
|
||||||
|
<PersonAvatar personId="anonymous" />
|
||||||
|
</div>
|
||||||
|
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||||
|
{renderResponseValue(response.value)}
|
||||||
|
</div>
|
||||||
|
<div className="px-4 text-slate-500 md:px-6">
|
||||||
|
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{elementSummary.samples.length > 0 && visibleResponses < elementSummary.samples.length && (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||||
|
{t("common.load_more")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TSurvey, TSurveyQuestionSummaryDate } from "@formbricks/types/surveys/types";
|
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
|
||||||
import { timeSince } from "@/lib/time";
|
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
|
||||||
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
|
|
||||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
|
||||||
|
|
||||||
interface DateQuestionSummary {
|
|
||||||
questionSummary: TSurveyQuestionSummaryDate;
|
|
||||||
environmentId: string;
|
|
||||||
survey: TSurvey;
|
|
||||||
locale: TUserLocale;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DateQuestionSummary = ({
|
|
||||||
questionSummary,
|
|
||||||
environmentId,
|
|
||||||
survey,
|
|
||||||
locale,
|
|
||||||
}: DateQuestionSummary) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [visibleResponses, setVisibleResponses] = useState(10);
|
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
|
||||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
|
||||||
setVisibleResponses((prevVisibleResponses) =>
|
|
||||||
Math.min(prevVisibleResponses + 10, questionSummary.samples.length)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderResponseValue = (value: string) => {
|
|
||||||
const parsedDate = new Date(value);
|
|
||||||
|
|
||||||
const formattedDate = isNaN(parsedDate.getTime())
|
|
||||||
? `${t("common.invalid_date")}(${value})`
|
|
||||||
: formatDateWithOrdinal(parsedDate);
|
|
||||||
|
|
||||||
return formattedDate;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
|
||||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
|
||||||
<div className="">
|
|
||||||
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
|
||||||
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
|
||||||
<div className="col-span-2 pl-4 md:pl-6">{t("common.response")}</div>
|
|
||||||
<div className="px-4 md:px-6">{t("common.time")}</div>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-[62vh] w-full overflow-y-auto">
|
|
||||||
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
|
|
||||||
<div
|
|
||||||
key={response.id}
|
|
||||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
|
||||||
<div className="pl-4 md:pl-6">
|
|
||||||
{response.contact ? (
|
|
||||||
<Link
|
|
||||||
className="ph-no-capture group flex items-center"
|
|
||||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
|
||||||
<div className="hidden md:flex">
|
|
||||||
<PersonAvatar personId={response.contact.id} />
|
|
||||||
</div>
|
|
||||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
|
||||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<div className="group flex items-center">
|
|
||||||
<div className="hidden md:flex">
|
|
||||||
<PersonAvatar personId="anonymous" />
|
|
||||||
</div>
|
|
||||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
|
||||||
{renderResponseValue(response.value)}
|
|
||||||
</div>
|
|
||||||
<div className="px-4 text-slate-500 md:px-6">
|
|
||||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{visibleResponses < questionSummary.samples.length && (
|
|
||||||
<div className="flex justify-center py-4">
|
|
||||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
|
||||||
{t("common.load_more")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -3,28 +3,28 @@
|
|||||||
import { InboxIcon } from "lucide-react";
|
import { 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,23 +41,23 @@ 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={questionSummary.question.id} />
|
<IdBadge id={elementSummary.element.id} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,28 +3,24 @@
|
|||||||
import { BarChart, BarChartHorizontal } from "lucide-react";
|
import { BarChart, BarChartHorizontal } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||||
import { TooltipProvider } from "@/modules/ui/components/tooltip";
|
import { TooltipProvider } from "@/modules/ui/components/tooltip";
|
||||||
import { convertFloatToNDecimal } from "../lib/utils";
|
import { convertFloatToNDecimal } from "../lib/utils";
|
||||||
import { ClickableBarSegment } from "./ClickableBarSegment";
|
import { ClickableBarSegment } from "./ClickableBarSegment";
|
||||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||||
import { SatisfactionIndicator } from "./SatisfactionIndicator";
|
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;
|
||||||
@@ -40,7 +36,7 @@ const calculateNPSOpacity = (rating: number): number => {
|
|||||||
return 0.8 + ((rating - 8) / 2) * 0.2;
|
return 0.8 + ((rating - 8) / 2) * 0.2;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryProps) => {
|
export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
|
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
|
||||||
|
|
||||||
@@ -68,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
|
||||||
);
|
);
|
||||||
@@ -79,15 +75,15 @@ 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
|
<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 rounded-lg bg-slate-100 p-2">
|
||||||
<SatisfactionIndicator percentage={questionSummary.promoters.percentage} />
|
<SatisfactionIndicator percentage={elementSummary.promoters.percentage} />
|
||||||
<div>
|
<div>
|
||||||
{t("environments.surveys.summary.promoters")}:{" "}
|
{t("environments.surveys.summary.promoters")}:{" "}
|
||||||
{convertFloatToNDecimal(questionSummary.promoters.percentage, 2)}%
|
{convertFloatToNDecimal(elementSummary.promoters.percentage, 2)}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -106,43 +102,45 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="aggregated" className="mt-4">
|
<TabsContent value="aggregated" className="mt-4">
|
||||||
<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 md:px-6">
|
||||||
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
<div className="space-y-5 text-sm md:text-base">
|
||||||
<button
|
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
||||||
className="w-full cursor-pointer hover:opacity-80"
|
<button
|
||||||
key={group}
|
className="w-full cursor-pointer hover:opacity-80"
|
||||||
onClick={() => applyFilter(group)}>
|
key={group}
|
||||||
<div
|
onClick={() => applyFilter(group)}>
|
||||||
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
|
<div
|
||||||
<div className="mr-8 flex space-x-1">
|
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
|
||||||
<p
|
<div className="mr-8 flex space-x-1">
|
||||||
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
<p
|
||||||
{group}
|
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||||
</p>
|
{group}
|
||||||
<div>
|
|
||||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
|
||||||
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
|
|
||||||
</p>
|
</p>
|
||||||
|
<div>
|
||||||
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
|
{convertFloatToNDecimal(elementSummary[group]?.percentage, 2)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<ProgressBar
|
||||||
{questionSummary[group]?.count}{" "}
|
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
|
||||||
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
|
progress={elementSummary[group]?.percentage / 100}
|
||||||
</p>
|
/>
|
||||||
</div>
|
</button>
|
||||||
<ProgressBar
|
))}
|
||||||
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
|
</div>
|
||||||
progress={questionSummary[group]?.percentage / 100}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="individual" className="mt-4">
|
<TabsContent value="individual" className="mt-4">
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<div className="grid grid-cols-11 gap-2 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
<div className="grid grid-cols-11 gap-2 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||||
{questionSummary.choices.map((choice) => {
|
{elementSummary.choices.map((choice) => {
|
||||||
const opacity = calculateNPSOpacity(choice.rating);
|
const opacity = calculateNPSOpacity(choice.rating);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -151,9 +149,9 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
|||||||
className="group flex cursor-pointer flex-col items-center"
|
className="group flex cursor-pointer flex-col items-center"
|
||||||
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.is_equal_to"),
|
t("environments.surveys.summary.is_equal_to"),
|
||||||
choice.rating.toString()
|
choice.rating.toString()
|
||||||
)
|
)
|
||||||
@@ -185,7 +183,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
|||||||
</Tabs>
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 })}`]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -3,65 +3,61 @@
|
|||||||
import { BarChart, BarChartHorizontal, CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
|
import { BarChart, BarChartHorizontal, CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||||
import { TooltipProvider } from "@/modules/ui/components/tooltip";
|
import { TooltipProvider } from "@/modules/ui/components/tooltip";
|
||||||
import { ClickableBarSegment } from "./ClickableBarSegment";
|
import { ClickableBarSegment } from "./ClickableBarSegment";
|
||||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||||
import { RatingScaleLegend } from "./RatingScaleLegend";
|
import { RatingScaleLegend } from "./RatingScaleLegend";
|
||||||
import { SatisfactionIndicator } from "./SatisfactionIndicator";
|
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 [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">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||||
{getIconBasedOnScale}
|
{getIconBasedOnScale}
|
||||||
<div>
|
<div>
|
||||||
{t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)}
|
{t("environments.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||||
<SatisfactionIndicator percentage={questionSummary.csat.satisfiedPercentage} />
|
<SatisfactionIndicator percentage={elementSummary.csat.satisfiedPercentage} />
|
||||||
<div>
|
<div>
|
||||||
CSAT: {questionSummary.csat.satisfiedPercentage}%{" "}
|
CSAT: {elementSummary.csat.satisfiedPercentage}% {t("environments.surveys.summary.satisfied")}
|
||||||
{t("environments.surveys.summary.satisfied")}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,29 +78,25 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
|||||||
|
|
||||||
<TabsContent value="aggregated" className="mt-4">
|
<TabsContent value="aggregated" className="mt-4">
|
||||||
<div className="px-4 pb-6 pt-4 md:px-6">
|
<div className="px-4 pb-6 pt-4 md:px-6">
|
||||||
{questionSummary.responseCount === 0 ? (
|
{elementSummary.responseCount === 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-8 text-center">
|
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
||||||
<p className="text-sm text-slate-500">
|
|
||||||
{t("environments.surveys.summary.no_responses_found")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<RatingScaleLegend
|
<RatingScaleLegend
|
||||||
scale={questionSummary.question.scale}
|
scale={elementSummary.element.scale}
|
||||||
range={questionSummary.question.range}
|
range={elementSummary.element.range}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
|
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
|
||||||
{questionSummary.choices.map((result, index) => {
|
{elementSummary.choices.map((result, index) => {
|
||||||
if (result.percentage === 0) return null;
|
if (result.percentage === 0) return null;
|
||||||
|
|
||||||
const range = questionSummary.question.range;
|
const range = elementSummary.element.range;
|
||||||
const opacity = 0.3 + (result.rating / range) * 0.8;
|
const opacity = 0.3 + (result.rating / range) * 0.8;
|
||||||
const isFirst = index === 0;
|
const isFirst = index === 0;
|
||||||
const isLast = index === questionSummary.choices.length - 1;
|
const isLast = index === elementSummary.choices.length - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClickableBarSegment
|
<ClickableBarSegment
|
||||||
@@ -116,9 +108,9 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
|||||||
}}
|
}}
|
||||||
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.is_equal_to"),
|
t("environments.surveys.summary.is_equal_to"),
|
||||||
result.rating.toString()
|
result.rating.toString()
|
||||||
)
|
)
|
||||||
@@ -133,7 +125,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
|||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50">
|
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50">
|
||||||
{questionSummary.choices.map((result, index) => {
|
{elementSummary.choices.map((result, index) => {
|
||||||
if (result.percentage === 0) return null;
|
if (result.percentage === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -143,15 +135,15 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
|||||||
style={{
|
style={{
|
||||||
width: `${result.percentage}%`,
|
width: `${result.percentage}%`,
|
||||||
borderRight:
|
borderRight:
|
||||||
index < questionSummary.choices.length - 1
|
index < elementSummary.choices.length - 1
|
||||||
? "1px solid rgb(226, 232, 240)"
|
? "1px solid rgb(226, 232, 240)"
|
||||||
: "none",
|
: "none",
|
||||||
}}>
|
}}>
|
||||||
<div className="mb-1 flex items-center justify-center">
|
<div className="mb-1 flex items-center justify-center">
|
||||||
<RatingResponse
|
<RatingResponse
|
||||||
scale={questionSummary.question.scale}
|
scale={elementSummary.element.scale}
|
||||||
answer={result.rating}
|
answer={result.rating}
|
||||||
range={questionSummary.question.range}
|
range={elementSummary.element.range}
|
||||||
addColors={false}
|
addColors={false}
|
||||||
variant="aggregated"
|
variant="aggregated"
|
||||||
/>
|
/>
|
||||||
@@ -164,8 +156,8 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<RatingScaleLegend
|
<RatingScaleLegend
|
||||||
scale={questionSummary.question.scale}
|
scale={elementSummary.element.scale}
|
||||||
range={questionSummary.question.range}
|
range={elementSummary.element.range}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -175,15 +167,15 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
|||||||
<TabsContent value="individual" className="mt-4">
|
<TabsContent value="individual" className="mt-4">
|
||||||
<div className="px-4 pb-6 pt-4 md:px-6">
|
<div className="px-4 pb-6 pt-4 md:px-6">
|
||||||
<div className="space-y-5 text-sm md:text-base">
|
<div className="space-y-5 text-sm md:text-base">
|
||||||
{questionSummary.choices.map((result) => (
|
{elementSummary.choices.map((result) => (
|
||||||
<div key={result.rating}>
|
<div key={result.rating}>
|
||||||
<button
|
<button
|
||||||
className="w-full cursor-pointer hover:opacity-80"
|
className="w-full cursor-pointer hover:opacity-80"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setFilter(
|
setFilter(
|
||||||
questionSummary.question.id,
|
elementSummary.element.id,
|
||||||
questionSummary.question.headline,
|
elementSummary.element.headline,
|
||||||
questionSummary.question.type,
|
elementSummary.element.type,
|
||||||
t("environments.surveys.summary.is_equal_to"),
|
t("environments.surveys.summary.is_equal_to"),
|
||||||
result.rating.toString()
|
result.rating.toString()
|
||||||
)
|
)
|
||||||
@@ -192,10 +184,10 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
|||||||
<div className="mr-8 flex items-center space-x-1">
|
<div className="mr-8 flex items-center space-x-1">
|
||||||
<div className="font-semibold text-slate-700">
|
<div className="font-semibold text-slate-700">
|
||||||
<RatingResponse
|
<RatingResponse
|
||||||
scale={questionSummary.question.scale}
|
scale={elementSummary.element.scale}
|
||||||
answer={result.rating}
|
answer={result.rating}
|
||||||
range={questionSummary.question.range}
|
range={elementSummary.element.range}
|
||||||
addColors={questionSummary.question.isColorCodingEnabled}
|
addColors={elementSummary.element.isColorCodingEnabled}
|
||||||
variant="individual"
|
variant="individual"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,14 +209,14 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
|
{elementSummary.dismissed && elementSummary.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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -3,28 +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 {
|
import { TI18nString } from "@formbricks/types/i18n";
|
||||||
TI18nString,
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
TSurvey,
|
import { TSurveySummary } from "@formbricks/types/surveys/types";
|
||||||
TSurveyQuestionId,
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
TSurveyQuestionTypeEnum,
|
|
||||||
TSurveySummary,
|
|
||||||
} from "@formbricks/types/surveys/types";
|
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
|
import { EmptyAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
|
||||||
import {
|
import {
|
||||||
SelectedFilterValue,
|
SelectedFilterValue,
|
||||||
useResponseFilter,
|
useResponseFilter,
|
||||||
} from "@/app/(app)/environments/[environmentId]/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";
|
||||||
@@ -32,7 +29,7 @@ import { PictureChoiceSummary } from "@/app/(app)/environments/[environmentId]/s
|
|||||||
import { RankingSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary";
|
import { RankingSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary";
|
||||||
import { RatingSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary";
|
import { RatingSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary";
|
||||||
import { constructToastMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
import { constructToastMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/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 { EmptyState } from "@/modules/ui/components/empty-state";
|
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||||
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
|
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
|
||||||
@@ -50,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: getTextContent(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,
|
||||||
@@ -82,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 }
|
||||||
);
|
);
|
||||||
@@ -110,12 +107,12 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
|||||||
) : responseCount === 0 ? (
|
) : responseCount === 0 ? (
|
||||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} />
|
<EmptyState text={t("environments.surveys.summary.no_responses_found")} />
|
||||||
) : (
|
) : (
|
||||||
summary.map((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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,12 +543,12 @@ 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++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -558,8 +581,8 @@ export const getQuestionSummary = async (
|
|||||||
totalResponseCount > 0 ? Math.round((satisfiedCount / totalResponseCount) * 100) : 0;
|
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,
|
||||||
@@ -575,7 +598,7 @@ export const getQuestionSummary = async (
|
|||||||
values = [];
|
values = [];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case TSurveyQuestionTypeEnum.NPS: {
|
case TSurveyElementTypeEnum.NPS: {
|
||||||
const data = {
|
const data = {
|
||||||
promoters: 0,
|
promoters: 0,
|
||||||
passives: 0,
|
passives: 0,
|
||||||
@@ -592,7 +615,7 @@ export const getQuestionSummary = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
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]++;
|
scoreCountMap[value]++;
|
||||||
@@ -603,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++;
|
||||||
}
|
}
|
||||||
@@ -622,8 +645,8 @@ export const getQuestionSummary = async (
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
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,
|
||||||
@@ -647,14 +670,19 @@ export const getQuestionSummary = async (
|
|||||||
});
|
});
|
||||||
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") {
|
||||||
@@ -663,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,
|
||||||
@@ -680,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++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -698,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,
|
||||||
@@ -715,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,
|
||||||
@@ -731,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),
|
||||||
});
|
});
|
||||||
@@ -740,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,
|
||||||
@@ -756,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),
|
||||||
});
|
});
|
||||||
@@ -765,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,
|
||||||
@@ -798,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
|
||||||
@@ -813,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]
|
||||||
);
|
);
|
||||||
@@ -852,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,
|
||||||
@@ -876,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),
|
||||||
});
|
});
|
||||||
@@ -885,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;
|
||||||
});
|
});
|
||||||
@@ -901,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]++;
|
||||||
}
|
}
|
||||||
@@ -916,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({
|
||||||
@@ -927,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,
|
||||||
});
|
});
|
||||||
@@ -939,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") {
|
||||||
@@ -975,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;
|
||||||
|
|
||||||
@@ -1005,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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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}>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
272
apps/web/app/api/(internal)/pipeline/lib/telemetry.test.ts
Normal file
272
apps/web/app/api/(internal)/pipeline/lib/telemetry.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
270
apps/web/app/api/(internal)/pipeline/lib/telemetry.ts
Normal file
270
apps/web/app/api/(internal)/pipeline/lib/telemetry.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -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: {} });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import { Organization } from "@prisma/client";
|
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
|
||||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
|
||||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
|
||||||
|
|
||||||
export const handleBillingLimitsCheck = async (
|
|
||||||
environmentId: string,
|
|
||||||
organizationId: string,
|
|
||||||
organizationBilling: Organization["billing"]
|
|
||||||
): Promise<void> => {
|
|
||||||
if (!IS_FORMBRICKS_CLOUD) return;
|
|
||||||
|
|
||||||
const responsesCount = await getMonthlyOrganizationResponseCount(organizationId);
|
|
||||||
const responsesLimit = organizationBilling.limits.monthly.responses;
|
|
||||||
|
|
||||||
if (responsesLimit && responsesCount >= responsesLimit) {
|
|
||||||
try {
|
|
||||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
|
||||||
plan: organizationBilling.plan,
|
|
||||||
limits: {
|
|
||||||
projects: null,
|
|
||||||
monthly: {
|
|
||||||
responses: responsesLimit,
|
|
||||||
miu: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
// Log error but do not throw
|
|
||||||
logger.error(err, "Error sending plan limits reached event to Posthog");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -18,10 +18,6 @@ import {
|
|||||||
getMonthlyOrganizationResponseCount,
|
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
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
|
|||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
|
||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createDisplay } from "./lib/display";
|
import { createDisplay } from "./lib/display";
|
||||||
|
|
||||||
@@ -59,7 +58,6 @@ export const POST = withV1ApiWrapper({
|
|||||||
try {
|
try {
|
||||||
const response = await createDisplay(inputValidation.data);
|
const response = await createDisplay(inputValidation.data);
|
||||||
|
|
||||||
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(response, true),
|
response: responses.successResponse(response, true),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
|||||||
welcomeCard: true,
|
welcomeCard: true,
|
||||||
name: true,
|
name: true,
|
||||||
questions: true,
|
questions: true,
|
||||||
|
blocks: true,
|
||||||
variables: true,
|
variables: true,
|
||||||
type: true,
|
type: true,
|
||||||
showLanguageSwitch: true,
|
showLanguageSwitch: true,
|
||||||
|
|||||||
@@ -8,16 +8,11 @@ import { TOrganization } from "@formbricks/types/organizations";
|
|||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { cache } from "@/lib/cache";
|
import { cache } from "@/lib/cache";
|
||||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||||
import {
|
|
||||||
capturePosthogEnvironmentEvent,
|
|
||||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
|
||||||
} from "@/lib/posthogServer";
|
|
||||||
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
|
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
|
||||||
import { getEnvironmentState } from "./environmentState";
|
import { getEnvironmentState } from "./environmentState";
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock("@/lib/organization/service");
|
vi.mock("@/lib/organization/service");
|
||||||
vi.mock("@/lib/posthogServer");
|
|
||||||
vi.mock("@/lib/cache", () => ({
|
vi.mock("@/lib/cache", () => ({
|
||||||
cache: {
|
cache: {
|
||||||
withCache: vi.fn(),
|
withCache: vi.fn(),
|
||||||
@@ -43,7 +38,6 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key",
|
RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key",
|
||||||
IS_RECAPTCHA_CONFIGURED: true,
|
IS_RECAPTCHA_CONFIGURED: true,
|
||||||
IS_PRODUCTION: true,
|
IS_PRODUCTION: true,
|
||||||
IS_POSTHOG_CONFIGURED: false,
|
|
||||||
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
|
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -188,9 +182,7 @@ describe("getEnvironmentState", () => {
|
|||||||
expect(result.data).toEqual(expectedData);
|
expect(result.data).toEqual(expectedData);
|
||||||
expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId);
|
expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId);
|
||||||
expect(prisma.environment.update).not.toHaveBeenCalled();
|
expect(prisma.environment.update).not.toHaveBeenCalled();
|
||||||
expect(capturePosthogEnvironmentEvent).not.toHaveBeenCalled();
|
|
||||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
||||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should throw ResourceNotFoundError if environment not found", async () => {
|
test("should throw ResourceNotFoundError if environment not found", async () => {
|
||||||
@@ -226,7 +218,6 @@ describe("getEnvironmentState", () => {
|
|||||||
where: { id: environmentId },
|
where: { id: environmentId },
|
||||||
data: { appSetupCompleted: true },
|
data: { appSetupCompleted: true },
|
||||||
});
|
});
|
||||||
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(environmentId, "app setup completed");
|
|
||||||
expect(result.data).toBeDefined();
|
expect(result.data).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -237,16 +228,6 @@ describe("getEnvironmentState", () => {
|
|||||||
|
|
||||||
expect(result.data.surveys).toEqual([]);
|
expect(result.data.surveys).toEqual([]);
|
||||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
||||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
|
|
||||||
plan: mockOrganization.billing.plan,
|
|
||||||
limits: {
|
|
||||||
projects: null,
|
|
||||||
monthly: {
|
|
||||||
miu: null,
|
|
||||||
responses: mockOrganization.billing.limits.monthly.responses,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return surveys if monthly response limit not reached (Cloud)", async () => {
|
test("should return surveys if monthly response limit not reached (Cloud)", async () => {
|
||||||
@@ -256,21 +237,6 @@ describe("getEnvironmentState", () => {
|
|||||||
|
|
||||||
expect(result.data.surveys).toEqual(mockSurveys);
|
expect(result.data.surveys).toEqual(mockSurveys);
|
||||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
||||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle error when sending Posthog limit reached event", async () => {
|
|
||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
|
||||||
const posthogError = new Error("Posthog failed");
|
|
||||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
|
|
||||||
|
|
||||||
const result = await getEnvironmentState(environmentId);
|
|
||||||
|
|
||||||
expect(result.data.surveys).toEqual([]);
|
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
|
||||||
posthogError,
|
|
||||||
"Error sending plan limits reached event to Posthog"
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should include recaptchaSiteKey if recaptcha variables are set", async () => {
|
test("should include recaptchaSiteKey if recaptcha variables are set", async () => {
|
||||||
@@ -313,7 +279,6 @@ describe("getEnvironmentState", () => {
|
|||||||
|
|
||||||
// Should return surveys even with high count since limit is null (unlimited)
|
// Should return surveys even with high count since limit is null (unlimited)
|
||||||
expect(result.data.surveys).toEqual(mockSurveys);
|
expect(result.data.surveys).toEqual(mockSurveys);
|
||||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should propagate database update errors", async () => {
|
test("should propagate database update errors", async () => {
|
||||||
@@ -331,21 +296,6 @@ describe("getEnvironmentState", () => {
|
|||||||
await expect(getEnvironmentState(environmentId)).rejects.toThrow("Database error");
|
await expect(getEnvironmentState(environmentId)).rejects.toThrow("Database error");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should propagate PostHog event capture errors", async () => {
|
|
||||||
const incompleteEnvironmentData = {
|
|
||||||
...mockEnvironmentStateData,
|
|
||||||
environment: {
|
|
||||||
...mockEnvironmentStateData.environment,
|
|
||||||
appSetupCompleted: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
vi.mocked(getEnvironmentStateData).mockResolvedValue(incompleteEnvironmentData);
|
|
||||||
vi.mocked(capturePosthogEnvironmentEvent).mockRejectedValue(new Error("PostHog error"));
|
|
||||||
|
|
||||||
// Should throw error since Promise.all will fail if PostHog event capture fails
|
|
||||||
await expect(getEnvironmentState(environmentId)).rejects.toThrow("PostHog error");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should include recaptchaSiteKey when IS_RECAPTCHA_CONFIGURED is true", async () => {
|
test("should include recaptchaSiteKey when IS_RECAPTCHA_CONFIGURED is true", async () => {
|
||||||
const result = await getEnvironmentState(environmentId);
|
const result = await getEnvironmentState(environmentId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { createCacheKey } from "@formbricks/cache";
|
import { createCacheKey } from "@formbricks/cache";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { TJsEnvironmentState } from "@formbricks/types/js";
|
import { TJsEnvironmentState } from "@formbricks/types/js";
|
||||||
import { cache } from "@/lib/cache";
|
import { cache } from "@/lib/cache";
|
||||||
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
|
||||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||||
import {
|
|
||||||
capturePosthogEnvironmentEvent,
|
|
||||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
|
||||||
} from "@/lib/posthogServer";
|
|
||||||
import { getEnvironmentStateData } from "./data";
|
import { getEnvironmentStateData } from "./data";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,13 +28,10 @@ export const getEnvironmentState = async (
|
|||||||
// Handle app setup completion update if needed
|
// Handle app setup completion update if needed
|
||||||
// This is a one-time setup flag that can tolerate TTL-based cache expiration
|
// This is a one-time setup flag that can tolerate TTL-based cache expiration
|
||||||
if (!environment.appSetupCompleted) {
|
if (!environment.appSetupCompleted) {
|
||||||
await Promise.all([
|
await prisma.environment.update({
|
||||||
prisma.environment.update({
|
where: { id: environmentId },
|
||||||
where: { id: environmentId },
|
data: { appSetupCompleted: true },
|
||||||
data: { appSetupCompleted: true },
|
});
|
||||||
}),
|
|
||||||
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check monthly response limits for Formbricks Cloud
|
// Check monthly response limits for Formbricks Cloud
|
||||||
@@ -49,24 +41,6 @@ export const getEnvironmentState = async (
|
|||||||
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
|
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||||
isMonthlyResponsesLimitReached =
|
isMonthlyResponsesLimitReached =
|
||||||
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||||
|
|
||||||
// Send plan limits event if needed
|
|
||||||
if (isMonthlyResponsesLimitReached) {
|
|
||||||
try {
|
|
||||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
|
||||||
plan: organization.billing.plan,
|
|
||||||
limits: {
|
|
||||||
projects: null,
|
|
||||||
monthly: {
|
|
||||||
miu: null,
|
|
||||||
responses: organization.billing.limits.monthly.responses,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(err, "Error sending plan limits reached event to Posthog");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the response data
|
// Build the response data
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
|||||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||||
import { getResponse } from "@/lib/response/service";
|
import { getResponse } from "@/lib/response/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
|
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||||
import { validateFileUploads } from "@/modules/storage/utils";
|
import { validateFileUploads } from "@/modules/storage/utils";
|
||||||
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||||
import { TResponseInput } from "@formbricks/types/responses";
|
import { TResponseInput } from "@formbricks/types/responses";
|
||||||
import {
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
getMonthlyOrganizationResponseCount,
|
|
||||||
getOrganizationByEnvironmentId,
|
|
||||||
} from "@/lib/organization/service";
|
|
||||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
|
||||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||||
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
|
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
|
||||||
@@ -24,22 +19,13 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/organization/service", () => ({
|
vi.mock("@/lib/organization/service", () => ({
|
||||||
getMonthlyOrganizationResponseCount: vi.fn(),
|
|
||||||
getOrganizationByEnvironmentId: vi.fn(),
|
getOrganizationByEnvironmentId: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/posthogServer", () => ({
|
|
||||||
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/response/utils", () => ({
|
vi.mock("@/lib/response/utils", () => ({
|
||||||
calculateTtcTotal: vi.fn((ttc) => ttc),
|
calculateTtcTotal: vi.fn((ttc) => ttc),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/telemetry", () => ({
|
|
||||||
captureTelemetry: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/utils/validate", () => ({
|
vi.mock("@/lib/utils/validate", () => ({
|
||||||
validateInputs: vi.fn(),
|
validateInputs: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -138,35 +124,6 @@ describe("createResponse", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should check response limits if IS_FORMBRICKS_CLOUD is true", async () => {
|
|
||||||
mockIsFormbricksCloud = true;
|
|
||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
|
||||||
|
|
||||||
await createResponse(mockResponseInput, prisma);
|
|
||||||
|
|
||||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
|
||||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => {
|
|
||||||
mockIsFormbricksCloud = true;
|
|
||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
|
||||||
|
|
||||||
await createResponse(mockResponseInput, prisma);
|
|
||||||
|
|
||||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
|
||||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
|
|
||||||
plan: "free",
|
|
||||||
limits: {
|
|
||||||
projects: null,
|
|
||||||
monthly: {
|
|
||||||
responses: 100,
|
|
||||||
miu: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw ResourceNotFoundError if organization not found", async () => {
|
test("should throw ResourceNotFoundError if organization not found", async () => {
|
||||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||||
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(ResourceNotFoundError);
|
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(ResourceNotFoundError);
|
||||||
@@ -186,20 +143,6 @@ describe("createResponse", () => {
|
|||||||
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
|
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
|
||||||
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
|
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
|
|
||||||
mockIsFormbricksCloud = true;
|
|
||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
|
||||||
const posthogError = new Error("PostHog error");
|
|
||||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
|
|
||||||
|
|
||||||
await createResponse(mockResponseInput);
|
|
||||||
|
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
|
||||||
posthogError,
|
|
||||||
"Error sending plan limits reached event to Posthog"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createResponseWithQuotaEvaluation", () => {
|
describe("createResponseWithQuotaEvaluation", () => {
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|||||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||||
import { TTag } from "@formbricks/types/tags";
|
import { TTag } from "@formbricks/types/tags";
|
||||||
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
|
|
||||||
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||||
import { captureTelemetry } from "@/lib/telemetry";
|
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||||
import { getContactByUserId } from "./contact";
|
import { getContactByUserId } from "./contact";
|
||||||
@@ -83,7 +81,6 @@ export const createResponse = async (
|
|||||||
tx: Prisma.TransactionClient
|
tx: Prisma.TransactionClient
|
||||||
): Promise<TResponse> => {
|
): Promise<TResponse> => {
|
||||||
validateInputs([responseInput, ZResponseInput]);
|
validateInputs([responseInput, ZResponseInput]);
|
||||||
captureTelemetry("response created");
|
|
||||||
|
|
||||||
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
|
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
|
||||||
|
|
||||||
@@ -121,8 +118,6 @@ export const createResponse = async (
|
|||||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||||
};
|
};
|
||||||
|
|
||||||
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { responses } from "@/app/lib/api/response";
|
|||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||||
@@ -172,11 +171,6 @@ export const POST = withV1ApiWrapper({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
|
|
||||||
surveyId: responseData.surveyId,
|
|
||||||
surveyType: survey.type,
|
|
||||||
});
|
|
||||||
|
|
||||||
const quotaObj = createQuotaFullObject(quotaFull);
|
const quotaObj = createQuotaFullObject(quotaFull);
|
||||||
|
|
||||||
const responseDataWithQuota = {
|
const responseDataWithQuota = {
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import { prisma } from "@formbricks/database";
|
|||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { TResponse, TResponseInput } from "@formbricks/types/responses";
|
import { TResponse, TResponseInput } from "@formbricks/types/responses";
|
||||||
import {
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
getMonthlyOrganizationResponseCount,
|
|
||||||
getOrganizationByEnvironmentId,
|
|
||||||
} from "@/lib/organization/service";
|
|
||||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
|
||||||
import { getResponseContact } from "@/lib/response/service";
|
import { getResponseContact } from "@/lib/response/service";
|
||||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
@@ -96,9 +92,6 @@ const mockTransformedResponses = [mockResponse, { ...mockResponse, id: "response
|
|||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock("@/lib/constants", () => ({
|
vi.mock("@/lib/constants", () => ({
|
||||||
IS_FORMBRICKS_CLOUD: true,
|
IS_FORMBRICKS_CLOUD: true,
|
||||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
|
||||||
POSTHOG_HOST: "mock-posthog-host",
|
|
||||||
IS_POSTHOG_CONFIGURED: true,
|
|
||||||
ENCRYPTION_KEY: "mock-encryption-key",
|
ENCRYPTION_KEY: "mock-encryption-key",
|
||||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||||
GITHUB_ID: "mock-github-id",
|
GITHUB_ID: "mock-github-id",
|
||||||
@@ -118,10 +111,8 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/organization/service");
|
vi.mock("@/lib/organization/service");
|
||||||
vi.mock("@/lib/posthogServer");
|
|
||||||
vi.mock("@/lib/response/service");
|
vi.mock("@/lib/response/service");
|
||||||
vi.mock("@/lib/response/utils");
|
vi.mock("@/lib/response/utils");
|
||||||
vi.mock("@/lib/telemetry");
|
|
||||||
vi.mock("@/lib/utils/validate");
|
vi.mock("@/lib/utils/validate");
|
||||||
vi.mock("@formbricks/database", () => ({
|
vi.mock("@formbricks/database", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
@@ -162,7 +153,6 @@ describe("Response Lib Tests", () => {
|
|||||||
vi.mocked(mockTx.response.create).mockResolvedValue({
|
vi.mocked(mockTx.response.create).mockResolvedValue({
|
||||||
...mockResponsePrisma,
|
...mockResponsePrisma,
|
||||||
});
|
});
|
||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
|
||||||
|
|
||||||
const response = await createResponse(mockResponseInputWithUserId, mockTx);
|
const response = await createResponse(mockResponseInputWithUserId, mockTx);
|
||||||
|
|
||||||
@@ -217,68 +207,6 @@ describe("Response Lib Tests", () => {
|
|||||||
|
|
||||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
|
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Cloud specific tests", () => {
|
|
||||||
test("should check response limit and send event if limit reached", async () => {
|
|
||||||
// IS_FORMBRICKS_CLOUD is true by default from the top-level mock
|
|
||||||
const limit = 100;
|
|
||||||
const mockOrgWithBilling = {
|
|
||||||
...mockOrganization,
|
|
||||||
billing: { limits: { monthly: { responses: limit } } },
|
|
||||||
} as any;
|
|
||||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
|
|
||||||
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
|
|
||||||
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
|
|
||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
|
|
||||||
|
|
||||||
await createResponse(mockResponseInput, mockTx);
|
|
||||||
|
|
||||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
|
||||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should check response limit and not send event if limit not reached", async () => {
|
|
||||||
const limit = 100;
|
|
||||||
const mockOrgWithBilling = {
|
|
||||||
...mockOrganization,
|
|
||||||
billing: { limits: { monthly: { responses: limit } } },
|
|
||||||
} as any;
|
|
||||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
|
|
||||||
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
|
|
||||||
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
|
|
||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit - 1); // Limit not reached
|
|
||||||
|
|
||||||
await createResponse(mockResponseInput, mockTx);
|
|
||||||
|
|
||||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
|
||||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should log error if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
|
|
||||||
const limit = 100;
|
|
||||||
const mockOrgWithBilling = {
|
|
||||||
...mockOrganization,
|
|
||||||
billing: { limits: { monthly: { responses: limit } } },
|
|
||||||
} as any;
|
|
||||||
const posthogError = new Error("Posthog error");
|
|
||||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
|
|
||||||
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
|
|
||||||
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
|
|
||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
|
|
||||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
|
|
||||||
|
|
||||||
// Expecting successful response creation despite PostHog error
|
|
||||||
const response = await createResponse(mockResponseInput, mockTx);
|
|
||||||
|
|
||||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
|
||||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
|
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
|
||||||
posthogError,
|
|
||||||
"Error sending plan limits reached event to Posthog"
|
|
||||||
);
|
|
||||||
expect(response).toEqual(mockResponse); // Should still return the created response
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getResponsesByEnvironmentIds", () => {
|
describe("getResponsesByEnvironmentIds", () => {
|
||||||
|
|||||||
@@ -8,14 +8,12 @@ import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
|||||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||||
import { TTag } from "@formbricks/types/tags";
|
import { TTag } from "@formbricks/types/tags";
|
||||||
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
|
|
||||||
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
||||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { getResponseContact } from "@/lib/response/service";
|
import { getResponseContact } from "@/lib/response/service";
|
||||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { captureTelemetry } from "@/lib/telemetry";
|
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||||
import { getContactByUserId } from "./contact";
|
import { getContactByUserId } from "./contact";
|
||||||
@@ -93,7 +91,6 @@ export const createResponse = async (
|
|||||||
tx?: Prisma.TransactionClient
|
tx?: Prisma.TransactionClient
|
||||||
): Promise<TResponse> => {
|
): Promise<TResponse> => {
|
||||||
validateInputs([responseInput, ZResponseInput]);
|
validateInputs([responseInput, ZResponseInput]);
|
||||||
captureTelemetry("response created");
|
|
||||||
|
|
||||||
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
|
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
|
||||||
|
|
||||||
@@ -131,8 +128,6 @@ export const createResponse = async (
|
|||||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||||
};
|
};
|
||||||
|
|
||||||
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import { handleErrorResponse } from "@/app/api/v1/auth";
|
|||||||
import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys";
|
import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys";
|
||||||
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
|
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
|
import {
|
||||||
|
transformBlocksToQuestions,
|
||||||
|
transformQuestionsToBlocks,
|
||||||
|
validateSurveyInput,
|
||||||
|
} from "@/app/lib/api/survey-transformation";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
@@ -45,6 +50,22 @@ export const GET = withV1ApiWrapper({
|
|||||||
response: result.error,
|
response: result.error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldTransformToQuestions =
|
||||||
|
result.survey.blocks &&
|
||||||
|
result.survey.blocks.length > 0 &&
|
||||||
|
result.survey.blocks.every((block) => block.elements.length === 1);
|
||||||
|
|
||||||
|
if (shouldTransformToQuestions) {
|
||||||
|
return {
|
||||||
|
response: responses.successResponse({
|
||||||
|
...result.survey,
|
||||||
|
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
|
||||||
|
blocks: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(result.survey),
|
response: responses.successResponse(result.survey),
|
||||||
};
|
};
|
||||||
@@ -131,6 +152,23 @@ export const PUT = withV1ApiWrapper({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validateResult = validateSurveyInput({ ...surveyUpdate, updateOnly: true });
|
||||||
|
if (!validateResult.ok) {
|
||||||
|
return {
|
||||||
|
response: responses.badRequestResponse(validateResult.error.message),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hasQuestions } = validateResult.data;
|
||||||
|
|
||||||
|
if (hasQuestions) {
|
||||||
|
surveyUpdate.blocks = transformQuestionsToBlocks(
|
||||||
|
surveyUpdate.questions,
|
||||||
|
surveyUpdate.endings || result.survey.endings
|
||||||
|
);
|
||||||
|
surveyUpdate.questions = [];
|
||||||
|
}
|
||||||
|
|
||||||
const inputValidation = ZSurveyUpdateInput.safeParse({
|
const inputValidation = ZSurveyUpdateInput.safeParse({
|
||||||
...result.survey,
|
...result.survey,
|
||||||
...surveyUpdate,
|
...surveyUpdate,
|
||||||
@@ -155,6 +193,19 @@ export const PUT = withV1ApiWrapper({
|
|||||||
try {
|
try {
|
||||||
const updatedSurvey = await updateSurvey({ ...inputValidation.data, id: params.surveyId });
|
const updatedSurvey = await updateSurvey({ ...inputValidation.data, id: params.surveyId });
|
||||||
auditLog.newObject = updatedSurvey;
|
auditLog.newObject = updatedSurvey;
|
||||||
|
|
||||||
|
if (hasQuestions) {
|
||||||
|
const surveyWithQuestions = {
|
||||||
|
...updatedSurvey,
|
||||||
|
questions: transformBlocksToQuestions(updatedSurvey.blocks, updatedSurvey.endings),
|
||||||
|
blocks: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: responses.successResponse(surveyWithQuestions),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(updatedSurvey),
|
response: responses.successResponse(updatedSurvey),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { DatabaseError } from "@formbricks/types/errors";
|
|||||||
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
|
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
|
||||||
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
|
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
|
import {
|
||||||
|
transformBlocksToQuestions,
|
||||||
|
transformQuestionsToBlocks,
|
||||||
|
validateSurveyInput,
|
||||||
|
} from "@/app/lib/api/survey-transformation";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
@@ -27,10 +32,30 @@ export const GET = withV1ApiWrapper({
|
|||||||
const environmentIds = authentication.environmentPermissions.map(
|
const environmentIds = authentication.environmentPermissions.map(
|
||||||
(permission) => permission.environmentId
|
(permission) => permission.environmentId
|
||||||
);
|
);
|
||||||
|
|
||||||
const surveys = await getSurveys(environmentIds, limit, offset);
|
const surveys = await getSurveys(environmentIds, limit, offset);
|
||||||
|
|
||||||
|
const surveysWithQuestions = surveys.map((survey) => {
|
||||||
|
// If the survey has blocks and each block has ONLY ONE element, we can transform the blocks to questions
|
||||||
|
// This is only for backwards compatibility with the older surveys
|
||||||
|
const shouldTransformToQuestions =
|
||||||
|
survey.blocks &&
|
||||||
|
survey.blocks.length > 0 &&
|
||||||
|
survey.blocks.every((block) => block.elements.length === 1);
|
||||||
|
|
||||||
|
if (shouldTransformToQuestions) {
|
||||||
|
return {
|
||||||
|
...survey,
|
||||||
|
questions: transformBlocksToQuestions(survey.blocks, survey.endings),
|
||||||
|
blocks: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return survey;
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(surveys),
|
response: responses.successResponse(surveysWithQuestions),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DatabaseError) {
|
if (error instanceof DatabaseError) {
|
||||||
@@ -63,6 +88,7 @@ export const POST = withV1ApiWrapper({
|
|||||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
|
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
|
||||||
|
|
||||||
if (!inputValidation.success) {
|
if (!inputValidation.success) {
|
||||||
@@ -92,6 +118,20 @@ export const POST = withV1ApiWrapper({
|
|||||||
|
|
||||||
const surveyData = { ...inputValidation.data, environmentId };
|
const surveyData = { ...inputValidation.data, environmentId };
|
||||||
|
|
||||||
|
const validateResult = validateSurveyInput(surveyData);
|
||||||
|
if (!validateResult.ok) {
|
||||||
|
return {
|
||||||
|
response: responses.badRequestResponse(validateResult.error.message),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hasQuestions } = validateResult.data;
|
||||||
|
|
||||||
|
if (hasQuestions) {
|
||||||
|
surveyData.blocks = transformQuestionsToBlocks(surveyData.questions, surveyData.endings || []);
|
||||||
|
surveyData.questions = [];
|
||||||
|
}
|
||||||
|
|
||||||
const featureCheckResult = await checkFeaturePermissions(surveyData, organization);
|
const featureCheckResult = await checkFeaturePermissions(surveyData, organization);
|
||||||
if (featureCheckResult) {
|
if (featureCheckResult) {
|
||||||
return {
|
return {
|
||||||
@@ -103,6 +143,18 @@ export const POST = withV1ApiWrapper({
|
|||||||
auditLog.targetId = survey.id;
|
auditLog.targetId = survey.id;
|
||||||
auditLog.newObject = survey;
|
auditLog.newObject = survey;
|
||||||
|
|
||||||
|
if (hasQuestions) {
|
||||||
|
const surveyWithQuestions = {
|
||||||
|
...survey,
|
||||||
|
questions: transformBlocksToQuestions(survey.blocks, survey.endings),
|
||||||
|
blocks: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: responses.successResponse(surveyWithQuestions),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(survey),
|
response: responses.successResponse(survey),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
|
|||||||
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
|
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
|
||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createDisplay } from "./lib/display";
|
import { createDisplay } from "./lib/display";
|
||||||
|
|
||||||
@@ -49,7 +48,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
|||||||
try {
|
try {
|
||||||
const response = await createDisplay(inputValidation.data);
|
const response = await createDisplay(inputValidation.data);
|
||||||
|
|
||||||
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
|
||||||
return responses.successResponse(response, true);
|
return responses.successResponse(response, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ResourceNotFoundError) {
|
if (error instanceof ResourceNotFoundError) {
|
||||||
|
|||||||
@@ -8,13 +8,8 @@ import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
|
|||||||
import { TResponse } from "@formbricks/types/responses";
|
import { TResponse } from "@formbricks/types/responses";
|
||||||
import { TTag } from "@formbricks/types/tags";
|
import { TTag } from "@formbricks/types/tags";
|
||||||
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
||||||
import {
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
getMonthlyOrganizationResponseCount,
|
|
||||||
getOrganizationByEnvironmentId,
|
|
||||||
} from "@/lib/organization/service";
|
|
||||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
|
||||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||||
import { captureTelemetry } from "@/lib/telemetry";
|
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||||
import { getContact } from "./contact";
|
import { getContact } from "./contact";
|
||||||
@@ -49,9 +44,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/organization/service");
|
vi.mock("@/lib/organization/service");
|
||||||
vi.mock("@/lib/posthogServer");
|
|
||||||
vi.mock("@/lib/response/utils");
|
vi.mock("@/lib/response/utils");
|
||||||
vi.mock("@/lib/telemetry");
|
|
||||||
vi.mock("@/lib/utils/validate");
|
vi.mock("@/lib/utils/validate");
|
||||||
vi.mock("@/modules/ee/quotas/lib/evaluation-service");
|
vi.mock("@/modules/ee/quotas/lib/evaluation-service");
|
||||||
vi.mock("@formbricks/database", () => ({
|
vi.mock("@formbricks/database", () => ({
|
||||||
@@ -166,9 +159,6 @@ describe("createResponse V2", () => {
|
|||||||
...ttc,
|
...ttc,
|
||||||
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
|
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
|
||||||
}));
|
}));
|
||||||
vi.mocked(captureTelemetry).mockResolvedValue(undefined);
|
|
||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
|
||||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockResolvedValue(undefined);
|
|
||||||
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
||||||
shouldEndSurvey: false,
|
shouldEndSurvey: false,
|
||||||
quotaFull: null,
|
quotaFull: null,
|
||||||
@@ -179,32 +169,6 @@ describe("createResponse V2", () => {
|
|||||||
mockIsFormbricksCloud = false;
|
mockIsFormbricksCloud = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should check response limits if IS_FORMBRICKS_CLOUD is true", async () => {
|
|
||||||
mockIsFormbricksCloud = true;
|
|
||||||
await createResponse(mockResponseInput, mockTx);
|
|
||||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
|
||||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => {
|
|
||||||
mockIsFormbricksCloud = true;
|
|
||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
|
||||||
|
|
||||||
await createResponse(mockResponseInput, mockTx);
|
|
||||||
|
|
||||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
|
||||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
|
|
||||||
plan: "free",
|
|
||||||
limits: {
|
|
||||||
projects: null,
|
|
||||||
monthly: {
|
|
||||||
responses: 100,
|
|
||||||
miu: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw ResourceNotFoundError if organization not found", async () => {
|
test("should throw ResourceNotFoundError if organization not found", async () => {
|
||||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(ResourceNotFoundError);
|
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(ResourceNotFoundError);
|
||||||
@@ -225,20 +189,6 @@ describe("createResponse V2", () => {
|
|||||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
|
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
|
|
||||||
mockIsFormbricksCloud = true;
|
|
||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
|
||||||
const posthogError = new Error("PostHog error");
|
|
||||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
|
|
||||||
|
|
||||||
await createResponse(mockResponseInput, mockTx); // Should not throw
|
|
||||||
|
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
|
||||||
posthogError,
|
|
||||||
"Error sending plan limits reached event to Posthog"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should correctly map prisma tags to response tags", async () => {
|
test("should correctly map prisma tags to response tags", async () => {
|
||||||
const mockTag: TTag = { id: "tag1", name: "Tag 1", environmentId };
|
const mockTag: TTag = { id: "tag1", name: "Tag 1", environmentId };
|
||||||
const prismaResponseWithTags = {
|
const prismaResponseWithTags = {
|
||||||
@@ -269,7 +219,6 @@ describe("createResponseWithQuotaEvaluation V2", () => {
|
|||||||
...ttc,
|
...ttc,
|
||||||
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
|
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
|
||||||
}));
|
}));
|
||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
|
||||||
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
||||||
shouldEndSurvey: false,
|
shouldEndSurvey: false,
|
||||||
quotaFull: null,
|
quotaFull: null,
|
||||||
|
|||||||
@@ -6,12 +6,10 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|||||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||||
import { TTag } from "@formbricks/types/tags";
|
import { TTag } from "@formbricks/types/tags";
|
||||||
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
|
|
||||||
import { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
|
import { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
|
||||||
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||||
import { captureTelemetry } from "@/lib/telemetry";
|
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||||
import { getContact } from "./contact";
|
import { getContact } from "./contact";
|
||||||
@@ -91,7 +89,6 @@ export const createResponse = async (
|
|||||||
tx?: Prisma.TransactionClient
|
tx?: Prisma.TransactionClient
|
||||||
): Promise<TResponse> => {
|
): Promise<TResponse> => {
|
||||||
validateInputs([responseInput, ZResponseInput]);
|
validateInputs([responseInput, ZResponseInput]);
|
||||||
captureTelemetry("response created");
|
|
||||||
|
|
||||||
const { environmentId, contactId, finished, ttc: initialTtc } = responseInput;
|
const { environmentId, contactId, finished, ttc: initialTtc } = responseInput;
|
||||||
|
|
||||||
@@ -129,8 +126,6 @@ export const createResponse = async (
|
|||||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||||
};
|
};
|
||||||
|
|
||||||
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/respons
|
|||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
|
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||||
|
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||||
import { createResponseWithQuotaEvaluation } from "./lib/response";
|
import { createResponseWithQuotaEvaluation } from "./lib/response";
|
||||||
@@ -91,7 +91,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
|||||||
// Validate response data for "other" options exceeding character limit
|
// Validate response data for "other" options exceeding character limit
|
||||||
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
|
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
|
||||||
responseData: responseInputData.data,
|
responseData: responseInputData.data,
|
||||||
surveyQuestions: survey.questions,
|
surveyQuestions: getElementsFromBlocks(survey.blocks),
|
||||||
responseLanguage: responseInputData.language,
|
responseLanguage: responseInputData.language,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,11 +148,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await capturePosthogEnvironmentEvent(environmentId, "response created", {
|
|
||||||
surveyId: responseData.surveyId,
|
|
||||||
surveyType: survey.type,
|
|
||||||
});
|
|
||||||
|
|
||||||
const quotaObj = createQuotaFullObject(quotaFull);
|
const quotaObj = createQuotaFullObject(quotaFull);
|
||||||
|
|
||||||
const responseDataWithQuota = {
|
const responseDataWithQuota = {
|
||||||
|
|||||||
97
apps/web/app/chatwoot/ChatwootWidget.tsx
Normal file
97
apps/web/app/chatwoot/ChatwootWidget.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface ChatwootWidgetProps {
|
||||||
|
chatwootBaseUrl: string;
|
||||||
|
chatwootWebsiteToken?: string;
|
||||||
|
userEmail?: string | null;
|
||||||
|
userName?: string | null;
|
||||||
|
userId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHATWOOT_SCRIPT_ID = "chatwoot-script";
|
||||||
|
|
||||||
|
export const ChatwootWidget = ({
|
||||||
|
userEmail,
|
||||||
|
userName,
|
||||||
|
userId,
|
||||||
|
chatwootWebsiteToken,
|
||||||
|
chatwootBaseUrl,
|
||||||
|
}: ChatwootWidgetProps) => {
|
||||||
|
const userSetRef = useRef(false);
|
||||||
|
|
||||||
|
const setUserInfo = useCallback(() => {
|
||||||
|
const $chatwoot = (
|
||||||
|
globalThis as unknown as {
|
||||||
|
$chatwoot: {
|
||||||
|
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).$chatwoot;
|
||||||
|
if (userId && $chatwoot && !userSetRef.current) {
|
||||||
|
$chatwoot.setUser(userId, {
|
||||||
|
email: userEmail,
|
||||||
|
name: userName,
|
||||||
|
});
|
||||||
|
userSetRef.current = true;
|
||||||
|
}
|
||||||
|
}, [userId, userEmail, userName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chatwootWebsiteToken) return;
|
||||||
|
|
||||||
|
const existingScript = document.getElementById(CHATWOOT_SCRIPT_ID);
|
||||||
|
if (existingScript) return;
|
||||||
|
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = `${chatwootBaseUrl}/packs/js/sdk.js`;
|
||||||
|
script.id = CHATWOOT_SCRIPT_ID;
|
||||||
|
script.async = true;
|
||||||
|
|
||||||
|
script.onload = () => {
|
||||||
|
(
|
||||||
|
globalThis as unknown as {
|
||||||
|
chatwootSDK: { run: (options: { websiteToken: string; baseUrl: string }) => void };
|
||||||
|
}
|
||||||
|
).chatwootSDK?.run({
|
||||||
|
websiteToken: chatwootWebsiteToken,
|
||||||
|
baseUrl: chatwootBaseUrl,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.head.appendChild(script);
|
||||||
|
|
||||||
|
const handleChatwootReady = () => setUserInfo();
|
||||||
|
globalThis.addEventListener("chatwoot:ready", handleChatwootReady);
|
||||||
|
|
||||||
|
// Check if Chatwoot is already ready
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
globalThis as unknown as {
|
||||||
|
$chatwoot: {
|
||||||
|
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).$chatwoot
|
||||||
|
) {
|
||||||
|
setUserInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
globalThis.removeEventListener("chatwoot:ready", handleChatwootReady);
|
||||||
|
|
||||||
|
const $chatwoot = (globalThis as unknown as { $chatwoot: { reset: () => void } }).$chatwoot;
|
||||||
|
if ($chatwoot) {
|
||||||
|
$chatwoot.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptElement = document.getElementById(CHATWOOT_SCRIPT_ID);
|
||||||
|
scriptElement?.remove();
|
||||||
|
|
||||||
|
userSetRef.current = false;
|
||||||
|
};
|
||||||
|
}, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Intercom from "@intercom/messenger-js-sdk";
|
|
||||||
import { useCallback, useEffect } from "react";
|
|
||||||
import { TUser } from "@formbricks/types/user";
|
|
||||||
|
|
||||||
interface IntercomClientProps {
|
|
||||||
isIntercomConfigured: boolean;
|
|
||||||
intercomUserHash?: string;
|
|
||||||
user?: TUser | null;
|
|
||||||
intercomAppId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const IntercomClient = ({
|
|
||||||
user,
|
|
||||||
intercomUserHash,
|
|
||||||
isIntercomConfigured,
|
|
||||||
intercomAppId,
|
|
||||||
}: IntercomClientProps) => {
|
|
||||||
const initializeIntercom = useCallback(() => {
|
|
||||||
let initParams = {};
|
|
||||||
|
|
||||||
if (user && intercomUserHash) {
|
|
||||||
const { id, name, email, createdAt } = user;
|
|
||||||
|
|
||||||
initParams = {
|
|
||||||
user_id: id,
|
|
||||||
user_hash: intercomUserHash,
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
created_at: createdAt ? Math.floor(createdAt.getTime() / 1000) : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Intercom({
|
|
||||||
app_id: intercomAppId!,
|
|
||||||
...initParams,
|
|
||||||
});
|
|
||||||
}, [user, intercomUserHash, intercomAppId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
if (isIntercomConfigured) {
|
|
||||||
if (!intercomAppId) {
|
|
||||||
throw new Error("Intercom app ID is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user && !intercomUserHash) {
|
|
||||||
throw new Error("Intercom user hash is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeIntercom();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// Shutdown Intercom when component unmounts
|
|
||||||
if (typeof window !== "undefined" && window.Intercom) {
|
|
||||||
window.Intercom("shutdown");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to initialize Intercom:", error);
|
|
||||||
}
|
|
||||||
}, [isIntercomConfigured, initializeIntercom, intercomAppId, intercomUserHash, user]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { createHmac } from "crypto";
|
|
||||||
import type { TUser } from "@formbricks/types/user";
|
|
||||||
import { INTERCOM_APP_ID, INTERCOM_SECRET_KEY, IS_INTERCOM_CONFIGURED } from "@/lib/constants";
|
|
||||||
import { IntercomClient } from "./IntercomClient";
|
|
||||||
|
|
||||||
interface IntercomClientWrapperProps {
|
|
||||||
user?: TUser | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const IntercomClientWrapper = ({ user }: IntercomClientWrapperProps) => {
|
|
||||||
let intercomUserHash: string | undefined;
|
|
||||||
if (user) {
|
|
||||||
const secretKey = INTERCOM_SECRET_KEY;
|
|
||||||
if (secretKey) {
|
|
||||||
intercomUserHash = createHmac("sha256", secretKey).update(user.id).digest("hex");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<IntercomClient
|
|
||||||
isIntercomConfigured={IS_INTERCOM_CONFIGURED}
|
|
||||||
user={user}
|
|
||||||
intercomAppId={INTERCOM_APP_ID}
|
|
||||||
intercomUserHash={intercomUserHash}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
1336
apps/web/app/lib/api/survey-transformation.test.ts
Normal file
1336
apps/web/app/lib/api/survey-transformation.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user