mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-20 09:20:49 -05:00
Compare commits
3 Commits
4.4.2
...
fix-6739-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a651defd74 | ||
|
|
b3fe7ee4d9 | ||
|
|
bf2f4ea381 |
@@ -1,352 +0,0 @@
|
|||||||
# 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
|
|
||||||
|
|
||||||
@@ -179,14 +179,14 @@ For endpoints serving client SDKs, coordinate TTLs across layers:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Client SDK cache (expiresAt) - longest TTL for fewer requests
|
// Client SDK cache (expiresAt) - longest TTL for fewer requests
|
||||||
const CLIENT_TTL = 60; // 1 minute (seconds for client)
|
const CLIENT_TTL = 60 * 60; // 1 hour (seconds for client)
|
||||||
|
|
||||||
// Server Redis cache - shorter TTL ensures fresh data for clients
|
// Server Redis cache - shorter TTL ensures fresh data for clients
|
||||||
const SERVER_TTL = 60 * 1000; // 1 minutes in milliseconds
|
const SERVER_TTL = 60 * 30 * 1000; // 30 minutes in milliseconds
|
||||||
|
|
||||||
// HTTP cache headers (seconds)
|
// HTTP cache headers (seconds)
|
||||||
const BROWSER_TTL = 60; // 1 minute (max-age)
|
const BROWSER_TTL = 60 * 60; // 1 hour (max-age)
|
||||||
const CDN_TTL = 60; // 1 minute (s-maxage)
|
const CDN_TTL = 60 * 30; // 30 minutes (s-maxage)
|
||||||
const CORS_TTL = 60 * 60; // 1 hour (balanced approach)
|
const CORS_TTL = 60 * 60; // 1 hour (balanced approach)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
---
|
---
|
||||||
description: >
|
description: >
|
||||||
globs: schema.prisma
|
This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
|
||||||
alwaysApply: false
|
and data patterns. It should be used **only when the agent explicitly requests database schema-level
|
||||||
|
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
|
||||||
|
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
|
||||||
|
globs: []
|
||||||
|
alwaysApply: agent-requested
|
||||||
---
|
---
|
||||||
|
|
||||||
# Formbricks Database Schema Reference
|
# Formbricks Database Schema Reference
|
||||||
|
|
||||||
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
|
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
|
||||||
|
|||||||
5
.cursor/rules/performance-optimization.mdc
Normal file
5
.cursor/rules/performance-optimization.mdc
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
5
.cursor/rules/react-context-providers.mdc
Normal file
5
.cursor/rules/react-context-providers.mdc
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
@@ -9,12 +9,8 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -193,9 +189,8 @@ 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:
|
||||||
|
|
||||||
# Chatwoot
|
# INTERCOM_APP_ID=
|
||||||
# CHATWOOT_BASE_URL=
|
# INTERCOM_SECRET_KEY=
|
||||||
# CHATWOOT_WEBSITE_TOKEN=
|
|
||||||
|
|
||||||
# Enable Prometheus metrics
|
# Enable Prometheus metrics
|
||||||
# PROMETHEUS_ENABLED=
|
# PROMETHEUS_ENABLED=
|
||||||
|
|||||||
46
.github/workflows/e2e.yml
vendored
46
.github/workflows/e2e.yml
vendored
@@ -3,9 +3,13 @@ name: E2E Tests
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
secrets:
|
secrets:
|
||||||
PLAYWRIGHT_SERVICE_URL:
|
AZURE_CLIENT_ID:
|
||||||
required: false
|
required: false
|
||||||
PLAYWRIGHT_SERVICE_ACCESS_TOKEN:
|
AZURE_TENANT_ID:
|
||||||
|
required: false
|
||||||
|
AZURE_SUBSCRIPTION_ID:
|
||||||
|
required: false
|
||||||
|
PLAYWRIGHT_SERVICE_URL:
|
||||||
required: false
|
required: false
|
||||||
ENTERPRISE_LICENSE_KEY:
|
ENTERPRISE_LICENSE_KEY:
|
||||||
required: true
|
required: true
|
||||||
@@ -13,10 +17,12 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
TELEMETRY_DISABLED: 1
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
id-token: write
|
||||||
contents: read
|
contents: read
|
||||||
actions: read
|
actions: read
|
||||||
|
|
||||||
@@ -109,7 +115,7 @@ jobs:
|
|||||||
- name: Start MinIO Server
|
- name: Start MinIO Server
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Start MinIO server in background
|
# Start MinIO server in background
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name minio-server \
|
--name minio-server \
|
||||||
@@ -119,7 +125,7 @@ jobs:
|
|||||||
-e MINIO_ROOT_PASSWORD=devminio123 \
|
-e MINIO_ROOT_PASSWORD=devminio123 \
|
||||||
minio/minio:RELEASE.2025-09-07T16-13-09Z \
|
minio/minio:RELEASE.2025-09-07T16-13-09Z \
|
||||||
server /data --console-address :9001
|
server /data --console-address :9001
|
||||||
|
|
||||||
echo "MinIO server started"
|
echo "MinIO server started"
|
||||||
|
|
||||||
- name: Wait for MinIO and create S3 bucket
|
- name: Wait for MinIO and create S3 bucket
|
||||||
@@ -202,30 +208,32 @@ jobs:
|
|||||||
- name: Install Playwright
|
- name: Install Playwright
|
||||||
run: pnpm exec playwright install --with-deps
|
run: pnpm exec playwright install --with-deps
|
||||||
|
|
||||||
- name: Determine Playwright execution mode
|
- name: Set Azure Secret Variables
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
|
||||||
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
if [[ -n "${{ secrets.AZURE_CLIENT_ID }}" && -n "${{ secrets.AZURE_TENANT_ID }}" && -n "${{ secrets.AZURE_SUBSCRIPTION_ID }}" ]]; then
|
||||||
|
echo "AZURE_ENABLED=true" >> $GITHUB_ENV
|
||||||
if [[ -n "${PLAYWRIGHT_SERVICE_URL}" && -n "${PLAYWRIGHT_SERVICE_ACCESS_TOKEN}" ]]; then
|
|
||||||
echo "PW_MODE=service" >> "$GITHUB_ENV"
|
|
||||||
else
|
else
|
||||||
echo "PW_MODE=local" >> "$GITHUB_ENV"
|
echo "AZURE_ENABLED=false" >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Run E2E Tests (Playwright Service)
|
- name: Azure login
|
||||||
if: env.PW_MODE == 'service'
|
if: env.AZURE_ENABLED == 'true'
|
||||||
|
uses: azure/login@a65d910e8af852a8061c627c456678983e180302 # v2.2.0
|
||||||
|
with:
|
||||||
|
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
|
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||||
|
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||||
|
|
||||||
|
- name: Run E2E Tests (Azure)
|
||||||
|
if: env.AZURE_ENABLED == 'true'
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
||||||
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
|
|
||||||
CI: true
|
CI: true
|
||||||
run: pnpm test-e2e:azure
|
run: |
|
||||||
|
pnpm test-e2e:azure
|
||||||
|
|
||||||
- name: Run E2E Tests (Local)
|
- name: Run E2E Tests (Local)
|
||||||
if: env.PW_MODE == 'local'
|
if: env.AZURE_ENABLED == 'false'
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
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 == 'true' }}
|
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||||
|
|
||||||
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 == 'true' }}
|
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||||
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 == 'true' }}
|
make_latest: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
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, resolve } from "path";
|
import { dirname, join } 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.
|
||||||
@@ -16,7 +13,7 @@ function getAbsolutePath(value: string): any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
stories: ["../src/**/*.mdx", "../../../packages/survey-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
|
||||||
addons: [
|
addons: [
|
||||||
getAbsolutePath("@storybook/addon-onboarding"),
|
getAbsolutePath("@storybook/addon-onboarding"),
|
||||||
getAbsolutePath("@storybook/addon-links"),
|
getAbsolutePath("@storybook/addon-links"),
|
||||||
@@ -28,25 +25,5 @@ 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,6 +1,19 @@
|
|||||||
import type { Preview } from "@storybook/react-vite";
|
import type { Preview } from "@storybook/react-vite";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import "../../../packages/survey-ui/src/styles/globals.css";
|
import { I18nProvider } from "../../web/lingodotdev/client";
|
||||||
|
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: {
|
||||||
@@ -9,23 +22,9 @@ const preview: Preview = {
|
|||||||
color: /(background|color)$/i,
|
color: /(background|color)$/i,
|
||||||
date: /Date$/i,
|
date: /Date$/i,
|
||||||
},
|
},
|
||||||
expanded: true,
|
|
||||||
},
|
|
||||||
backgrounds: {
|
|
||||||
default: "light",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
decorators: [
|
decorators: [withLingodotDev],
|
||||||
(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,24 +11,22 @@
|
|||||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formbricks/survey-ui": "workspace:*",
|
"eslint-plugin-react-refresh": "0.4.20"
|
||||||
"eslint-plugin-react-refresh": "0.4.24"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "^4.1.3",
|
"@chromatic-com/storybook": "^4.0.1",
|
||||||
"@storybook/addon-a11y": "10.0.8",
|
"@storybook/addon-a11y": "9.0.15",
|
||||||
"@storybook/addon-links": "10.0.8",
|
"@storybook/addon-links": "9.0.15",
|
||||||
"@storybook/addon-onboarding": "10.0.8",
|
"@storybook/addon-onboarding": "9.0.15",
|
||||||
"@storybook/react-vite": "10.0.8",
|
"@storybook/react-vite": "9.0.15",
|
||||||
"@typescript-eslint/eslint-plugin": "8.48.0",
|
"@typescript-eslint/eslint-plugin": "8.32.0",
|
||||||
"@tailwindcss/vite": "4.1.17",
|
"@typescript-eslint/parser": "8.32.0",
|
||||||
"@typescript-eslint/parser": "8.48.0",
|
"@vitejs/plugin-react": "4.4.1",
|
||||||
"@vitejs/plugin-react": "5.1.1",
|
"esbuild": "0.25.4",
|
||||||
"esbuild": "0.27.0",
|
"eslint-plugin-storybook": "9.0.15",
|
||||||
"eslint-plugin-storybook": "10.0.8",
|
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
"storybook": "10.0.8",
|
"storybook": "9.0.15",
|
||||||
"vite": "7.2.4",
|
"vite": "6.4.1",
|
||||||
"@storybook/addon-docs": "10.0.8"
|
"@storybook/addon-docs": "9.0.15"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
apps/storybook/postcss.config.js
Normal file
6
apps/storybook/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,15 +1,7 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
import surveyUi from "../../packages/survey-ui/tailwind.config";
|
import base from "../web/tailwind.config";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
content: [
|
...base,
|
||||||
"./index.html",
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}", "../web/modules/ui/**/*.{js,ts,jsx,tsx}"],
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
|
||||||
"../../packages/survey-ui/src/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
...surveyUi.theme?.extend,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
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(), tailwindcss()],
|
plugins: [react()],
|
||||||
define: {
|
define: {
|
||||||
"process.env": {},
|
"process.env": {},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@formbricks/survey-ui": path.resolve(__dirname, "../../packages/survey-ui/src"),
|
"@": path.resolve(__dirname, "../web"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,10 +37,6 @@ 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
|
||||||
|
|
||||||
@@ -77,8 +73,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
|
||||||
corepack enable
|
RUN corepack enable
|
||||||
|
|
||||||
RUN apk add --no-cache curl \
|
RUN apk add --no-cache curl \
|
||||||
&& apk add --no-cache supercronic \
|
&& apk add --no-cache supercronic \
|
||||||
@@ -128,7 +124,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@6
|
RUN npm install -g prisma
|
||||||
|
|
||||||
# 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
|
||||||
@@ -138,13 +134,12 @@ EXPOSE 3000
|
|||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
# Prepare pnpm as the nextjs user to ensure it's available at runtime
|
# Prepare volume for uploads
|
||||||
# Prepare volumes for uploads and SAML connections
|
RUN mkdir -p /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
|
|
||||||
|
|
||||||
VOLUME /home/nextjs/apps/web/uploads/
|
VOLUME /home/nextjs/apps/web/uploads/
|
||||||
|
|
||||||
|
# Prepare volume for SAML preloaded connection
|
||||||
|
RUN mkdir -p /home/nextjs/apps/web/saml-connection
|
||||||
VOLUME /home/nextjs/apps/web/saml-connection
|
VOLUME /home/nextjs/apps/web/saml-connection
|
||||||
|
|
||||||
CMD ["/home/nextjs/start.sh"]
|
CMD ["/home/nextjs/start.sh"]
|
||||||
@@ -32,22 +32,14 @@ const mockProject: TProject = {
|
|||||||
};
|
};
|
||||||
const mockTemplate: TXMTemplate = {
|
const mockTemplate: TXMTemplate = {
|
||||||
name: "$[projectName] Survey",
|
name: "$[projectName] Survey",
|
||||||
blocks: [
|
questions: [
|
||||||
{
|
{
|
||||||
id: "block1",
|
id: "q1",
|
||||||
name: "Block 1",
|
inputType: "text",
|
||||||
elements: [
|
type: "email" as any,
|
||||||
{
|
headline: { default: "$[projectName] Question" },
|
||||||
id: "q1",
|
required: false,
|
||||||
type: "openText" as const,
|
charLimit: { enabled: true, min: 400, max: 1000 },
|
||||||
inputType: "text" as const,
|
|
||||||
headline: { default: "$[projectName] Question" },
|
|
||||||
subheader: { default: "" },
|
|
||||||
required: false,
|
|
||||||
placeholder: { default: "" },
|
|
||||||
charLimit: 1000,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
endings: [
|
endings: [
|
||||||
@@ -74,9 +66,9 @@ describe("replacePresetPlaceholders", () => {
|
|||||||
expect(result.name).toBe("Test Project Survey");
|
expect(result.name).toBe("Test Project Survey");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("replaces projectName placeholder in element headline", () => {
|
test("replaces projectName placeholder in question headline", () => {
|
||||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||||
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
|
expect(result.questions[0].headline.default).toBe("Test Project Question");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns a new object without mutating the original template", () => {
|
test("returns a new object without mutating the original template", () => {
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { TProject } from "@formbricks/types/project";
|
import { TProject } from "@formbricks/types/project";
|
||||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
|
||||||
import { TXMTemplate } from "@formbricks/types/templates";
|
import { TXMTemplate } from "@formbricks/types/templates";
|
||||||
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
|
import { replaceQuestionPresetPlaceholders } from "@/lib/utils/templates";
|
||||||
|
|
||||||
// replace all occurences of projectName with the actual project name in the current template
|
// replace all occurences of projectName with the actual project name in the current template
|
||||||
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => {
|
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject) => {
|
||||||
const survey = structuredClone(template);
|
const survey = structuredClone(template);
|
||||||
|
survey.name = survey.name.replace("$[projectName]", project.name);
|
||||||
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
|
survey.questions = survey.questions.map((question) => {
|
||||||
...block,
|
return replaceQuestionPresetPlaceholders(question, project);
|
||||||
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
|
});
|
||||||
}));
|
return { ...template, ...survey };
|
||||||
|
|
||||||
return { ...survey, name: survey.name.replace("$[projectName]", project.name), blocks: modifiedBlocks };
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ describe("xm-templates", () => {
|
|||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
name: "",
|
name: "",
|
||||||
endings: expect.any(Array),
|
endings: expect.any(Array),
|
||||||
blocks: [],
|
questions: [],
|
||||||
styling: {
|
styling: {
|
||||||
overwriteThemeStyling: true,
|
overwriteThemeStyling: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,21 +3,19 @@ import { TFunction } from "i18next";
|
|||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { TXMTemplate } from "@formbricks/types/templates";
|
import { TXMTemplate } from "@formbricks/types/templates";
|
||||||
import {
|
import {
|
||||||
buildBlock,
|
buildCTAQuestion,
|
||||||
buildCTAElement,
|
buildNPSQuestion,
|
||||||
buildNPSElement,
|
buildOpenTextQuestion,
|
||||||
buildOpenTextElement,
|
buildRatingQuestion,
|
||||||
buildRatingElement,
|
getDefaultEndingCard,
|
||||||
createBlockJumpLogic,
|
} from "@/app/lib/survey-builder";
|
||||||
} from "@/app/lib/survey-block-builder";
|
|
||||||
import { getDefaultEndingCard } from "@/app/lib/survey-builder";
|
|
||||||
|
|
||||||
export const getXMSurveyDefault = (t: TFunction): TXMTemplate => {
|
export const getXMSurveyDefault = (t: TFunction): TXMTemplate => {
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
name: "",
|
name: "",
|
||||||
endings: [getDefaultEndingCard([], t)],
|
endings: [getDefaultEndingCard([], t)],
|
||||||
blocks: [],
|
questions: [],
|
||||||
styling: {
|
styling: {
|
||||||
overwriteThemeStyling: true,
|
overwriteThemeStyling: true,
|
||||||
},
|
},
|
||||||
@@ -32,40 +30,25 @@ const npsSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
return {
|
return {
|
||||||
...getXMSurveyDefault(t),
|
...getXMSurveyDefault(t),
|
||||||
name: t("templates.nps_survey_name"),
|
name: t("templates.nps_survey_name"),
|
||||||
blocks: [
|
questions: [
|
||||||
buildBlock({
|
buildNPSQuestion({
|
||||||
name: "Block 1",
|
headline: t("templates.nps_survey_question_1_headline"),
|
||||||
elements: [
|
required: true,
|
||||||
buildNPSElement({
|
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
|
||||||
headline: t("templates.nps_survey_question_1_headline"),
|
upperLabel: t("templates.nps_survey_question_1_upper_label"),
|
||||||
required: true,
|
isColorCodingEnabled: true,
|
||||||
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
|
|
||||||
upperLabel: t("templates.nps_survey_question_1_upper_label"),
|
|
||||||
isColorCodingEnabled: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildBlock({
|
buildOpenTextQuestion({
|
||||||
name: "Block 2",
|
headline: t("templates.nps_survey_question_2_headline"),
|
||||||
elements: [
|
required: false,
|
||||||
buildOpenTextElement({
|
inputType: "text",
|
||||||
headline: t("templates.nps_survey_question_2_headline"),
|
|
||||||
required: false,
|
|
||||||
inputType: "text",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildBlock({
|
buildOpenTextQuestion({
|
||||||
name: "Block 3",
|
headline: t("templates.nps_survey_question_3_headline"),
|
||||||
elements: [
|
required: false,
|
||||||
buildOpenTextElement({
|
inputType: "text",
|
||||||
headline: t("templates.nps_survey_question_3_headline"),
|
|
||||||
required: false,
|
|
||||||
inputType: "text",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -73,27 +56,15 @@ const npsSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||||
const reusableElementIds = [createId(), createId(), createId()];
|
const reusableQuestionIds = [createId(), createId(), createId()];
|
||||||
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
|
|
||||||
const defaultSurvey = getXMSurveyDefault(t);
|
const defaultSurvey = getXMSurveyDefault(t);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...defaultSurvey,
|
...defaultSurvey,
|
||||||
name: t("templates.star_rating_survey_name"),
|
name: t("templates.star_rating_survey_name"),
|
||||||
blocks: [
|
questions: [
|
||||||
buildBlock({
|
buildRatingQuestion({
|
||||||
name: "Block 1",
|
id: reusableQuestionIds[0],
|
||||||
elements: [
|
|
||||||
buildRatingElement({
|
|
||||||
id: reusableElementIds[0],
|
|
||||||
range: 5,
|
|
||||||
scale: "number",
|
|
||||||
headline: t("templates.star_rating_survey_question_1_headline"),
|
|
||||||
required: true,
|
|
||||||
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
|
|
||||||
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
logic: [
|
logic: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -104,8 +75,8 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableElementIds[0],
|
value: reusableQuestionIds[0],
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "isLessThanOrEqual",
|
operator: "isLessThanOrEqual",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
@@ -118,44 +89,64 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
objective: "jumpToBlock",
|
objective: "jumpToQuestion",
|
||||||
target: block3Id,
|
target: reusableQuestionIds[2],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
range: 5,
|
||||||
|
scale: "number",
|
||||||
|
headline: t("templates.star_rating_survey_question_1_headline"),
|
||||||
|
required: true,
|
||||||
|
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
|
||||||
|
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildBlock({
|
buildCTAQuestion({
|
||||||
name: "Block 2",
|
id: reusableQuestionIds[1],
|
||||||
elements: [
|
subheader: t("templates.star_rating_survey_question_2_html"),
|
||||||
buildCTAElement({
|
logic: [
|
||||||
id: reusableElementIds[1],
|
{
|
||||||
subheader: t("templates.star_rating_survey_question_2_html"),
|
id: createId(),
|
||||||
headline: t("templates.star_rating_survey_question_2_headline"),
|
conditions: {
|
||||||
required: false,
|
id: createId(),
|
||||||
buttonUrl: "https://formbricks.com/github",
|
connector: "and",
|
||||||
buttonExternal: true,
|
conditions: [
|
||||||
ctaButtonLabel: t("templates.star_rating_survey_question_2_button_label"),
|
{
|
||||||
}),
|
id: createId(),
|
||||||
|
leftOperand: {
|
||||||
|
value: reusableQuestionIds[1],
|
||||||
|
type: "question",
|
||||||
|
},
|
||||||
|
operator: "isClicked",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: createId(),
|
||||||
|
objective: "jumpToQuestion",
|
||||||
|
target: defaultSurvey.endings[0].id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
|
headline: t("templates.star_rating_survey_question_2_headline"),
|
||||||
|
required: true,
|
||||||
|
buttonUrl: "https://formbricks.com/github",
|
||||||
|
buttonLabel: t("templates.star_rating_survey_question_2_button_label"),
|
||||||
|
buttonExternal: true,
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildBlock({
|
buildOpenTextQuestion({
|
||||||
id: block3Id,
|
id: reusableQuestionIds[2],
|
||||||
name: "Block 3",
|
headline: t("templates.star_rating_survey_question_3_headline"),
|
||||||
elements: [
|
required: true,
|
||||||
buildOpenTextElement({
|
subheader: t("templates.star_rating_survey_question_3_subheader"),
|
||||||
id: reusableElementIds[2],
|
|
||||||
headline: t("templates.star_rating_survey_question_3_headline"),
|
|
||||||
required: true,
|
|
||||||
subheader: t("templates.star_rating_survey_question_3_subheader"),
|
|
||||||
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
|
|
||||||
inputType: "text",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
buttonLabel: t("templates.star_rating_survey_question_3_button_label"),
|
buttonLabel: t("templates.star_rating_survey_question_3_button_label"),
|
||||||
|
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
|
||||||
|
inputType: "text",
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -163,27 +154,15 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const csatSurvey = (t: TFunction): TXMTemplate => {
|
const csatSurvey = (t: TFunction): TXMTemplate => {
|
||||||
const reusableElementIds = [createId(), createId(), createId()];
|
const reusableQuestionIds = [createId(), createId(), createId()];
|
||||||
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
|
|
||||||
const defaultSurvey = getXMSurveyDefault(t);
|
const defaultSurvey = getXMSurveyDefault(t);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...defaultSurvey,
|
...defaultSurvey,
|
||||||
name: t("templates.csat_survey_name"),
|
name: t("templates.csat_survey_name"),
|
||||||
blocks: [
|
questions: [
|
||||||
buildBlock({
|
buildRatingQuestion({
|
||||||
name: "Block 1",
|
id: reusableQuestionIds[0],
|
||||||
elements: [
|
|
||||||
buildRatingElement({
|
|
||||||
id: reusableElementIds[0],
|
|
||||||
range: 5,
|
|
||||||
scale: "smiley",
|
|
||||||
headline: t("templates.csat_survey_question_1_headline"),
|
|
||||||
required: true,
|
|
||||||
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
|
|
||||||
upperLabel: t("templates.csat_survey_question_1_upper_label"),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
logic: [
|
logic: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -194,8 +173,8 @@ const csatSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableElementIds[0],
|
value: reusableQuestionIds[0],
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "isLessThanOrEqual",
|
operator: "isLessThanOrEqual",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
@@ -208,40 +187,60 @@ const csatSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
objective: "jumpToBlock",
|
objective: "jumpToQuestion",
|
||||||
target: block3Id,
|
target: reusableQuestionIds[2],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
range: 5,
|
||||||
|
scale: "smiley",
|
||||||
|
headline: t("templates.csat_survey_question_1_headline"),
|
||||||
|
required: true,
|
||||||
|
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
|
||||||
|
upperLabel: t("templates.csat_survey_question_1_upper_label"),
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildBlock({
|
buildOpenTextQuestion({
|
||||||
name: "Block 2",
|
id: reusableQuestionIds[1],
|
||||||
elements: [
|
logic: [
|
||||||
buildOpenTextElement({
|
{
|
||||||
id: reusableElementIds[1],
|
id: createId(),
|
||||||
headline: t("templates.csat_survey_question_2_headline"),
|
conditions: {
|
||||||
required: false,
|
id: createId(),
|
||||||
placeholder: t("templates.csat_survey_question_2_placeholder"),
|
connector: "and",
|
||||||
inputType: "text",
|
conditions: [
|
||||||
}),
|
{
|
||||||
|
id: createId(),
|
||||||
|
leftOperand: {
|
||||||
|
value: reusableQuestionIds[1],
|
||||||
|
type: "question",
|
||||||
|
},
|
||||||
|
operator: "isSubmitted",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: createId(),
|
||||||
|
objective: "jumpToQuestion",
|
||||||
|
target: defaultSurvey.endings[0].id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isSubmitted")],
|
headline: t("templates.csat_survey_question_2_headline"),
|
||||||
|
required: false,
|
||||||
|
placeholder: t("templates.csat_survey_question_2_placeholder"),
|
||||||
|
inputType: "text",
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildBlock({
|
buildOpenTextQuestion({
|
||||||
id: block3Id,
|
id: reusableQuestionIds[2],
|
||||||
name: "Block 3",
|
headline: t("templates.csat_survey_question_3_headline"),
|
||||||
elements: [
|
required: false,
|
||||||
buildOpenTextElement({
|
placeholder: t("templates.csat_survey_question_3_placeholder"),
|
||||||
id: reusableElementIds[2],
|
inputType: "text",
|
||||||
headline: t("templates.csat_survey_question_3_headline"),
|
|
||||||
required: false,
|
|
||||||
placeholder: t("templates.csat_survey_question_3_placeholder"),
|
|
||||||
inputType: "text",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -252,31 +251,21 @@ const cessSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
return {
|
return {
|
||||||
...getXMSurveyDefault(t),
|
...getXMSurveyDefault(t),
|
||||||
name: t("templates.cess_survey_name"),
|
name: t("templates.cess_survey_name"),
|
||||||
blocks: [
|
questions: [
|
||||||
buildBlock({
|
buildRatingQuestion({
|
||||||
name: "Block 1",
|
range: 5,
|
||||||
elements: [
|
scale: "number",
|
||||||
buildRatingElement({
|
headline: t("templates.cess_survey_question_1_headline"),
|
||||||
range: 5,
|
required: true,
|
||||||
scale: "number",
|
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
|
||||||
headline: t("templates.cess_survey_question_1_headline"),
|
upperLabel: t("templates.cess_survey_question_1_upper_label"),
|
||||||
required: true,
|
|
||||||
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
|
|
||||||
upperLabel: t("templates.cess_survey_question_1_upper_label"),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildBlock({
|
buildOpenTextQuestion({
|
||||||
name: "Block 2",
|
headline: t("templates.cess_survey_question_2_headline"),
|
||||||
elements: [
|
required: true,
|
||||||
buildOpenTextElement({
|
placeholder: t("templates.cess_survey_question_2_placeholder"),
|
||||||
headline: t("templates.cess_survey_question_2_headline"),
|
inputType: "text",
|
||||||
required: true,
|
|
||||||
placeholder: t("templates.cess_survey_question_2_placeholder"),
|
|
||||||
inputType: "text",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -284,27 +273,15 @@ const cessSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
|
const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||||
const reusableElementIds = [createId(), createId(), createId()];
|
const reusableQuestionIds = [createId(), createId(), createId()];
|
||||||
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
|
|
||||||
const defaultSurvey = getXMSurveyDefault(t);
|
const defaultSurvey = getXMSurveyDefault(t);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...defaultSurvey,
|
...defaultSurvey,
|
||||||
name: t("templates.smileys_survey_name"),
|
name: t("templates.smileys_survey_name"),
|
||||||
blocks: [
|
questions: [
|
||||||
buildBlock({
|
buildRatingQuestion({
|
||||||
name: "Block 1",
|
id: reusableQuestionIds[0],
|
||||||
elements: [
|
|
||||||
buildRatingElement({
|
|
||||||
id: reusableElementIds[0],
|
|
||||||
range: 5,
|
|
||||||
scale: "smiley",
|
|
||||||
headline: t("templates.smileys_survey_question_1_headline"),
|
|
||||||
required: true,
|
|
||||||
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
|
|
||||||
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
logic: [
|
logic: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -315,8 +292,8 @@ const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableElementIds[0],
|
value: reusableQuestionIds[0],
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "isLessThanOrEqual",
|
operator: "isLessThanOrEqual",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
@@ -329,44 +306,64 @@ const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
objective: "jumpToBlock",
|
objective: "jumpToQuestion",
|
||||||
target: block3Id,
|
target: reusableQuestionIds[2],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
range: 5,
|
||||||
|
scale: "smiley",
|
||||||
|
headline: t("templates.smileys_survey_question_1_headline"),
|
||||||
|
required: true,
|
||||||
|
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
|
||||||
|
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildBlock({
|
buildCTAQuestion({
|
||||||
name: "Block 2",
|
id: reusableQuestionIds[1],
|
||||||
elements: [
|
subheader: t("templates.smileys_survey_question_2_html"),
|
||||||
buildCTAElement({
|
logic: [
|
||||||
id: reusableElementIds[1],
|
{
|
||||||
subheader: t("templates.smileys_survey_question_2_html"),
|
id: createId(),
|
||||||
headline: t("templates.smileys_survey_question_2_headline"),
|
conditions: {
|
||||||
required: false,
|
id: createId(),
|
||||||
buttonUrl: "https://formbricks.com/github",
|
connector: "and",
|
||||||
buttonExternal: true,
|
conditions: [
|
||||||
ctaButtonLabel: t("templates.smileys_survey_question_2_button_label"),
|
{
|
||||||
}),
|
id: createId(),
|
||||||
|
leftOperand: {
|
||||||
|
value: reusableQuestionIds[1],
|
||||||
|
type: "question",
|
||||||
|
},
|
||||||
|
operator: "isClicked",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: createId(),
|
||||||
|
objective: "jumpToQuestion",
|
||||||
|
target: defaultSurvey.endings[0].id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
|
headline: t("templates.smileys_survey_question_2_headline"),
|
||||||
|
required: true,
|
||||||
|
buttonUrl: "https://formbricks.com/github",
|
||||||
|
buttonLabel: t("templates.smileys_survey_question_2_button_label"),
|
||||||
|
buttonExternal: true,
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildBlock({
|
buildOpenTextQuestion({
|
||||||
id: block3Id,
|
id: reusableQuestionIds[2],
|
||||||
name: "Block 3",
|
headline: t("templates.smileys_survey_question_3_headline"),
|
||||||
elements: [
|
required: true,
|
||||||
buildOpenTextElement({
|
subheader: t("templates.smileys_survey_question_3_subheader"),
|
||||||
id: reusableElementIds[2],
|
|
||||||
headline: t("templates.smileys_survey_question_3_headline"),
|
|
||||||
required: true,
|
|
||||||
subheader: t("templates.smileys_survey_question_3_subheader"),
|
|
||||||
placeholder: t("templates.smileys_survey_question_3_placeholder"),
|
|
||||||
inputType: "text",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
buttonLabel: t("templates.smileys_survey_question_3_button_label"),
|
buttonLabel: t("templates.smileys_survey_question_3_button_label"),
|
||||||
|
placeholder: t("templates.smileys_survey_question_3_placeholder"),
|
||||||
|
inputType: "text",
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -377,40 +374,25 @@ const enpsSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
return {
|
return {
|
||||||
...getXMSurveyDefault(t),
|
...getXMSurveyDefault(t),
|
||||||
name: t("templates.enps_survey_name"),
|
name: t("templates.enps_survey_name"),
|
||||||
blocks: [
|
questions: [
|
||||||
buildBlock({
|
buildNPSQuestion({
|
||||||
name: "Block 1",
|
headline: t("templates.enps_survey_question_1_headline"),
|
||||||
elements: [
|
required: false,
|
||||||
buildNPSElement({
|
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
|
||||||
headline: t("templates.enps_survey_question_1_headline"),
|
upperLabel: t("templates.enps_survey_question_1_upper_label"),
|
||||||
required: false,
|
isColorCodingEnabled: true,
|
||||||
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
|
|
||||||
upperLabel: t("templates.enps_survey_question_1_upper_label"),
|
|
||||||
isColorCodingEnabled: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildBlock({
|
buildOpenTextQuestion({
|
||||||
name: "Block 2",
|
headline: t("templates.enps_survey_question_2_headline"),
|
||||||
elements: [
|
required: false,
|
||||||
buildOpenTextElement({
|
inputType: "text",
|
||||||
headline: t("templates.enps_survey_question_2_headline"),
|
|
||||||
required: false,
|
|
||||||
inputType: "text",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildBlock({
|
buildOpenTextQuestion({
|
||||||
name: "Block 3",
|
headline: t("templates.enps_survey_question_3_headline"),
|
||||||
elements: [
|
required: false,
|
||||||
buildOpenTextElement({
|
inputType: "text",
|
||||||
headline: t("templates.enps_survey_question_3_headline"),
|
|
||||||
required: false,
|
|
||||||
inputType: "text",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/co
|
|||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
|
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
@@ -23,6 +24,8 @@ const Page = async (props) => {
|
|||||||
const user = await getUser(session.user.id);
|
const user = await getUser(session.user.id);
|
||||||
if (!user) return notFound();
|
if (!user) return notFound();
|
||||||
|
|
||||||
|
const organizations = await getOrganizationsByUserId(session.user.id);
|
||||||
|
|
||||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||||
|
|
||||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||||
@@ -34,10 +37,11 @@ const Page = async (props) => {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */}
|
{/* we only need to render organization breadcrumb on this page, so we pass some default value without actually calculating them to ProjectAndOrgSwitch component */}
|
||||||
<ProjectAndOrgSwitch
|
<ProjectAndOrgSwitch
|
||||||
currentOrganizationId={organization.id}
|
currentOrganizationId={organization.id}
|
||||||
currentOrganizationName={organization.name}
|
organizations={organizations}
|
||||||
|
projects={[]}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
organizationProjectsLimit={0}
|
organizationProjectsLimit={0}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AuthorizationError } from "@formbricks/types/errors";
|
import { AuthorizationError } from "@formbricks/types/errors";
|
||||||
|
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
|
||||||
|
import { IS_POSTHOG_CONFIGURED } from "@/lib/constants";
|
||||||
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
@@ -38,6 +40,14 @@ const ProjectOnboardingLayout = async (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 bg-slate-50">
|
<div className="flex-1 bg-slate-50">
|
||||||
|
<PosthogIdentify
|
||||||
|
session={session}
|
||||||
|
user={user}
|
||||||
|
organizationId={organization.id}
|
||||||
|
organizationName={organization.name}
|
||||||
|
organizationBilling={organization.billing}
|
||||||
|
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
|
||||||
|
/>
|
||||||
<ToasterClient />
|
<ToasterClient />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ interface ProjectSettingsProps {
|
|||||||
organizationTeams: TOrganizationTeam[];
|
organizationTeams: TOrganizationTeam[];
|
||||||
isAccessControlAllowed: boolean;
|
isAccessControlAllowed: boolean;
|
||||||
userProjectsCount: number;
|
userProjectsCount: number;
|
||||||
publicDomain: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProjectSettings = ({
|
export const ProjectSettings = ({
|
||||||
@@ -56,7 +55,6 @@ export const ProjectSettings = ({
|
|||||||
organizationTeams,
|
organizationTeams,
|
||||||
isAccessControlAllowed = false,
|
isAccessControlAllowed = false,
|
||||||
userProjectsCount,
|
userProjectsCount,
|
||||||
publicDomain,
|
|
||||||
}: ProjectSettingsProps) => {
|
}: ProjectSettingsProps) => {
|
||||||
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
||||||
|
|
||||||
@@ -233,7 +231,6 @@ 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,7 +5,6 @@ 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";
|
||||||
@@ -48,8 +47,6 @@ 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
|
||||||
@@ -65,7 +62,6 @@ 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,13 +1,14 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||||
|
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
||||||
|
|
||||||
const SurveyEditorEnvironmentLayout = async (props) => {
|
const SurveyEditorEnvironmentLayout = async (props) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|
||||||
const { t, session, user } = await environmentIdLayoutChecks(params.environmentId);
|
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return redirect(`/auth/login`);
|
return redirect(`/auth/login`);
|
||||||
@@ -24,9 +25,15 @@ const SurveyEditorEnvironmentLayout = async (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<EnvironmentIdBaseLayout
|
||||||
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
environmentId={params.environmentId}
|
||||||
</div>
|
session={session}
|
||||||
|
user={user}
|
||||||
|
organization={organization}>
|
||||||
|
<div className="flex h-screen flex-col">
|
||||||
|
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
||||||
|
</div>
|
||||||
|
</EnvironmentIdBaseLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { z } from "zod";
|
|||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||||
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||||
import { updateUser } from "@/lib/user/service";
|
import { updateUser } from "@/lib/user/service";
|
||||||
@@ -17,8 +16,6 @@ import {
|
|||||||
getOrganizationProjectsLimit,
|
getOrganizationProjectsLimit,
|
||||||
} from "@/modules/ee/license-check/lib/utils";
|
} from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createProject } from "@/modules/projects/settings/lib/project";
|
import { createProject } from "@/modules/projects/settings/lib/project";
|
||||||
import { getOrganizationsByUserId } from "./lib/organization";
|
|
||||||
import { getProjectsByUserId } from "./lib/project";
|
|
||||||
|
|
||||||
const ZCreateProjectAction = z.object({
|
const ZCreateProjectAction = z.object({
|
||||||
organizationId: ZId,
|
organizationId: ZId,
|
||||||
@@ -87,59 +84,3 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const ZGetOrganizationsForSwitcherAction = z.object({
|
|
||||||
organizationId: ZId, // Changed from environmentId to avoid extra query
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches organizations list for switcher dropdown.
|
|
||||||
* Called on-demand when user opens the organization switcher.
|
|
||||||
*/
|
|
||||||
export const getOrganizationsForSwitcherAction = authenticatedActionClient
|
|
||||||
.schema(ZGetOrganizationsForSwitcherAction)
|
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
|
||||||
await checkAuthorizationUpdated({
|
|
||||||
userId: ctx.user.id,
|
|
||||||
organizationId: parsedInput.organizationId,
|
|
||||||
access: [
|
|
||||||
{
|
|
||||||
type: "organization",
|
|
||||||
roles: ["owner", "manager", "member", "billing"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
return await getOrganizationsByUserId(ctx.user.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ZGetProjectsForSwitcherAction = z.object({
|
|
||||||
organizationId: ZId, // Changed from environmentId to avoid extra query
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches projects list for switcher dropdown.
|
|
||||||
* Called on-demand when user opens the project switcher.
|
|
||||||
*/
|
|
||||||
export const getProjectsForSwitcherAction = authenticatedActionClient
|
|
||||||
.schema(ZGetProjectsForSwitcherAction)
|
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
|
||||||
await checkAuthorizationUpdated({
|
|
||||||
userId: ctx.user.id,
|
|
||||||
organizationId: parsedInput.organizationId,
|
|
||||||
access: [
|
|
||||||
{
|
|
||||||
type: "organization",
|
|
||||||
roles: ["owner", "manager", "member", "billing"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Need membership for getProjectsByUserId (1 DB query)
|
|
||||||
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
|
|
||||||
if (!membership) {
|
|
||||||
throw new Error("Membership not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
return await getProjectsByUserId(ctx.user.id, membership);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,51 +1,104 @@
|
|||||||
|
import type { Session } from "next-auth";
|
||||||
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 { getOrganizationsByUserId } from "@/app/(app)/environments/[environmentId]/lib/organization";
|
||||||
|
import { getProjectsByUserId } from "@/app/(app)/environments/[environmentId]/lib/project";
|
||||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||||
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
|
import {
|
||||||
|
getMonthlyActiveOrganizationPeopleCount,
|
||||||
|
getMonthlyOrganizationResponseCount,
|
||||||
|
getOrganizationByEnvironmentId,
|
||||||
|
} from "@/lib/organization/service";
|
||||||
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||||
import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth";
|
import {
|
||||||
|
getAccessControlPermission,
|
||||||
|
getOrganizationProjectsLimit,
|
||||||
|
} from "@/modules/ee/license-check/lib/utils";
|
||||||
|
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||||
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
||||||
|
|
||||||
interface EnvironmentLayoutProps {
|
interface EnvironmentLayoutProps {
|
||||||
layoutData: TEnvironmentLayoutData;
|
environmentId: string;
|
||||||
|
session: Session;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
|
export const EnvironmentLayout = async ({ environmentId, session, children }: EnvironmentLayoutProps) => {
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const publicDomain = getPublicDomain();
|
const [user, environment, organizations, organization] = await Promise.all([
|
||||||
|
getUser(session.user.id),
|
||||||
|
getEnvironment(environmentId),
|
||||||
|
getOrganizationsByUserId(session.user.id),
|
||||||
|
getOrganizationByEnvironmentId(environmentId),
|
||||||
|
]);
|
||||||
|
|
||||||
// Destructure all data from props (NO database queries)
|
if (!user) {
|
||||||
const {
|
throw new Error(t("common.user_not_found"));
|
||||||
user,
|
}
|
||||||
environment,
|
|
||||||
organization,
|
|
||||||
membership,
|
|
||||||
project, // Current project details
|
|
||||||
environments, // All project environments (for environment switcher)
|
|
||||||
isAccessControlAllowed,
|
|
||||||
projectPermission,
|
|
||||||
license,
|
|
||||||
peopleCount,
|
|
||||||
responseCount,
|
|
||||||
} = layoutData;
|
|
||||||
|
|
||||||
// Calculate derived values (no queries)
|
if (!organization) {
|
||||||
const { isMember, isOwner, isManager } = getAccessFlags(membership.role);
|
throw new Error(t("common.organization_not_found"));
|
||||||
|
}
|
||||||
|
|
||||||
const { features, lastChecked, isPendingDowngrade, active } = license;
|
if (!environment) {
|
||||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
throw new Error(t("common.environment_not_found"));
|
||||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
}
|
||||||
const isOwnerOrManager = isOwner || isManager;
|
|
||||||
|
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||||
|
if (!currentUserMembership) {
|
||||||
|
throw new Error(t("common.membership_not_found"));
|
||||||
|
}
|
||||||
|
const membershipRole = currentUserMembership?.role;
|
||||||
|
|
||||||
|
const [projects, environments, isAccessControlAllowed] = await Promise.all([
|
||||||
|
getProjectsByUserId(user.id, currentUserMembership),
|
||||||
|
getEnvironments(environment.projectId),
|
||||||
|
getAccessControlPermission(organization.billing.plan),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!projects || !environments || !organizations) {
|
||||||
|
throw new Error(t("environments.projects_environments_organizations_not_found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isMember } = getAccessFlags(membershipRole);
|
||||||
|
|
||||||
|
const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense();
|
||||||
|
|
||||||
|
const projectPermission = await getProjectPermissionByUserId(session.user.id, environment.projectId);
|
||||||
|
|
||||||
// Validate that project permission exists for members
|
|
||||||
if (isMember && !projectPermission) {
|
if (isMember && !projectPermission) {
|
||||||
throw new Error(t("common.project_permission_not_found"));
|
throw new Error(t("common.project_permission_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||||
|
|
||||||
|
let peopleCount = 0;
|
||||||
|
let responseCount = 0;
|
||||||
|
|
||||||
|
if (IS_FORMBRICKS_CLOUD) {
|
||||||
|
[peopleCount, responseCount] = await Promise.all([
|
||||||
|
getMonthlyActiveOrganizationPeopleCount(organization.id),
|
||||||
|
getMonthlyOrganizationResponseCount(organization.id),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||||
|
|
||||||
|
// Find the current project from the projects array
|
||||||
|
const project = projects.find((p) => p.id === environment.projectId);
|
||||||
|
if (!project) {
|
||||||
|
throw new Error(t("common.project_not_found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isManager, isOwner } = getAccessFlags(membershipRole);
|
||||||
|
const isOwnerOrManager = isManager || isOwner;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
|
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
|
||||||
{IS_FORMBRICKS_CLOUD && (
|
{IS_FORMBRICKS_CLOUD && (
|
||||||
@@ -69,25 +122,26 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
<MainNavigation
|
<MainNavigation
|
||||||
environment={environment}
|
environment={environment}
|
||||||
organization={organization}
|
organization={organization}
|
||||||
|
projects={projects}
|
||||||
user={user}
|
user={user}
|
||||||
project={{ id: project.id, name: project.name }}
|
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
isDevelopment={IS_DEVELOPMENT}
|
isDevelopment={IS_DEVELOPMENT}
|
||||||
membershipRole={membership.role}
|
membershipRole={membershipRole}
|
||||||
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
|
||||||
environments={environments}
|
environments={environments}
|
||||||
currentOrganizationId={organization.id}
|
currentOrganizationId={organization.id}
|
||||||
|
organizations={organizations}
|
||||||
currentProjectId={project.id}
|
currentProjectId={project.id}
|
||||||
|
projects={projects}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
organizationProjectsLimit={organizationProjectsLimit}
|
organizationProjectsLimit={organizationProjectsLimit}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
isLicenseActive={active}
|
isLicenseActive={active}
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
membershipRole={membership.role}
|
membershipRole={membershipRole}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 overflow-y-auto">{children}</div>
|
<div className="flex-1 overflow-y-auto">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,22 +42,20 @@ interface NavigationProps {
|
|||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
user: TUser;
|
user: TUser;
|
||||||
organization: TOrganization;
|
organization: TOrganization;
|
||||||
project: { id: string; name: string };
|
projects: { id: string; name: string }[];
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
isDevelopment: boolean;
|
isDevelopment: boolean;
|
||||||
membershipRole?: TOrganizationRole;
|
membershipRole?: TOrganizationRole;
|
||||||
publicDomain: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MainNavigation = ({
|
export const MainNavigation = ({
|
||||||
environment,
|
environment,
|
||||||
organization,
|
organization,
|
||||||
user,
|
user,
|
||||||
project,
|
projects,
|
||||||
membershipRole,
|
membershipRole,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
isDevelopment,
|
isDevelopment,
|
||||||
publicDomain,
|
|
||||||
}: NavigationProps) => {
|
}: NavigationProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -67,6 +65,7 @@ export const MainNavigation = ({
|
|||||||
const [latestVersion, setLatestVersion] = useState("");
|
const [latestVersion, setLatestVersion] = useState("");
|
||||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||||
|
|
||||||
|
const project = projects.find((project) => project.id === environment.projectId);
|
||||||
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
||||||
|
|
||||||
const isOwnerOrManager = isManager || isOwner;
|
const isOwnerOrManager = isManager || isOwner;
|
||||||
@@ -288,16 +287,15 @@ 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: loginUrl,
|
redirectUrl: "/auth/login",
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
redirect: false,
|
redirect: false,
|
||||||
callbackUrl: loginUrl,
|
callbackUrl: "/auth/login",
|
||||||
clearEnvironmentId: true,
|
clearEnvironmentId: true,
|
||||||
});
|
});
|
||||||
router.push(route?.url || loginUrl); // NOSONAR // We want to check for empty strings
|
router.push(route?.url || "/auth/login"); // 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")}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Session } from "next-auth";
|
||||||
|
import { usePostHog } from "posthog-js/react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { TOrganizationBilling } from "@formbricks/types/organizations";
|
||||||
|
import { TUser } from "@formbricks/types/user";
|
||||||
|
|
||||||
|
interface PosthogIdentifyProps {
|
||||||
|
session: Session;
|
||||||
|
user: TUser;
|
||||||
|
environmentId?: string;
|
||||||
|
organizationId?: string;
|
||||||
|
organizationName?: string;
|
||||||
|
organizationBilling?: TOrganizationBilling;
|
||||||
|
isPosthogEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PosthogIdentify = ({
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
environmentId,
|
||||||
|
organizationId,
|
||||||
|
organizationName,
|
||||||
|
organizationBilling,
|
||||||
|
isPosthogEnabled,
|
||||||
|
}: PosthogIdentifyProps) => {
|
||||||
|
const posthog = usePostHog();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPosthogEnabled && session.user && posthog) {
|
||||||
|
posthog.identify(session.user.id, {
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
});
|
||||||
|
if (environmentId) {
|
||||||
|
posthog.group("environment", environmentId, { name: environmentId });
|
||||||
|
}
|
||||||
|
if (organizationId) {
|
||||||
|
posthog.group("organization", organizationId, {
|
||||||
|
name: organizationName,
|
||||||
|
plan: organizationBilling?.plan,
|
||||||
|
responseLimit: organizationBilling?.limits.monthly.responses,
|
||||||
|
miuLimit: organizationBilling?.limits.monthly.miu,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
posthog,
|
||||||
|
session.user,
|
||||||
|
environmentId,
|
||||||
|
organizationId,
|
||||||
|
organizationName,
|
||||||
|
organizationBilling,
|
||||||
|
user.name,
|
||||||
|
user.email,
|
||||||
|
isPosthogEnabled,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
import React, { createContext, useCallback, useContext, useState } from "react";
|
import React, { createContext, useCallback, useContext, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ElementOption,
|
QuestionOption,
|
||||||
ElementOptions,
|
QuestionOptions,
|
||||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementsComboBox";
|
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||||
import { ElementFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
|
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
|
||||||
import { getTodayDate } from "@/app/lib/surveys/surveys";
|
import { getTodayDate } from "@/app/lib/surveys/surveys";
|
||||||
|
|
||||||
export interface FilterValue {
|
export interface FilterValue {
|
||||||
elementType: Partial<ElementOption>;
|
questionType: Partial<QuestionOption>;
|
||||||
filterType: {
|
filterType: {
|
||||||
filterValue: string | undefined;
|
filterValue: string | undefined;
|
||||||
filterComboBoxValue: string | string[] | undefined;
|
filterComboBoxValue: string | string[] | undefined;
|
||||||
@@ -24,8 +24,8 @@ export interface SelectedFilterValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SelectedFilterOptions {
|
interface SelectedFilterOptions {
|
||||||
elementOptions: ElementOptions[];
|
questionOptions: QuestionOptions[];
|
||||||
elementFilterOptions: ElementFilterOptions[];
|
questionFilterOptions: QuestionFilterOptions[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DateRange {
|
export interface DateRange {
|
||||||
@@ -53,8 +53,8 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
|||||||
});
|
});
|
||||||
// state holds all the options of the responses fetched
|
// state holds all the options of the responses fetched
|
||||||
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
|
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
|
||||||
elementFilterOptions: [],
|
questionFilterOptions: [],
|
||||||
elementOptions: [],
|
questionOptions: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const [dateRange, setDateRange] = useState<DateRange>({
|
const [dateRange, setDateRange] = useState<DateRange>({
|
||||||
@@ -9,7 +9,9 @@ import { getAccessFlags } from "@/lib/membership/utils";
|
|||||||
interface TopControlBarProps {
|
interface TopControlBarProps {
|
||||||
environments: TEnvironment[];
|
environments: TEnvironment[];
|
||||||
currentOrganizationId: string;
|
currentOrganizationId: string;
|
||||||
|
organizations: { id: string; name: string }[];
|
||||||
currentProjectId: string;
|
currentProjectId: string;
|
||||||
|
projects: { id: string; name: string }[];
|
||||||
isMultiOrgEnabled: boolean;
|
isMultiOrgEnabled: boolean;
|
||||||
organizationProjectsLimit: number;
|
organizationProjectsLimit: number;
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
@@ -22,7 +24,9 @@ interface TopControlBarProps {
|
|||||||
export const TopControlBar = ({
|
export const TopControlBar = ({
|
||||||
environments,
|
environments,
|
||||||
currentOrganizationId,
|
currentOrganizationId,
|
||||||
|
organizations,
|
||||||
currentProjectId,
|
currentProjectId,
|
||||||
|
projects,
|
||||||
isMultiOrgEnabled,
|
isMultiOrgEnabled,
|
||||||
organizationProjectsLimit,
|
organizationProjectsLimit,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
@@ -42,7 +46,9 @@ export const TopControlBar = ({
|
|||||||
currentEnvironmentId={environment.id}
|
currentEnvironmentId={environment.id}
|
||||||
environments={environments}
|
environments={environments}
|
||||||
currentOrganizationId={currentOrganizationId}
|
currentOrganizationId={currentOrganizationId}
|
||||||
|
organizations={organizations}
|
||||||
currentProjectId={currentProjectId}
|
currentProjectId={currentProjectId}
|
||||||
|
projects={projects}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
organizationProjectsLimit={organizationProjectsLimit}
|
organizationProjectsLimit={organizationProjectsLimit}
|
||||||
isFormbricksCloud={isFormbricksCloud}
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
|
|||||||
@@ -10,11 +10,9 @@ import {
|
|||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { getOrganizationsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
|
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
|
||||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||||
import {
|
import {
|
||||||
@@ -25,11 +23,10 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import { useOrganization } from "../context/environment-context";
|
|
||||||
|
|
||||||
interface OrganizationBreadcrumbProps {
|
interface OrganizationBreadcrumbProps {
|
||||||
currentOrganizationId: string;
|
currentOrganizationId: string;
|
||||||
currentOrganizationName?: string; // Optional: pass directly if context not available
|
organizations: { id: string; name: string }[];
|
||||||
isMultiOrgEnabled: boolean;
|
isMultiOrgEnabled: boolean;
|
||||||
currentEnvironmentId?: string;
|
currentEnvironmentId?: string;
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
@@ -50,7 +47,7 @@ const isActiveOrganizationSetting = (pathname: string, settingId: string): boole
|
|||||||
|
|
||||||
export const OrganizationBreadcrumb = ({
|
export const OrganizationBreadcrumb = ({
|
||||||
currentOrganizationId,
|
currentOrganizationId,
|
||||||
currentOrganizationName,
|
organizations,
|
||||||
isMultiOrgEnabled,
|
isMultiOrgEnabled,
|
||||||
currentEnvironmentId,
|
currentEnvironmentId,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
@@ -63,45 +60,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
|
const currentOrganization = organizations.find((org) => org.id === currentOrganizationId);
|
||||||
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
|
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Get current organization name from context OR prop
|
|
||||||
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
|
||||||
const { organization: currentOrganization } = useOrganization();
|
|
||||||
const organizationName = currentOrganization?.name || currentOrganizationName || "";
|
|
||||||
|
|
||||||
// Lazy-load organizations when dropdown opens
|
|
||||||
useEffect(() => {
|
|
||||||
// Only fetch when dropdown opened for first time (and no error state)
|
|
||||||
if (isOrganizationDropdownOpen && organizations.length === 0 && !isLoadingOrganizations && !loadError) {
|
|
||||||
setIsLoadingOrganizations(true);
|
|
||||||
setLoadError(null); // Clear any previous errors
|
|
||||||
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
|
||||||
if (result?.data) {
|
|
||||||
// Sort organizations by name
|
|
||||||
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
|
|
||||||
setOrganizations(sorted);
|
|
||||||
} else {
|
|
||||||
// Handle server errors or validation errors
|
|
||||||
const errorMessage = getFormattedErrorMessage(result);
|
|
||||||
const error = new Error(errorMessage);
|
|
||||||
logger.error(error, "Failed to load organizations");
|
|
||||||
Sentry.captureException(error);
|
|
||||||
setLoadError(errorMessage || t("common.failed_to_load_organizations"));
|
|
||||||
}
|
|
||||||
setIsLoadingOrganizations(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isOrganizationDropdownOpen,
|
|
||||||
currentOrganizationId,
|
|
||||||
organizations.length,
|
|
||||||
isLoadingOrganizations,
|
|
||||||
loadError,
|
|
||||||
t,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!currentOrganization) {
|
if (!currentOrganization) {
|
||||||
const errorMessage = `Organization not found for organization id: ${currentOrganizationId}`;
|
const errorMessage = `Organization not found for organization id: ${currentOrganizationId}`;
|
||||||
@@ -135,7 +94,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "teams",
|
id: "teams",
|
||||||
label: t("common.members_and_teams"),
|
label: t("common.teams"),
|
||||||
href: `/environments/${currentEnvironmentId}/settings/teams`,
|
href: `/environments/${currentEnvironmentId}/settings/teams`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -167,7 +126,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
asChild>
|
asChild>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<BuildingIcon className="h-3 w-3" strokeWidth={1.5} />
|
<BuildingIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
<span>{organizationName}</span>
|
<span>{currentOrganization.name}</span>
|
||||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||||
{isOrganizationDropdownOpen ? (
|
{isOrganizationDropdownOpen ? (
|
||||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
@@ -183,52 +142,30 @@ export const OrganizationBreadcrumb = ({
|
|||||||
<BuildingIcon className="mr-2 inline h-4 w-4" />
|
<BuildingIcon className="mr-2 inline h-4 w-4" />
|
||||||
{t("common.choose_organization")}
|
{t("common.choose_organization")}
|
||||||
</div>
|
</div>
|
||||||
{isLoadingOrganizations && (
|
<DropdownMenuGroup>
|
||||||
<div className="flex items-center justify-center py-2">
|
{organizations.map((org) => (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<DropdownMenuCheckboxItem
|
||||||
</div>
|
key={org.id}
|
||||||
)}
|
checked={org.id === currentOrganization.id}
|
||||||
{!isLoadingOrganizations && loadError && (
|
onClick={() => handleOrganizationChange(org.id)}
|
||||||
<div className="px-2 py-4">
|
className="cursor-pointer">
|
||||||
<p className="mb-2 text-sm text-red-600">{loadError}</p>
|
{org.name}
|
||||||
<button
|
</DropdownMenuCheckboxItem>
|
||||||
onClick={() => {
|
))}
|
||||||
setLoadError(null);
|
</DropdownMenuGroup>
|
||||||
setOrganizations([]);
|
{isMultiOrgEnabled && (
|
||||||
}}
|
<DropdownMenuCheckboxItem
|
||||||
className="text-xs text-slate-600 underline hover:text-slate-800">
|
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||||
{t("common.try_again")}
|
className="cursor-pointer">
|
||||||
</button>
|
<span>{t("common.create_new_organization")}</span>
|
||||||
</div>
|
<PlusIcon className="ml-2 h-4 w-4" />
|
||||||
)}
|
</DropdownMenuCheckboxItem>
|
||||||
{!isLoadingOrganizations && !loadError && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
{organizations.map((org) => (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={org.id}
|
|
||||||
checked={org.id === currentOrganizationId}
|
|
||||||
onClick={() => handleOrganizationChange(org.id)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
{org.name}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
{isMultiOrgEnabled && (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
<span>{t("common.create_new_organization")}</span>
|
|
||||||
<PlusIcon className="ml-2 h-4 w-4" />
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{currentEnvironmentId && (
|
{currentEnvironmentId && (
|
||||||
<div>
|
<div>
|
||||||
{showOrganizationDropdown && <DropdownMenuSeparator />}
|
<DropdownMenuSeparator />
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
<SettingsIcon className="mr-2 inline h-4 w-4" />
|
<SettingsIcon className="mr-2 inline h-4 w-4" />
|
||||||
{t("common.organization_settings")}
|
{t("common.organization_settings")}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
|
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
|
||||||
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
|
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
|
||||||
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
|
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
|
||||||
@@ -7,9 +8,9 @@ import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
|
|||||||
|
|
||||||
interface ProjectAndOrgSwitchProps {
|
interface ProjectAndOrgSwitchProps {
|
||||||
currentOrganizationId: string;
|
currentOrganizationId: string;
|
||||||
currentOrganizationName?: string; // Optional: for pages without context
|
organizations: { id: string; name: string }[];
|
||||||
currentProjectId?: string;
|
currentProjectId?: string;
|
||||||
currentProjectName?: string; // Optional: for pages without context
|
projects: { id: string; name: string }[];
|
||||||
currentEnvironmentId?: string;
|
currentEnvironmentId?: string;
|
||||||
environments: { id: string; type: string }[];
|
environments: { id: string; type: string }[];
|
||||||
isMultiOrgEnabled: boolean;
|
isMultiOrgEnabled: boolean;
|
||||||
@@ -17,15 +18,15 @@ interface ProjectAndOrgSwitchProps {
|
|||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
isLicenseActive: boolean;
|
isLicenseActive: boolean;
|
||||||
isOwnerOrManager: boolean;
|
isOwnerOrManager: boolean;
|
||||||
isMember: boolean;
|
|
||||||
isAccessControlAllowed: boolean;
|
isAccessControlAllowed: boolean;
|
||||||
|
isMember: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProjectAndOrgSwitch = ({
|
export const ProjectAndOrgSwitch = ({
|
||||||
currentOrganizationId,
|
currentOrganizationId,
|
||||||
currentOrganizationName,
|
organizations,
|
||||||
currentProjectId,
|
currentProjectId,
|
||||||
currentProjectName,
|
projects,
|
||||||
currentEnvironmentId,
|
currentEnvironmentId,
|
||||||
environments,
|
environments,
|
||||||
isMultiOrgEnabled,
|
isMultiOrgEnabled,
|
||||||
@@ -36,6 +37,11 @@ export const ProjectAndOrgSwitch = ({
|
|||||||
isAccessControlAllowed,
|
isAccessControlAllowed,
|
||||||
isMember,
|
isMember,
|
||||||
}: ProjectAndOrgSwitchProps) => {
|
}: ProjectAndOrgSwitchProps) => {
|
||||||
|
const sortedProjects = useMemo(() => projects.toSorted((a, b) => a.name.localeCompare(b.name)), [projects]);
|
||||||
|
const sortedOrganizations = useMemo(
|
||||||
|
() => organizations.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
[organizations]
|
||||||
|
);
|
||||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||||
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
||||||
|
|
||||||
@@ -44,9 +50,9 @@ export const ProjectAndOrgSwitch = ({
|
|||||||
<BreadcrumbList className="gap-0">
|
<BreadcrumbList className="gap-0">
|
||||||
<OrganizationBreadcrumb
|
<OrganizationBreadcrumb
|
||||||
currentOrganizationId={currentOrganizationId}
|
currentOrganizationId={currentOrganizationId}
|
||||||
currentOrganizationName={currentOrganizationName}
|
organizations={sortedOrganizations}
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
|
currentEnvironmentId={currentEnvironmentId}
|
||||||
isFormbricksCloud={isFormbricksCloud}
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
isMember={isMember}
|
isMember={isMember}
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
@@ -54,9 +60,9 @@ export const ProjectAndOrgSwitch = ({
|
|||||||
{currentProjectId && currentEnvironmentId && (
|
{currentProjectId && currentEnvironmentId && (
|
||||||
<ProjectBreadcrumb
|
<ProjectBreadcrumb
|
||||||
currentProjectId={currentProjectId}
|
currentProjectId={currentProjectId}
|
||||||
currentProjectName={currentProjectName}
|
|
||||||
currentOrganizationId={currentOrganizationId}
|
currentOrganizationId={currentOrganizationId}
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
currentEnvironmentId={currentEnvironmentId}
|
||||||
|
projects={sortedProjects}
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
organizationProjectsLimit={organizationProjectsLimit}
|
organizationProjectsLimit={organizationProjectsLimit}
|
||||||
isFormbricksCloud={isFormbricksCloud}
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
|
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { getProjectsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
|
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
|
||||||
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
|
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
|
||||||
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
||||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||||
@@ -20,11 +18,10 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||||
import { useProject } from "../context/environment-context";
|
|
||||||
|
|
||||||
interface ProjectBreadcrumbProps {
|
interface ProjectBreadcrumbProps {
|
||||||
currentProjectId: string;
|
currentProjectId: string;
|
||||||
currentProjectName?: string; // Optional: pass directly if context not available
|
projects: { id: string; name: string }[];
|
||||||
isOwnerOrManager: boolean;
|
isOwnerOrManager: boolean;
|
||||||
organizationProjectsLimit: number;
|
organizationProjectsLimit: number;
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
@@ -47,7 +44,7 @@ const isActiveProjectSetting = (pathname: string, settingId: string): boolean =>
|
|||||||
|
|
||||||
export const ProjectBreadcrumb = ({
|
export const ProjectBreadcrumb = ({
|
||||||
currentProjectId,
|
currentProjectId,
|
||||||
currentProjectName,
|
projects,
|
||||||
isOwnerOrManager,
|
isOwnerOrManager,
|
||||||
organizationProjectsLimit,
|
organizationProjectsLimit,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
@@ -62,41 +59,9 @@ export const ProjectBreadcrumb = ({
|
|||||||
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
|
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
|
||||||
const [openLimitModal, setOpenLimitModal] = useState(false);
|
const [openLimitModal, setOpenLimitModal] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
|
||||||
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
|
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
// Get current project name from context OR prop
|
|
||||||
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
|
||||||
const { project: currentProject } = useProject();
|
|
||||||
const projectName = currentProject?.name || currentProjectName || "";
|
|
||||||
|
|
||||||
// Lazy-load projects when dropdown opens
|
|
||||||
useEffect(() => {
|
|
||||||
// Only fetch when dropdown opened for first time (and no error state)
|
|
||||||
if (isProjectDropdownOpen && projects.length === 0 && !isLoadingProjects && !loadError) {
|
|
||||||
setIsLoadingProjects(true);
|
|
||||||
setLoadError(null); // Clear any previous errors
|
|
||||||
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
|
||||||
if (result?.data) {
|
|
||||||
// Sort projects by name
|
|
||||||
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
|
|
||||||
setProjects(sorted);
|
|
||||||
} else {
|
|
||||||
// Handle server errors or validation errors
|
|
||||||
const errorMessage = getFormattedErrorMessage(result);
|
|
||||||
const error = new Error(errorMessage);
|
|
||||||
logger.error(error, "Failed to load projects");
|
|
||||||
Sentry.captureException(error);
|
|
||||||
setLoadError(errorMessage || t("common.failed_to_load_projects"));
|
|
||||||
}
|
|
||||||
setIsLoadingProjects(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
|
|
||||||
|
|
||||||
const projectSettings = [
|
const projectSettings = [
|
||||||
{
|
{
|
||||||
id: "general",
|
id: "general",
|
||||||
@@ -135,6 +100,8 @@ export const ProjectBreadcrumb = ({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const currentProject = projects.find((project) => project.id === currentProjectId);
|
||||||
|
|
||||||
if (!currentProject) {
|
if (!currentProject) {
|
||||||
const errorMessage = `Project not found for project id: ${currentProjectId}`;
|
const errorMessage = `Project not found for project id: ${currentProjectId}`;
|
||||||
logger.error(errorMessage);
|
logger.error(errorMessage);
|
||||||
@@ -199,7 +166,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
asChild>
|
asChild>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<FolderOpenIcon className="h-3 w-3" strokeWidth={1.5} />
|
<FolderOpenIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
<span>{projectName}</span>
|
<span>{currentProject.name}</span>
|
||||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||||
{isProjectDropdownOpen ? (
|
{isProjectDropdownOpen ? (
|
||||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
@@ -214,48 +181,26 @@ export const ProjectBreadcrumb = ({
|
|||||||
<FolderOpenIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
<FolderOpenIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
{t("common.choose_project")}
|
{t("common.choose_project")}
|
||||||
</div>
|
</div>
|
||||||
{isLoadingProjects && (
|
<DropdownMenuGroup>
|
||||||
<div className="flex items-center justify-center py-2">
|
{projects.map((proj) => (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<DropdownMenuCheckboxItem
|
||||||
</div>
|
key={proj.id}
|
||||||
)}
|
checked={proj.id === currentProject.id}
|
||||||
{!isLoadingProjects && loadError && (
|
onClick={() => handleProjectChange(proj.id)}
|
||||||
<div className="px-2 py-4">
|
className="cursor-pointer">
|
||||||
<p className="mb-2 text-sm text-red-600">{loadError}</p>
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<span>{proj.name}</span>
|
||||||
onClick={() => {
|
</div>
|
||||||
setLoadError(null);
|
</DropdownMenuCheckboxItem>
|
||||||
setProjects([]);
|
))}
|
||||||
}}
|
</DropdownMenuGroup>
|
||||||
className="text-xs text-slate-600 underline hover:text-slate-800">
|
{isOwnerOrManager && (
|
||||||
{t("common.try_again")}
|
<DropdownMenuCheckboxItem
|
||||||
</button>
|
onClick={handleAddProject}
|
||||||
</div>
|
className="w-full cursor-pointer justify-between">
|
||||||
)}
|
<span>{t("common.add_new_project")}</span>
|
||||||
{!isLoadingProjects && !loadError && (
|
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||||
<>
|
</DropdownMenuCheckboxItem>
|
||||||
<DropdownMenuGroup>
|
|
||||||
{projects.map((proj) => (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={proj.id}
|
|
||||||
checked={proj.id === currentProjectId}
|
|
||||||
onClick={() => handleProjectChange(proj.id)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>{proj.name}</span>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
{isOwnerOrManager && (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
onClick={handleAddProject}
|
|
||||||
className="w-full cursor-pointer justify-between">
|
|
||||||
<span>{t("common.add_new_project")}</span>
|
|
||||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|||||||
@@ -2,13 +2,11 @@
|
|||||||
|
|
||||||
import { createContext, useContext, useMemo } from "react";
|
import { createContext, useContext, useMemo } from "react";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
|
||||||
import { TProject } from "@formbricks/types/project";
|
import { TProject } from "@formbricks/types/project";
|
||||||
|
|
||||||
export interface EnvironmentContextType {
|
export interface EnvironmentContextType {
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
project: TProject;
|
project: TProject;
|
||||||
organization: TOrganization;
|
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,44 +20,25 @@ export const useEnvironment = () => {
|
|||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useProject = () => {
|
|
||||||
const context = useContext(EnvironmentContext);
|
|
||||||
if (!context) {
|
|
||||||
return { project: null };
|
|
||||||
}
|
|
||||||
return { project: context.project };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useOrganization = () => {
|
|
||||||
const context = useContext(EnvironmentContext);
|
|
||||||
if (!context) {
|
|
||||||
return { organization: null };
|
|
||||||
}
|
|
||||||
return { organization: context.organization };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Client wrapper component to be used in server components
|
// Client wrapper component to be used in server components
|
||||||
interface EnvironmentContextWrapperProps {
|
interface EnvironmentContextWrapperProps {
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
project: TProject;
|
project: TProject;
|
||||||
organization: TOrganization;
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EnvironmentContextWrapper = ({
|
export const EnvironmentContextWrapper = ({
|
||||||
environment,
|
environment,
|
||||||
project,
|
project,
|
||||||
organization,
|
|
||||||
children,
|
children,
|
||||||
}: EnvironmentContextWrapperProps) => {
|
}: EnvironmentContextWrapperProps) => {
|
||||||
const environmentContextValue = useMemo(
|
const environmentContextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
environment,
|
environment,
|
||||||
project,
|
project,
|
||||||
organization,
|
|
||||||
organizationId: project.organizationId,
|
organizationId: project.organizationId,
|
||||||
}),
|
}),
|
||||||
[environment, project, organization]
|
[environment, project]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||||
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 { getEnvironment } from "@/lib/environment/service";
|
||||||
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
|
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||||
|
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
||||||
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
|
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
|
||||||
|
|
||||||
const EnvLayout = async (props: {
|
const EnvLayout = async (props: {
|
||||||
@@ -13,25 +15,48 @@ const EnvLayout = async (props: {
|
|||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|
||||||
// Check session first (required for userId)
|
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session?.user) {
|
if (!session) {
|
||||||
return redirect(`/auth/login`);
|
return redirect(`/auth/login`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single consolidated data fetch (replaces ~12 individual fetches)
|
if (!user) {
|
||||||
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
throw new Error(t("common.user_not_found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [project, environment] = await Promise.all([
|
||||||
|
getProjectByEnvironmentId(params.environmentId),
|
||||||
|
getEnvironment(params.environmentId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new Error(t("common.project_not_found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!environment) {
|
||||||
|
throw new Error(t("common.environment_not_found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new Error(t("common.membership_not_found"));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<EnvironmentIdBaseLayout
|
||||||
|
environmentId={params.environmentId}
|
||||||
|
session={session}
|
||||||
|
user={user}
|
||||||
|
organization={organization}>
|
||||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||||
<EnvironmentContextWrapper
|
<EnvironmentContextWrapper environment={environment} project={project}>
|
||||||
environment={layoutData.environment}
|
<EnvironmentLayout environmentId={params.environmentId} session={session}>
|
||||||
project={layoutData.project}
|
{children}
|
||||||
organization={layoutData.organization}>
|
</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, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Control, Controller, useForm } from "react-hook-form";
|
import { Control, Controller, useForm } from "react-hook-form";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -14,15 +14,14 @@ import {
|
|||||||
TIntegrationAirtableInput,
|
TIntegrationAirtableInput,
|
||||||
TIntegrationAirtableTables,
|
TIntegrationAirtableTables,
|
||||||
} from "@formbricks/types/integration/airtable";
|
} from "@formbricks/types/integration/airtable";
|
||||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||||
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown";
|
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown";
|
||||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
|
import { fetchTables } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
|
||||||
import AirtableLogo from "@/images/airtableLogo.svg";
|
import AirtableLogo from "@/images/airtableLogo.svg";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -46,45 +45,6 @@ import {
|
|||||||
} from "@/modules/ui/components/select";
|
} from "@/modules/ui/components/select";
|
||||||
import { IntegrationModalInputs } from "../lib/types";
|
import { IntegrationModalInputs } from "../lib/types";
|
||||||
|
|
||||||
const ElementCheckbox = ({
|
|
||||||
element,
|
|
||||||
selectedSurvey,
|
|
||||||
field,
|
|
||||||
}: {
|
|
||||||
element: TSurveyElement;
|
|
||||||
selectedSurvey: TSurvey;
|
|
||||||
field: {
|
|
||||||
value: string[] | undefined;
|
|
||||||
onChange: (value: string[]) => void;
|
|
||||||
};
|
|
||||||
}) => {
|
|
||||||
const handleCheckedChange = (checked: boolean) => {
|
|
||||||
if (checked) {
|
|
||||||
field.onChange([...(field.value || []), element.id]);
|
|
||||||
} else {
|
|
||||||
field.onChange(field.value?.filter((value) => value !== element.id) || []);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="my-1 flex items-center space-x-2">
|
|
||||||
<label htmlFor={element.id} className="flex cursor-pointer items-center">
|
|
||||||
<Checkbox
|
|
||||||
type="button"
|
|
||||||
id={element.id}
|
|
||||||
value={element.id}
|
|
||||||
className="bg-white"
|
|
||||||
checked={field.value?.includes(element.id)}
|
|
||||||
onCheckedChange={handleCheckedChange}
|
|
||||||
/>
|
|
||||||
<span className="ml-2">
|
|
||||||
{getTextContent(recallToHeadline(element.headline, selectedSurvey, false, "default")["default"])}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type EditModeProps =
|
type EditModeProps =
|
||||||
| { isEditMode: false; defaultData?: never }
|
| { isEditMode: false; defaultData?: never }
|
||||||
| { isEditMode: true; defaultData: IntegrationModalInputs & { index: number } };
|
| { isEditMode: true; defaultData: IntegrationModalInputs & { index: number } };
|
||||||
@@ -108,10 +68,9 @@ const NoBaseFoundError = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderElementSelection = ({
|
const renderQuestionSelection = ({
|
||||||
t,
|
t,
|
||||||
selectedSurvey,
|
selectedSurvey,
|
||||||
elements,
|
|
||||||
control,
|
control,
|
||||||
includeVariables,
|
includeVariables,
|
||||||
setIncludeVariables,
|
setIncludeVariables,
|
||||||
@@ -124,7 +83,6 @@ const renderElementSelection = ({
|
|||||||
}: {
|
}: {
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
selectedSurvey: TSurvey;
|
selectedSurvey: TSurvey;
|
||||||
elements: TSurveyElement[];
|
|
||||||
control: Control<IntegrationModalInputs>;
|
control: Control<IntegrationModalInputs>;
|
||||||
includeVariables: boolean;
|
includeVariables: boolean;
|
||||||
setIncludeVariables: (value: boolean) => void;
|
setIncludeVariables: (value: boolean) => void;
|
||||||
@@ -141,13 +99,31 @@ const renderElementSelection = ({
|
|||||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||||
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
||||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||||
{elements.map((element) => (
|
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
|
||||||
<Controller
|
<Controller
|
||||||
key={element.id}
|
key={question.id}
|
||||||
control={control}
|
control={control}
|
||||||
name={"elements"}
|
name={"questions"}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<ElementCheckbox element={element} selectedSurvey={selectedSurvey} field={field} />
|
<div className="my-1 flex items-center space-x-2">
|
||||||
|
<label htmlFor={question.id} className="flex cursor-pointer items-center">
|
||||||
|
<Checkbox
|
||||||
|
type="button"
|
||||||
|
id={question.id}
|
||||||
|
value={question.id}
|
||||||
|
className="bg-white"
|
||||||
|
checked={field.value?.includes(question.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
return checked
|
||||||
|
? field.onChange([...field.value, question.id])
|
||||||
|
: field.onChange(field.value?.filter((value) => value !== question.id));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="ml-2">
|
||||||
|
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -218,11 +194,6 @@ export const AddIntegrationModal = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectedSurvey = surveys.find((item) => item.id === survey);
|
const selectedSurvey = surveys.find((item) => item.id === survey);
|
||||||
const elements = useMemo(
|
|
||||||
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
|
||||||
[selectedSurvey]
|
|
||||||
);
|
|
||||||
|
|
||||||
const submitHandler = async (data: IntegrationModalInputs) => {
|
const submitHandler = async (data: IntegrationModalInputs) => {
|
||||||
try {
|
try {
|
||||||
if (!data.base || data.base === "") {
|
if (!data.base || data.base === "") {
|
||||||
@@ -237,7 +208,7 @@ export const AddIntegrationModal = ({
|
|||||||
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.elements.length === 0) {
|
if (data.questions.length === 0) {
|
||||||
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,9 +216,9 @@ export const AddIntegrationModal = ({
|
|||||||
const integrationData: TIntegrationAirtableConfigData = {
|
const integrationData: TIntegrationAirtableConfigData = {
|
||||||
surveyId: selectedSurvey.id,
|
surveyId: selectedSurvey.id,
|
||||||
surveyName: selectedSurvey.name,
|
surveyName: selectedSurvey.name,
|
||||||
elementIds: data.elements,
|
questionIds: data.questions,
|
||||||
elements:
|
questions:
|
||||||
data.elements.length === elements.length
|
data.questions.length === selectedSurvey.questions.length
|
||||||
? t("common.all_questions")
|
? t("common.all_questions")
|
||||||
: t("common.selected_questions"),
|
: t("common.selected_questions"),
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -395,7 +366,7 @@ export const AddIntegrationModal = ({
|
|||||||
required
|
required
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
field.onChange(val);
|
field.onChange(val);
|
||||||
setValue("elements", []);
|
setValue("questions", []);
|
||||||
}}
|
}}
|
||||||
defaultValue={defaultData?.survey}>
|
defaultValue={defaultData?.survey}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -421,10 +392,9 @@ export const AddIntegrationModal = ({
|
|||||||
|
|
||||||
{survey &&
|
{survey &&
|
||||||
selectedSurvey &&
|
selectedSurvey &&
|
||||||
renderElementSelection({
|
renderQuestionSelection({
|
||||||
t,
|
t,
|
||||||
selectedSurvey,
|
selectedSurvey,
|
||||||
elements: elements,
|
|
||||||
control,
|
control,
|
||||||
includeVariables,
|
includeVariables,
|
||||||
setIncludeVariables,
|
setIncludeVariables,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
@@ -15,6 +16,7 @@ interface AirtableWrapperProps {
|
|||||||
airtableArray: TIntegrationItem[];
|
airtableArray: TIntegrationItem[];
|
||||||
airtableIntegration?: TIntegrationAirtable;
|
airtableIntegration?: TIntegrationAirtable;
|
||||||
surveys: TSurvey[];
|
surveys: TSurvey[];
|
||||||
|
environment: TEnvironment;
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
webAppUrl: string;
|
webAppUrl: string;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
@@ -25,6 +27,7 @@ export const AirtableWrapper = ({
|
|||||||
airtableArray,
|
airtableArray,
|
||||||
airtableIntegration,
|
airtableIntegration,
|
||||||
surveys,
|
surveys,
|
||||||
|
environment,
|
||||||
isEnabled,
|
isEnabled,
|
||||||
webAppUrl,
|
webAppUrl,
|
||||||
locale,
|
locale,
|
||||||
@@ -45,6 +48,7 @@ export const AirtableWrapper = ({
|
|||||||
<ManageIntegration
|
<ManageIntegration
|
||||||
airtableArray={airtableArray}
|
airtableArray={airtableArray}
|
||||||
environmentId={environmentId}
|
environmentId={environmentId}
|
||||||
|
environment={environment}
|
||||||
airtableIntegration={airtableIntegration}
|
airtableIntegration={airtableIntegration}
|
||||||
setIsConnected={setIsConnected}
|
setIsConnected={setIsConnected}
|
||||||
surveys={surveys}
|
surveys={surveys}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Trash2Icon } from "lucide-react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
@@ -14,11 +15,12 @@ import { timeSince } from "@/lib/time";
|
|||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||||
import { IntegrationModalInputs } from "../lib/types";
|
import { IntegrationModalInputs } from "../lib/types";
|
||||||
|
|
||||||
interface ManageIntegrationProps {
|
interface ManageIntegrationProps {
|
||||||
airtableIntegration: TIntegrationAirtable;
|
airtableIntegration: TIntegrationAirtable;
|
||||||
|
environment: TEnvironment;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
setIsConnected: (data: boolean) => void;
|
setIsConnected: (data: boolean) => void;
|
||||||
surveys: TSurvey[];
|
surveys: TSurvey[];
|
||||||
@@ -27,7 +29,7 @@ interface ManageIntegrationProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||||
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
|
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const tableHeaders = [
|
const tableHeaders = [
|
||||||
@@ -108,7 +110,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDefaultValues({
|
setDefaultValues({
|
||||||
base: data.baseId,
|
base: data.baseId,
|
||||||
elements: data.elementIds,
|
questions: data.questionIds,
|
||||||
survey: data.surveyId,
|
survey: data.surveyId,
|
||||||
table: data.tableId,
|
table: data.tableId,
|
||||||
includeVariables: !!data.includeVariables,
|
includeVariables: !!data.includeVariables,
|
||||||
@@ -121,7 +123,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
|||||||
}}>
|
}}>
|
||||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||||
<div className="col-span-2 text-center">{data.tableName}</div>
|
<div className="col-span-2 text-center">{data.tableName}</div>
|
||||||
<div className="col-span-2 text-center">{data.elements}</div>
|
<div className="col-span-2 text-center">{data.questions}</div>
|
||||||
<div className="col-span-2 text-center">
|
<div className="col-span-2 text-center">
|
||||||
{timeSince(data.createdAt.toString(), props.locale)}
|
{timeSince(data.createdAt.toString(), props.locale)}
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +132,12 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-4 w-full">
|
<div className="mt-4 w-full">
|
||||||
<EmptyState text={t("environments.integrations.airtable.no_integrations_yet")} />
|
<EmptySpaceFiller
|
||||||
|
type="table"
|
||||||
|
environment={environment}
|
||||||
|
noWidgetRequired={true}
|
||||||
|
emptyMessage={t("environments.integrations.airtable.no_integrations_yet")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export type IntegrationModalInputs = {
|
|||||||
base: string;
|
base: string;
|
||||||
table: string;
|
table: string;
|
||||||
survey: string;
|
survey: string;
|
||||||
elements: string[];
|
questions: string[];
|
||||||
includeVariables: boolean;
|
includeVariables: boolean;
|
||||||
includeHiddenFields: boolean;
|
includeHiddenFields: boolean;
|
||||||
includeMetadata: boolean;
|
includeMetadata: boolean;
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const Page = async (props) => {
|
|||||||
airtableArray={airtableArray}
|
airtableArray={airtableArray}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
surveys={surveys}
|
surveys={surveys}
|
||||||
|
environment={environment}
|
||||||
webAppUrl={WEBAPP_URL}
|
webAppUrl={WEBAPP_URL}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -20,9 +20,9 @@ import {
|
|||||||
isValidGoogleSheetsUrl,
|
isValidGoogleSheetsUrl,
|
||||||
} from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/util";
|
} from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/util";
|
||||||
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
|
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||||
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
|
||||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||||
@@ -62,12 +62,12 @@ export const AddIntegrationModal = ({
|
|||||||
spreadsheetName: "",
|
spreadsheetName: "",
|
||||||
surveyId: "",
|
surveyId: "",
|
||||||
surveyName: "",
|
surveyName: "",
|
||||||
elementIds: [""],
|
questionIds: [""],
|
||||||
elements: "",
|
questions: "",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
};
|
};
|
||||||
const { handleSubmit } = useForm();
|
const { handleSubmit } = useForm();
|
||||||
const [selectedElements, setSelectedElements] = useState<string[]>([]);
|
const [selectedQuestions, setSelectedQuestions] = useState<string[]>([]);
|
||||||
const [isLinkingSheet, setIsLinkingSheet] = useState(false);
|
const [isLinkingSheet, setIsLinkingSheet] = useState(false);
|
||||||
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
|
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
|
||||||
const [spreadsheetUrl, setSpreadsheetUrl] = useState("");
|
const [spreadsheetUrl, setSpreadsheetUrl] = useState("");
|
||||||
@@ -86,17 +86,12 @@ export const AddIntegrationModal = ({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const surveyElements = useMemo(
|
|
||||||
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
|
||||||
[selectedSurvey]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedSurvey && !selectedIntegration) {
|
if (selectedSurvey && !selectedIntegration) {
|
||||||
const elementIds = surveyElements.map((element) => element.id);
|
const questionIds = selectedSurvey.questions.map((question) => question.id);
|
||||||
setSelectedElements(elementIds);
|
setSelectedQuestions(questionIds);
|
||||||
}
|
}
|
||||||
}, [surveyElements, selectedIntegration, selectedSurvey]);
|
}, [selectedIntegration, selectedSurvey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedIntegration) {
|
if (selectedIntegration) {
|
||||||
@@ -106,7 +101,7 @@ export const AddIntegrationModal = ({
|
|||||||
return survey.id === selectedIntegration.surveyId;
|
return survey.id === selectedIntegration.surveyId;
|
||||||
})!
|
})!
|
||||||
);
|
);
|
||||||
setSelectedElements(selectedIntegration.elementIds);
|
setSelectedQuestions(selectedIntegration.questionIds);
|
||||||
setIncludeVariables(!!selectedIntegration.includeVariables);
|
setIncludeVariables(!!selectedIntegration.includeVariables);
|
||||||
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
|
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
|
||||||
setIncludeMetadata(!!selectedIntegration.includeMetadata);
|
setIncludeMetadata(!!selectedIntegration.includeMetadata);
|
||||||
@@ -126,7 +121,7 @@ export const AddIntegrationModal = ({
|
|||||||
if (!selectedSurvey) {
|
if (!selectedSurvey) {
|
||||||
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
||||||
}
|
}
|
||||||
if (selectedElements.length === 0) {
|
if (selectedQuestions.length === 0) {
|
||||||
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
||||||
}
|
}
|
||||||
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
|
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
|
||||||
@@ -148,9 +143,9 @@ export const AddIntegrationModal = ({
|
|||||||
integrationData.spreadsheetName = spreadsheetName;
|
integrationData.spreadsheetName = spreadsheetName;
|
||||||
integrationData.surveyId = selectedSurvey.id;
|
integrationData.surveyId = selectedSurvey.id;
|
||||||
integrationData.surveyName = selectedSurvey.name;
|
integrationData.surveyName = selectedSurvey.name;
|
||||||
integrationData.elementIds = selectedElements;
|
integrationData.questionIds = selectedQuestions;
|
||||||
integrationData.elements =
|
integrationData.questions =
|
||||||
selectedElements.length === surveyElements.length
|
selectedQuestions.length === selectedSurvey?.questions.length
|
||||||
? t("common.all_questions")
|
? t("common.all_questions")
|
||||||
: t("common.selected_questions");
|
: t("common.selected_questions");
|
||||||
integrationData.createdAt = new Date();
|
integrationData.createdAt = new Date();
|
||||||
@@ -181,7 +176,7 @@ export const AddIntegrationModal = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCheckboxChange = (questionId: TSurveyQuestionId) => {
|
const handleCheckboxChange = (questionId: TSurveyQuestionId) => {
|
||||||
setSelectedElements((prevValues) =>
|
setSelectedQuestions((prevValues) =>
|
||||||
prevValues.includes(questionId)
|
prevValues.includes(questionId)
|
||||||
? prevValues.filter((value) => value !== questionId)
|
? prevValues.filter((value) => value !== questionId)
|
||||||
: [...prevValues, questionId]
|
: [...prevValues, questionId]
|
||||||
@@ -268,7 +263,7 @@ export const AddIntegrationModal = ({
|
|||||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||||
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
||||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||||
{surveyElements.map((question) => (
|
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
|
||||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||||
<label htmlFor={question.id} className="flex cursor-pointer items-center">
|
<label htmlFor={question.id} className="flex cursor-pointer items-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -276,17 +271,13 @@ export const AddIntegrationModal = ({
|
|||||||
id={question.id}
|
id={question.id}
|
||||||
value={question.id}
|
value={question.id}
|
||||||
className="bg-white"
|
className="bg-white"
|
||||||
checked={selectedElements.includes(question.id)}
|
checked={selectedQuestions.includes(question.id)}
|
||||||
onCheckedChange={() => {
|
onCheckedChange={() => {
|
||||||
handleCheckboxChange(question.id);
|
handleCheckboxChange(question.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="ml-2 w-[30rem] truncate">
|
<span className="ml-2 w-[30rem] truncate">
|
||||||
{getTextContent(
|
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
||||||
recallToHeadline(question.headline, selectedSurvey, false, "default")[
|
|
||||||
"default"
|
|
||||||
]
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export const GoogleSheetWrapper = ({
|
|||||||
selectedIntegration={selectedIntegration}
|
selectedIntegration={selectedIntegration}
|
||||||
/>
|
/>
|
||||||
<ManageIntegration
|
<ManageIntegration
|
||||||
|
environment={environment}
|
||||||
googleSheetIntegration={googleSheetIntegration}
|
googleSheetIntegration={googleSheetIntegration}
|
||||||
setOpenAddIntegrationModal={setIsModalOpen}
|
setOpenAddIntegrationModal={setIsModalOpen}
|
||||||
setIsConnected={setIsConnected}
|
setIsConnected={setIsConnected}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Trash2Icon } from "lucide-react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import {
|
import {
|
||||||
TIntegrationGoogleSheets,
|
TIntegrationGoogleSheets,
|
||||||
TIntegrationGoogleSheetsConfigData,
|
TIntegrationGoogleSheetsConfigData,
|
||||||
@@ -14,9 +15,10 @@ import { timeSince } from "@/lib/time";
|
|||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||||
|
|
||||||
interface ManageIntegrationProps {
|
interface ManageIntegrationProps {
|
||||||
|
environment: TEnvironment;
|
||||||
googleSheetIntegration: TIntegrationGoogleSheets;
|
googleSheetIntegration: TIntegrationGoogleSheets;
|
||||||
setOpenAddIntegrationModal: (v: boolean) => void;
|
setOpenAddIntegrationModal: (v: boolean) => void;
|
||||||
setIsConnected: (v: boolean) => void;
|
setIsConnected: (v: boolean) => void;
|
||||||
@@ -25,6 +27,7 @@ interface ManageIntegrationProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ManageIntegration = ({
|
export const ManageIntegration = ({
|
||||||
|
environment,
|
||||||
googleSheetIntegration,
|
googleSheetIntegration,
|
||||||
setOpenAddIntegrationModal,
|
setOpenAddIntegrationModal,
|
||||||
setIsConnected,
|
setIsConnected,
|
||||||
@@ -87,7 +90,12 @@ export const ManageIntegration = ({
|
|||||||
</div>
|
</div>
|
||||||
{!integrationArray || integrationArray.length === 0 ? (
|
{!integrationArray || integrationArray.length === 0 ? (
|
||||||
<div className="mt-4 w-full">
|
<div className="mt-4 w-full">
|
||||||
<EmptyState text={t("environments.integrations.google_sheets.no_integrations_yet")} />
|
<EmptySpaceFiller
|
||||||
|
type="table"
|
||||||
|
environment={environment}
|
||||||
|
noWidgetRequired={true}
|
||||||
|
emptyMessage={t("environments.integrations.google_sheets.no_integrations_yet")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||||
@@ -110,7 +118,7 @@ export const ManageIntegration = ({
|
|||||||
}}>
|
}}>
|
||||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||||
<div className="col-span-2 text-center">{data.spreadsheetName}</div>
|
<div className="col-span-2 text-center">{data.spreadsheetName}</div>
|
||||||
<div className="col-span-2 text-center">{data.elements}</div>
|
<div className="col-span-2 text-center">{data.questions}</div>
|
||||||
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
|
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ import {
|
|||||||
TIntegrationNotionConfigData,
|
TIntegrationNotionConfigData,
|
||||||
TIntegrationNotionDatabase,
|
TIntegrationNotionDatabase,
|
||||||
} from "@formbricks/types/integration/notion";
|
} from "@formbricks/types/integration/notion";
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
|
||||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||||
import {
|
import {
|
||||||
ERRORS,
|
ERRORS,
|
||||||
@@ -22,10 +20,10 @@ import {
|
|||||||
UNSUPPORTED_TYPES_BY_NOTION,
|
UNSUPPORTED_TYPES_BY_NOTION,
|
||||||
} from "@/app/(app)/environments/[environmentId]/project/integrations/notion/constants";
|
} from "@/app/(app)/environments/[environmentId]/project/integrations/notion/constants";
|
||||||
import NotionLogo from "@/images/notion.png";
|
import NotionLogo from "@/images/notion.png";
|
||||||
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||||
import { getElementTypes } from "@/modules/survey/lib/elements";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -39,59 +37,6 @@ import {
|
|||||||
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||||
import { Label } from "@/modules/ui/components/label";
|
import { Label } from "@/modules/ui/components/label";
|
||||||
|
|
||||||
const MappingErrorMessage = ({
|
|
||||||
error,
|
|
||||||
col,
|
|
||||||
elem,
|
|
||||||
t,
|
|
||||||
}: {
|
|
||||||
error: { type: string; msg?: React.ReactNode | string } | null | undefined;
|
|
||||||
col: { id: string; name: string; type: string };
|
|
||||||
elem: { id: string; name: string; type: string };
|
|
||||||
t: ReturnType<typeof useTranslation>["t"];
|
|
||||||
}) => {
|
|
||||||
const showErrorMsg = useMemo(() => {
|
|
||||||
switch (error?.type) {
|
|
||||||
case ERRORS.UNSUPPORTED_TYPE:
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
-{" "}
|
|
||||||
{t("environments.integrations.notion.col_name_of_type_is_not_supported", {
|
|
||||||
col_name: col.name,
|
|
||||||
type: col.type,
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
case ERRORS.MAPPING:
|
|
||||||
const element = getElementTypes(t).find((et) => et.id === elem.type);
|
|
||||||
if (!element) return null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{t("environments.integrations.notion.que_name_of_type_cant_be_mapped_to", {
|
|
||||||
que_name: elem.name,
|
|
||||||
question_label: element.label,
|
|
||||||
col_name: col.name,
|
|
||||||
col_type: col.type,
|
|
||||||
mapped_type: TYPE_MAPPING[element.id].join(" ,"),
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [error, col, elem, t]);
|
|
||||||
|
|
||||||
if (!error) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="my-4 w-full rounded-lg bg-red-100 p-4 text-sm text-red-800">
|
|
||||||
<span className="mb-2 block">{error.type}</span>
|
|
||||||
{showErrorMsg}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface AddIntegrationModalProps {
|
interface AddIntegrationModalProps {
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
surveys: TSurvey[];
|
surveys: TSurvey[];
|
||||||
@@ -118,7 +63,7 @@ export const AddIntegrationModal = ({
|
|||||||
const [mapping, setMapping] = useState<
|
const [mapping, setMapping] = useState<
|
||||||
{
|
{
|
||||||
column: { id: string; name: string; type: string };
|
column: { id: string; name: string; type: string };
|
||||||
element: { id: string; name: string; type: string };
|
question: { id: string; name: string; type: string };
|
||||||
error?: {
|
error?: {
|
||||||
type: string;
|
type: string;
|
||||||
msg: React.ReactNode | string;
|
msg: React.ReactNode | string;
|
||||||
@@ -127,7 +72,7 @@ export const AddIntegrationModal = ({
|
|||||||
>([
|
>([
|
||||||
{
|
{
|
||||||
column: { id: "", name: "", type: "" },
|
column: { id: "", name: "", type: "" },
|
||||||
element: { id: "", name: "", type: "" },
|
question: { id: "", name: "", type: "" },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||||
@@ -140,17 +85,12 @@ export const AddIntegrationModal = ({
|
|||||||
mapping: [
|
mapping: [
|
||||||
{
|
{
|
||||||
column: { id: "", name: "", type: "" },
|
column: { id: "", name: "", type: "" },
|
||||||
element: { id: "", name: "", type: "" },
|
question: { id: "", name: "", type: "" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const elements = useMemo(
|
|
||||||
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
|
||||||
[selectedSurvey]
|
|
||||||
);
|
|
||||||
|
|
||||||
const notionIntegrationData: TIntegrationInput = {
|
const notionIntegrationData: TIntegrationInput = {
|
||||||
type: "notion",
|
type: "notion",
|
||||||
config: {
|
config: {
|
||||||
@@ -178,12 +118,12 @@ export const AddIntegrationModal = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedDatabase?.id]);
|
}, [selectedDatabase?.id]);
|
||||||
|
|
||||||
const elementItems = useMemo(() => {
|
const questionItems = useMemo(() => {
|
||||||
const mappedElements = selectedSurvey
|
const questions = selectedSurvey
|
||||||
? elements.map((el) => ({
|
? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
|
||||||
id: el.id,
|
id: q.id,
|
||||||
name: getTextContent(recallToHeadline(el.headline, selectedSurvey, false, "default")["default"]),
|
name: getLocalizedValue(q.headline, "default"),
|
||||||
type: el.type,
|
type: q.type,
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
@@ -191,31 +131,31 @@ export const AddIntegrationModal = ({
|
|||||||
selectedSurvey?.variables.map((variable) => ({
|
selectedSurvey?.variables.map((variable) => ({
|
||||||
id: variable.id,
|
id: variable.id,
|
||||||
name: variable.name,
|
name: variable.name,
|
||||||
type: TSurveyElementTypeEnum.OpenText,
|
type: TSurveyQuestionTypeEnum.OpenText,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
const hiddenFields =
|
const hiddenFields =
|
||||||
selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
|
selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
|
||||||
id: fId,
|
id: fId,
|
||||||
name: `${t("common.hidden_field")} : ${fId}`,
|
name: `${t("common.hidden_field")} : ${fId}`,
|
||||||
type: TSurveyElementTypeEnum.OpenText,
|
type: TSurveyQuestionTypeEnum.OpenText,
|
||||||
})) || [];
|
})) || [];
|
||||||
const Metadata = [
|
const Metadata = [
|
||||||
{
|
{
|
||||||
id: "metadata",
|
id: "metadata",
|
||||||
name: t("common.metadata"),
|
name: t("common.metadata"),
|
||||||
type: TSurveyElementTypeEnum.OpenText,
|
type: TSurveyQuestionTypeEnum.OpenText,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const createdAt = [
|
const createdAt = [
|
||||||
{
|
{
|
||||||
id: "createdAt",
|
id: "createdAt",
|
||||||
name: t("common.created_at"),
|
name: t("common.created_at"),
|
||||||
type: TSurveyElementTypeEnum.Date,
|
type: TSurveyQuestionTypeEnum.Date,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return [...mappedElements, ...variables, ...hiddenFields, ...Metadata, ...createdAt];
|
return [...questions, ...variables, ...hiddenFields, ...Metadata, ...createdAt];
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedSurvey?.id]);
|
}, [selectedSurvey?.id]);
|
||||||
|
|
||||||
@@ -249,7 +189,7 @@ export const AddIntegrationModal = ({
|
|||||||
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mapping.length === 1 && (!mapping[0].element.id || !mapping[0].column.id)) {
|
if (mapping.length === 1 && (!mapping[0].question.id || !mapping[0].column.id)) {
|
||||||
throw new Error(t("environments.integrations.notion.please_select_at_least_one_mapping"));
|
throw new Error(t("environments.integrations.notion.please_select_at_least_one_mapping"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,8 +198,8 @@ export const AddIntegrationModal = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
mapping.filter((m) => m.column.id && !m.element.id).length >= 1 ||
|
mapping.filter((m) => m.column.id && !m.question.id).length >= 1 ||
|
||||||
mapping.filter((m) => m.element.id && !m.column.id).length >= 1
|
mapping.filter((m) => m.question.id && !m.column.id).length >= 1
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
t("environments.integrations.notion.please_complete_mapping_fields_with_notion_property")
|
t("environments.integrations.notion.please_complete_mapping_fields_with_notion_property")
|
||||||
@@ -320,23 +260,23 @@ export const AddIntegrationModal = ({
|
|||||||
setSelectedDatabase(null);
|
setSelectedDatabase(null);
|
||||||
setSelectedSurvey(null);
|
setSelectedSurvey(null);
|
||||||
};
|
};
|
||||||
const getFilteredElementItems = (selectedIdx) => {
|
const getFilteredQuestionItems = (selectedIdx) => {
|
||||||
const selectedElementIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.element.id);
|
const selectedQuestionIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.question.id);
|
||||||
|
|
||||||
return elementItems.filter((el) => !selectedElementIds.includes(el.id));
|
return questionItems.filter((q) => !selectedQuestionIds.includes(q.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const createCopy = (item) => structuredClone(item);
|
const createCopy = (item) => structuredClone(item);
|
||||||
|
|
||||||
const MappingRow = ({ idx }: { idx: number }) => {
|
const MappingRow = ({ idx }: { idx: number }) => {
|
||||||
const filteredElementItems = getFilteredElementItems(idx);
|
const filteredQuestionItems = getFilteredQuestionItems(idx);
|
||||||
|
|
||||||
const addRow = () => {
|
const addRow = () => {
|
||||||
setMapping((prev) => [
|
setMapping((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
column: { id: "", name: "", type: "" },
|
column: { id: "", name: "", type: "" },
|
||||||
element: { id: "", name: "", type: "" },
|
question: { id: "", name: "", type: "" },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
@@ -347,6 +287,49 @@ export const AddIntegrationModal = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ErrorMsg = ({ error, col, ques }) => {
|
||||||
|
const showErrorMsg = useMemo(() => {
|
||||||
|
switch (error?.type) {
|
||||||
|
case ERRORS.UNSUPPORTED_TYPE:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
-{" "}
|
||||||
|
{t("environments.integrations.notion.col_name_of_type_is_not_supported", {
|
||||||
|
col_name: col.name,
|
||||||
|
type: col.type,
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case ERRORS.MAPPING:
|
||||||
|
const question = getQuestionTypes(t).find((qt) => qt.id === ques.type);
|
||||||
|
if (!question) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{t("environments.integrations.notion.que_name_of_type_cant_be_mapped_to", {
|
||||||
|
que_name: ques.name,
|
||||||
|
question_label: question.label,
|
||||||
|
col_name: col.name,
|
||||||
|
col_type: col.type,
|
||||||
|
mapped_type: TYPE_MAPPING[question.id].join(" ,"),
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
if (!error) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-4 w-full rounded-lg bg-red-100 p-4 text-sm text-red-800">
|
||||||
|
<span className="mb-2 block">{error.type}</span>
|
||||||
|
{showErrorMsg}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const getFilteredDbItems = () => {
|
const getFilteredDbItems = () => {
|
||||||
const colMapping = mapping.map((m) => m.column.id);
|
const colMapping = mapping.map((m) => m.column.id);
|
||||||
return dbItems.filter((item) => !colMapping.includes(item.id));
|
return dbItems.filter((item) => !colMapping.includes(item.id));
|
||||||
@@ -354,20 +337,19 @@ export const AddIntegrationModal = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<MappingErrorMessage
|
<ErrorMsg
|
||||||
key={idx}
|
key={idx}
|
||||||
error={mapping[idx]?.error}
|
error={mapping[idx]?.error}
|
||||||
col={mapping[idx].column}
|
col={mapping[idx].column}
|
||||||
elem={mapping[idx].element}
|
ques={mapping[idx].question}
|
||||||
t={t}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex w-full items-center space-x-2">
|
<div className="flex w-full items-center space-x-2">
|
||||||
<div className="flex w-full items-center">
|
<div className="flex w-full items-center">
|
||||||
<div className="max-w-full flex-1">
|
<div className="max-w-full flex-1">
|
||||||
<DropdownSelector
|
<DropdownSelector
|
||||||
placeholder={t("environments.integrations.notion.select_a_survey_question")}
|
placeholder={t("environments.integrations.notion.select_a_survey_question")}
|
||||||
items={filteredElementItems}
|
items={filteredQuestionItems}
|
||||||
selectedItem={mapping?.[idx]?.element}
|
selectedItem={mapping?.[idx]?.question}
|
||||||
setSelectedItem={(item) => {
|
setSelectedItem={(item) => {
|
||||||
setMapping((prev) => {
|
setMapping((prev) => {
|
||||||
const copy = createCopy(prev);
|
const copy = createCopy(prev);
|
||||||
@@ -379,7 +361,7 @@ export const AddIntegrationModal = ({
|
|||||||
error: {
|
error: {
|
||||||
type: ERRORS.UNSUPPORTED_TYPE,
|
type: ERRORS.UNSUPPORTED_TYPE,
|
||||||
},
|
},
|
||||||
element: item,
|
question: item,
|
||||||
};
|
};
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
@@ -391,7 +373,7 @@ export const AddIntegrationModal = ({
|
|||||||
error: {
|
error: {
|
||||||
type: ERRORS.MAPPING,
|
type: ERRORS.MAPPING,
|
||||||
},
|
},
|
||||||
element: item,
|
question: item,
|
||||||
};
|
};
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
@@ -399,13 +381,13 @@ export const AddIntegrationModal = ({
|
|||||||
|
|
||||||
copy[idx] = {
|
copy[idx] = {
|
||||||
...copy[idx],
|
...copy[idx],
|
||||||
element: item,
|
question: item,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
return copy;
|
return copy;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
disabled={elementItems.length === 0}
|
disabled={questionItems.length === 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-px w-4 border-t border-t-slate-300" />
|
<div className="h-px w-4 border-t border-t-slate-300" />
|
||||||
@@ -417,9 +399,9 @@ export const AddIntegrationModal = ({
|
|||||||
setSelectedItem={(item) => {
|
setSelectedItem={(item) => {
|
||||||
setMapping((prev) => {
|
setMapping((prev) => {
|
||||||
const copy = createCopy(prev);
|
const copy = createCopy(prev);
|
||||||
const elem = copy[idx].element;
|
const ques = copy[idx].question;
|
||||||
if (elem.id) {
|
if (ques.id) {
|
||||||
const isValidElemType = TYPE_MAPPING[elem.type].includes(item.type);
|
const isValidQuesType = TYPE_MAPPING[ques.type].includes(item.type);
|
||||||
|
|
||||||
if (UNSUPPORTED_TYPES_BY_NOTION.includes(item.type)) {
|
if (UNSUPPORTED_TYPES_BY_NOTION.includes(item.type)) {
|
||||||
copy[idx] = {
|
copy[idx] = {
|
||||||
@@ -432,7 +414,7 @@ export const AddIntegrationModal = ({
|
|||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidElemType) {
|
if (!isValidQuesType) {
|
||||||
copy[idx] = {
|
copy[idx] = {
|
||||||
...copy[idx],
|
...copy[idx],
|
||||||
error: {
|
error: {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||||
@@ -11,10 +12,11 @@ import { timeSince } from "@/lib/time";
|
|||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||||
|
|
||||||
interface ManageIntegrationProps {
|
interface ManageIntegrationProps {
|
||||||
|
environment: TEnvironment;
|
||||||
notionIntegration: TIntegrationNotion;
|
notionIntegration: TIntegrationNotion;
|
||||||
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
@@ -26,6 +28,7 @@ interface ManageIntegrationProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ManageIntegration = ({
|
export const ManageIntegration = ({
|
||||||
|
environment,
|
||||||
notionIntegration,
|
notionIntegration,
|
||||||
setOpenAddIntegrationModal,
|
setOpenAddIntegrationModal,
|
||||||
setIsConnected,
|
setIsConnected,
|
||||||
@@ -98,7 +101,12 @@ export const ManageIntegration = ({
|
|||||||
</div>
|
</div>
|
||||||
{!integrationArray || integrationArray.length === 0 ? (
|
{!integrationArray || integrationArray.length === 0 ? (
|
||||||
<div className="mt-4 w-full">
|
<div className="mt-4 w-full">
|
||||||
<EmptyState text={t("environments.integrations.notion.no_databases_found")} />
|
<EmptySpaceFiller
|
||||||
|
type="table"
|
||||||
|
environment={environment}
|
||||||
|
noWidgetRequired={true}
|
||||||
|
emptyMessage={t("environments.integrations.notion.no_databases_found")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const NotionWrapper = ({
|
|||||||
selectedIntegration={selectedIntegration}
|
selectedIntegration={selectedIntegration}
|
||||||
/>
|
/>
|
||||||
<ManageIntegration
|
<ManageIntegration
|
||||||
|
environment={environment}
|
||||||
notionIntegration={notionIntegration}
|
notionIntegration={notionIntegration}
|
||||||
setOpenAddIntegrationModal={setIsModalOpen}
|
setOpenAddIntegrationModal={setIsModalOpen}
|
||||||
setIsConnected={setIsConnected}
|
setIsConnected={setIsConnected}
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ import {
|
|||||||
TIntegrationSlackConfigData,
|
TIntegrationSlackConfigData,
|
||||||
TIntegrationSlackInput,
|
TIntegrationSlackInput,
|
||||||
} from "@formbricks/types/integration/slack";
|
} from "@formbricks/types/integration/slack";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||||
import SlackLogo from "@/images/slacklogo.png";
|
import SlackLogo from "@/images/slacklogo.png";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||||
@@ -55,7 +55,7 @@ export const AddChannelMappingModal = ({
|
|||||||
}: AddChannelMappingModalProps) => {
|
}: AddChannelMappingModalProps) => {
|
||||||
const { handleSubmit } = useForm();
|
const { handleSubmit } = useForm();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [selectedElements, setSelectedElements] = useState<string[]>([]);
|
const [selectedQuestions, setSelectedQuestions] = useState<string[]>([]);
|
||||||
const [isLinkingChannel, setIsLinkingChannel] = useState(false);
|
const [isLinkingChannel, setIsLinkingChannel] = useState(false);
|
||||||
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
|
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
|
||||||
const [selectedChannel, setSelectedChannel] = useState<TIntegrationItem | null>(null);
|
const [selectedChannel, setSelectedChannel] = useState<TIntegrationItem | null>(null);
|
||||||
@@ -73,19 +73,14 @@ export const AddChannelMappingModal = ({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const surveyElements = useMemo(
|
|
||||||
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
|
||||||
[selectedSurvey]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedSurvey) {
|
if (selectedSurvey) {
|
||||||
const elementIds = surveyElements.map((element) => element.id);
|
const questionIds = selectedSurvey.questions.map((question) => question.id);
|
||||||
if (!selectedIntegration) {
|
if (!selectedIntegration) {
|
||||||
setSelectedElements(elementIds);
|
setSelectedQuestions(questionIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [surveyElements, selectedIntegration, selectedSurvey]);
|
}, [selectedIntegration, selectedSurvey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedIntegration) {
|
if (selectedIntegration) {
|
||||||
@@ -98,7 +93,7 @@ export const AddChannelMappingModal = ({
|
|||||||
return survey.id === selectedIntegration.surveyId;
|
return survey.id === selectedIntegration.surveyId;
|
||||||
})!
|
})!
|
||||||
);
|
);
|
||||||
setSelectedElements(selectedIntegration.elementIds);
|
setSelectedQuestions(selectedIntegration.questionIds);
|
||||||
setIncludeVariables(!!selectedIntegration.includeVariables);
|
setIncludeVariables(!!selectedIntegration.includeVariables);
|
||||||
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
|
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
|
||||||
setIncludeMetadata(!!selectedIntegration.includeMetadata);
|
setIncludeMetadata(!!selectedIntegration.includeMetadata);
|
||||||
@@ -117,7 +112,7 @@ export const AddChannelMappingModal = ({
|
|||||||
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedElements.length === 0) {
|
if (selectedQuestions.length === 0) {
|
||||||
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
||||||
}
|
}
|
||||||
setIsLinkingChannel(true);
|
setIsLinkingChannel(true);
|
||||||
@@ -126,9 +121,9 @@ export const AddChannelMappingModal = ({
|
|||||||
channelName: selectedChannel.name,
|
channelName: selectedChannel.name,
|
||||||
surveyId: selectedSurvey.id,
|
surveyId: selectedSurvey.id,
|
||||||
surveyName: selectedSurvey.name,
|
surveyName: selectedSurvey.name,
|
||||||
elementIds: selectedElements,
|
questionIds: selectedQuestions,
|
||||||
elements:
|
questions:
|
||||||
selectedElements.length === surveyElements.length
|
selectedQuestions.length === selectedSurvey?.questions.length
|
||||||
? t("common.all_questions")
|
? t("common.all_questions")
|
||||||
: t("common.selected_questions"),
|
: t("common.selected_questions"),
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -159,11 +154,11 @@ export const AddChannelMappingModal = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCheckboxChange = (elementId: string) => {
|
const handleCheckboxChange = (questionId: TSurveyQuestionId) => {
|
||||||
setSelectedElements((prevValues) =>
|
setSelectedQuestions((prevValues) =>
|
||||||
prevValues.includes(elementId)
|
prevValues.includes(questionId)
|
||||||
? prevValues.filter((value) => value !== elementId)
|
? prevValues.filter((value) => value !== questionId)
|
||||||
: [...prevValues, elementId]
|
: [...prevValues, questionId]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -274,25 +269,21 @@ export const AddChannelMappingModal = ({
|
|||||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||||
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
||||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||||
{surveyElements.map((element) => (
|
{replaceHeadlineRecall(selectedSurvey, "default")?.questions?.map((question) => (
|
||||||
<div key={element.id} className="my-1 flex items-center space-x-2">
|
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||||
<label htmlFor={element.id} className="flex cursor-pointer items-center">
|
<label htmlFor={question.id} className="flex cursor-pointer items-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
type="button"
|
type="button"
|
||||||
id={element.id}
|
id={question.id}
|
||||||
value={element.id}
|
value={question.id}
|
||||||
className="bg-white"
|
className="bg-white"
|
||||||
checked={selectedElements.includes(element.id)}
|
checked={selectedQuestions.includes(question.id)}
|
||||||
onCheckedChange={() => {
|
onCheckedChange={() => {
|
||||||
handleCheckboxChange(element.id);
|
handleCheckboxChange(question.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="ml-2">
|
<span className="ml-2">
|
||||||
{getTextContent(
|
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
||||||
recallToHeadline(element.headline, selectedSurvey, false, "default")[
|
|
||||||
"default"
|
|
||||||
]
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Trash2Icon } from "lucide-react";
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||||
@@ -11,9 +12,10 @@ import { timeSince } from "@/lib/time";
|
|||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||||
|
|
||||||
interface ManageIntegrationProps {
|
interface ManageIntegrationProps {
|
||||||
|
environment: TEnvironment;
|
||||||
slackIntegration: TIntegrationSlack;
|
slackIntegration: TIntegrationSlack;
|
||||||
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
@@ -27,6 +29,7 @@ interface ManageIntegrationProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ManageIntegration = ({
|
export const ManageIntegration = ({
|
||||||
|
environment,
|
||||||
slackIntegration,
|
slackIntegration,
|
||||||
setOpenAddIntegrationModal,
|
setOpenAddIntegrationModal,
|
||||||
setIsConnected,
|
setIsConnected,
|
||||||
@@ -103,7 +106,12 @@ export const ManageIntegration = ({
|
|||||||
</div>
|
</div>
|
||||||
{!integrationArray || integrationArray.length === 0 ? (
|
{!integrationArray || integrationArray.length === 0 ? (
|
||||||
<div className="mt-4 w-full">
|
<div className="mt-4 w-full">
|
||||||
<EmptyState text={t("environments.integrations.slack.connect_your_first_slack_channel")} />
|
<EmptySpaceFiller
|
||||||
|
type="table"
|
||||||
|
environment={environment}
|
||||||
|
noWidgetRequired={true}
|
||||||
|
emptyMessage={t("environments.integrations.slack.connect_your_first_slack_channel")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||||
@@ -126,7 +134,7 @@ export const ManageIntegration = ({
|
|||||||
}}>
|
}}>
|
||||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||||
<div className="col-span-2 text-center">{data.channelName}</div>
|
<div className="col-span-2 text-center">{data.channelName}</div>
|
||||||
<div className="col-span-2 text-center">{data.elements}</div>
|
<div className="col-span-2 text-center">{data.questions}</div>
|
||||||
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
|
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export const SlackWrapper = ({
|
|||||||
selectedIntegration={selectedIntegration}
|
selectedIntegration={selectedIntegration}
|
||||||
/>
|
/>
|
||||||
<ManageIntegration
|
<ManageIntegration
|
||||||
|
environment={environment}
|
||||||
slackIntegration={slackIntegration}
|
slackIntegration={slackIntegration}
|
||||||
setOpenAddIntegrationModal={setIsModalOpen}
|
setOpenAddIntegrationModal={setIsModalOpen}
|
||||||
setIsConnected={setIsConnected}
|
setIsConnected={setIsConnected}
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ export const EditProfileDetailsForm = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
|
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-slate-50 text-slate-700"
|
||||||
align="start">
|
align="start">
|
||||||
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
|
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
|
||||||
{appLanguages.map((lang) => (
|
{appLanguages.map((lang) => (
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const OrganizationSettingsNavbar = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "teams",
|
id: "teams",
|
||||||
label: t("common.members_and_teams"),
|
label: t("common.teams"),
|
||||||
href: `/environments/${environmentId}/settings/teams`,
|
href: `/environments/${environmentId}/settings/teams`,
|
||||||
current: pathname?.includes("/teams"),
|
current: pathname?.includes("/teams"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
|
||||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
@@ -26,7 +25,7 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SurveyLayout = async ({ children }) => {
|
const SurveyLayout = async ({ children }) => {
|
||||||
return <ResponseFilterProvider>{children}</ResponseFilterProvider>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SurveyLayout;
|
export default SurveyLayout;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TResponse } from "@formbricks/types/responses";
|
import { TResponse } from "@formbricks/types/responses";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
@@ -8,14 +8,7 @@ import { TTag } from "@formbricks/types/tags";
|
|||||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||||
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
|
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import {
|
import { Dialog, DialogBody, DialogContent, DialogFooter, DialogTitle } from "@/modules/ui/components/dialog";
|
||||||
Dialog,
|
|
||||||
DialogBody,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/modules/ui/components/dialog";
|
|
||||||
|
|
||||||
interface ResponseCardModalProps {
|
interface ResponseCardModalProps {
|
||||||
responses: TResponse[];
|
responses: TResponse[];
|
||||||
@@ -49,37 +42,25 @@ export const ResponseCardModal = ({
|
|||||||
locale,
|
locale,
|
||||||
}: ResponseCardModalProps) => {
|
}: ResponseCardModalProps) => {
|
||||||
const [currentIndex, setCurrentIndex] = useState<number | null>(null);
|
const [currentIndex, setCurrentIndex] = useState<number | null>(null);
|
||||||
const [isNavigating, setIsNavigating] = useState(false);
|
|
||||||
|
|
||||||
const idToIndexMap = useMemo(() => {
|
|
||||||
const map = new Map<string, number>();
|
|
||||||
for (let i = 0; i < responses.length; i++) {
|
|
||||||
map.set(responses[i].id, i);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [responses]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedResponseId) {
|
if (selectedResponseId) {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
const index = idToIndexMap.get(selectedResponseId) ?? -1;
|
const index = responses.findIndex((response) => response.id === selectedResponseId);
|
||||||
setCurrentIndex(index);
|
setCurrentIndex(index);
|
||||||
setIsNavigating(false);
|
|
||||||
} else {
|
} else {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
}, [selectedResponseId, idToIndexMap, setOpen]);
|
}, [selectedResponseId, responses, setOpen]);
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
if (currentIndex !== null && currentIndex < responses.length - 1) {
|
if (currentIndex !== null && currentIndex < responses.length - 1) {
|
||||||
setIsNavigating(true);
|
|
||||||
setSelectedResponseId(responses[currentIndex + 1].id);
|
setSelectedResponseId(responses[currentIndex + 1].id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
if (currentIndex !== null && currentIndex > 0) {
|
if (currentIndex !== null && currentIndex > 0) {
|
||||||
setIsNavigating(true);
|
|
||||||
setSelectedResponseId(responses[currentIndex - 1].id);
|
setSelectedResponseId(responses[currentIndex - 1].id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -91,8 +72,8 @@ export const ResponseCardModal = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// If no response is selected or currentIndex is null or invalid, do not render the modal
|
// If no response is selected or currentIndex is null, do not render the modal
|
||||||
if (selectedResponseId === null || currentIndex === null || currentIndex === -1) return null;
|
if (selectedResponseId === null || currentIndex === null) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
@@ -100,11 +81,6 @@ export const ResponseCardModal = ({
|
|||||||
<VisuallyHidden asChild>
|
<VisuallyHidden asChild>
|
||||||
<DialogTitle>Survey Response Details</DialogTitle>
|
<DialogTitle>Survey Response Details</DialogTitle>
|
||||||
</VisuallyHidden>
|
</VisuallyHidden>
|
||||||
<VisuallyHidden asChild>
|
|
||||||
<DialogDescription>
|
|
||||||
Response {currentIndex + 1} of {responses.length}
|
|
||||||
</DialogDescription>
|
|
||||||
</VisuallyHidden>
|
|
||||||
<DialogBody>
|
<DialogBody>
|
||||||
<SingleResponseCard
|
<SingleResponseCard
|
||||||
survey={survey}
|
survey={survey}
|
||||||
@@ -120,16 +96,12 @@ export const ResponseCardModal = ({
|
|||||||
/>
|
/>
|
||||||
</DialogBody>
|
</DialogBody>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button onClick={handleBack} disabled={currentIndex === 0} variant="outline" size="icon">
|
||||||
onClick={handleBack}
|
|
||||||
disabled={currentIndex === 0 || isNavigating}
|
|
||||||
variant="outline"
|
|
||||||
size="icon">
|
|
||||||
<ChevronLeft />
|
<ChevronLeft />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
disabled={currentIndex === responses.length - 1 || isNavigating}
|
disabled={currentIndex === responses.length - 1}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon">
|
size="icon">
|
||||||
<ChevronRight />
|
<ChevronRight />
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
|||||||
import { TTag } from "@formbricks/types/tags";
|
import { TTag } from "@formbricks/types/tags";
|
||||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||||
import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable";
|
import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
|
||||||
|
|
||||||
interface ResponseDataViewProps {
|
interface ResponseDataViewProps {
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
@@ -29,65 +28,60 @@ interface ResponseDataViewProps {
|
|||||||
quotas: TSurveyQuota[];
|
quotas: TSurveyQuota[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to format array values to record with specified keys
|
|
||||||
const formatArrayToRecord = (responseValue: TResponseDataValue, keys: string[]): Record<string, string> => {
|
|
||||||
if (!Array.isArray(responseValue)) return {};
|
|
||||||
const result: Record<string, string> = {};
|
|
||||||
for (let index = 0; index < responseValue.length; index++) {
|
|
||||||
const curr = responseValue[index];
|
|
||||||
result[keys[index]] = curr || "";
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Export for testing
|
// Export for testing
|
||||||
export const formatAddressData = (responseValue: TResponseDataValue): Record<string, string> => {
|
export const formatAddressData = (responseValue: TResponseDataValue): Record<string, string> => {
|
||||||
const addressKeys = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
|
const addressKeys = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
|
||||||
return formatArrayToRecord(responseValue, addressKeys);
|
return Array.isArray(responseValue)
|
||||||
|
? responseValue.reduce((acc, curr, index) => {
|
||||||
|
acc[addressKeys[index]] = curr || ""; // Fallback to empty string if undefined
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
: {};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export for testing
|
// Export for testing
|
||||||
export const formatContactInfoData = (responseValue: TResponseDataValue): Record<string, string> => {
|
export const formatContactInfoData = (responseValue: TResponseDataValue): Record<string, string> => {
|
||||||
const contactInfoKeys = ["firstName", "lastName", "email", "phone", "company"];
|
const addressKeys = ["firstName", "lastName", "email", "phone", "company"];
|
||||||
return formatArrayToRecord(responseValue, contactInfoKeys);
|
return Array.isArray(responseValue)
|
||||||
|
? responseValue.reduce((acc, curr, index) => {
|
||||||
|
acc[addressKeys[index]] = curr || ""; // Fallback to empty string if undefined
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
: {};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export for testing
|
// Export for testing
|
||||||
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> = {};
|
let responseData: Record<string, any> = {};
|
||||||
|
|
||||||
const elements = getElementsFromBlocks(survey.blocks);
|
survey.questions.forEach((question) => {
|
||||||
|
const responseValue = response.data[question.id];
|
||||||
for (const element of elements) {
|
switch (question.type) {
|
||||||
const responseValue = response.data[element.id];
|
|
||||||
switch (element.type) {
|
|
||||||
case "matrix":
|
case "matrix":
|
||||||
if (typeof responseValue === "object") {
|
if (typeof responseValue === "object") {
|
||||||
Object.assign(responseData, responseValue);
|
responseData = { ...responseData, ...responseValue };
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "address":
|
case "address":
|
||||||
Object.assign(responseData, formatAddressData(responseValue));
|
responseData = { ...responseData, ...formatAddressData(responseValue) };
|
||||||
break;
|
break;
|
||||||
case "contactInfo":
|
case "contactInfo":
|
||||||
Object.assign(responseData, formatContactInfoData(responseValue));
|
responseData = { ...responseData, ...formatContactInfoData(responseValue) };
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
responseData[element.id] = responseValue;
|
responseData[question.id] = responseValue;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
if (survey.hiddenFields.fieldIds) {
|
survey.hiddenFields.fieldIds?.forEach((fieldId) => {
|
||||||
for (const fieldId of survey.hiddenFields.fieldIds) {
|
responseData[fieldId] = response.data[fieldId];
|
||||||
responseData[fieldId] = response.data[fieldId];
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return responseData;
|
return responseData;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export for testing
|
// Export for testing
|
||||||
const mapResponsesToTableData = (
|
export const mapResponsesToTableData = (
|
||||||
responses: TResponseWithQuotas[],
|
responses: TResponseWithQuotas[],
|
||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
t: TFunction
|
t: TFunction
|
||||||
@@ -99,7 +93,6 @@ const mapResponsesToTableData = (
|
|||||||
? t("environments.surveys.responses.completed")
|
? t("environments.surveys.responses.completed")
|
||||||
: t("environments.surveys.responses.not_completed"),
|
: t("environments.surveys.responses.not_completed"),
|
||||||
responseId: response.id,
|
responseId: response.id,
|
||||||
singleUseId: response.singleUseId,
|
|
||||||
tags: response.tags,
|
tags: response.tags,
|
||||||
variables: survey.variables.reduce(
|
variables: survey.variables.reduce(
|
||||||
(acc, curr) => {
|
(acc, curr) => {
|
||||||
@@ -133,10 +126,6 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
|
|||||||
quotas,
|
quotas,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [selectedResponseId, setSelectedResponseId] = React.useState<string | null>(null);
|
|
||||||
const setSelectedResponseIdTransition = React.useCallback((id: string | null) => {
|
|
||||||
React.startTransition(() => setSelectedResponseId(id));
|
|
||||||
}, []);
|
|
||||||
const data = mapResponsesToTableData(responses, survey, t);
|
const data = mapResponsesToTableData(responses, survey, t);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -157,8 +146,6 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
|
|||||||
locale={locale}
|
locale={locale}
|
||||||
isQuotasAllowed={isQuotasAllowed}
|
isQuotasAllowed={isQuotasAllowed}
|
||||||
quotas={quotas}
|
quotas={quotas}
|
||||||
selectedResponseId={selectedResponseId}
|
|
||||||
setSelectedResponseId={setSelectedResponseIdTransition}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { TResponseWithQuotas } from "@formbricks/types/responses";
|
|||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TTag } from "@formbricks/types/tags";
|
import { TTag } from "@formbricks/types/tags";
|
||||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||||
|
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||||
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
|
||||||
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
|
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
|
||||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||||
@@ -26,7 +26,6 @@ interface ResponsePageProps {
|
|||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
isQuotasAllowed: boolean;
|
isQuotasAllowed: boolean;
|
||||||
quotas: TSurveyQuota[];
|
quotas: TSurveyQuota[];
|
||||||
initialResponses?: TResponseWithQuotas[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ResponsePage = ({
|
export const ResponsePage = ({
|
||||||
@@ -40,12 +39,11 @@ export const ResponsePage = ({
|
|||||||
isReadOnly,
|
isReadOnly,
|
||||||
isQuotasAllowed,
|
isQuotasAllowed,
|
||||||
quotas,
|
quotas,
|
||||||
initialResponses = [],
|
|
||||||
}: ResponsePageProps) => {
|
}: ResponsePageProps) => {
|
||||||
const [responses, setResponses] = useState<TResponseWithQuotas[]>(initialResponses);
|
const [responses, setResponses] = useState<TResponseWithQuotas[]>([]);
|
||||||
const [page, setPage] = useState<number | null>(null);
|
const [page, setPage] = useState<number>(1);
|
||||||
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
|
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||||
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
|
const [isFetchingFirstPage, setFetchingFirstPage] = useState<boolean>(true);
|
||||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||||
|
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
@@ -58,7 +56,6 @@ export const ResponsePage = ({
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const fetchNextPage = useCallback(async () => {
|
const fetchNextPage = useCallback(async () => {
|
||||||
if (page === null) return;
|
|
||||||
const newPage = page + 1;
|
const newPage = page + 1;
|
||||||
|
|
||||||
let newResponses: TResponseWithQuotas[] = [];
|
let newResponses: TResponseWithQuotas[] = [];
|
||||||
@@ -96,22 +93,10 @@ 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 fetchInitialResponses = async () => {
|
||||||
try {
|
try {
|
||||||
// skip call for initial mount
|
setFetchingFirstPage(true);
|
||||||
if (page === null && !hasFilters) {
|
|
||||||
setPage(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPage(1);
|
|
||||||
setIsFetchingFirstPage(true);
|
|
||||||
let responses: TResponseWithQuotas[] = [];
|
let responses: TResponseWithQuotas[] = [];
|
||||||
|
|
||||||
const getResponsesActionResponse = await getResponsesAction({
|
const getResponsesActionResponse = await getResponsesAction({
|
||||||
@@ -125,20 +110,24 @@ export const ResponsePage = ({
|
|||||||
|
|
||||||
if (responses.length < responsesPerPage) {
|
if (responses.length < responsesPerPage) {
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
} else {
|
|
||||||
setHasMore(true);
|
|
||||||
}
|
}
|
||||||
setResponses(responses);
|
setResponses(responses);
|
||||||
} finally {
|
} finally {
|
||||||
setIsFetchingFirstPage(false);
|
setFetchingFirstPage(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchFilteredResponses();
|
fetchInitialResponses();
|
||||||
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
|
}, [surveyId, filters, responsesPerPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
setHasMore(true);
|
||||||
|
setResponses([]);
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-9 gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<CustomFilter survey={surveyMemoized} />
|
<CustomFilter survey={surveyMemoized} />
|
||||||
</div>
|
</div>
|
||||||
<ResponseDataView
|
<ResponseDataView
|
||||||
|
|||||||
@@ -39,12 +39,6 @@ import {
|
|||||||
import { Skeleton } from "@/modules/ui/components/skeleton";
|
import { Skeleton } from "@/modules/ui/components/skeleton";
|
||||||
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/modules/ui/components/table";
|
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||||
|
|
||||||
const SkeletonCell = () => (
|
|
||||||
<Skeleton className="w-full">
|
|
||||||
<div className="h-6"></div>
|
|
||||||
</Skeleton>
|
|
||||||
);
|
|
||||||
|
|
||||||
interface ResponseTableProps {
|
interface ResponseTableProps {
|
||||||
data: TResponseTableData[];
|
data: TResponseTableData[];
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
@@ -61,8 +55,6 @@ interface ResponseTableProps {
|
|||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
isQuotasAllowed: boolean;
|
isQuotasAllowed: boolean;
|
||||||
quotas: TSurveyQuota[];
|
quotas: TSurveyQuota[];
|
||||||
selectedResponseId: string | null;
|
|
||||||
setSelectedResponseId: (id: string | null) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ResponseTable = ({
|
export const ResponseTable = ({
|
||||||
@@ -81,13 +73,12 @@ export const ResponseTable = ({
|
|||||||
locale,
|
locale,
|
||||||
isQuotasAllowed,
|
isQuotasAllowed,
|
||||||
quotas,
|
quotas,
|
||||||
selectedResponseId,
|
|
||||||
setSelectedResponseId,
|
|
||||||
}: ResponseTableProps) => {
|
}: ResponseTableProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||||
const [rowSelection, setRowSelection] = useState({});
|
const [rowSelection, setRowSelection] = useState({});
|
||||||
const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false);
|
const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false);
|
||||||
|
const [selectedResponseId, setSelectedResponseId] = useState<string | null>(null);
|
||||||
const selectedResponse = responses?.find((response) => response.id === selectedResponseId) ?? null;
|
const selectedResponse = responses?.find((response) => response.id === selectedResponseId) ?? null;
|
||||||
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
|
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
|
||||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||||
@@ -95,10 +86,7 @@ export const ResponseTable = ({
|
|||||||
|
|
||||||
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
||||||
// Generate columns
|
// Generate columns
|
||||||
const columns = useMemo(
|
const columns = generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn);
|
||||||
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
|
|
||||||
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Save settings to localStorage when they change
|
// Save settings to localStorage when they change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -122,13 +110,7 @@ export const ResponseTable = ({
|
|||||||
|
|
||||||
// Memoize table data and columns
|
// Memoize table data and columns
|
||||||
const tableData: TResponseTableData[] = useMemo(
|
const tableData: TResponseTableData[] = useMemo(
|
||||||
() =>
|
() => (isFetchingFirstPage ? Array(10).fill({}) : data),
|
||||||
isFetchingFirstPage
|
|
||||||
? Array.from(
|
|
||||||
{ length: 10 },
|
|
||||||
(_, index) => ({ responseId: `skeleton-${index}` }) as TResponseTableData
|
|
||||||
)
|
|
||||||
: data,
|
|
||||||
[data, isFetchingFirstPage]
|
[data, isFetchingFirstPage]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -137,7 +119,11 @@ export const ResponseTable = ({
|
|||||||
isFetchingFirstPage
|
isFetchingFirstPage
|
||||||
? columns.map((column) => ({
|
? columns.map((column) => ({
|
||||||
...column,
|
...column,
|
||||||
cell: SkeletonCell,
|
cell: () => (
|
||||||
|
<Skeleton className="w-full">
|
||||||
|
<div className="h-6"></div>
|
||||||
|
</Skeleton>
|
||||||
|
),
|
||||||
}))
|
}))
|
||||||
: columns,
|
: columns,
|
||||||
[columns, isFetchingFirstPage]
|
[columns, isFetchingFirstPage]
|
||||||
@@ -261,8 +247,8 @@ export const ResponseTable = ({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
{/* disable auto animation if there are more than 200 responses for performance optimizations */}
|
|
||||||
<TableBody ref={responses && responses.length > 200 ? undefined : parent}>
|
<TableBody ref={parent}>
|
||||||
{table.getRowModel().rows.map((row) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
@@ -275,6 +261,7 @@ export const ResponseTable = ({
|
|||||||
row={row}
|
row={row}
|
||||||
isExpanded={isExpanded ?? false}
|
isExpanded={isExpanded ?? false}
|
||||||
setSelectedResponseId={setSelectedResponseId}
|
setSelectedResponseId={setSelectedResponseId}
|
||||||
|
responses={responses}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Cell, Row, flexRender } from "@tanstack/react-table";
|
import { Cell, Row, flexRender } from "@tanstack/react-table";
|
||||||
import { Maximize2Icon } from "lucide-react";
|
import { Maximize2Icon } from "lucide-react";
|
||||||
import React from "react";
|
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
|
||||||
import { TResponseTableData } from "@formbricks/types/responses";
|
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { getCommonPinningStyles } from "@/modules/ui/components/data-table/lib/utils";
|
import { getCommonPinningStyles } from "@/modules/ui/components/data-table/lib/utils";
|
||||||
import { TableCell } from "@/modules/ui/components/table";
|
import { TableCell } from "@/modules/ui/components/table";
|
||||||
@@ -11,18 +10,21 @@ interface ResponseTableCellProps {
|
|||||||
row: Row<TResponseTableData>;
|
row: Row<TResponseTableData>;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
setSelectedResponseId: (responseId: string | null) => void;
|
setSelectedResponseId: (responseId: string | null) => void;
|
||||||
|
responses: TResponse[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ResponseTableCellComponent = ({
|
export const ResponseTableCell = ({
|
||||||
cell,
|
cell,
|
||||||
row,
|
row,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
setSelectedResponseId,
|
setSelectedResponseId,
|
||||||
|
responses,
|
||||||
}: ResponseTableCellProps) => {
|
}: ResponseTableCellProps) => {
|
||||||
// Function to handle cell click
|
// Function to handle cell click
|
||||||
const handleCellClick = () => {
|
const handleCellClick = () => {
|
||||||
if (cell.column.id !== "select") {
|
if (cell.column.id !== "select") {
|
||||||
setSelectedResponseId(row.id);
|
const response = responses?.find((response) => response.id === row.id);
|
||||||
|
if (response) setSelectedResponseId(response.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,5 +66,3 @@ const ResponseTableCellComponent = ({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ResponseTableCell = React.memo(ResponseTableCellComponent);
|
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { TFunction } from "i18next";
|
|||||||
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { TResponseTableData } from "@formbricks/types/responses";
|
import { TResponseTableData } from "@formbricks/types/responses";
|
||||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
||||||
@@ -14,8 +13,7 @@ import { getContactIdentifier } from "@/lib/utils/contact";
|
|||||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
|
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@/modules/survey/lib/questions";
|
||||||
import { VARIABLES_ICON_MAP, getElementIconMap } from "@/modules/survey/lib/elements";
|
|
||||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||||
import { ResponseBadges } from "@/modules/ui/components/response-badges";
|
import { ResponseBadges } from "@/modules/ui/components/response-badges";
|
||||||
@@ -30,33 +28,35 @@ import {
|
|||||||
getMetadataValue,
|
getMetadataValue,
|
||||||
} from "../lib/utils";
|
} from "../lib/utils";
|
||||||
|
|
||||||
const getElementColumnsData = (
|
const getQuestionColumnsData = (
|
||||||
element: TSurveyElement,
|
question: TSurveyQuestion,
|
||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
isExpanded: boolean,
|
isExpanded: boolean,
|
||||||
t: TFunction
|
t: TFunction
|
||||||
): ColumnDef<TResponseTableData>[] => {
|
): ColumnDef<TResponseTableData>[] => {
|
||||||
const ELEMENTS_ICON_MAP = getElementIconMap(t);
|
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
|
||||||
const addressFields = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
|
const addressFields = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
|
||||||
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
|
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
|
||||||
|
|
||||||
// Helper function to create consistent column headers
|
// Helper function to create consistent column headers
|
||||||
const createElementHeader = (elementType: string, headline: string, suffix?: string) => {
|
const createQuestionHeader = (questionType: string, headline: string, suffix?: string) => {
|
||||||
const title = suffix ? `${headline} - ${suffix}` : headline;
|
const title = suffix ? `${headline} - ${suffix}` : headline;
|
||||||
const ElementHeader = () => (
|
const QuestionHeader = () => (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2 overflow-hidden">
|
<div className="flex items-center space-x-2 overflow-hidden">
|
||||||
<span className="h-4 w-4">{ELEMENTS_ICON_MAP[elementType]}</span>
|
<span className="h-4 w-4">{QUESTIONS_ICON_MAP[questionType]}</span>
|
||||||
<span className="truncate">{title}</span>
|
<span className="truncate">{title}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
return ElementHeader;
|
QuestionHeader.displayName = "QuestionHeader";
|
||||||
|
return QuestionHeader;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getElementHeadline = (element: TSurveyElement, survey: TSurvey) => {
|
// Helper function to get localized question headline
|
||||||
|
const getQuestionHeadline = (question: TSurveyQuestion, survey: TSurvey) => {
|
||||||
return getTextContent(
|
return getTextContent(
|
||||||
getLocalizedValue(recallToHeadline(element.headline, survey, false, "default"), "default")
|
getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default")
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,18 +75,18 @@ const getElementColumnsData = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (element.type) {
|
switch (question.type) {
|
||||||
case "matrix":
|
case "matrix":
|
||||||
return element.rows.map((matrixRow) => {
|
return question.rows.map((matrixRow) => {
|
||||||
return {
|
return {
|
||||||
accessorKey: "ELEMENT_" + element.id + "_" + matrixRow.label.default,
|
accessorKey: "QUESTION_" + question.id + "_" + matrixRow.label.default,
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2 overflow-hidden">
|
<div className="flex items-center space-x-2 overflow-hidden">
|
||||||
<span className="h-4 w-4">{ELEMENTS_ICON_MAP["matrix"]}</span>
|
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["matrix"]}</span>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{getTextContent(getLocalizedValue(element.headline, "default")) +
|
{getTextContent(getLocalizedValue(question.headline, "default")) +
|
||||||
" - " +
|
" - " +
|
||||||
getLocalizedValue(matrixRow.label, "default")}
|
getLocalizedValue(matrixRow.label, "default")}
|
||||||
</span>
|
</span>
|
||||||
@@ -106,12 +106,12 @@ const getElementColumnsData = (
|
|||||||
case "address":
|
case "address":
|
||||||
return addressFields.map((addressField) => {
|
return addressFields.map((addressField) => {
|
||||||
return {
|
return {
|
||||||
accessorKey: "ELEMENT_" + element.id + "_" + addressField,
|
accessorKey: "QUESTION_" + question.id + "_" + addressField,
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2 overflow-hidden">
|
<div className="flex items-center space-x-2 overflow-hidden">
|
||||||
<span className="h-4 w-4">{ELEMENTS_ICON_MAP["address"]}</span>
|
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["address"]}</span>
|
||||||
<span className="truncate">{getAddressFieldLabel(addressField, t)}</span>
|
<span className="truncate">{getAddressFieldLabel(addressField, t)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,12 +129,12 @@ const getElementColumnsData = (
|
|||||||
case "contactInfo":
|
case "contactInfo":
|
||||||
return contactInfoFields.map((contactInfoField) => {
|
return contactInfoFields.map((contactInfoField) => {
|
||||||
return {
|
return {
|
||||||
accessorKey: "ELEMENT_" + element.id + "_" + contactInfoField,
|
accessorKey: "QUESTION_" + question.id + "_" + contactInfoField,
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2 overflow-hidden">
|
<div className="flex items-center space-x-2 overflow-hidden">
|
||||||
<span className="h-4 w-4">{ELEMENTS_ICON_MAP["contactInfo"]}</span>
|
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["contactInfo"]}</span>
|
||||||
<span className="truncate">{getContactInfoFieldLabel(contactInfoField, t)}</span>
|
<span className="truncate">{getContactInfoFieldLabel(contactInfoField, t)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,17 +153,17 @@ const getElementColumnsData = (
|
|||||||
case "multipleChoiceSingle":
|
case "multipleChoiceSingle":
|
||||||
case "ranking":
|
case "ranking":
|
||||||
case "pictureSelection": {
|
case "pictureSelection": {
|
||||||
const elementHeadline = getElementHeadline(element, survey);
|
const questionHeadline = getQuestionHeadline(question, survey);
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: "ELEMENT_" + element.id,
|
accessorKey: "QUESTION_" + question.id,
|
||||||
header: createElementHeader(element.type, elementHeadline),
|
header: createQuestionHeader(question.type, questionHeadline),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const responseValue = row.original.responseData[element.id];
|
const responseValue = row.original.responseData[question.id];
|
||||||
const language = row.original.language;
|
const language = row.original.language;
|
||||||
return (
|
return (
|
||||||
<RenderResponse
|
<RenderResponse
|
||||||
element={element}
|
question={question}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
responseData={responseValue}
|
responseData={responseValue}
|
||||||
language={language}
|
language={language}
|
||||||
@@ -174,15 +174,15 @@ const getElementColumnsData = (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "ELEMENT_" + element.id + "optionIds",
|
accessorKey: "QUESTION_" + question.id + "optionIds",
|
||||||
header: createElementHeader(element.type, elementHeadline, t("common.option_id")),
|
header: createQuestionHeader(question.type, questionHeadline, t("common.option_id")),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const responseValue = row.original.responseData[element.id];
|
const responseValue = row.original.responseData[question.id];
|
||||||
// Type guard to ensure responseValue is the correct type
|
// Type guard to ensure responseValue is the correct type
|
||||||
if (typeof responseValue === "string" || Array.isArray(responseValue)) {
|
if (typeof responseValue === "string" || Array.isArray(responseValue)) {
|
||||||
const choiceIds = extractChoiceIdsFromResponse(
|
const choiceIds = extractChoiceIdsFromResponse(
|
||||||
responseValue,
|
responseValue,
|
||||||
element,
|
question,
|
||||||
row.original.language || undefined
|
row.original.language || undefined
|
||||||
);
|
);
|
||||||
return renderChoiceIdBadges(choiceIds, isExpanded);
|
return renderChoiceIdBadges(choiceIds, isExpanded);
|
||||||
@@ -196,25 +196,28 @@ const getElementColumnsData = (
|
|||||||
default:
|
default:
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: "ELEMENT_" + element.id,
|
accessorKey: "QUESTION_" + question.id,
|
||||||
header: () => (
|
header: () => (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2 overflow-hidden">
|
<div className="flex items-center space-x-2 overflow-hidden">
|
||||||
<span className="h-4 w-4">{ELEMENTS_ICON_MAP[element.type]}</span>
|
<span className="h-4 w-4">{QUESTIONS_ICON_MAP[question.type]}</span>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{getTextContent(
|
{getTextContent(
|
||||||
getLocalizedValue(recallToHeadline(element.headline, survey, false, "default"), "default")
|
getLocalizedValue(
|
||||||
|
recallToHeadline(question.headline, survey, false, "default"),
|
||||||
|
"default"
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const responseValue = row.original.responseData[element.id];
|
const responseValue = row.original.responseData[question.id];
|
||||||
const language = row.original.language;
|
const language = row.original.language;
|
||||||
return (
|
return (
|
||||||
<RenderResponse
|
<RenderResponse
|
||||||
element={element}
|
question={question}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
responseData={responseValue}
|
responseData={responseValue}
|
||||||
language={language}
|
language={language}
|
||||||
@@ -262,8 +265,9 @@ export const generateResponseTableColumns = (
|
|||||||
t: TFunction,
|
t: TFunction,
|
||||||
showQuotasColumn: boolean
|
showQuotasColumn: boolean
|
||||||
): ColumnDef<TResponseTableData>[] => {
|
): ColumnDef<TResponseTableData>[] => {
|
||||||
const elements = getElementsFromBlocks(survey.blocks);
|
const questionColumns = survey.questions.flatMap((question) =>
|
||||||
const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t));
|
getQuestionColumnsData(question, survey, isExpanded, t)
|
||||||
|
);
|
||||||
|
|
||||||
const dateColumn: ColumnDef<TResponseTableData> = {
|
const dateColumn: ColumnDef<TResponseTableData> = {
|
||||||
accessorKey: "createdAt",
|
accessorKey: "createdAt",
|
||||||
@@ -308,14 +312,6 @@ export const generateResponseTableColumns = (
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const singleUseIdColumn: ColumnDef<TResponseTableData> = {
|
|
||||||
accessorKey: "singleUseId",
|
|
||||||
header: () => <div className="gap-x-1.5">{t("environments.surveys.responses.single_use_id")}</div>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return <p className="truncate text-slate-900">{row.original.singleUseId}</p>;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const quotasColumn: ColumnDef<TResponseTableData> = {
|
const quotasColumn: ColumnDef<TResponseTableData> = {
|
||||||
accessorKey: "quota",
|
accessorKey: "quota",
|
||||||
header: t("common.quota"),
|
header: t("common.quota"),
|
||||||
@@ -410,15 +406,14 @@ export const generateResponseTableColumns = (
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Combine the selection column with the dynamic element columns
|
// Combine the selection column with the dynamic question columns
|
||||||
const baseColumns = [
|
const baseColumns = [
|
||||||
personColumn,
|
personColumn,
|
||||||
singleUseIdColumn,
|
|
||||||
dateColumn,
|
dateColumn,
|
||||||
...(showQuotasColumn ? [quotasColumn] : []),
|
...(showQuotasColumn ? [quotasColumn] : []),
|
||||||
statusColumn,
|
statusColumn,
|
||||||
...(survey.isVerifyEmailEnabled ? [verifiedEmailColumn] : []),
|
...(survey.isVerifyEmailEnabled ? [verifiedEmailColumn] : []),
|
||||||
...elementColumns,
|
...questionColumns,
|
||||||
...variableColumns,
|
...variableColumns,
|
||||||
...hiddenFieldColumns,
|
...hiddenFieldColumns,
|
||||||
...metadataColumns,
|
...metadataColumns,
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
|||||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||||
|
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
@@ -13,6 +14,7 @@ import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
|||||||
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
|
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
@@ -21,43 +23,44 @@ const Page = async (props) => {
|
|||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
|
|
||||||
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
const { session, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
|
const survey = await getSurvey(params.surveyId);
|
||||||
getSurvey(params.surveyId),
|
|
||||||
getUser(session.user.id),
|
|
||||||
getTagsByEnvironmentId(params.environmentId),
|
|
||||||
getIsContactsEnabled(),
|
|
||||||
getResponseCountBySurveyId(params.surveyId),
|
|
||||||
findMatchingLocale(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!survey) {
|
if (!survey) {
|
||||||
throw new Error(t("common.survey_not_found"));
|
throw new Error(t("common.survey_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const user = await getUser(session.user.id);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(t("common.user_not_found"));
|
throw new Error(t("common.user_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!organization) {
|
const tags = await getTagsByEnvironmentId(params.environmentId);
|
||||||
throw new Error(t("common.organization_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const isContactsEnabled = await getIsContactsEnabled();
|
||||||
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
||||||
|
|
||||||
|
// Get response count for the CTA component
|
||||||
|
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||||
|
const displayCount = await getDisplayCountBySurveyId(params.surveyId);
|
||||||
|
|
||||||
|
const locale = await findMatchingLocale();
|
||||||
const publicDomain = getPublicDomain();
|
const publicDomain = getPublicDomain();
|
||||||
|
|
||||||
const organizationBilling = await getOrganizationBilling(organization.id);
|
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||||
|
if (!organizationId) {
|
||||||
|
throw new Error(t("common.organization_not_found"));
|
||||||
|
}
|
||||||
|
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||||
if (!organizationBilling) {
|
if (!organizationBilling) {
|
||||||
throw new Error(t("common.organization_not_found"));
|
throw new Error(t("common.organization_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
|
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
|
||||||
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
|
|
||||||
|
|
||||||
// Fetch initial responses on the server to prevent duplicate client-side fetch
|
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
|
||||||
const initialResponses = await getResponses(params.surveyId, RESPONSES_PER_PAGE, 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
@@ -71,6 +74,7 @@ const Page = async (props) => {
|
|||||||
user={user}
|
user={user}
|
||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
responseCount={responseCount}
|
responseCount={responseCount}
|
||||||
|
displayCount={displayCount}
|
||||||
segments={segments}
|
segments={segments}
|
||||||
isContactsEnabled={isContactsEnabled}
|
isContactsEnabled={isContactsEnabled}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
@@ -90,7 +94,6 @@ const Page = async (props) => {
|
|||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
isQuotasAllowed={isQuotasAllowed}
|
isQuotasAllowed={isQuotasAllowed}
|
||||||
quotas={quotas}
|
quotas={quotas}
|
||||||
initialResponses={initialResponses}
|
|
||||||
/>
|
/>
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,27 +2,26 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TSurvey, TSurveyElementSummaryAddress } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestionSummaryAddress } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { ArrayResponse } from "@/modules/ui/components/array-response";
|
import { ArrayResponse } from "@/modules/ui/components/array-response";
|
||||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
|
||||||
|
|
||||||
interface AddressSummaryProps {
|
interface AddressSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryAddress;
|
questionSummary: TSurveyQuestionSummaryAddress;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddressSummary = ({ elementSummary, environmentId, survey, locale }: AddressSummaryProps) => {
|
export const AddressSummary = ({ questionSummary, environmentId, survey, locale }: AddressSummaryProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||||
<div>
|
<div>
|
||||||
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||||
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
||||||
@@ -30,48 +29,42 @@ export const AddressSummary = ({ elementSummary, environmentId, survey, locale }
|
|||||||
<div className="px-4 md:px-6">{t("common.time")}</div>
|
<div className="px-4 md:px-6">{t("common.time")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[62vh] w-full overflow-y-auto">
|
<div className="max-h-[62vh] w-full overflow-y-auto">
|
||||||
{elementSummary.samples.length === 0 ? (
|
{questionSummary.samples.map((response) => {
|
||||||
<div className="p-8">
|
return (
|
||||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
<div
|
||||||
</div>
|
key={response.id}
|
||||||
) : (
|
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
||||||
elementSummary.samples.map((response) => {
|
<div className="pl-4 md:pl-6">
|
||||||
return (
|
{response.contact ? (
|
||||||
<div
|
<Link
|
||||||
key={response.id}
|
className="ph-no-capture group flex items-center"
|
||||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||||
<div className="pl-4 md:pl-6">
|
<div className="hidden md:flex">
|
||||||
{response.contact ? (
|
<PersonAvatar personId={response.contact.id} />
|
||||||
<Link
|
|
||||||
className="ph-no-capture group flex items-center"
|
|
||||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
|
||||||
<div className="hidden md:flex">
|
|
||||||
<PersonAvatar personId={response.contact.id} />
|
|
||||||
</div>
|
|
||||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
|
||||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<div className="group flex items-center">
|
|
||||||
<div className="hidden md:flex">
|
|
||||||
<PersonAvatar personId="anonymous" />
|
|
||||||
</div>
|
|
||||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||||
</div>
|
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
|
</p>
|
||||||
<ArrayResponse value={response.value} />
|
</Link>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="group flex items-center">
|
||||||
<div className="px-4 text-slate-500 md:px-6">
|
<div className="hidden md:flex">
|
||||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
<PersonAvatar personId="anonymous" />
|
||||||
</div>
|
</div>
|
||||||
|
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
|
||||||
})
|
<ArrayResponse value={response.value} />
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 text-slate-500 md:px-6">
|
||||||
|
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,39 +2,39 @@
|
|||||||
|
|
||||||
import { InboxIcon } from "lucide-react";
|
import { InboxIcon } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TSurvey, TSurveyElementSummaryCta } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestionSummaryCta } from "@formbricks/types/surveys/types";
|
||||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||||
import { convertFloatToNDecimal } from "../lib/utils";
|
import { convertFloatToNDecimal } from "../lib/utils";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
|
|
||||||
interface CTASummaryProps {
|
interface CTASummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryCta;
|
questionSummary: TSurveyQuestionSummaryCta;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CTASummary = ({ elementSummary, survey }: CTASummaryProps) => {
|
export const CTASummary = ({ questionSummary, survey }: CTASummaryProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader
|
<QuestionSummaryHeader
|
||||||
survey={survey}
|
survey={survey}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
showResponses={false}
|
showResponses={false}
|
||||||
additionalInfo={
|
additionalInfo={
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{`${elementSummary.impressionCount} ${t("common.impressions")}`}
|
{`${questionSummary.impressionCount} ${t("common.impressions")}`}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{`${elementSummary.clickCount} ${t("common.clicks")}`}
|
{`${questionSummary.clickCount} ${t("common.clicks")}`}
|
||||||
</div>
|
</div>
|
||||||
{!elementSummary.element.required && (
|
{!questionSummary.question.required && (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{`${elementSummary.skipCount} ${t("common.skips")}`}
|
{`${questionSummary.skipCount} ${t("common.skips")}`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -46,16 +46,16 @@ export const CTASummary = ({ elementSummary, survey }: CTASummaryProps) => {
|
|||||||
<p className="font-semibold text-slate-700">CTR</p>
|
<p className="font-semibold text-slate-700">CTR</p>
|
||||||
<div>
|
<div>
|
||||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
{convertFloatToNDecimal(elementSummary.ctr.percentage, 2)}%
|
{convertFloatToNDecimal(questionSummary.ctr.percentage, 2)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{elementSummary.ctr.count}{" "}
|
{questionSummary.ctr.count}{" "}
|
||||||
{elementSummary.ctr.count === 1 ? t("common.click") : t("common.clicks")}
|
{questionSummary.ctr.count === 1 ? t("common.click") : t("common.clicks")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.ctr.percentage / 100} />
|
<ProgressBar barColor="bg-brand-dark" progress={questionSummary.ctr.percentage / 100} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TSurvey, TSurveyElementSummaryCal } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestionSummaryCal } from "@formbricks/types/surveys/types";
|
||||||
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
|
|
||||||
interface CalSummaryProps {
|
interface CalSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryCal;
|
questionSummary: TSurveyQuestionSummaryCal;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
|
export const CalSummary = ({ questionSummary, survey }: CalSummaryProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||||
<div>
|
<div>
|
||||||
<div className="text flex justify-between px-2 pb-2">
|
<div className="text flex justify-between px-2 pb-2">
|
||||||
@@ -25,16 +25,16 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
|
|||||||
<p className="font-semibold text-slate-700">{t("common.booked")}</p>
|
<p className="font-semibold text-slate-700">{t("common.booked")}</p>
|
||||||
<div>
|
<div>
|
||||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
{convertFloatToNDecimal(elementSummary.booked.percentage, 2)}%
|
{convertFloatToNDecimal(questionSummary.booked.percentage, 2)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{elementSummary.booked.count}{" "}
|
{questionSummary.booked.count}{" "}
|
||||||
{elementSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
|
{questionSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} />
|
<ProgressBar barColor="bg-brand-dark" progress={questionSummary.booked.percentage / 100} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text flex justify-between px-2 pb-2">
|
<div className="text flex justify-between px-2 pb-2">
|
||||||
@@ -42,16 +42,16 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
|
|||||||
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
|
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
|
||||||
<div>
|
<div>
|
||||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
{convertFloatToNDecimal(elementSummary.skipped.percentage, 2)}%
|
{convertFloatToNDecimal(questionSummary.skipped.percentage, 2)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{elementSummary.skipped.count}{" "}
|
{questionSummary.skipped.count}{" "}
|
||||||
{elementSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
|
{questionSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} />
|
<ProgressBar barColor="bg-brand-dark" progress={questionSummary.skipped.percentage / 100} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { CSSProperties, ReactNode } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
|
||||||
|
|
||||||
interface ClickableBarSegmentProps {
|
|
||||||
children: ReactNode;
|
|
||||||
onClick: () => void;
|
|
||||||
className?: string;
|
|
||||||
style?: CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ClickableBarSegment = ({
|
|
||||||
children,
|
|
||||||
onClick,
|
|
||||||
className = "",
|
|
||||||
style,
|
|
||||||
}: ClickableBarSegmentProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button className={className} style={style} onClick={onClick}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{t("common.click_to_filter")}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,42 +1,46 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type TI18nString } from "@formbricks/types/i18n";
|
import {
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
TI18nString,
|
||||||
import { TSurvey, TSurveyElementSummaryConsent } from "@formbricks/types/surveys/types";
|
TSurvey,
|
||||||
|
TSurveyQuestionId,
|
||||||
|
TSurveyQuestionSummaryConsent,
|
||||||
|
TSurveyQuestionTypeEnum,
|
||||||
|
} from "@formbricks/types/surveys/types";
|
||||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||||
import { convertFloatToNDecimal } from "../lib/utils";
|
import { convertFloatToNDecimal } from "../lib/utils";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
|
|
||||||
interface ConsentSummaryProps {
|
interface ConsentSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryConsent;
|
questionSummary: TSurveyQuestionSummaryConsent;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
setFilter: (
|
setFilter: (
|
||||||
elementId: string,
|
questionId: TSurveyQuestionId,
|
||||||
label: TI18nString,
|
label: TI18nString,
|
||||||
elementType: TSurveyElementTypeEnum,
|
questionType: TSurveyQuestionTypeEnum,
|
||||||
filterValue: string,
|
filterValue: string,
|
||||||
filterComboBoxValue?: string | string[]
|
filterComboBoxValue?: string | string[]
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSummaryProps) => {
|
export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSummaryProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const summaryItems = [
|
const summaryItems = [
|
||||||
{
|
{
|
||||||
title: t("common.accepted"),
|
title: t("common.accepted"),
|
||||||
percentage: elementSummary.accepted.percentage,
|
percentage: questionSummary.accepted.percentage,
|
||||||
count: elementSummary.accepted.count,
|
count: questionSummary.accepted.count,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("common.dismissed"),
|
title: t("common.dismissed"),
|
||||||
percentage: elementSummary.dismissed.percentage,
|
percentage: questionSummary.dismissed.percentage,
|
||||||
count: elementSummary.dismissed.count,
|
count: questionSummary.dismissed.count,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||||
{summaryItems.map((summaryItem) => {
|
{summaryItems.map((summaryItem) => {
|
||||||
return (
|
return (
|
||||||
@@ -45,9 +49,9 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
|
|||||||
key={summaryItem.title}
|
key={summaryItem.title}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setFilter(
|
setFilter(
|
||||||
elementSummary.element.id,
|
questionSummary.question.id,
|
||||||
elementSummary.element.headline,
|
questionSummary.question.headline,
|
||||||
elementSummary.element.type,
|
questionSummary.question.type,
|
||||||
"is",
|
"is",
|
||||||
summaryItem.title
|
summaryItem.title
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,24 +2,23 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TSurvey, TSurveyElementSummaryContactInfo } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestionSummaryContactInfo } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { ArrayResponse } from "@/modules/ui/components/array-response";
|
import { ArrayResponse } from "@/modules/ui/components/array-response";
|
||||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
|
||||||
|
|
||||||
interface ContactInfoSummaryProps {
|
interface ContactInfoSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryContactInfo;
|
questionSummary: TSurveyQuestionSummaryContactInfo;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ContactInfoSummary = ({
|
export const ContactInfoSummary = ({
|
||||||
elementSummary,
|
questionSummary,
|
||||||
environmentId,
|
environmentId,
|
||||||
survey,
|
survey,
|
||||||
locale,
|
locale,
|
||||||
@@ -27,7 +26,7 @@ export const ContactInfoSummary = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||||
<div>
|
<div>
|
||||||
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||||
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
||||||
@@ -35,48 +34,42 @@ export const ContactInfoSummary = ({
|
|||||||
<div className="px-4 md:px-6">{t("common.time")}</div>
|
<div className="px-4 md:px-6">{t("common.time")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[62vh] w-full overflow-y-auto">
|
<div className="max-h-[62vh] w-full overflow-y-auto">
|
||||||
{elementSummary.samples.length === 0 ? (
|
{questionSummary.samples.map((response) => {
|
||||||
<div className="p-8">
|
return (
|
||||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
<div
|
||||||
</div>
|
key={response.id}
|
||||||
) : (
|
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
||||||
elementSummary.samples.map((response) => {
|
<div className="pl-4 md:pl-6">
|
||||||
return (
|
{response.contact ? (
|
||||||
<div
|
<Link
|
||||||
key={response.id}
|
className="ph-no-capture group flex items-center"
|
||||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||||
<div className="pl-4 md:pl-6">
|
<div className="hidden md:flex">
|
||||||
{response.contact ? (
|
<PersonAvatar personId={response.contact.id} />
|
||||||
<Link
|
|
||||||
className="ph-no-capture group flex items-center"
|
|
||||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
|
||||||
<div className="hidden md:flex">
|
|
||||||
<PersonAvatar personId={response.contact.id} />
|
|
||||||
</div>
|
|
||||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
|
||||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<div className="group flex items-center">
|
|
||||||
<div className="hidden md:flex">
|
|
||||||
<PersonAvatar personId="anonymous" />
|
|
||||||
</div>
|
|
||||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||||
</div>
|
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
|
</p>
|
||||||
<ArrayResponse value={response.value} />
|
</Link>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="group flex items-center">
|
||||||
<div className="px-4 text-slate-500 md:px-6">
|
<div className="hidden md:flex">
|
||||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
<PersonAvatar personId="anonymous" />
|
||||||
</div>
|
</div>
|
||||||
|
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
|
||||||
})
|
<ArrayResponse value={response.value} />
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 text-slate-500 md:px-6">
|
||||||
|
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/types";
|
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
|
||||||
import { timeSince } from "@/lib/time";
|
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
|
||||||
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
|
|
||||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
|
||||||
|
|
||||||
interface DateElementSummary {
|
|
||||||
elementSummary: TSurveyElementSummaryDate;
|
|
||||||
environmentId: string;
|
|
||||||
survey: TSurvey;
|
|
||||||
locale: TUserLocale;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DateElementSummary = ({ elementSummary, environmentId, survey, locale }: DateElementSummary) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [visibleResponses, setVisibleResponses] = useState(10);
|
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
|
||||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
|
||||||
setVisibleResponses((prevVisibleResponses) =>
|
|
||||||
Math.min(prevVisibleResponses + 10, elementSummary.samples.length)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderResponseValue = (value: string) => {
|
|
||||||
const parsedDate = new Date(value);
|
|
||||||
|
|
||||||
const formattedDate = isNaN(parsedDate.getTime())
|
|
||||||
? `${t("common.invalid_date")}(${value})`
|
|
||||||
: formatDateWithOrdinal(parsedDate);
|
|
||||||
|
|
||||||
return formattedDate;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
|
||||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
|
||||||
<div className="">
|
|
||||||
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
|
||||||
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
|
||||||
<div className="col-span-2 pl-4 md:pl-6">{t("common.response")}</div>
|
|
||||||
<div className="px-4 md:px-6">{t("common.time")}</div>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-[62vh] w-full overflow-y-auto">
|
|
||||||
{elementSummary.samples.length === 0 ? (
|
|
||||||
<div className="p-8">
|
|
||||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
elementSummary.samples.slice(0, visibleResponses).map((response) => (
|
|
||||||
<div
|
|
||||||
key={response.id}
|
|
||||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
|
||||||
<div className="pl-4 md:pl-6">
|
|
||||||
{response.contact ? (
|
|
||||||
<Link
|
|
||||||
className="ph-no-capture group flex items-center"
|
|
||||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
|
||||||
<div className="hidden md:flex">
|
|
||||||
<PersonAvatar personId={response.contact.id} />
|
|
||||||
</div>
|
|
||||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
|
||||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<div className="group flex items-center">
|
|
||||||
<div className="hidden md:flex">
|
|
||||||
<PersonAvatar personId="anonymous" />
|
|
||||||
</div>
|
|
||||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
|
||||||
{renderResponseValue(response.value)}
|
|
||||||
</div>
|
|
||||||
<div className="px-4 text-slate-500 md:px-6">
|
|
||||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{elementSummary.samples.length > 0 && visibleResponses < elementSummary.samples.length && (
|
|
||||||
<div className="flex justify-center py-4">
|
|
||||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
|
||||||
{t("common.load_more")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TSurvey, TSurveyQuestionSummaryDate } from "@formbricks/types/surveys/types";
|
||||||
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
|
import { timeSince } from "@/lib/time";
|
||||||
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
|
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
|
||||||
|
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
|
|
||||||
|
interface DateQuestionSummary {
|
||||||
|
questionSummary: TSurveyQuestionSummaryDate;
|
||||||
|
environmentId: string;
|
||||||
|
survey: TSurvey;
|
||||||
|
locale: TUserLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DateQuestionSummary = ({
|
||||||
|
questionSummary,
|
||||||
|
environmentId,
|
||||||
|
survey,
|
||||||
|
locale,
|
||||||
|
}: DateQuestionSummary) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [visibleResponses, setVisibleResponses] = useState(10);
|
||||||
|
|
||||||
|
const handleLoadMore = () => {
|
||||||
|
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||||
|
setVisibleResponses((prevVisibleResponses) =>
|
||||||
|
Math.min(prevVisibleResponses + 10, questionSummary.samples.length)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderResponseValue = (value: string) => {
|
||||||
|
const parsedDate = new Date(value);
|
||||||
|
|
||||||
|
const formattedDate = isNaN(parsedDate.getTime())
|
||||||
|
? `${t("common.invalid_date")}(${value})`
|
||||||
|
: formatDateWithOrdinal(parsedDate);
|
||||||
|
|
||||||
|
return formattedDate;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||||
|
<div className="">
|
||||||
|
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||||
|
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
||||||
|
<div className="col-span-2 pl-4 md:pl-6">{t("common.response")}</div>
|
||||||
|
<div className="px-4 md:px-6">{t("common.time")}</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[62vh] w-full overflow-y-auto">
|
||||||
|
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||||
|
<div
|
||||||
|
key={response.id}
|
||||||
|
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
||||||
|
<div className="pl-4 md:pl-6">
|
||||||
|
{response.contact ? (
|
||||||
|
<Link
|
||||||
|
className="ph-no-capture group flex items-center"
|
||||||
|
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||||
|
<div className="hidden md:flex">
|
||||||
|
<PersonAvatar personId={response.contact.id} />
|
||||||
|
</div>
|
||||||
|
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||||
|
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="group flex items-center">
|
||||||
|
<div className="hidden md:flex">
|
||||||
|
<PersonAvatar personId="anonymous" />
|
||||||
|
</div>
|
||||||
|
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||||
|
{renderResponseValue(response.value)}
|
||||||
|
</div>
|
||||||
|
<div className="px-4 text-slate-500 md:px-6">
|
||||||
|
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{visibleResponses < questionSummary.samples.length && (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||||
|
{t("common.load_more")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,25 +4,24 @@ import { DownloadIcon, FileIcon } from "lucide-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TSurvey, TSurveyElementSummaryFileUpload } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestionSummaryFileUpload } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
|
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
|
||||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
|
||||||
|
|
||||||
interface FileUploadSummaryProps {
|
interface FileUploadSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryFileUpload;
|
questionSummary: TSurveyQuestionSummaryFileUpload;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FileUploadSummary = ({
|
export const FileUploadSummary = ({
|
||||||
elementSummary,
|
questionSummary,
|
||||||
environmentId,
|
environmentId,
|
||||||
survey,
|
survey,
|
||||||
locale,
|
locale,
|
||||||
@@ -32,13 +31,13 @@ export const FileUploadSummary = ({
|
|||||||
const handleLoadMore = () => {
|
const handleLoadMore = () => {
|
||||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||||
setVisibleResponses((prevVisibleResponses) =>
|
setVisibleResponses((prevVisibleResponses) =>
|
||||||
Math.min(prevVisibleResponses + 10, elementSummary.files.length)
|
Math.min(prevVisibleResponses + 10, questionSummary.files.length)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||||
<div className="">
|
<div className="">
|
||||||
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||||
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
||||||
@@ -46,77 +45,71 @@ export const FileUploadSummary = ({
|
|||||||
<div className="px-4 md:px-6">{t("common.time")}</div>
|
<div className="px-4 md:px-6">{t("common.time")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[62vh] w-full overflow-y-auto">
|
<div className="max-h-[62vh] w-full overflow-y-auto">
|
||||||
{elementSummary.files.length === 0 ? (
|
{questionSummary.files.slice(0, visibleResponses).map((response) => (
|
||||||
<div className="p-8">
|
<div
|
||||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
key={response.id}
|
||||||
</div>
|
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
||||||
) : (
|
<div className="pl-4 md:pl-6">
|
||||||
elementSummary.files.slice(0, visibleResponses).map((response) => (
|
{response.contact ? (
|
||||||
<div
|
<Link
|
||||||
key={response.id}
|
className="ph-no-capture group flex items-center"
|
||||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||||
<div className="pl-4 md:pl-6">
|
<div className="hidden md:flex">
|
||||||
{response.contact ? (
|
<PersonAvatar personId={response.contact.id} />
|
||||||
<Link
|
|
||||||
className="ph-no-capture group flex items-center"
|
|
||||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
|
||||||
<div className="hidden md:flex">
|
|
||||||
<PersonAvatar personId={response.contact.id} />
|
|
||||||
</div>
|
|
||||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
|
||||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<div className="group flex items-center">
|
|
||||||
<div className="hidden md:flex">
|
|
||||||
<PersonAvatar personId="anonymous" />
|
|
||||||
</div>
|
|
||||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||||
</div>
|
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||||
|
</p>
|
||||||
<div className="col-span-2 grid">
|
</Link>
|
||||||
{Array.isArray(response.value) &&
|
) : (
|
||||||
(response.value.length > 0 ? (
|
<div className="group flex items-center">
|
||||||
response.value.map((fileUrl) => {
|
<div className="hidden md:flex">
|
||||||
const fileName = getOriginalFileNameFromUrl(fileUrl);
|
<PersonAvatar personId="anonymous" />
|
||||||
|
</div>
|
||||||
return (
|
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||||
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
|
</div>
|
||||||
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
|
)}
|
||||||
<div className="absolute right-0 top-0 m-2">
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
|
|
||||||
<DownloadIcon className="h-6 text-slate-500" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center justify-center p-2">
|
|
||||||
<FileIcon className="h-6 text-slate-500" />
|
|
||||||
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{fileName}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div className="flex w-full flex-col items-center justify-center p-2">
|
|
||||||
<p className="mt-2 text-sm font-semibold text-slate-500 dark:text-slate-400">
|
|
||||||
{t("common.skipped")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-4 text-slate-500 md:px-6">
|
|
||||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
|
||||||
)}
|
<div className="col-span-2 grid">
|
||||||
|
{Array.isArray(response.value) &&
|
||||||
|
(response.value.length > 0 ? (
|
||||||
|
response.value.map((fileUrl) => {
|
||||||
|
const fileName = getOriginalFileNameFromUrl(fileUrl);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
|
||||||
|
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<div className="absolute right-0 top-0 m-2">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
|
||||||
|
<DownloadIcon className="h-6 text-slate-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center p-2">
|
||||||
|
<FileIcon className="h-6 text-slate-500" />
|
||||||
|
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{fileName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="flex w-full flex-col items-center justify-center p-2">
|
||||||
|
<p className="mt-2 text-sm font-semibold text-slate-500 dark:text-slate-400">
|
||||||
|
{t("common.skipped")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 text-slate-500 md:px-6">
|
||||||
|
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{elementSummary.files.length > 0 && visibleResponses < elementSummary.files.length && (
|
{visibleResponses < questionSummary.files.length && (
|
||||||
<div className="flex justify-center py-4">
|
<div className="flex justify-center py-4">
|
||||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||||
{t("common.load_more")}
|
{t("common.load_more")}
|
||||||
|
|||||||
@@ -4,34 +4,33 @@ import { InboxIcon, Link, MessageSquareTextIcon } from "lucide-react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TSurveyElementSummaryHiddenFields } from "@formbricks/types/surveys/types";
|
import { TSurveyQuestionSummaryHiddenFields } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
|
||||||
|
|
||||||
interface HiddenFieldsSummaryProps {
|
interface HiddenFieldsSummaryProps {
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
elementSummary: TSurveyElementSummaryHiddenFields;
|
questionSummary: TSurveyQuestionSummaryHiddenFields;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: HiddenFieldsSummaryProps) => {
|
export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: HiddenFieldsSummaryProps) => {
|
||||||
const [visibleResponses, setVisibleResponses] = useState(10);
|
const [visibleResponses, setVisibleResponses] = useState(10);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const handleLoadMore = () => {
|
const handleLoadMore = () => {
|
||||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||||
setVisibleResponses((prevVisibleResponses) =>
|
setVisibleResponses((prevVisibleResponses) =>
|
||||||
Math.min(prevVisibleResponses + 10, elementSummary.samples.length)
|
Math.min(prevVisibleResponses + 10, questionSummary.samples.length)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
||||||
<div className={"align-center flex justify-between gap-4"}>
|
<div className={"align-center flex justify-between gap-4"}>
|
||||||
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{elementSummary.id}</h3>
|
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{questionSummary.id}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
|
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
|
||||||
@@ -41,8 +40,8 @@ export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: Hid
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{elementSummary.responseCount}{" "}
|
{questionSummary.responseCount}{" "}
|
||||||
{elementSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
|
{questionSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,46 +51,40 @@ export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: Hid
|
|||||||
<div className="col-span-2 pl-4 md:pl-6">{t("common.response")}</div>
|
<div className="col-span-2 pl-4 md:pl-6">{t("common.response")}</div>
|
||||||
<div className="px-4 md:px-6">{t("common.time")}</div>
|
<div className="px-4 md:px-6">{t("common.time")}</div>
|
||||||
</div>
|
</div>
|
||||||
{elementSummary.samples.length === 0 ? (
|
{questionSummary.samples.slice(0, visibleResponses).map((response, idx) => (
|
||||||
<div className="p-8">
|
<div
|
||||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
key={`${response.value}-${idx}`}
|
||||||
</div>
|
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
||||||
) : (
|
<div className="pl-4 md:pl-6">
|
||||||
elementSummary.samples.slice(0, visibleResponses).map((response, idx) => (
|
{response.contact ? (
|
||||||
<div
|
<Link
|
||||||
key={`${response.value}-${idx}`}
|
className="ph-no-capture group flex items-center"
|
||||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
href={`/environments/${environment.id}/contacts/${response.contact.id}`}>
|
||||||
<div className="pl-4 md:pl-6">
|
<div className="hidden md:flex">
|
||||||
{response.contact ? (
|
<PersonAvatar personId={response.contact.id} />
|
||||||
<Link
|
|
||||||
className="ph-no-capture group flex items-center"
|
|
||||||
href={`/environments/${environment.id}/contacts/${response.contact.id}`}>
|
|
||||||
<div className="hidden md:flex">
|
|
||||||
<PersonAvatar personId={response.contact.id} />
|
|
||||||
</div>
|
|
||||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
|
||||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<div className="group flex items-center">
|
|
||||||
<div className="hidden md:flex">
|
|
||||||
<PersonAvatar personId="anonymous" />
|
|
||||||
</div>
|
|
||||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||||
</div>
|
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
</p>
|
||||||
{response.value}
|
</Link>
|
||||||
</div>
|
) : (
|
||||||
<div className="px-4 text-slate-500 md:px-6">
|
<div className="group flex items-center">
|
||||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
<div className="hidden md:flex">
|
||||||
</div>
|
<PersonAvatar personId="anonymous" />
|
||||||
|
</div>
|
||||||
|
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||||
)}
|
{response.value}
|
||||||
{elementSummary.samples.length > 0 && visibleResponses < elementSummary.samples.length && (
|
</div>
|
||||||
|
<div className="px-4 text-slate-500 md:px-6">
|
||||||
|
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{visibleResponses < questionSummary.samples.length && (
|
||||||
<div className="flex justify-center py-4">
|
<div className="flex justify-center py-4">
|
||||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||||
{t("common.load_more")}
|
{t("common.load_more")}
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type TI18nString } from "@formbricks/types/i18n";
|
import {
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
TI18nString,
|
||||||
import { TSurvey, TSurveyElementSummaryMatrix } from "@formbricks/types/surveys/types";
|
TSurvey,
|
||||||
|
TSurveyQuestionId,
|
||||||
|
TSurveyQuestionSummaryMatrix,
|
||||||
|
TSurveyQuestionTypeEnum,
|
||||||
|
} from "@formbricks/types/surveys/types";
|
||||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
|
|
||||||
interface MatrixElementSummaryProps {
|
interface MatrixQuestionSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryMatrix;
|
questionSummary: TSurveyQuestionSummaryMatrix;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
setFilter: (
|
setFilter: (
|
||||||
elementId: string,
|
questionId: TSurveyQuestionId,
|
||||||
label: TI18nString,
|
label: TI18nString,
|
||||||
elementType: TSurveyElementTypeEnum,
|
questionType: TSurveyQuestionTypeEnum,
|
||||||
filterValue: string,
|
filterValue: string,
|
||||||
filterComboBoxValue?: string | string[]
|
filterComboBoxValue?: string | string[]
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: MatrixElementSummaryProps) => {
|
export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: MatrixQuestionSummaryProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const getOpacityLevel = (percentage: number): string => {
|
const getOpacityLevel = (percentage: number): string => {
|
||||||
const parsedPercentage = percentage;
|
const parsedPercentage = percentage;
|
||||||
@@ -36,11 +40,13 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
|
|||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = elementSummary.data[0] ? elementSummary.data[0].columnPercentages.map((c) => c.column) : [];
|
const columns = questionSummary.data[0]
|
||||||
|
? questionSummary.data[0].columnPercentages.map((c) => c.column)
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||||
<div className="overflow-x-auto p-6">
|
<div className="overflow-x-auto p-6">
|
||||||
{/* Summary Table */}
|
{/* Summary Table */}
|
||||||
<table className="mx-auto border-collapse cursor-default text-left">
|
<table className="mx-auto border-collapse cursor-default text-left">
|
||||||
@@ -57,7 +63,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{elementSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
|
{questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
|
||||||
<tr key={rowLabel}>
|
<tr key={rowLabel}>
|
||||||
<td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4">
|
<td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4">
|
||||||
<TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
|
<TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
|
||||||
@@ -73,16 +79,16 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
|
|||||||
tooltipContent={getTooltipContent(
|
tooltipContent={getTooltipContent(
|
||||||
undefined,
|
undefined,
|
||||||
percentage,
|
percentage,
|
||||||
elementSummary.data[rowIndex].totalResponsesForRow
|
questionSummary.data[rowIndex].totalResponsesForRow
|
||||||
)}>
|
)}>
|
||||||
<button
|
<button
|
||||||
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
|
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
|
||||||
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
|
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setFilter(
|
setFilter(
|
||||||
elementSummary.element.id,
|
questionSummary.question.id,
|
||||||
elementSummary.element.headline,
|
questionSummary.question.headline,
|
||||||
elementSummary.element.type,
|
questionSummary.question.type,
|
||||||
rowLabel,
|
rowLabel,
|
||||||
column
|
column
|
||||||
)
|
)
|
||||||
@@ -4,9 +4,14 @@ import { InboxIcon } from "lucide-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Fragment, useState } from "react";
|
import { Fragment, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TI18nString } from "@formbricks/types/i18n";
|
import {
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
TI18nString,
|
||||||
import { TSurvey, TSurveyElementSummaryMultipleChoice, TSurveyType } from "@formbricks/types/surveys/types";
|
TSurvey,
|
||||||
|
TSurveyQuestionId,
|
||||||
|
TSurveyQuestionSummaryMultipleChoice,
|
||||||
|
TSurveyQuestionTypeEnum,
|
||||||
|
TSurveyType,
|
||||||
|
} from "@formbricks/types/surveys/types";
|
||||||
import { getChoiceIdByValue } from "@/lib/response/utils";
|
import { getChoiceIdByValue } from "@/lib/response/utils";
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||||
@@ -14,24 +19,24 @@ import { Button } from "@/modules/ui/components/button";
|
|||||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||||
import { convertFloatToNDecimal } from "../lib/utils";
|
import { convertFloatToNDecimal } from "../lib/utils";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
|
|
||||||
interface MultipleChoiceSummaryProps {
|
interface MultipleChoiceSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryMultipleChoice;
|
questionSummary: TSurveyQuestionSummaryMultipleChoice;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
surveyType: TSurveyType;
|
surveyType: TSurveyType;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
setFilter: (
|
setFilter: (
|
||||||
elementId: string,
|
questionId: TSurveyQuestionId,
|
||||||
label: TI18nString,
|
label: TI18nString,
|
||||||
elementType: TSurveyElementTypeEnum,
|
questionType: TSurveyQuestionTypeEnum,
|
||||||
filterValue: string,
|
filterValue: string,
|
||||||
filterComboBoxValue?: string | string[]
|
filterComboBoxValue?: string | string[]
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MultipleChoiceSummary = ({
|
export const MultipleChoiceSummary = ({
|
||||||
elementSummary,
|
questionSummary,
|
||||||
environmentId,
|
environmentId,
|
||||||
surveyType,
|
surveyType,
|
||||||
survey,
|
survey,
|
||||||
@@ -39,9 +44,9 @@ export const MultipleChoiceSummary = ({
|
|||||||
}: MultipleChoiceSummaryProps) => {
|
}: MultipleChoiceSummaryProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [visibleOtherResponses, setVisibleOtherResponses] = useState(10);
|
const [visibleOtherResponses, setVisibleOtherResponses] = useState(10);
|
||||||
const otherValue = elementSummary.element.choices.find((choice) => choice.id === "other")?.label.default;
|
const otherValue = questionSummary.question.choices.find((choice) => choice.id === "other")?.label.default;
|
||||||
// sort by count and transform to array
|
// sort by count and transform to array
|
||||||
const results = Object.values(elementSummary.choices).sort((a, b) => {
|
const results = Object.values(questionSummary.choices).sort((a, b) => {
|
||||||
const aHasOthers = (a.others?.length ?? 0) > 0;
|
const aHasOthers = (a.others?.length ?? 0) > 0;
|
||||||
const bHasOthers = (b.others?.length ?? 0) > 0;
|
const bHasOthers = (b.others?.length ?? 0) > 0;
|
||||||
|
|
||||||
@@ -68,111 +73,108 @@ export const MultipleChoiceSummary = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader
|
<QuestionSummaryHeader
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
additionalInfo={
|
additionalInfo={
|
||||||
elementSummary.type === "multipleChoiceMulti" ? (
|
questionSummary.type === "multipleChoiceMulti" ? (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{`${elementSummary.selectionCount} ${t("common.selections")}`}
|
{`${questionSummary.selectionCount} ${t("common.selections")}`}
|
||||||
</div>
|
</div>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||||
<div className="space-y-5">
|
{results.map((result, resultsIdx) => {
|
||||||
{results.map((result) => {
|
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
|
||||||
const choiceId = getChoiceIdByValue(result.value, elementSummary.element);
|
return (
|
||||||
return (
|
<Fragment key={result.value}>
|
||||||
<Fragment key={result.value}>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className="group w-full cursor-pointer"
|
||||||
className="group w-full cursor-pointer"
|
onClick={() =>
|
||||||
onClick={() =>
|
setFilter(
|
||||||
setFilter(
|
questionSummary.question.id,
|
||||||
elementSummary.element.id,
|
questionSummary.question.headline,
|
||||||
elementSummary.element.headline,
|
questionSummary.question.type,
|
||||||
elementSummary.element.type,
|
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
|
||||||
elementSummary.type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
|
? t("environments.surveys.summary.includes_either")
|
||||||
otherValue === result.value
|
: t("environments.surveys.summary.includes_all"),
|
||||||
? t("environments.surveys.summary.includes_either")
|
[result.value]
|
||||||
: t("environments.surveys.summary.includes_all"),
|
)
|
||||||
[result.value]
|
}>
|
||||||
)
|
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||||
}>
|
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
|
||||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
|
||||||
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
|
{results.length - resultsIdx} - {result.value}
|
||||||
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
|
</p>
|
||||||
{result.value}
|
{choiceId && <IdBadge id={choiceId} />}
|
||||||
</p>
|
|
||||||
{choiceId && <IdBadge id={choiceId} />}
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full space-x-2">
|
|
||||||
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
|
||||||
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
|
||||||
</p>
|
|
||||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
|
||||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="group-hover:opacity-80">
|
<div className="flex w-full space-x-2">
|
||||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
||||||
|
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
||||||
|
</p>
|
||||||
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
|
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
{result.others && result.others.length > 0 && (
|
<div className="group-hover:opacity-80">
|
||||||
<div className="mt-4 rounded-lg border border-slate-200">
|
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||||
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
</div>
|
||||||
<div className="col-span-1 pl-6">
|
</button>
|
||||||
{t("environments.surveys.summary.other_values_found")}
|
{result.others && result.others.length > 0 && (
|
||||||
</div>
|
<div className="mt-4 rounded-lg border border-slate-200">
|
||||||
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
|
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||||
|
<div className="col-span-1 pl-6">
|
||||||
|
{t("environments.surveys.summary.other_values_found")}
|
||||||
</div>
|
</div>
|
||||||
{result.others
|
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
|
||||||
.filter((otherValue) => otherValue.value !== "")
|
</div>
|
||||||
.slice(0, visibleOtherResponses)
|
{result.others
|
||||||
.map((otherValue, idx) => (
|
.filter((otherValue) => otherValue.value !== "")
|
||||||
<div key={`${idx}-${otherValue}`} dir="auto">
|
.slice(0, visibleOtherResponses)
|
||||||
{surveyType === "link" && (
|
.map((otherValue, idx) => (
|
||||||
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
|
<div key={`${idx}-${otherValue}`} dir="auto">
|
||||||
|
{surveyType === "link" && (
|
||||||
|
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
|
||||||
|
<span>{otherValue.value}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{surveyType === "app" && otherValue.contact && (
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
otherValue.contact.id
|
||||||
|
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
|
||||||
|
: { pathname: null }
|
||||||
|
}
|
||||||
|
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
|
||||||
|
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
|
||||||
<span>{otherValue.value}</span>
|
<span>{otherValue.value}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
|
||||||
{surveyType === "app" && otherValue.contact && (
|
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
|
||||||
<Link
|
<span>
|
||||||
href={
|
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
|
||||||
otherValue.contact.id
|
</span>
|
||||||
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
|
</div>
|
||||||
: { pathname: null }
|
</Link>
|
||||||
}
|
)}
|
||||||
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
|
|
||||||
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
|
|
||||||
<span>{otherValue.value}</span>
|
|
||||||
</div>
|
|
||||||
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
|
|
||||||
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
|
|
||||||
<span>
|
|
||||||
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{visibleOtherResponses < result.others.length && (
|
|
||||||
<div className="flex justify-center py-4">
|
|
||||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
|
||||||
{t("common.load_more")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
{visibleOtherResponses < result.others.length && (
|
||||||
)}
|
<div className="flex justify-center py-4">
|
||||||
</Fragment>
|
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||||
);
|
{t("common.load_more")}
|
||||||
})}
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,45 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BarChart, BarChartHorizontal } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type TI18nString } from "@formbricks/types/i18n";
|
import {
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
TI18nString,
|
||||||
import { TSurvey, TSurveyElementSummaryNps } from "@formbricks/types/surveys/types";
|
TSurvey,
|
||||||
|
TSurveyQuestionId,
|
||||||
|
TSurveyQuestionSummaryNps,
|
||||||
|
TSurveyQuestionTypeEnum,
|
||||||
|
} from "@formbricks/types/surveys/types";
|
||||||
import { HalfCircle, ProgressBar } from "@/modules/ui/components/progress-bar";
|
import { HalfCircle, ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
|
||||||
import { TooltipProvider } from "@/modules/ui/components/tooltip";
|
|
||||||
import { convertFloatToNDecimal } from "../lib/utils";
|
import { convertFloatToNDecimal } from "../lib/utils";
|
||||||
import { ClickableBarSegment } from "./ClickableBarSegment";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
|
||||||
import { SatisfactionIndicator } from "./SatisfactionIndicator";
|
|
||||||
|
|
||||||
interface NPSSummaryProps {
|
interface NPSSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryNps;
|
questionSummary: TSurveyQuestionSummaryNps;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
setFilter: (
|
setFilter: (
|
||||||
elementId: string,
|
questionId: TSurveyQuestionId,
|
||||||
label: TI18nString,
|
label: TI18nString,
|
||||||
elementType: TSurveyElementTypeEnum,
|
questionType: TSurveyQuestionTypeEnum,
|
||||||
filterValue: string,
|
filterValue: string,
|
||||||
filterComboBoxValue?: string | string[]
|
filterComboBoxValue?: string | string[]
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const calculateNPSOpacity = (rating: number): number => {
|
export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryProps) => {
|
||||||
if (rating <= 6) {
|
|
||||||
return 0.3 + (rating / 6) * 0.3;
|
|
||||||
}
|
|
||||||
if (rating <= 8) {
|
|
||||||
return 0.6 + ((rating - 6) / 2) * 0.2;
|
|
||||||
}
|
|
||||||
return 0.8 + ((rating - 8) / 2) * 0.2;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProps) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
|
|
||||||
|
|
||||||
const applyFilter = (group: string) => {
|
const applyFilter = (group: string) => {
|
||||||
const filters = {
|
const filters = {
|
||||||
promoters: {
|
promoters: {
|
||||||
@@ -64,9 +50,9 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
|||||||
|
|
||||||
if (filter) {
|
if (filter) {
|
||||||
setFilter(
|
setFilter(
|
||||||
elementSummary.element.id,
|
questionSummary.question.id,
|
||||||
elementSummary.element.headline,
|
questionSummary.question.headline,
|
||||||
elementSummary.element.type,
|
questionSummary.question.type,
|
||||||
filter.comparison,
|
filter.comparison,
|
||||||
filter.values
|
filter.values
|
||||||
);
|
);
|
||||||
@@ -75,115 +61,41 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader
|
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||||
elementSummary={elementSummary}
|
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||||
survey={survey}
|
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
||||||
additionalInfo={
|
<button
|
||||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
className="w-full cursor-pointer hover:opacity-80"
|
||||||
<SatisfactionIndicator percentage={elementSummary.promoters.percentage} />
|
key={group}
|
||||||
<div>
|
onClick={() => applyFilter(group)}>
|
||||||
{t("environments.surveys.summary.promoters")}:{" "}
|
<div
|
||||||
{convertFloatToNDecimal(elementSummary.promoters.percentage, 2)}%
|
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
|
||||||
|
<div className="mr-8 flex space-x-1">
|
||||||
|
<p
|
||||||
|
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||||
|
{group}
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
|
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
|
{questionSummary[group]?.count}{" "}
|
||||||
|
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<ProgressBar
|
||||||
}
|
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
|
||||||
/>
|
progress={questionSummary[group]?.percentage / 100}
|
||||||
|
/>
|
||||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
|
</button>
|
||||||
<div className="flex justify-end px-4 md:px-6">
|
))}
|
||||||
<TabsList>
|
</div>
|
||||||
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
|
|
||||||
{t("environments.surveys.summary.aggregated")}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
|
|
||||||
{t("environments.surveys.summary.individual")}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TabsContent value="aggregated" className="mt-4">
|
|
||||||
<div className="px-4 pb-6 pt-4 md:px-6">
|
|
||||||
<div className="space-y-5 text-sm md:text-base">
|
|
||||||
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
|
||||||
<button
|
|
||||||
className="w-full cursor-pointer hover:opacity-80"
|
|
||||||
key={group}
|
|
||||||
onClick={() => applyFilter(group)}>
|
|
||||||
<div
|
|
||||||
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
|
|
||||||
<div className="mr-8 flex space-x-1">
|
|
||||||
<p
|
|
||||||
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
|
||||||
{group}
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
|
||||||
{convertFloatToNDecimal(elementSummary[group]?.percentage, 2)}%
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
|
||||||
{elementSummary[group]?.count}{" "}
|
|
||||||
{elementSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ProgressBar
|
|
||||||
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
|
|
||||||
progress={elementSummary[group]?.percentage / 100}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="individual" className="mt-4">
|
|
||||||
<TooltipProvider delayDuration={200}>
|
|
||||||
<div className="grid grid-cols-11 gap-2 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
|
||||||
{elementSummary.choices.map((choice) => {
|
|
||||||
const opacity = calculateNPSOpacity(choice.rating);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ClickableBarSegment
|
|
||||||
key={choice.rating}
|
|
||||||
className="group flex cursor-pointer flex-col items-center"
|
|
||||||
onClick={() =>
|
|
||||||
setFilter(
|
|
||||||
elementSummary.element.id,
|
|
||||||
elementSummary.element.headline,
|
|
||||||
elementSummary.element.type,
|
|
||||||
t("environments.surveys.summary.is_equal_to"),
|
|
||||||
choice.rating.toString()
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
<div className="flex h-32 w-full flex-col items-center justify-end">
|
|
||||||
<div
|
|
||||||
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
|
|
||||||
style={{
|
|
||||||
height: `${Math.max(choice.percentage, 2)}%`,
|
|
||||||
opacity,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full flex-col items-center rounded-b-lg border border-t-0 border-slate-200 bg-slate-50 px-1 py-2">
|
|
||||||
<div className="mb-1.5 text-xs font-medium text-slate-500">{choice.rating}</div>
|
|
||||||
<div className="mb-1 flex items-center space-x-1">
|
|
||||||
<div className="text-base font-semibold text-slate-700">{choice.count}</div>
|
|
||||||
<div className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600">
|
|
||||||
{convertFloatToNDecimal(choice.percentage, 1)}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ClickableBarSegment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<div className="flex justify-center pb-4 pt-4">
|
<div className="flex justify-center pb-4 pt-4">
|
||||||
<HalfCircle value={elementSummary.score} />
|
<HalfCircle value={questionSummary.score} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,98 +3,91 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TSurvey, TSurveyElementSummaryOpenText } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
|
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
|
||||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
|
|
||||||
interface OpenTextSummaryProps {
|
interface OpenTextSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryOpenText;
|
questionSummary: TSurveyQuestionSummaryOpenText;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OpenTextSummary = ({ elementSummary, environmentId, survey, locale }: OpenTextSummaryProps) => {
|
export const OpenTextSummary = ({ questionSummary, environmentId, survey, locale }: OpenTextSummaryProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [visibleResponses, setVisibleResponses] = useState(10);
|
const [visibleResponses, setVisibleResponses] = useState(10);
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
const handleLoadMore = () => {
|
||||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||||
setVisibleResponses((prevVisibleResponses) =>
|
setVisibleResponses((prevVisibleResponses) =>
|
||||||
Math.min(prevVisibleResponses + 10, elementSummary.samples.length)
|
Math.min(prevVisibleResponses + 10, questionSummary.samples.length)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||||
<div className="border-t border-slate-200"></div>
|
<div className="border-t border-slate-200"></div>
|
||||||
{elementSummary.samples.length === 0 ? (
|
<div className="max-h-[40vh] overflow-y-auto">
|
||||||
<div className="p-8">
|
<Table>
|
||||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
<TableHeader className="bg-slate-100">
|
||||||
</div>
|
<TableRow>
|
||||||
) : (
|
<TableHead>{t("common.user")}</TableHead>
|
||||||
<div className="max-h-[40vh] overflow-y-auto">
|
<TableHead>{t("common.response")}</TableHead>
|
||||||
<Table>
|
<TableHead>{t("common.time")}</TableHead>
|
||||||
<TableHeader className="bg-slate-100">
|
</TableRow>
|
||||||
<TableRow>
|
</TableHeader>
|
||||||
<TableHead className="w-1/4">{t("common.user")}</TableHead>
|
<TableBody>
|
||||||
<TableHead className="w-2/4">{t("common.response")}</TableHead>
|
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||||
<TableHead className="w-1/4">{t("common.time")}</TableHead>
|
<TableRow key={response.id}>
|
||||||
</TableRow>
|
<TableCell>
|
||||||
</TableHeader>
|
{response.contact ? (
|
||||||
<TableBody>
|
<Link
|
||||||
{elementSummary.samples.slice(0, visibleResponses).map((response) => (
|
className="ph-no-capture group flex items-center"
|
||||||
<TableRow key={response.id}>
|
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||||
<TableCell className="w-1/4">
|
<div className="hidden md:flex">
|
||||||
{response.contact ? (
|
<PersonAvatar personId={response.contact.id} />
|
||||||
<Link
|
|
||||||
className="ph-no-capture group flex items-center"
|
|
||||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
|
||||||
<div className="hidden md:flex">
|
|
||||||
<PersonAvatar personId={response.contact.id} />
|
|
||||||
</div>
|
|
||||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
|
||||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<div className="group flex items-center">
|
|
||||||
<div className="hidden md:flex">
|
|
||||||
<PersonAvatar personId="anonymous" />
|
|
||||||
</div>
|
|
||||||
<p className="break-normal text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||||
</TableCell>
|
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||||
<TableCell className="w-2/4 font-medium">
|
</p>
|
||||||
{typeof response.value === "string"
|
</Link>
|
||||||
? renderHyperlinkedContent(response.value)
|
) : (
|
||||||
: response.value}
|
<div className="group flex items-center">
|
||||||
</TableCell>
|
<div className="hidden md:flex">
|
||||||
<TableCell className="w-1/4">
|
<PersonAvatar personId="anonymous" />
|
||||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
</div>
|
||||||
</TableCell>
|
<p className="break-normal text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||||
</TableRow>
|
</div>
|
||||||
))}
|
)}
|
||||||
</TableBody>
|
</TableCell>
|
||||||
</Table>
|
<TableCell className="font-medium">
|
||||||
{visibleResponses < elementSummary.samples.length && (
|
{typeof response.value === "string"
|
||||||
<div className="flex justify-center py-4">
|
? renderHyperlinkedContent(response.value)
|
||||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
: response.value}
|
||||||
{t("common.load_more")}
|
</TableCell>
|
||||||
</Button>
|
<TableCell width={120}>
|
||||||
</div>
|
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||||
)}
|
</TableCell>
|
||||||
</div>
|
</TableRow>
|
||||||
)}
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{visibleResponses < questionSummary.samples.length && (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||||
|
{t("common.load_more")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,48 +3,52 @@
|
|||||||
import { InboxIcon } from "lucide-react";
|
import { InboxIcon } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type TI18nString } from "@formbricks/types/i18n";
|
import {
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
TI18nString,
|
||||||
import { TSurvey, TSurveyElementSummaryPictureSelection } from "@formbricks/types/surveys/types";
|
TSurvey,
|
||||||
|
TSurveyQuestionId,
|
||||||
|
TSurveyQuestionSummaryPictureSelection,
|
||||||
|
TSurveyQuestionTypeEnum,
|
||||||
|
} from "@formbricks/types/surveys/types";
|
||||||
import { getChoiceIdByValue } from "@/lib/response/utils";
|
import { getChoiceIdByValue } from "@/lib/response/utils";
|
||||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||||
import { convertFloatToNDecimal } from "../lib/utils";
|
import { convertFloatToNDecimal } from "../lib/utils";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
|
|
||||||
interface PictureChoiceSummaryProps {
|
interface PictureChoiceSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryPictureSelection;
|
questionSummary: TSurveyQuestionSummaryPictureSelection;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
setFilter: (
|
setFilter: (
|
||||||
elementId: string,
|
questionId: TSurveyQuestionId,
|
||||||
label: TI18nString,
|
label: TI18nString,
|
||||||
elementType: TSurveyElementTypeEnum,
|
questionType: TSurveyQuestionTypeEnum,
|
||||||
filterValue: string,
|
filterValue: string,
|
||||||
filterComboBoxValue?: string | string[]
|
filterComboBoxValue?: string | string[]
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: PictureChoiceSummaryProps) => {
|
export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: PictureChoiceSummaryProps) => {
|
||||||
const results = elementSummary.choices;
|
const results = questionSummary.choices;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader
|
<QuestionSummaryHeader
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
additionalInfo={
|
additionalInfo={
|
||||||
elementSummary.element.allowMulti ? (
|
questionSummary.question.allowMulti ? (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{`${elementSummary.selectionCount} ${t("common.selections")}`}
|
{`${questionSummary.selectionCount} ${t("common.selections")}`}
|
||||||
</div>
|
</div>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||||
{results.map((result, index) => {
|
{results.map((result, index) => {
|
||||||
const choiceId = getChoiceIdByValue(result.imageUrl, elementSummary.element);
|
const choiceId = getChoiceIdByValue(result.imageUrl, questionSummary.question);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -52,9 +56,9 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
|
|||||||
key={result.id}
|
key={result.id}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setFilter(
|
setFilter(
|
||||||
elementSummary.element.id,
|
questionSummary.question.id,
|
||||||
elementSummary.element.headline,
|
questionSummary.question.headline,
|
||||||
elementSummary.element.type,
|
questionSummary.question.type,
|
||||||
t("environments.surveys.summary.includes_all"),
|
t("environments.surveys.summary.includes_all"),
|
||||||
[`${t("environments.surveys.edit.picture_idx", { idx: index + 1 })}`]
|
[`${t("environments.surveys.edit.picture_idx", { idx: index + 1 })}`]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,28 +3,28 @@
|
|||||||
import { InboxIcon } from "lucide-react";
|
import { InboxIcon } from "lucide-react";
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TSurvey, TSurveyElementSummary } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestionSummary } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||||
import { getElementTypes } from "@/modules/survey/lib/elements";
|
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||||
|
|
||||||
interface HeadProps {
|
interface HeadProps {
|
||||||
elementSummary: TSurveyElementSummary;
|
questionSummary: TSurveyQuestionSummary;
|
||||||
showResponses?: boolean;
|
showResponses?: boolean;
|
||||||
additionalInfo?: JSX.Element;
|
additionalInfo?: JSX.Element;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ElementSummaryHeader = ({
|
export const QuestionSummaryHeader = ({
|
||||||
elementSummary,
|
questionSummary,
|
||||||
additionalInfo,
|
additionalInfo,
|
||||||
showResponses = true,
|
showResponses = true,
|
||||||
survey,
|
survey,
|
||||||
}: HeadProps) => {
|
}: HeadProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const elementType = getElementTypes(t).find((type) => type.id === elementSummary.element.type);
|
const questionType = getQuestionTypes(t).find((type) => type.id === questionSummary.question.type);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
||||||
@@ -32,7 +32,7 @@ export const ElementSummaryHeader = ({
|
|||||||
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
|
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
|
||||||
{formatTextWithSlashes(
|
{formatTextWithSlashes(
|
||||||
getTextContent(
|
getTextContent(
|
||||||
recallToHeadline(elementSummary.element.headline, survey, true, "default")["default"]
|
recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"]
|
||||||
),
|
),
|
||||||
"@",
|
"@",
|
||||||
["text-lg"]
|
["text-lg"]
|
||||||
@@ -41,24 +41,24 @@ export const ElementSummaryHeader = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
|
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
{elementType && <elementType.icon className="mr-2 h-4 w-4" />}
|
{questionType && <questionType.icon className="mr-2 h-4 w-4" />}
|
||||||
{elementType ? elementType.label : t("environments.surveys.summary.unknown_question_type")}{" "}
|
{questionType ? questionType.label : t("environments.surveys.summary.unknown_question_type")}{" "}
|
||||||
{t("common.question")}
|
{t("common.question")}
|
||||||
</div>
|
</div>
|
||||||
{showResponses && (
|
{showResponses && (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{`${elementSummary.responseCount} ${t("common.responses")}`}
|
{`${questionSummary.responseCount} ${t("common.responses")}`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{additionalInfo}
|
{additionalInfo}
|
||||||
{!elementSummary.element.required && (
|
{!questionSummary.question.required && (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
{t("environments.surveys.edit.optional")}
|
{t("environments.surveys.edit.optional")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<IdBadge id={elementSummary.element.id} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<IdBadge id={questionSummary.question.id} label={t("common.question_id")} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,28 +1,28 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TSurvey, TSurveyElementSummaryRanking } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestionSummaryRanking } from "@formbricks/types/surveys/types";
|
||||||
import { getChoiceIdByValue } from "@/lib/response/utils";
|
import { getChoiceIdByValue } from "@/lib/response/utils";
|
||||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||||
import { convertFloatToNDecimal } from "../lib/utils";
|
import { convertFloatToNDecimal } from "../lib/utils";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
|
|
||||||
interface RankingSummaryProps {
|
interface RankingSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryRanking;
|
questionSummary: TSurveyQuestionSummaryRanking;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RankingSummary = ({ elementSummary, survey }: RankingSummaryProps) => {
|
export const RankingSummary = ({ questionSummary, survey }: RankingSummaryProps) => {
|
||||||
// sort by count and transform to array
|
// sort by count and transform to array
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const results = Object.values(elementSummary.choices).sort((a, b) => {
|
const results = Object.values(questionSummary.choices).sort((a, b) => {
|
||||||
return a.avgRanking - b.avgRanking; // Sort by count
|
return a.avgRanking - b.avgRanking; // Sort by count
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||||
{results.map((result, resultsIdx) => {
|
{results.map((result, resultsIdx) => {
|
||||||
const choiceId = getChoiceIdByValue(result.value, elementSummary.element);
|
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
|
||||||
return (
|
return (
|
||||||
<div key={result.value} className="group cursor-pointer">
|
<div key={result.value} className="group cursor-pointer">
|
||||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { TSurveyRatingQuestion } from "@formbricks/types/surveys/types";
|
|
||||||
import { RatingResponse } from "@/modules/ui/components/rating-response";
|
|
||||||
|
|
||||||
interface RatingScaleLegendProps {
|
|
||||||
scale: TSurveyRatingQuestion["scale"];
|
|
||||||
range: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RatingScaleLegend = ({ scale, range }: RatingScaleLegendProps) => {
|
|
||||||
return (
|
|
||||||
<div className="mt-3 flex w-full items-start justify-between px-1">
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
<RatingResponse scale={scale} answer={1} range={range} addColors={false} variant="scale" />
|
|
||||||
<span className="text-xs text-slate-500">1</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
<span className="text-xs text-slate-500">{range}</span>
|
|
||||||
<RatingResponse scale={scale} answer={range} range={range} addColors={false} variant="scale" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,222 +1,101 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BarChart, BarChartHorizontal, CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
|
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type TI18nString } from "@formbricks/types/i18n";
|
import {
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
TI18nString,
|
||||||
import { TSurvey, TSurveyElementSummaryRating } from "@formbricks/types/surveys/types";
|
TSurvey,
|
||||||
|
TSurveyQuestionId,
|
||||||
|
TSurveyQuestionSummaryRating,
|
||||||
|
TSurveyQuestionTypeEnum,
|
||||||
|
} from "@formbricks/types/surveys/types";
|
||||||
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
|
||||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||||
import { RatingResponse } from "@/modules/ui/components/rating-response";
|
import { RatingResponse } from "@/modules/ui/components/rating-response";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
import { TooltipProvider } from "@/modules/ui/components/tooltip";
|
|
||||||
import { ClickableBarSegment } from "./ClickableBarSegment";
|
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
|
||||||
import { RatingScaleLegend } from "./RatingScaleLegend";
|
|
||||||
import { SatisfactionIndicator } from "./SatisfactionIndicator";
|
|
||||||
|
|
||||||
interface RatingSummaryProps {
|
interface RatingSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryRating;
|
questionSummary: TSurveyQuestionSummaryRating;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
setFilter: (
|
setFilter: (
|
||||||
elementId: string,
|
questionId: TSurveyQuestionId,
|
||||||
label: TI18nString,
|
label: TI18nString,
|
||||||
elementType: TSurveyElementTypeEnum,
|
questionType: TSurveyQuestionTypeEnum,
|
||||||
filterValue: string,
|
filterValue: string,
|
||||||
filterComboBoxValue?: string | string[]
|
filterComboBoxValue?: string | string[]
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSummaryProps) => {
|
export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSummaryProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
|
|
||||||
|
|
||||||
const getIconBasedOnScale = useMemo(() => {
|
const getIconBasedOnScale = useMemo(() => {
|
||||||
const scale = elementSummary.element.scale;
|
const scale = questionSummary.question.scale;
|
||||||
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
|
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
|
||||||
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
|
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
|
||||||
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
|
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
|
||||||
}, [elementSummary]);
|
}, [questionSummary]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader
|
<QuestionSummaryHeader
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
additionalInfo={
|
additionalInfo={
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2 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 className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
|
||||||
<SatisfactionIndicator percentage={elementSummary.csat.satisfiedPercentage} />
|
|
||||||
<div>
|
|
||||||
CSAT: {elementSummary.csat.satisfiedPercentage}% {t("environments.surveys.summary.satisfied")}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
|
{questionSummary.choices.map((result) => (
|
||||||
<div className="flex justify-end px-4 md:px-6">
|
<button
|
||||||
<TabsList>
|
className="w-full cursor-pointer hover:opacity-80"
|
||||||
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
|
key={result.rating}
|
||||||
{t("environments.surveys.summary.aggregated")}
|
onClick={() =>
|
||||||
</TabsTrigger>
|
setFilter(
|
||||||
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
|
questionSummary.question.id,
|
||||||
{t("environments.surveys.summary.individual")}
|
questionSummary.question.headline,
|
||||||
</TabsTrigger>
|
questionSummary.question.type,
|
||||||
</TabsList>
|
t("environments.surveys.summary.is_equal_to"),
|
||||||
</div>
|
result.rating.toString()
|
||||||
|
)
|
||||||
<TabsContent value="aggregated" className="mt-4">
|
}>
|
||||||
<div className="px-4 pb-6 pt-4 md:px-6">
|
<div className="text flex justify-between px-2 pb-2">
|
||||||
{elementSummary.responseCount === 0 ? (
|
<div className="mr-8 flex items-center space-x-1">
|
||||||
<>
|
<div className="font-semibold text-slate-700">
|
||||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
<RatingResponse
|
||||||
<RatingScaleLegend
|
scale={questionSummary.question.scale}
|
||||||
scale={elementSummary.element.scale}
|
answer={result.rating}
|
||||||
range={elementSummary.element.range}
|
range={questionSummary.question.range}
|
||||||
/>
|
addColors={questionSummary.question.isColorCodingEnabled}
|
||||||
</>
|
/>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<TooltipProvider delayDuration={200}>
|
|
||||||
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
|
|
||||||
{elementSummary.choices.map((result, index) => {
|
|
||||||
if (result.percentage === 0) return null;
|
|
||||||
|
|
||||||
const range = elementSummary.element.range;
|
|
||||||
const opacity = 0.3 + (result.rating / range) * 0.8;
|
|
||||||
const isFirst = index === 0;
|
|
||||||
const isLast = index === elementSummary.choices.length - 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ClickableBarSegment
|
|
||||||
key={result.rating}
|
|
||||||
className="relative h-full cursor-pointer transition-opacity hover:brightness-110"
|
|
||||||
style={{
|
|
||||||
width: `${result.percentage}%`,
|
|
||||||
borderRight: isLast ? "none" : "1px solid rgb(226, 232, 240)",
|
|
||||||
}}
|
|
||||||
onClick={() =>
|
|
||||||
setFilter(
|
|
||||||
elementSummary.element.id,
|
|
||||||
elementSummary.element.headline,
|
|
||||||
elementSummary.element.type,
|
|
||||||
t("environments.surveys.summary.is_equal_to"),
|
|
||||||
result.rating.toString()
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
<div
|
|
||||||
className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
|
|
||||||
style={{ opacity }}
|
|
||||||
/>
|
|
||||||
</ClickableBarSegment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50">
|
|
||||||
{elementSummary.choices.map((result, index) => {
|
|
||||||
if (result.percentage === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={result.rating}
|
|
||||||
className="flex flex-col items-center justify-center py-2"
|
|
||||||
style={{
|
|
||||||
width: `${result.percentage}%`,
|
|
||||||
borderRight:
|
|
||||||
index < elementSummary.choices.length - 1
|
|
||||||
? "1px solid rgb(226, 232, 240)"
|
|
||||||
: "none",
|
|
||||||
}}>
|
|
||||||
<div className="mb-1 flex items-center justify-center">
|
|
||||||
<RatingResponse
|
|
||||||
scale={elementSummary.element.scale}
|
|
||||||
answer={result.rating}
|
|
||||||
range={elementSummary.element.range}
|
|
||||||
addColors={false}
|
|
||||||
variant="aggregated"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs font-medium text-slate-600">
|
|
||||||
{convertFloatToNDecimal(result.percentage, 1)}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
<RatingScaleLegend
|
<div>
|
||||||
scale={elementSummary.element.scale}
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
range={elementSummary.element.range}
|
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||||
/>
|
</p>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="individual" className="mt-4">
|
|
||||||
<div className="px-4 pb-6 pt-4 md:px-6">
|
|
||||||
<div className="space-y-5 text-sm md:text-base">
|
|
||||||
{elementSummary.choices.map((result) => (
|
|
||||||
<div key={result.rating}>
|
|
||||||
<button
|
|
||||||
className="w-full cursor-pointer hover:opacity-80"
|
|
||||||
onClick={() =>
|
|
||||||
setFilter(
|
|
||||||
elementSummary.element.id,
|
|
||||||
elementSummary.element.headline,
|
|
||||||
elementSummary.element.type,
|
|
||||||
t("environments.surveys.summary.is_equal_to"),
|
|
||||||
result.rating.toString()
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
<div className="text flex justify-between px-2 pb-2">
|
|
||||||
<div className="mr-8 flex items-center space-x-1">
|
|
||||||
<div className="font-semibold text-slate-700">
|
|
||||||
<RatingResponse
|
|
||||||
scale={elementSummary.element.scale}
|
|
||||||
answer={result.rating}
|
|
||||||
range={elementSummary.element.range}
|
|
||||||
addColors={elementSummary.element.isColorCodingEnabled}
|
|
||||||
variant="individual"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
|
||||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
|
||||||
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
|
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||||
</TabsContent>
|
</button>
|
||||||
</Tabs>
|
))}
|
||||||
{elementSummary.dismissed && elementSummary.dismissed.count > 0 && (
|
</div>
|
||||||
|
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
|
||||||
<div className="rounded-b-lg border-t bg-white px-6 py-4">
|
<div className="rounded-b-lg border-t bg-white px-6 py-4">
|
||||||
<div key="dismissed">
|
<div key="dismissed">
|
||||||
<div className="text flex justify-between px-2">
|
<div className="text flex justify-between px-2">
|
||||||
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
|
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{elementSummary.dismissed.count}{" "}
|
{questionSummary.dismissed.count}{" "}
|
||||||
{elementSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
|
{questionSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
interface SatisfactionIndicatorProps {
|
|
||||||
percentage: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SatisfactionIndicator = ({ percentage }: SatisfactionIndicatorProps) => {
|
|
||||||
let colorClass = "";
|
|
||||||
|
|
||||||
if (percentage > 80) {
|
|
||||||
colorClass = "bg-emerald-500";
|
|
||||||
} else if (percentage >= 55) {
|
|
||||||
colorClass = "bg-orange-500";
|
|
||||||
} else {
|
|
||||||
colorClass = "bg-rose-500";
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className={`h-3 w-3 rounded-full ${colorClass}`} />;
|
|
||||||
};
|
|
||||||
@@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
import { TimerIcon } from "lucide-react";
|
import { TimerIcon } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurvey, TSurveyQuestionType, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||||
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||||
import { getElementIcon } from "@/modules/survey/lib/elements";
|
import { getQuestionIcon } from "@/modules/survey/lib/questions";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||||
|
|
||||||
interface SummaryDropOffsProps {
|
interface SummaryDropOffsProps {
|
||||||
@@ -16,8 +15,8 @@ interface SummaryDropOffsProps {
|
|||||||
|
|
||||||
export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const getIcon = (elementType: TSurveyElementTypeEnum) => {
|
const getIcon = (questionType: TSurveyQuestionType) => {
|
||||||
const Icon = getElementIcon(elementType, t);
|
const Icon = getQuestionIcon(questionType, t);
|
||||||
return <Icon className="mt-[3px] h-5 w-5 shrink-0 text-slate-600" />;
|
return <Icon className="mt-[3px] h-5 w-5 shrink-0 text-slate-600" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,10 +44,10 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
|||||||
</div>
|
</div>
|
||||||
{dropOff.map((quesDropOff) => (
|
{dropOff.map((quesDropOff) => (
|
||||||
<div
|
<div
|
||||||
key={quesDropOff.elementId}
|
key={quesDropOff.questionId}
|
||||||
className="grid grid-cols-6 items-start border-b border-slate-100 text-xs text-slate-800 md:text-sm">
|
className="grid grid-cols-6 items-start border-b border-slate-100 text-xs text-slate-800 md:text-sm">
|
||||||
<div className="col-span-3 flex gap-3 px-4 py-2 md:px-6">
|
<div className="col-span-3 flex gap-3 px-4 py-2 md:px-6">
|
||||||
{getIcon(quesDropOff.elementType)}
|
{getIcon(quesDropOff.questionType)}
|
||||||
<p>
|
<p>
|
||||||
{formatTextWithSlashes(
|
{formatTextWithSlashes(
|
||||||
recallToHeadline(
|
recallToHeadline(
|
||||||
|
|||||||
@@ -3,25 +3,23 @@
|
|||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TI18nString } from "@formbricks/types/i18n";
|
import { TI18nString, TSurveyQuestionId, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||||
import { TSurveySummary } from "@formbricks/types/surveys/types";
|
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { EmptyAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
|
|
||||||
import {
|
import {
|
||||||
SelectedFilterValue,
|
SelectedFilterValue,
|
||||||
useResponseFilter,
|
useResponseFilter,
|
||||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||||
|
import { EmptyAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
|
||||||
import { CTASummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary";
|
import { CTASummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary";
|
||||||
import { CalSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary";
|
import { CalSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary";
|
||||||
import { ConsentSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary";
|
import { ConsentSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary";
|
||||||
import { ContactInfoSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ContactInfoSummary";
|
import { ContactInfoSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ContactInfoSummary";
|
||||||
import { DateElementSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateElementSummary";
|
import { DateQuestionSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary";
|
||||||
import { FileUploadSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary";
|
import { FileUploadSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary";
|
||||||
import { HiddenFieldsSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary";
|
import { HiddenFieldsSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary";
|
||||||
import { MatrixElementSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixElementSummary";
|
import { MatrixQuestionSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary";
|
||||||
import { MultipleChoiceSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary";
|
import { MultipleChoiceSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary";
|
||||||
import { NPSSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary";
|
import { NPSSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary";
|
||||||
import { OpenTextSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary";
|
import { OpenTextSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary";
|
||||||
@@ -29,9 +27,9 @@ import { PictureChoiceSummary } from "@/app/(app)/environments/[environmentId]/s
|
|||||||
import { RankingSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary";
|
import { RankingSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary";
|
||||||
import { RatingSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary";
|
import { RatingSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary";
|
||||||
import { constructToastMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
import { constructToastMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementsComboBox";
|
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||||
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
|
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
|
||||||
import { AddressSummary } from "./AddressSummary";
|
import { AddressSummary } from "./AddressSummary";
|
||||||
|
|
||||||
@@ -47,29 +45,29 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
|||||||
const { setSelectedFilter, selectedFilter } = useResponseFilter();
|
const { setSelectedFilter, selectedFilter } = useResponseFilter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const setFilter = (
|
const setFilter = (
|
||||||
elementId: string,
|
questionId: TSurveyQuestionId,
|
||||||
label: TI18nString,
|
label: TI18nString,
|
||||||
elementType: TSurveyElementTypeEnum,
|
questionType: TSurveyQuestionTypeEnum,
|
||||||
filterValue: string,
|
filterValue: string,
|
||||||
filterComboBoxValue?: string | string[]
|
filterComboBoxValue?: string | string[]
|
||||||
) => {
|
) => {
|
||||||
const filterObject: SelectedFilterValue = { ...selectedFilter };
|
const filterObject: SelectedFilterValue = { ...selectedFilter };
|
||||||
const value = {
|
const value = {
|
||||||
id: elementId,
|
id: questionId,
|
||||||
label: getTextContent(getLocalizedValue(label, "default")),
|
label: getLocalizedValue(label, "default"),
|
||||||
elementType,
|
questionType: questionType,
|
||||||
type: OptionsType.ELEMENTS,
|
type: OptionsType.QUESTIONS,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find the index of the existing filter with the same elementId
|
// Find the index of the existing filter with the same questionId
|
||||||
const existingFilterIndex = filterObject.filter.findIndex(
|
const existingFilterIndex = filterObject.filter.findIndex(
|
||||||
(filter) => filter.elementType.id === elementId
|
(filter) => filter.questionType.id === questionId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingFilterIndex !== -1) {
|
if (existingFilterIndex !== -1) {
|
||||||
// Replace the existing filter
|
// Replace the existing filter
|
||||||
filterObject.filter[existingFilterIndex] = {
|
filterObject.filter[existingFilterIndex] = {
|
||||||
elementType: value,
|
questionType: value,
|
||||||
filterType: {
|
filterType: {
|
||||||
filterComboBoxValue: filterComboBoxValue,
|
filterComboBoxValue: filterComboBoxValue,
|
||||||
filterValue: filterValue,
|
filterValue: filterValue,
|
||||||
@@ -79,14 +77,14 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
|||||||
} else {
|
} else {
|
||||||
// Add new filter
|
// Add new filter
|
||||||
filterObject.filter.push({
|
filterObject.filter.push({
|
||||||
elementType: value,
|
questionType: value,
|
||||||
filterType: {
|
filterType: {
|
||||||
filterComboBoxValue: filterComboBoxValue,
|
filterComboBoxValue: filterComboBoxValue,
|
||||||
filterValue: filterValue,
|
filterValue: filterValue,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast.success(
|
toast.success(
|
||||||
constructToastMessage(elementType, filterValue, survey, elementId, t, filterComboBoxValue) ??
|
constructToastMessage(questionType, filterValue, survey, questionId, t, filterComboBoxValue) ??
|
||||||
t("environments.surveys.summary.filter_added_successfully"),
|
t("environments.surveys.summary.filter_added_successfully"),
|
||||||
{ duration: 5000 }
|
{ duration: 5000 }
|
||||||
);
|
);
|
||||||
@@ -105,14 +103,19 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
|||||||
) : summary.length === 0 ? (
|
) : summary.length === 0 ? (
|
||||||
<SkeletonLoader type="summary" />
|
<SkeletonLoader type="summary" />
|
||||||
) : responseCount === 0 ? (
|
) : responseCount === 0 ? (
|
||||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} />
|
<EmptySpaceFiller
|
||||||
|
type="response"
|
||||||
|
environment={environment}
|
||||||
|
noWidgetRequired={survey.type === "link"}
|
||||||
|
emptyMessage={t("environments.surveys.summary.no_responses_found")}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
summary.map((elementSummary) => {
|
summary.map((questionSummary) => {
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.OpenText) {
|
if (questionSummary.type === TSurveyQuestionTypeEnum.OpenText) {
|
||||||
return (
|
return (
|
||||||
<OpenTextSummary
|
<OpenTextSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
@@ -120,13 +123,13 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
elementSummary.type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
|
questionSummary.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||||
elementSummary.type === TSurveyElementTypeEnum.MultipleChoiceMulti
|
questionSummary.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<MultipleChoiceSummary
|
<MultipleChoiceSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
surveyType={survey.type}
|
surveyType={survey.type}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
@@ -134,128 +137,132 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.NPS) {
|
if (questionSummary.type === TSurveyQuestionTypeEnum.NPS) {
|
||||||
return (
|
return (
|
||||||
<NPSSummary
|
<NPSSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
setFilter={setFilter}
|
setFilter={setFilter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.CTA) {
|
if (questionSummary.type === TSurveyQuestionTypeEnum.CTA) {
|
||||||
return (
|
return (
|
||||||
<CTASummary key={elementSummary.element.id} elementSummary={elementSummary} survey={survey} />
|
<CTASummary
|
||||||
|
key={questionSummary.question.id}
|
||||||
|
questionSummary={questionSummary}
|
||||||
|
survey={survey}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.Rating) {
|
if (questionSummary.type === TSurveyQuestionTypeEnum.Rating) {
|
||||||
return (
|
return (
|
||||||
<RatingSummary
|
<RatingSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
setFilter={setFilter}
|
setFilter={setFilter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.Consent) {
|
if (questionSummary.type === TSurveyQuestionTypeEnum.Consent) {
|
||||||
return (
|
return (
|
||||||
<ConsentSummary
|
<ConsentSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
setFilter={setFilter}
|
setFilter={setFilter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.PictureSelection) {
|
if (questionSummary.type === TSurveyQuestionTypeEnum.PictureSelection) {
|
||||||
return (
|
return (
|
||||||
<PictureChoiceSummary
|
<PictureChoiceSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
setFilter={setFilter}
|
setFilter={setFilter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.Date) {
|
if (questionSummary.type === TSurveyQuestionTypeEnum.Date) {
|
||||||
return (
|
return (
|
||||||
<DateElementSummary
|
<DateQuestionSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.FileUpload) {
|
if (questionSummary.type === TSurveyQuestionTypeEnum.FileUpload) {
|
||||||
return (
|
return (
|
||||||
<FileUploadSummary
|
<FileUploadSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.Cal) {
|
if (questionSummary.type === TSurveyQuestionTypeEnum.Cal) {
|
||||||
return (
|
return (
|
||||||
<CalSummary
|
<CalSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.Matrix) {
|
if (questionSummary.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||||
return (
|
return (
|
||||||
<MatrixElementSummary
|
<MatrixQuestionSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
setFilter={setFilter}
|
setFilter={setFilter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.Address) {
|
if (questionSummary.type === TSurveyQuestionTypeEnum.Address) {
|
||||||
return (
|
return (
|
||||||
<AddressSummary
|
<AddressSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.Ranking) {
|
if (questionSummary.type === TSurveyQuestionTypeEnum.Ranking) {
|
||||||
return (
|
return (
|
||||||
<RankingSummary
|
<RankingSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === "hiddenField") {
|
if (questionSummary.type === "hiddenField") {
|
||||||
return (
|
return (
|
||||||
<HiddenFieldsSummary
|
<HiddenFieldsSummary
|
||||||
key={elementSummary.id}
|
key={questionSummary.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
environment={environment}
|
environment={environment}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.ContactInfo) {
|
if (questionSummary.type === TSurveyQuestionTypeEnum.ContactInfo) {
|
||||||
return (
|
return (
|
||||||
<ContactInfoSummary
|
<ContactInfoSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { cn } from "@/modules/ui/lib/utils";
|
|||||||
|
|
||||||
interface SummaryMetadataProps {
|
interface SummaryMetadataProps {
|
||||||
surveySummary: TSurveySummary["meta"];
|
surveySummary: TSurveySummary["meta"];
|
||||||
quotasCount: number;
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
tab: "dropOffs" | "quotas" | undefined;
|
tab: "dropOffs" | "quotas" | undefined;
|
||||||
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
|
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
|
||||||
@@ -32,7 +31,6 @@ const formatTime = (ttc) => {
|
|||||||
|
|
||||||
export const SummaryMetadata = ({
|
export const SummaryMetadata = ({
|
||||||
surveySummary,
|
surveySummary,
|
||||||
quotasCount,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
tab,
|
tab,
|
||||||
setTab,
|
setTab,
|
||||||
@@ -63,7 +61,7 @@ export const SummaryMetadata = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
|
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
|
||||||
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6"
|
isQuotasAllowed && "2xl:grid-cols-6"
|
||||||
)}>
|
)}>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t("environments.surveys.summary.impressions")}
|
label={t("environments.surveys.summary.impressions")}
|
||||||
@@ -107,7 +105,7 @@ export const SummaryMetadata = ({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isQuotasAllowed && quotasCount > 0 && (
|
{isQuotasAllowed && (
|
||||||
<InteractiveCard
|
<InteractiveCard
|
||||||
key="quotas"
|
key="quotas"
|
||||||
tab="quotas"
|
tab="quotas"
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
|
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||||
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
|
||||||
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
|
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
|
||||||
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
|
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
|
||||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||||
@@ -115,7 +115,6 @@ export const SummaryPage = ({
|
|||||||
<>
|
<>
|
||||||
<SummaryMetadata
|
<SummaryMetadata
|
||||||
surveySummary={surveySummary.meta}
|
surveySummary={surveySummary.meta}
|
||||||
quotasCount={surveySummary.quotas?.length ?? 0}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
tab={tab}
|
tab={tab}
|
||||||
setTab={setTab}
|
setTab={setTab}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface SurveyAnalysisCTAProps {
|
|||||||
user: TUser;
|
user: TUser;
|
||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
responseCount: number;
|
responseCount: number;
|
||||||
|
displayCount: number;
|
||||||
segments: TSegment[];
|
segments: TSegment[];
|
||||||
isContactsEnabled: boolean;
|
isContactsEnabled: boolean;
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
@@ -47,6 +48,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
user,
|
user,
|
||||||
publicDomain,
|
publicDomain,
|
||||||
responseCount,
|
responseCount,
|
||||||
|
displayCount,
|
||||||
segments,
|
segments,
|
||||||
isContactsEnabled,
|
isContactsEnabled,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
@@ -94,6 +96,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
const duplicateSurveyAndRoute = async (surveyId: string) => {
|
const duplicateSurveyAndRoute = async (surveyId: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
|
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
|
||||||
|
environmentId: environment.id,
|
||||||
surveyId: surveyId,
|
surveyId: surveyId,
|
||||||
targetEnvironmentId: environment.id,
|
targetEnvironmentId: environment.id,
|
||||||
});
|
});
|
||||||
@@ -167,7 +170,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
icon: ListRestart,
|
icon: ListRestart,
|
||||||
tooltip: t("environments.surveys.summary.reset_survey"),
|
tooltip: t("environments.surveys.summary.reset_survey"),
|
||||||
onClick: () => setIsResetModalOpen(true),
|
onClick: () => setIsResetModalOpen(true),
|
||||||
isVisible: !isReadOnly,
|
isVisible: !isReadOnly && (responseCount > 0 || displayCount > 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: SquarePenIcon,
|
icon: SquarePenIcon,
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { useForm } from "react-hook-form";
|
|||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||||
import { TI18nString } from "@formbricks/types/i18n";
|
import { TI18nString, TSurvey, TSurveyMetadata } from "@formbricks/types/surveys/types";
|
||||||
import { TSurvey, TSurveyMetadata } from "@formbricks/types/surveys/types";
|
|
||||||
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
||||||
import { createI18nString, extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
|
import { createI18nString, extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
|
||||||
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,24 +14,23 @@ import {
|
|||||||
TResponseVariables,
|
TResponseVariables,
|
||||||
ZResponseFilterCriteria,
|
ZResponseFilterCriteria,
|
||||||
} from "@formbricks/types/responses";
|
} from "@formbricks/types/responses";
|
||||||
import {
|
|
||||||
TSurveyElement,
|
|
||||||
TSurveyElementChoice,
|
|
||||||
TSurveyElementTypeEnum,
|
|
||||||
} from "@formbricks/types/surveys/elements";
|
|
||||||
import {
|
import {
|
||||||
TSurvey,
|
TSurvey,
|
||||||
TSurveyElementSummaryAddress,
|
TSurveyContactInfoQuestion,
|
||||||
TSurveyElementSummaryContactInfo,
|
|
||||||
TSurveyElementSummaryDate,
|
|
||||||
TSurveyElementSummaryFileUpload,
|
|
||||||
TSurveyElementSummaryHiddenFields,
|
|
||||||
TSurveyElementSummaryMultipleChoice,
|
|
||||||
TSurveyElementSummaryOpenText,
|
|
||||||
TSurveyElementSummaryPictureSelection,
|
|
||||||
TSurveyElementSummaryRanking,
|
|
||||||
TSurveyElementSummaryRating,
|
|
||||||
TSurveyLanguage,
|
TSurveyLanguage,
|
||||||
|
TSurveyMultipleChoiceQuestion,
|
||||||
|
TSurveyQuestion,
|
||||||
|
TSurveyQuestionId,
|
||||||
|
TSurveyQuestionSummaryAddress,
|
||||||
|
TSurveyQuestionSummaryDate,
|
||||||
|
TSurveyQuestionSummaryFileUpload,
|
||||||
|
TSurveyQuestionSummaryHiddenFields,
|
||||||
|
TSurveyQuestionSummaryMultipleChoice,
|
||||||
|
TSurveyQuestionSummaryOpenText,
|
||||||
|
TSurveyQuestionSummaryPictureSelection,
|
||||||
|
TSurveyQuestionSummaryRanking,
|
||||||
|
TSurveyQuestionSummaryRating,
|
||||||
|
TSurveyQuestionTypeEnum,
|
||||||
TSurveySummary,
|
TSurveySummary,
|
||||||
} from "@formbricks/types/surveys/types";
|
} from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
@@ -41,7 +40,6 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
|||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { buildWhereClause } from "@/lib/response/utils";
|
import { buildWhereClause } from "@/lib/response/utils";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { findElementLocation, getElementsFromBlocks } from "@/lib/survey/utils";
|
|
||||||
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { convertFloatTo2Decimal } from "./utils";
|
import { convertFloatTo2Decimal } from "./utils";
|
||||||
@@ -97,44 +95,39 @@ export const getSurveySummaryMeta = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const evaluateLogicAndGetNextElementId = (
|
const evaluateLogicAndGetNextQuestionId = (
|
||||||
localSurvey: TSurvey,
|
localSurvey: TSurvey,
|
||||||
elements: TSurveyElement[],
|
|
||||||
data: TResponseData,
|
data: TResponseData,
|
||||||
localVariables: TResponseVariables,
|
localVariables: TResponseVariables,
|
||||||
currentElementIndex: number,
|
currentQuestionIndex: number,
|
||||||
currElementTemp: TSurveyElement,
|
currQuesTemp: TSurveyQuestion,
|
||||||
selectedLanguage: string | null
|
selectedLanguage: string | null
|
||||||
): {
|
): {
|
||||||
nextElementId: string | undefined;
|
nextQuestionId: TSurveyQuestionId | undefined;
|
||||||
updatedSurvey: TSurvey;
|
updatedSurvey: TSurvey;
|
||||||
updatedVariables: TResponseVariables;
|
updatedVariables: TResponseVariables;
|
||||||
} => {
|
} => {
|
||||||
|
const questions = localSurvey.questions;
|
||||||
|
|
||||||
let updatedSurvey = { ...localSurvey };
|
let updatedSurvey = { ...localSurvey };
|
||||||
let updatedVariables = { ...localVariables };
|
let updatedVariables = { ...localVariables };
|
||||||
|
|
||||||
let firstJumpTarget: string | undefined;
|
let firstJumpTarget: string | undefined;
|
||||||
|
|
||||||
const { block: currentBlock } = findElementLocation(localSurvey, currElementTemp.id);
|
if (currQuesTemp.logic && currQuesTemp.logic.length > 0) {
|
||||||
|
for (const logic of currQuesTemp.logic) {
|
||||||
if (currentBlock?.logic && currentBlock.logic.length > 0) {
|
|
||||||
for (const logic of currentBlock.logic) {
|
|
||||||
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
|
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
|
||||||
const { jumpTarget, requiredElementIds, calculations } = performActions(
|
const { jumpTarget, requiredQuestionIds, calculations } = performActions(
|
||||||
updatedSurvey,
|
updatedSurvey,
|
||||||
logic.actions,
|
logic.actions,
|
||||||
data,
|
data,
|
||||||
updatedVariables
|
updatedVariables
|
||||||
);
|
);
|
||||||
|
|
||||||
if (requiredElementIds.length > 0) {
|
if (requiredQuestionIds.length > 0) {
|
||||||
// Update blocks to mark elements as required
|
updatedSurvey.questions = updatedSurvey.questions.map((q) =>
|
||||||
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
|
requiredQuestionIds.includes(q.id) ? { ...q, required: true } : q
|
||||||
...block,
|
);
|
||||||
elements: block.elements.map((e) =>
|
|
||||||
requiredElementIds.includes(e.id) ? { ...e, required: true } : e
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
updatedVariables = { ...updatedVariables, ...calculations };
|
updatedVariables = { ...updatedVariables, ...calculations };
|
||||||
|
|
||||||
@@ -146,33 +139,32 @@ const evaluateLogicAndGetNextElementId = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If no jump target was set, check for a fallback logic
|
// If no jump target was set, check for a fallback logic
|
||||||
if (!firstJumpTarget && currentBlock?.logicFallback) {
|
if (!firstJumpTarget && currQuesTemp.logicFallback) {
|
||||||
firstJumpTarget = currentBlock.logicFallback;
|
firstJumpTarget = currQuesTemp.logicFallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the first jump target if found, otherwise go to the next element
|
// Return the first jump target if found, otherwise go to the next question
|
||||||
const nextElementId = firstJumpTarget || elements[currentElementIndex + 1]?.id || undefined;
|
const nextQuestionId = firstJumpTarget || questions[currentQuestionIndex + 1]?.id || undefined;
|
||||||
|
|
||||||
return { nextElementId, updatedSurvey, updatedVariables };
|
return { nextQuestionId, updatedSurvey, updatedVariables };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSurveySummaryDropOff = (
|
export const getSurveySummaryDropOff = (
|
||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
elements: TSurveyElement[],
|
|
||||||
responses: TSurveySummaryResponse[],
|
responses: TSurveySummaryResponse[],
|
||||||
displayCount: number
|
displayCount: number
|
||||||
): TSurveySummary["dropOff"] => {
|
): TSurveySummary["dropOff"] => {
|
||||||
const initialTtc = elements.reduce((acc: Record<string, number>, element) => {
|
const initialTtc = survey.questions.reduce((acc: Record<string, number>, question) => {
|
||||||
acc[element.id] = 0;
|
acc[question.id] = 0;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
let totalTtc = { ...initialTtc };
|
let totalTtc = { ...initialTtc };
|
||||||
let responseCounts = { ...initialTtc };
|
let responseCounts = { ...initialTtc };
|
||||||
|
|
||||||
let dropOffArr = new Array(elements.length).fill(0) as number[];
|
let dropOffArr = new Array(survey.questions.length).fill(0) as number[];
|
||||||
let impressionsArr = new Array(elements.length).fill(0) as number[];
|
let impressionsArr = new Array(survey.questions.length).fill(0) as number[];
|
||||||
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
|
let dropOffPercentageArr = new Array(survey.questions.length).fill(0) as number[];
|
||||||
|
|
||||||
const surveyVariablesData = survey.variables?.reduce(
|
const surveyVariablesData = survey.variables?.reduce(
|
||||||
(acc, variable) => {
|
(acc, variable) => {
|
||||||
@@ -184,10 +176,10 @@ export const getSurveySummaryDropOff = (
|
|||||||
|
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
// Calculate total time-to-completion
|
// Calculate total time-to-completion
|
||||||
Object.keys(totalTtc).forEach((elementId) => {
|
Object.keys(totalTtc).forEach((questionId) => {
|
||||||
if (response.ttc && response.ttc[elementId]) {
|
if (response.ttc && response.ttc[questionId]) {
|
||||||
totalTtc[elementId] += response.ttc[elementId];
|
totalTtc[questionId] += response.ttc[questionId];
|
||||||
responseCounts[elementId]++;
|
responseCounts[questionId]++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,11 +191,11 @@ export const getSurveySummaryDropOff = (
|
|||||||
|
|
||||||
let currQuesIdx = 0;
|
let currQuesIdx = 0;
|
||||||
|
|
||||||
while (currQuesIdx < elements.length) {
|
while (currQuesIdx < localSurvey.questions.length) {
|
||||||
const currQues = elements[currQuesIdx];
|
const currQues = localSurvey.questions[currQuesIdx];
|
||||||
if (!currQues) break;
|
if (!currQues) break;
|
||||||
|
|
||||||
// element is not answered and required
|
// question is not answered and required
|
||||||
if (response.data[currQues.id] === undefined && currQues.required) {
|
if (response.data[currQues.id] === undefined && currQues.required) {
|
||||||
dropOffArr[currQuesIdx]++;
|
dropOffArr[currQuesIdx]++;
|
||||||
impressionsArr[currQuesIdx]++;
|
impressionsArr[currQuesIdx]++;
|
||||||
@@ -212,9 +204,8 @@ export const getSurveySummaryDropOff = (
|
|||||||
|
|
||||||
impressionsArr[currQuesIdx]++;
|
impressionsArr[currQuesIdx]++;
|
||||||
|
|
||||||
const { nextElementId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextElementId(
|
const { nextQuestionId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextQuestionId(
|
||||||
localSurvey,
|
localSurvey,
|
||||||
elements,
|
|
||||||
localResponseData,
|
localResponseData,
|
||||||
localVariables,
|
localVariables,
|
||||||
currQuesIdx,
|
currQuesIdx,
|
||||||
@@ -225,9 +216,9 @@ export const getSurveySummaryDropOff = (
|
|||||||
localSurvey = updatedSurvey;
|
localSurvey = updatedSurvey;
|
||||||
localVariables = updatedVariables;
|
localVariables = updatedVariables;
|
||||||
|
|
||||||
if (nextElementId) {
|
if (nextQuestionId) {
|
||||||
const nextQuesIdx = elements.findIndex((q) => q.id === nextElementId);
|
const nextQuesIdx = survey.questions.findIndex((q) => q.id === nextQuestionId);
|
||||||
if (!response.data[nextElementId] && !response.finished) {
|
if (!response.data[nextQuestionId] && !response.finished) {
|
||||||
dropOffArr[nextQuesIdx]++;
|
dropOffArr[nextQuesIdx]++;
|
||||||
impressionsArr[nextQuesIdx]++;
|
impressionsArr[nextQuesIdx]++;
|
||||||
break;
|
break;
|
||||||
@@ -239,9 +230,10 @@ export const getSurveySummaryDropOff = (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate the average time for each element
|
// Calculate the average time for each question
|
||||||
Object.keys(totalTtc).forEach((elementId) => {
|
Object.keys(totalTtc).forEach((questionId) => {
|
||||||
totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0;
|
totalTtc[questionId] =
|
||||||
|
responseCounts[questionId] > 0 ? totalTtc[questionId] / responseCounts[questionId] : 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!survey.welcomeCard.enabled) {
|
if (!survey.welcomeCard.enabled) {
|
||||||
@@ -258,18 +250,18 @@ export const getSurveySummaryDropOff = (
|
|||||||
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
|
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 1; i < elements.length; i++) {
|
for (let i = 1; i < survey.questions.length; i++) {
|
||||||
if (impressionsArr[i] !== 0) {
|
if (impressionsArr[i] !== 0) {
|
||||||
dropOffPercentageArr[i] = (dropOffArr[i] / impressionsArr[i]) * 100;
|
dropOffPercentageArr[i] = (dropOffArr[i] / impressionsArr[i]) * 100;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dropOff = elements.map((element, index) => {
|
const dropOff = survey.questions.map((question, index) => {
|
||||||
return {
|
return {
|
||||||
elementId: element.id,
|
questionId: question.id,
|
||||||
elementType: element.type,
|
questionType: question.type,
|
||||||
headline: getTextContent(getLocalizedValue(element.headline, "default")),
|
headline: getTextContent(getLocalizedValue(question.headline, "default")),
|
||||||
ttc: convertFloatTo2Decimal(totalTtc[element.id]) || 0,
|
ttc: convertFloatTo2Decimal(totalTtc[question.id]) || 0,
|
||||||
impressions: impressionsArr[index] || 0,
|
impressions: impressionsArr[index] || 0,
|
||||||
dropOffCount: dropOffArr[index] || 0,
|
dropOffCount: dropOffArr[index] || 0,
|
||||||
dropOffPercentage: convertFloatTo2Decimal(dropOffPercentageArr[index]) || 0,
|
dropOffPercentage: convertFloatTo2Decimal(dropOffPercentageArr[index]) || 0,
|
||||||
@@ -285,66 +277,51 @@ const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: strin
|
|||||||
return language?.default ? "default" : language?.language.code || "default";
|
return language?.default ? "default" : language?.language.code || "default";
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkForI18n = (
|
const checkForI18n = (responseData: TResponseData, id: string, survey: TSurvey, languageCode: string) => {
|
||||||
responseData: TResponseData,
|
const question = survey.questions.find((question) => question.id === id);
|
||||||
id: string,
|
|
||||||
elements: TSurveyElement[],
|
|
||||||
languageCode: string
|
|
||||||
) => {
|
|
||||||
const element = elements.find((element) => element.id === id);
|
|
||||||
|
|
||||||
if (element?.type === "multipleChoiceMulti" || element?.type === "ranking") {
|
if (question?.type === "multipleChoiceMulti" || question?.type === "ranking") {
|
||||||
// Initialize an array to hold the choice values
|
// Initialize an array to hold the choice values
|
||||||
let choiceValues = [] as string[];
|
let choiceValues = [] as string[];
|
||||||
|
|
||||||
// Type guard: both element types have choices property
|
|
||||||
const hasChoices = "choices" in element;
|
|
||||||
if (!hasChoices) return [];
|
|
||||||
|
|
||||||
(typeof responseData[id] === "string"
|
(typeof responseData[id] === "string"
|
||||||
? ([responseData[id]] as string[])
|
? ([responseData[id]] as string[])
|
||||||
: (responseData[id] as string[])
|
: (responseData[id] as string[])
|
||||||
)?.forEach((data) => {
|
)?.forEach((data) => {
|
||||||
choiceValues.push(
|
choiceValues.push(
|
||||||
getLocalizedValue(
|
getLocalizedValue(
|
||||||
element.choices.find((choice) => choice.label[languageCode] === data)?.label,
|
question.choices.find((choice) => choice.label[languageCode] === data)?.label,
|
||||||
"default"
|
"default"
|
||||||
) || data
|
) || data
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return the array of localized choice values of multiSelect multi elements
|
// Return the array of localized choice values of multiSelect multi questions
|
||||||
return choiceValues;
|
return choiceValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the localized value of the choice fo multiSelect single element
|
// Return the localized value of the choice fo multiSelect single question
|
||||||
if (element && "choices" in element) {
|
const choice = (question as TSurveyMultipleChoiceQuestion)?.choices.find(
|
||||||
const choice = element.choices?.find(
|
(choice) => choice.label[languageCode] === responseData[id]
|
||||||
(choice: TSurveyElementChoice) => choice.label?.[languageCode] === responseData[id]
|
);
|
||||||
);
|
|
||||||
return choice && "label" in choice
|
|
||||||
? getLocalizedValue(choice.label, "default") || responseData[id]
|
|
||||||
: responseData[id];
|
|
||||||
}
|
|
||||||
|
|
||||||
return responseData[id];
|
return getLocalizedValue(choice?.label, "default") || responseData[id];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getElementSummary = async (
|
export const getQuestionSummary = async (
|
||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
elements: TSurveyElement[],
|
|
||||||
responses: TSurveySummaryResponse[],
|
responses: TSurveySummaryResponse[],
|
||||||
dropOff: TSurveySummary["dropOff"]
|
dropOff: TSurveySummary["dropOff"]
|
||||||
): Promise<TSurveySummary["summary"]> => {
|
): Promise<TSurveySummary["summary"]> => {
|
||||||
const VALUES_LIMIT = 50;
|
const VALUES_LIMIT = 50;
|
||||||
let summary: TSurveySummary["summary"] = [];
|
let summary: TSurveySummary["summary"] = [];
|
||||||
|
|
||||||
for (const element of elements) {
|
for (const question of survey.questions) {
|
||||||
switch (element.type) {
|
switch (question.type) {
|
||||||
case TSurveyElementTypeEnum.OpenText: {
|
case TSurveyQuestionTypeEnum.OpenText: {
|
||||||
let values: TSurveyElementSummaryOpenText["samples"] = [];
|
let values: TSurveyQuestionSummaryOpenText["samples"] = [];
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const answer = response.data[element.id];
|
const answer = response.data[question.id];
|
||||||
if (answer && typeof answer === "string") {
|
if (answer && typeof answer === "string") {
|
||||||
values.push({
|
values.push({
|
||||||
id: response.id,
|
id: response.id,
|
||||||
@@ -357,8 +334,8 @@ export const getElementSummary = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element: element,
|
question,
|
||||||
responseCount: values.length,
|
responseCount: values.length,
|
||||||
samples: values.slice(0, VALUES_LIMIT),
|
samples: values.slice(0, VALUES_LIMIT),
|
||||||
});
|
});
|
||||||
@@ -366,18 +343,18 @@ export const getElementSummary = async (
|
|||||||
values = [];
|
values = [];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||||
case TSurveyElementTypeEnum.MultipleChoiceMulti: {
|
case TSurveyQuestionTypeEnum.MultipleChoiceMulti: {
|
||||||
let values: TSurveyElementSummaryMultipleChoice["choices"] = [];
|
let values: TSurveyQuestionSummaryMultipleChoice["choices"] = [];
|
||||||
|
|
||||||
const otherOption = element.choices.find((choice) => choice.id === "other");
|
const otherOption = question.choices.find((choice) => choice.id === "other");
|
||||||
const noneOption = element.choices.find((choice) => choice.id === "none");
|
const noneOption = question.choices.find((choice) => choice.id === "none");
|
||||||
|
|
||||||
const elementChoices = element.choices
|
const questionChoices = question.choices
|
||||||
.filter((choice) => choice.id !== "other" && choice.id !== "none")
|
.filter((choice) => choice.id !== "other" && choice.id !== "none")
|
||||||
.map((choice) => getLocalizedValue(choice.label, "default"));
|
.map((choice) => getLocalizedValue(choice.label, "default"));
|
||||||
|
|
||||||
const choiceCountMap = elementChoices.reduce((acc: Record<string, number>, choice) => {
|
const choiceCountMap = questionChoices.reduce((acc: Record<string, number>, choice) => {
|
||||||
acc[choice] = 0;
|
acc[choice] = 0;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
@@ -386,7 +363,7 @@ export const getElementSummary = async (
|
|||||||
const noneLabel = noneOption ? getLocalizedValue(noneOption.label, "default") : null;
|
const noneLabel = noneOption ? getLocalizedValue(noneOption.label, "default") : null;
|
||||||
let noneCount = 0;
|
let noneCount = 0;
|
||||||
|
|
||||||
const otherValues: TSurveyElementSummaryMultipleChoice["choices"][number]["others"] = [];
|
const otherValues: TSurveyQuestionSummaryMultipleChoice["choices"][number]["others"] = [];
|
||||||
let totalSelectionCount = 0;
|
let totalSelectionCount = 0;
|
||||||
let totalResponseCount = 0;
|
let totalResponseCount = 0;
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
@@ -394,16 +371,16 @@ export const getElementSummary = async (
|
|||||||
|
|
||||||
const answer =
|
const answer =
|
||||||
responseLanguageCode === "default"
|
responseLanguageCode === "default"
|
||||||
? response.data[element.id]
|
? response.data[question.id]
|
||||||
: checkForI18n(response.data, element.id, elements, responseLanguageCode);
|
: checkForI18n(response.data, question.id, survey, responseLanguageCode);
|
||||||
|
|
||||||
let hasValidAnswer = false;
|
let hasValidAnswer = false;
|
||||||
|
|
||||||
if (Array.isArray(answer) && element.type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
|
if (Array.isArray(answer) && question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
|
||||||
answer.forEach((value) => {
|
answer.forEach((value) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
totalSelectionCount++;
|
totalSelectionCount++;
|
||||||
if (elementChoices.includes(value)) {
|
if (questionChoices.includes(value)) {
|
||||||
choiceCountMap[value]++;
|
choiceCountMap[value]++;
|
||||||
} else if (noneLabel && value === noneLabel) {
|
} else if (noneLabel && value === noneLabel) {
|
||||||
noneCount++;
|
noneCount++;
|
||||||
@@ -419,11 +396,11 @@ export const getElementSummary = async (
|
|||||||
});
|
});
|
||||||
} else if (
|
} else if (
|
||||||
typeof answer === "string" &&
|
typeof answer === "string" &&
|
||||||
element.type === TSurveyElementTypeEnum.MultipleChoiceSingle
|
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle
|
||||||
) {
|
) {
|
||||||
if (answer) {
|
if (answer) {
|
||||||
totalSelectionCount++;
|
totalSelectionCount++;
|
||||||
if (elementChoices.includes(answer)) {
|
if (questionChoices.includes(answer)) {
|
||||||
choiceCountMap[answer]++;
|
choiceCountMap[answer]++;
|
||||||
} else if (noneLabel && answer === noneLabel) {
|
} else if (noneLabel && answer === noneLabel) {
|
||||||
noneCount++;
|
noneCount++;
|
||||||
@@ -475,8 +452,8 @@ export const getElementSummary = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
responseCount: totalResponseCount,
|
responseCount: totalResponseCount,
|
||||||
selectionCount: totalSelectionCount,
|
selectionCount: totalSelectionCount,
|
||||||
choices: values,
|
choices: values,
|
||||||
@@ -485,18 +462,18 @@ export const getElementSummary = async (
|
|||||||
values = [];
|
values = [];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case TSurveyElementTypeEnum.PictureSelection: {
|
case TSurveyQuestionTypeEnum.PictureSelection: {
|
||||||
let values: TSurveyElementSummaryPictureSelection["choices"] = [];
|
let values: TSurveyQuestionSummaryPictureSelection["choices"] = [];
|
||||||
const choiceCountMap: Record<string, number> = {};
|
const choiceCountMap: Record<string, number> = {};
|
||||||
|
|
||||||
element.choices.forEach((choice) => {
|
question.choices.forEach((choice) => {
|
||||||
choiceCountMap[choice.id] = 0;
|
choiceCountMap[choice.id] = 0;
|
||||||
});
|
});
|
||||||
let totalResponseCount = 0;
|
let totalResponseCount = 0;
|
||||||
let totalSelectionCount = 0;
|
let totalSelectionCount = 0;
|
||||||
|
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const answer = response.data[element.id];
|
const answer = response.data[question.id];
|
||||||
if (Array.isArray(answer)) {
|
if (Array.isArray(answer)) {
|
||||||
totalResponseCount++;
|
totalResponseCount++;
|
||||||
answer.forEach((value) => {
|
answer.forEach((value) => {
|
||||||
@@ -506,7 +483,7 @@ export const getElementSummary = async (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
element.choices.forEach((choice) => {
|
question.choices.forEach((choice) => {
|
||||||
values.push({
|
values.push({
|
||||||
id: choice.id,
|
id: choice.id,
|
||||||
imageUrl: choice.imageUrl,
|
imageUrl: choice.imageUrl,
|
||||||
@@ -519,8 +496,8 @@ export const getElementSummary = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
responseCount: totalResponseCount,
|
responseCount: totalResponseCount,
|
||||||
selectionCount: totalSelectionCount,
|
selectionCount: totalSelectionCount,
|
||||||
choices: values,
|
choices: values,
|
||||||
@@ -529,10 +506,10 @@ export const getElementSummary = async (
|
|||||||
values = [];
|
values = [];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case TSurveyElementTypeEnum.Rating: {
|
case TSurveyQuestionTypeEnum.Rating: {
|
||||||
let values: TSurveyElementSummaryRating["choices"] = [];
|
let values: TSurveyQuestionSummaryRating["choices"] = [];
|
||||||
const choiceCountMap: Record<number, number> = {};
|
const choiceCountMap: Record<number, number> = {};
|
||||||
const range = element.range;
|
const range = question.range;
|
||||||
|
|
||||||
for (let i = 1; i <= range; i++) {
|
for (let i = 1; i <= range; i++) {
|
||||||
choiceCountMap[i] = 0;
|
choiceCountMap[i] = 0;
|
||||||
@@ -543,62 +520,40 @@ export const getElementSummary = async (
|
|||||||
let dismissed = 0;
|
let dismissed = 0;
|
||||||
|
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const answer = response.data[element.id];
|
const answer = response.data[question.id];
|
||||||
if (typeof answer === "number") {
|
if (typeof answer === "number") {
|
||||||
totalResponseCount++;
|
totalResponseCount++;
|
||||||
choiceCountMap[answer]++;
|
choiceCountMap[answer]++;
|
||||||
totalRating += answer;
|
totalRating += answer;
|
||||||
} else if (response.ttc && response.ttc[element.id] > 0) {
|
} else if (response.ttc && response.ttc[question.id] > 0) {
|
||||||
dismissed++;
|
dismissed++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Object.entries(choiceCountMap).forEach(([label, count]) => {
|
Object.entries(choiceCountMap).forEach(([label, count]) => {
|
||||||
values.push({
|
values.push({
|
||||||
rating: Number.parseInt(label),
|
rating: parseInt(label),
|
||||||
count,
|
count,
|
||||||
percentage:
|
percentage:
|
||||||
totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
|
totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate CSAT based on range
|
|
||||||
let satisfiedCount = 0;
|
|
||||||
if (range === 3) {
|
|
||||||
satisfiedCount = choiceCountMap[3] || 0;
|
|
||||||
} else if (range === 4) {
|
|
||||||
satisfiedCount = (choiceCountMap[3] || 0) + (choiceCountMap[4] || 0);
|
|
||||||
} else if (range === 5) {
|
|
||||||
satisfiedCount = (choiceCountMap[4] || 0) + (choiceCountMap[5] || 0);
|
|
||||||
} else if (range === 6) {
|
|
||||||
satisfiedCount = (choiceCountMap[5] || 0) + (choiceCountMap[6] || 0);
|
|
||||||
} else if (range === 7) {
|
|
||||||
satisfiedCount = (choiceCountMap[6] || 0) + (choiceCountMap[7] || 0);
|
|
||||||
} else if (range === 10) {
|
|
||||||
satisfiedCount = (choiceCountMap[8] || 0) + (choiceCountMap[9] || 0) + (choiceCountMap[10] || 0);
|
|
||||||
}
|
|
||||||
const satisfiedPercentage =
|
|
||||||
totalResponseCount > 0 ? Math.round((satisfiedCount / totalResponseCount) * 100) : 0;
|
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
average: convertFloatTo2Decimal(totalRating / totalResponseCount) || 0,
|
average: convertFloatTo2Decimal(totalRating / totalResponseCount) || 0,
|
||||||
responseCount: totalResponseCount,
|
responseCount: totalResponseCount,
|
||||||
choices: values,
|
choices: values,
|
||||||
dismissed: {
|
dismissed: {
|
||||||
count: dismissed,
|
count: dismissed,
|
||||||
},
|
},
|
||||||
csat: {
|
|
||||||
satisfiedCount,
|
|
||||||
satisfiedPercentage,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
values = [];
|
values = [];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case TSurveyElementTypeEnum.NPS: {
|
case TSurveyQuestionTypeEnum.NPS: {
|
||||||
const data = {
|
const data = {
|
||||||
promoters: 0,
|
promoters: 0,
|
||||||
passives: 0,
|
passives: 0,
|
||||||
@@ -608,17 +563,10 @@ export const getElementSummary = async (
|
|||||||
score: 0,
|
score: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Track individual score counts (0-10)
|
|
||||||
const scoreCountMap: Record<number, number> = {};
|
|
||||||
for (let i = 0; i <= 10; i++) {
|
|
||||||
scoreCountMap[i] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const value = response.data[element.id];
|
const value = response.data[question.id];
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
data.total++;
|
data.total++;
|
||||||
scoreCountMap[value]++;
|
|
||||||
if (value >= 9) {
|
if (value >= 9) {
|
||||||
data.promoters++;
|
data.promoters++;
|
||||||
} else if (value >= 7) {
|
} else if (value >= 7) {
|
||||||
@@ -626,7 +574,7 @@ export const getElementSummary = async (
|
|||||||
} else {
|
} else {
|
||||||
data.detractors++;
|
data.detractors++;
|
||||||
}
|
}
|
||||||
} else if (response.ttc && response.ttc[element.id] > 0) {
|
} else if (response.ttc && response.ttc[question.id] > 0) {
|
||||||
data.total++;
|
data.total++;
|
||||||
data.dismissed++;
|
data.dismissed++;
|
||||||
}
|
}
|
||||||
@@ -637,16 +585,9 @@ export const getElementSummary = async (
|
|||||||
? convertFloatTo2Decimal(((data.promoters - data.detractors) / data.total) * 100)
|
? convertFloatTo2Decimal(((data.promoters - data.detractors) / data.total) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Build choices array with individual score breakdown
|
|
||||||
const choices = Object.entries(scoreCountMap).map(([rating, count]) => ({
|
|
||||||
rating: Number.parseInt(rating),
|
|
||||||
count,
|
|
||||||
percentage: data.total > 0 ? convertFloatTo2Decimal((count / data.total) * 100) : 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
responseCount: data.total,
|
responseCount: data.total,
|
||||||
total: data.total,
|
total: data.total,
|
||||||
score: data.score,
|
score: data.score,
|
||||||
@@ -666,23 +607,17 @@ export const getElementSummary = async (
|
|||||||
count: data.dismissed,
|
count: data.dismissed,
|
||||||
percentage: data.total > 0 ? convertFloatTo2Decimal((data.dismissed / data.total) * 100) : 0,
|
percentage: data.total > 0 ? convertFloatTo2Decimal((data.dismissed / data.total) * 100) : 0,
|
||||||
},
|
},
|
||||||
choices,
|
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case TSurveyElementTypeEnum.CTA: {
|
case TSurveyQuestionTypeEnum.CTA: {
|
||||||
// Only calculate summary for CTA elements with external buttons (CTR tracking is only meaningful for external links)
|
|
||||||
if (!element.buttonExternal) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
clicked: 0,
|
clicked: 0,
|
||||||
dismissed: 0,
|
dismissed: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const value = response.data[element.id];
|
const value = response.data[question.id];
|
||||||
if (value === "clicked") {
|
if (value === "clicked") {
|
||||||
data.clicked++;
|
data.clicked++;
|
||||||
} else if (value === "dismissed") {
|
} else if (value === "dismissed") {
|
||||||
@@ -691,12 +626,12 @@ export const getElementSummary = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const totalResponses = data.clicked + data.dismissed;
|
const totalResponses = data.clicked + data.dismissed;
|
||||||
const idx = elements.findIndex((q) => q.id === element.id);
|
const idx = survey.questions.findIndex((q) => q.id === question.id);
|
||||||
const impressions = dropOff[idx].impressions;
|
const impressions = dropOff[idx].impressions;
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
impressionCount: impressions,
|
impressionCount: impressions,
|
||||||
clickCount: data.clicked,
|
clickCount: data.clicked,
|
||||||
skipCount: data.dismissed,
|
skipCount: data.dismissed,
|
||||||
@@ -708,17 +643,17 @@ export const getElementSummary = async (
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case TSurveyElementTypeEnum.Consent: {
|
case TSurveyQuestionTypeEnum.Consent: {
|
||||||
const data = {
|
const data = {
|
||||||
accepted: 0,
|
accepted: 0,
|
||||||
dismissed: 0,
|
dismissed: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const value = response.data[element.id];
|
const value = response.data[question.id];
|
||||||
if (value === "accepted") {
|
if (value === "accepted") {
|
||||||
data.accepted++;
|
data.accepted++;
|
||||||
} else if (response.ttc && response.ttc[element.id] > 0) {
|
} else if (response.ttc && response.ttc[question.id] > 0) {
|
||||||
data.dismissed++;
|
data.dismissed++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -726,8 +661,8 @@ export const getElementSummary = async (
|
|||||||
const totalResponses = data.accepted + data.dismissed;
|
const totalResponses = data.accepted + data.dismissed;
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
responseCount: totalResponses,
|
responseCount: totalResponses,
|
||||||
accepted: {
|
accepted: {
|
||||||
count: data.accepted,
|
count: data.accepted,
|
||||||
@@ -743,10 +678,10 @@ export const getElementSummary = async (
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case TSurveyElementTypeEnum.Date: {
|
case TSurveyQuestionTypeEnum.Date: {
|
||||||
let values: TSurveyElementSummaryDate["samples"] = [];
|
let values: TSurveyQuestionSummaryDate["samples"] = [];
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const answer = response.data[element.id];
|
const answer = response.data[question.id];
|
||||||
if (answer && typeof answer === "string") {
|
if (answer && typeof answer === "string") {
|
||||||
values.push({
|
values.push({
|
||||||
id: response.id,
|
id: response.id,
|
||||||
@@ -759,8 +694,8 @@ export const getElementSummary = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
responseCount: values.length,
|
responseCount: values.length,
|
||||||
samples: values.slice(0, VALUES_LIMIT),
|
samples: values.slice(0, VALUES_LIMIT),
|
||||||
});
|
});
|
||||||
@@ -768,10 +703,10 @@ export const getElementSummary = async (
|
|||||||
values = [];
|
values = [];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case TSurveyElementTypeEnum.FileUpload: {
|
case TSurveyQuestionTypeEnum.FileUpload: {
|
||||||
let values: TSurveyElementSummaryFileUpload["files"] = [];
|
let values: TSurveyQuestionSummaryFileUpload["files"] = [];
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const answer = response.data[element.id];
|
const answer = response.data[question.id];
|
||||||
if (Array.isArray(answer)) {
|
if (Array.isArray(answer)) {
|
||||||
values.push({
|
values.push({
|
||||||
id: response.id,
|
id: response.id,
|
||||||
@@ -784,8 +719,8 @@ export const getElementSummary = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
responseCount: values.length,
|
responseCount: values.length,
|
||||||
files: values.slice(0, VALUES_LIMIT),
|
files: values.slice(0, VALUES_LIMIT),
|
||||||
});
|
});
|
||||||
@@ -793,25 +728,25 @@ export const getElementSummary = async (
|
|||||||
values = [];
|
values = [];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case TSurveyElementTypeEnum.Cal: {
|
case TSurveyQuestionTypeEnum.Cal: {
|
||||||
const data = {
|
const data = {
|
||||||
booked: 0,
|
booked: 0,
|
||||||
skipped: 0,
|
skipped: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const value = response.data[element.id];
|
const value = response.data[question.id];
|
||||||
if (value === "booked") {
|
if (value === "booked") {
|
||||||
data.booked++;
|
data.booked++;
|
||||||
} else if (response.ttc && response.ttc[element.id] > 0) {
|
} else if (response.ttc && response.ttc[question.id] > 0) {
|
||||||
data.skipped++;
|
data.skipped++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const totalResponses = data.booked + data.skipped;
|
const totalResponses = data.booked + data.skipped;
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
responseCount: totalResponses,
|
responseCount: totalResponses,
|
||||||
booked: {
|
booked: {
|
||||||
count: data.booked,
|
count: data.booked,
|
||||||
@@ -826,9 +761,9 @@ export const getElementSummary = async (
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case TSurveyElementTypeEnum.Matrix: {
|
case TSurveyQuestionTypeEnum.Matrix: {
|
||||||
const rows = element.rows.map((row) => getLocalizedValue(row.label, "default"));
|
const rows = question.rows.map((row) => getLocalizedValue(row.label, "default"));
|
||||||
const columns = element.columns.map((column) => getLocalizedValue(column.label, "default"));
|
const columns = question.columns.map((column) => getLocalizedValue(column.label, "default"));
|
||||||
let totalResponseCount = 0;
|
let totalResponseCount = 0;
|
||||||
|
|
||||||
// Initialize count object
|
// Initialize count object
|
||||||
@@ -841,13 +776,13 @@ export const getElementSummary = async (
|
|||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const selectedResponses = response.data[element.id] as Record<string, string>;
|
const selectedResponses = response.data[question.id] as Record<string, string>;
|
||||||
const responseLanguageCode = getLanguageCode(survey.languages, response.language);
|
const responseLanguageCode = getLanguageCode(survey.languages, response.language);
|
||||||
if (selectedResponses) {
|
if (selectedResponses) {
|
||||||
totalResponseCount++;
|
totalResponseCount++;
|
||||||
element.rows.forEach((row) => {
|
question.rows.forEach((row) => {
|
||||||
const localizedRow = getLocalizedValue(row.label, responseLanguageCode);
|
const localizedRow = getLocalizedValue(row.label, responseLanguageCode);
|
||||||
const colValue = element.columns.find((column) => {
|
const colValue = question.columns.find((column) => {
|
||||||
return (
|
return (
|
||||||
getLocalizedValue(column.label, responseLanguageCode) === selectedResponses[localizedRow]
|
getLocalizedValue(column.label, responseLanguageCode) === selectedResponses[localizedRow]
|
||||||
);
|
);
|
||||||
@@ -880,17 +815,18 @@ export const getElementSummary = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
responseCount: totalResponseCount,
|
responseCount: totalResponseCount,
|
||||||
data: matrixSummary,
|
data: matrixSummary,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case TSurveyElementTypeEnum.Address: {
|
case TSurveyQuestionTypeEnum.Address:
|
||||||
let values: TSurveyElementSummaryAddress["samples"] = [];
|
case TSurveyQuestionTypeEnum.ContactInfo: {
|
||||||
|
let values: TSurveyQuestionSummaryAddress["samples"] = [];
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const answer = response.data[element.id];
|
const answer = response.data[question.id];
|
||||||
if (Array.isArray(answer) && answer.length > 0) {
|
if (Array.isArray(answer) && answer.length > 0) {
|
||||||
values.push({
|
values.push({
|
||||||
id: response.id,
|
id: response.id,
|
||||||
@@ -903,8 +839,8 @@ export const getElementSummary = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: TSurveyElementTypeEnum.Address,
|
type: question.type as TSurveyQuestionTypeEnum.ContactInfo,
|
||||||
element,
|
question: question as TSurveyContactInfoQuestion,
|
||||||
responseCount: values.length,
|
responseCount: values.length,
|
||||||
samples: values.slice(0, VALUES_LIMIT),
|
samples: values.slice(0, VALUES_LIMIT),
|
||||||
});
|
});
|
||||||
@@ -912,39 +848,13 @@ export const getElementSummary = async (
|
|||||||
values = [];
|
values = [];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case TSurveyElementTypeEnum.ContactInfo: {
|
case TSurveyQuestionTypeEnum.Ranking: {
|
||||||
let values: TSurveyElementSummaryContactInfo["samples"] = [];
|
let values: TSurveyQuestionSummaryRanking["choices"] = [];
|
||||||
responses.forEach((response) => {
|
const questionChoices = question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
|
||||||
const answer = response.data[element.id];
|
|
||||||
if (Array.isArray(answer) && answer.length > 0) {
|
|
||||||
values.push({
|
|
||||||
id: response.id,
|
|
||||||
updatedAt: response.updatedAt,
|
|
||||||
value: answer,
|
|
||||||
contact: response.contact,
|
|
||||||
contactAttributes: response.contactAttributes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
summary.push({
|
|
||||||
type: TSurveyElementTypeEnum.ContactInfo,
|
|
||||||
element,
|
|
||||||
responseCount: values.length,
|
|
||||||
samples: values.slice(0, VALUES_LIMIT),
|
|
||||||
});
|
|
||||||
|
|
||||||
values = [];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case TSurveyElementTypeEnum.Ranking: {
|
|
||||||
let values: TSurveyElementSummaryRanking["choices"] = [];
|
|
||||||
const elementChoices = element.choices.map((choice) => getLocalizedValue(choice.label, "default"));
|
|
||||||
let totalResponseCount = 0;
|
let totalResponseCount = 0;
|
||||||
const choiceRankSums: Record<string, number> = {};
|
const choiceRankSums: Record<string, number> = {};
|
||||||
const choiceCountMap: Record<string, number> = {};
|
const choiceCountMap: Record<string, number> = {};
|
||||||
|
questionChoices.forEach((choice) => {
|
||||||
elementChoices.forEach((choice: string) => {
|
|
||||||
choiceRankSums[choice] = 0;
|
choiceRankSums[choice] = 0;
|
||||||
choiceCountMap[choice] = 0;
|
choiceCountMap[choice] = 0;
|
||||||
});
|
});
|
||||||
@@ -954,14 +864,14 @@ export const getElementSummary = async (
|
|||||||
|
|
||||||
const answer =
|
const answer =
|
||||||
responseLanguageCode === "default"
|
responseLanguageCode === "default"
|
||||||
? response.data[element.id]
|
? response.data[question.id]
|
||||||
: checkForI18n(response.data, element.id, elements, responseLanguageCode);
|
: checkForI18n(response.data, question.id, survey, responseLanguageCode);
|
||||||
|
|
||||||
if (Array.isArray(answer)) {
|
if (Array.isArray(answer)) {
|
||||||
totalResponseCount++;
|
totalResponseCount++;
|
||||||
answer.forEach((value, index) => {
|
answer.forEach((value, index) => {
|
||||||
const ranking = index + 1; // Calculate ranking based on index
|
const ranking = index + 1; // Calculate ranking based on index
|
||||||
if (elementChoices.includes(value)) {
|
if (questionChoices.includes(value)) {
|
||||||
choiceRankSums[value] += ranking;
|
choiceRankSums[value] += ranking;
|
||||||
choiceCountMap[value]++;
|
choiceCountMap[value]++;
|
||||||
}
|
}
|
||||||
@@ -969,7 +879,7 @@ export const getElementSummary = async (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
elementChoices.forEach((choice: string) => {
|
questionChoices.forEach((choice) => {
|
||||||
const count = choiceCountMap[choice];
|
const count = choiceCountMap[choice];
|
||||||
const avgRanking = count > 0 ? choiceRankSums[choice] / count : 0;
|
const avgRanking = count > 0 ? choiceRankSums[choice] / count : 0;
|
||||||
values.push({
|
values.push({
|
||||||
@@ -980,8 +890,8 @@ export const getElementSummary = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
responseCount: totalResponseCount,
|
responseCount: totalResponseCount,
|
||||||
choices: values,
|
choices: values,
|
||||||
});
|
});
|
||||||
@@ -992,7 +902,7 @@ export const getElementSummary = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
survey.hiddenFields?.fieldIds?.forEach((hiddenFieldId) => {
|
survey.hiddenFields?.fieldIds?.forEach((hiddenFieldId) => {
|
||||||
let values: TSurveyElementSummaryHiddenFields["samples"] = [];
|
let values: TSurveyQuestionSummaryHiddenFields["samples"] = [];
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const answer = response.data[hiddenFieldId];
|
const answer = response.data[hiddenFieldId];
|
||||||
if (answer && typeof answer === "string") {
|
if (answer && typeof answer === "string") {
|
||||||
@@ -1028,8 +938,6 @@ export const getSurveySummary = reactCache(
|
|||||||
throw new ResourceNotFoundError("Survey", surveyId);
|
throw new ResourceNotFoundError("Survey", surveyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const elements = getElementsFromBlocks(survey.blocks);
|
|
||||||
|
|
||||||
const batchSize = 5000;
|
const batchSize = 5000;
|
||||||
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
|
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
|
||||||
|
|
||||||
@@ -1060,16 +968,16 @@ export const getSurveySummary = reactCache(
|
|||||||
getQuotasSummary(surveyId),
|
getQuotasSummary(surveyId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
|
const dropOff = getSurveySummaryDropOff(survey, responses, displayCount);
|
||||||
const [meta, elementSummary] = await Promise.all([
|
const [meta, questionWiseSummary] = await Promise.all([
|
||||||
getSurveySummaryMeta(responses, displayCount, quotas),
|
getSurveySummaryMeta(responses, displayCount, quotas),
|
||||||
getElementSummary(survey, elements, responses, dropOff),
|
getQuestionSummary(survey, responses, dropOff),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
meta,
|
meta,
|
||||||
dropOff,
|
dropOff,
|
||||||
summary: elementSummary,
|
summary: questionWiseSummary,
|
||||||
quotas,
|
quotas,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
|
||||||
import { constructToastMessage, convertFloatTo2Decimal, convertFloatToNDecimal } from "./utils";
|
import { constructToastMessage, convertFloatTo2Decimal, convertFloatToNDecimal } from "./utils";
|
||||||
|
|
||||||
describe("Utils Tests", () => {
|
describe("Utils Tests", () => {
|
||||||
@@ -35,40 +34,29 @@ describe("Utils Tests", () => {
|
|||||||
type: "app",
|
type: "app",
|
||||||
environmentId: "env1",
|
environmentId: "env1",
|
||||||
status: "draft",
|
status: "draft",
|
||||||
blocks: [
|
questions: [
|
||||||
{
|
{
|
||||||
id: "block1",
|
id: "q1",
|
||||||
name: "Block 1",
|
type: TSurveyQuestionTypeEnum.OpenText,
|
||||||
elements: [
|
headline: { default: "Q1" },
|
||||||
{
|
required: false,
|
||||||
id: "q1",
|
} as unknown as TSurveyQuestion,
|
||||||
type: TSurveyElementTypeEnum.OpenText,
|
{
|
||||||
headline: { default: "Q1" },
|
id: "q2",
|
||||||
required: false,
|
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||||
charLimit: { enabled: false },
|
headline: { default: "Q2" },
|
||||||
},
|
required: false,
|
||||||
{
|
choices: [{ id: "c1", label: { default: "Choice 1" } }],
|
||||||
id: "q2",
|
},
|
||||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
{
|
||||||
headline: { default: "Q2" },
|
id: "q3",
|
||||||
required: false,
|
type: TSurveyQuestionTypeEnum.Matrix,
|
||||||
choices: [{ id: "c1", label: { default: "Choice 1" } }],
|
headline: { default: "Q3" },
|
||||||
buttonLabel: { default: "Next" },
|
required: false,
|
||||||
shuffleOption: "none",
|
rows: [{ id: "r1", label: { default: "Row 1" } }],
|
||||||
},
|
columns: [{ id: "col1", label: { default: "Col 1" } }],
|
||||||
{
|
|
||||||
id: "q3",
|
|
||||||
type: TSurveyElementTypeEnum.Matrix,
|
|
||||||
headline: { default: "Q3" },
|
|
||||||
required: false,
|
|
||||||
rows: [{ id: "r1", label: { default: "Row 1" } }],
|
|
||||||
columns: [{ id: "col1", label: { default: "Col 1" } }],
|
|
||||||
buttonLabel: { default: "Next" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
questions: [],
|
|
||||||
triggers: [],
|
triggers: [],
|
||||||
recontactDays: null,
|
recontactDays: null,
|
||||||
autoClose: null,
|
autoClose: null,
|
||||||
@@ -86,7 +74,7 @@ describe("Utils Tests", () => {
|
|||||||
|
|
||||||
test("should construct message for matrix question type", () => {
|
test("should construct message for matrix question type", () => {
|
||||||
const message = constructToastMessage(
|
const message = constructToastMessage(
|
||||||
TSurveyElementTypeEnum.Matrix,
|
TSurveyQuestionTypeEnum.Matrix,
|
||||||
"is",
|
"is",
|
||||||
mockSurvey,
|
mockSurvey,
|
||||||
"q3",
|
"q3",
|
||||||
@@ -107,7 +95,7 @@ describe("Utils Tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should construct message for matrix question type with array filterComboBoxValue", () => {
|
test("should construct message for matrix question type with array filterComboBoxValue", () => {
|
||||||
const message = constructToastMessage(TSurveyElementTypeEnum.Matrix, "is", mockSurvey, "q3", mockT, [
|
const message = constructToastMessage(TSurveyQuestionTypeEnum.Matrix, "is", mockSurvey, "q3", mockT, [
|
||||||
"MatrixValue1",
|
"MatrixValue1",
|
||||||
"MatrixValue2",
|
"MatrixValue2",
|
||||||
]);
|
]);
|
||||||
@@ -126,7 +114,7 @@ describe("Utils Tests", () => {
|
|||||||
|
|
||||||
test("should construct message when filterComboBoxValue is undefined (skipped)", () => {
|
test("should construct message when filterComboBoxValue is undefined (skipped)", () => {
|
||||||
const message = constructToastMessage(
|
const message = constructToastMessage(
|
||||||
TSurveyElementTypeEnum.OpenText,
|
TSurveyQuestionTypeEnum.OpenText,
|
||||||
"is skipped",
|
"is skipped",
|
||||||
mockSurvey,
|
mockSurvey,
|
||||||
"q1",
|
"q1",
|
||||||
@@ -146,7 +134,7 @@ describe("Utils Tests", () => {
|
|||||||
|
|
||||||
test("should construct message for non-matrix question with string filterComboBoxValue", () => {
|
test("should construct message for non-matrix question with string filterComboBoxValue", () => {
|
||||||
const message = constructToastMessage(
|
const message = constructToastMessage(
|
||||||
TSurveyElementTypeEnum.MultipleChoiceSingle,
|
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||||
"is",
|
"is",
|
||||||
mockSurvey,
|
mockSurvey,
|
||||||
"q2",
|
"q2",
|
||||||
@@ -168,7 +156,7 @@ describe("Utils Tests", () => {
|
|||||||
|
|
||||||
test("should construct message for non-matrix question with array filterComboBoxValue", () => {
|
test("should construct message for non-matrix question with array filterComboBoxValue", () => {
|
||||||
const message = constructToastMessage(
|
const message = constructToastMessage(
|
||||||
TSurveyElementTypeEnum.MultipleChoiceMulti,
|
TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||||
"includes all of",
|
"includes all of",
|
||||||
mockSurvey,
|
mockSurvey,
|
||||||
"q2", // Assuming q2 can be multi for this test case logic
|
"q2", // Assuming q2 can be multi for this test case logic
|
||||||
@@ -190,7 +178,7 @@ describe("Utils Tests", () => {
|
|||||||
|
|
||||||
test("should handle questionId not found in survey", () => {
|
test("should handle questionId not found in survey", () => {
|
||||||
const message = constructToastMessage(
|
const message = constructToastMessage(
|
||||||
TSurveyElementTypeEnum.OpenText,
|
TSurveyQuestionTypeEnum.OpenText,
|
||||||
"is",
|
"is",
|
||||||
mockSurvey,
|
mockSurvey,
|
||||||
"qNonExistent",
|
"qNonExistent",
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { TFunction } from "i18next";
|
import { TFunction } from "i18next";
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurvey, TSurveyQuestionId, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
|
||||||
|
|
||||||
export const convertFloatToNDecimal = (num: number, N: number = 2) => {
|
export const convertFloatToNDecimal = (num: number, N: number = 2) => {
|
||||||
return Math.round(num * Math.pow(10, N)) / Math.pow(10, N);
|
return Math.round(num * Math.pow(10, N)) / Math.pow(10, N);
|
||||||
@@ -12,28 +10,27 @@ export const convertFloatTo2Decimal = (num: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const constructToastMessage = (
|
export const constructToastMessage = (
|
||||||
elementType: TSurveyElementTypeEnum,
|
questionType: TSurveyQuestionTypeEnum,
|
||||||
filterValue: string,
|
filterValue: string,
|
||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
elementId: string,
|
questionId: TSurveyQuestionId,
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
filterComboBoxValue?: string | string[]
|
filterComboBoxValue?: string | string[]
|
||||||
) => {
|
) => {
|
||||||
const elements = getElementsFromBlocks(survey.blocks);
|
const questionIdx = survey.questions.findIndex((question) => question.id === questionId);
|
||||||
const elementIdx = elements.findIndex((element) => element.id === elementId);
|
if (questionType === "matrix") {
|
||||||
if (elementType === "matrix") {
|
|
||||||
return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question", {
|
return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question", {
|
||||||
questionIdx: elementIdx + 1,
|
questionIdx: questionIdx + 1,
|
||||||
filterComboBoxValue: filterComboBoxValue?.toString() ?? "",
|
filterComboBoxValue: filterComboBoxValue?.toString() ?? "",
|
||||||
filterValue,
|
filterValue,
|
||||||
});
|
});
|
||||||
} else if (filterComboBoxValue === undefined) {
|
} else if (filterComboBoxValue === undefined) {
|
||||||
return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question_is_skipped", {
|
return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question_is_skipped", {
|
||||||
questionIdx: elementIdx + 1,
|
questionIdx: questionIdx + 1,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question", {
|
return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question", {
|
||||||
questionIdx: elementIdx + 1,
|
questionIdx: questionIdx + 1,
|
||||||
filterComboBoxValue: Array.isArray(filterComboBoxValue)
|
filterComboBoxValue: Array.isArray(filterComboBoxValue)
|
||||||
? filterComboBoxValue.join(",")
|
? filterComboBoxValue.join(",")
|
||||||
: filterComboBoxValue,
|
: filterComboBoxValue,
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
|||||||
user={user}
|
user={user}
|
||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
|
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
|
||||||
|
displayCount={initialSurveySummary?.meta.displayCount ?? 0}
|
||||||
segments={segments}
|
segments={segments}
|
||||||
isContactsEnabled={isContactsEnabled}
|
isContactsEnabled={isContactsEnabled}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
subYears,
|
subYears,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { TFunction } from "i18next";
|
import { TFunction } from "i18next";
|
||||||
import { Loader2 } from "lucide-react";
|
import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon, Loader2Icon } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -25,7 +25,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
|||||||
import {
|
import {
|
||||||
DateRange,
|
DateRange,
|
||||||
useResponseFilter,
|
useResponseFilter,
|
||||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||||
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||||
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
|
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
|
||||||
import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys";
|
import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys";
|
||||||
@@ -37,7 +37,8 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import { PopoverTriggerButton, ResponseFilter } from "./ResponseFilter";
|
import { cn } from "@/modules/ui/lib/utils";
|
||||||
|
import { ResponseFilter } from "./ResponseFilter";
|
||||||
|
|
||||||
enum DateSelected {
|
enum DateSelected {
|
||||||
FROM = "common.from",
|
FROM = "common.from",
|
||||||
@@ -136,7 +137,6 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
|||||||
const [selectingDate, setSelectingDate] = useState<DateSelected>(DateSelected.FROM);
|
const [selectingDate, setSelectingDate] = useState<DateSelected>(DateSelected.FROM);
|
||||||
const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false);
|
const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false);
|
||||||
const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState<boolean>(false);
|
const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState<boolean>(false);
|
||||||
const [isDownloadDropDownOpen, setIsDownloadDropDownOpen] = useState<boolean>(false);
|
|
||||||
const [hoveredRange, setHoveredRange] = useState<DateRange | null>(null);
|
const [hoveredRange, setHoveredRange] = useState<DateRange | null>(null);
|
||||||
const [isDownloading, setIsDownloading] = useState<boolean>(false);
|
const [isDownloading, setIsDownloading] = useState<boolean>(false);
|
||||||
|
|
||||||
@@ -164,12 +164,12 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
|||||||
|
|
||||||
const datePickerRef = useRef<HTMLDivElement>(null);
|
const datePickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const extractMetadataKeys = useCallback((obj, parentKey = "") => {
|
const extracMetadataKeys = useCallback((obj, parentKey = "") => {
|
||||||
let keys: string[] = [];
|
let keys: string[] = [];
|
||||||
|
|
||||||
for (let key in obj) {
|
for (let key in obj) {
|
||||||
if (typeof obj[key] === "object" && obj[key] !== null) {
|
if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||||
keys = keys.concat(extractMetadataKeys(obj[key], parentKey + key + " - "));
|
keys = keys.concat(extracMetadataKeys(obj[key], parentKey + key + " - "));
|
||||||
} else {
|
} else {
|
||||||
keys.push(parentKey + key);
|
keys.push(parentKey + key);
|
||||||
}
|
}
|
||||||
@@ -270,179 +270,201 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
|||||||
|
|
||||||
useClickOutside(datePickerRef, () => handleDatePickerClose());
|
useClickOutside(datePickerRef, () => handleDatePickerClose());
|
||||||
return (
|
return (
|
||||||
<div className="relative flex justify-between">
|
<>
|
||||||
<div className="flex justify-stretch gap-x-1.5">
|
<div className="relative flex justify-between">
|
||||||
<ResponseFilter survey={survey} />
|
<div className="flex justify-stretch gap-x-1.5">
|
||||||
<DropdownMenu
|
<ResponseFilter survey={survey} />
|
||||||
onOpenChange={(value) => {
|
<DropdownMenu
|
||||||
value && handleDatePickerClose();
|
onOpenChange={(value) => {
|
||||||
setIsFilterDropDownOpen(value);
|
value && handleDatePickerClose();
|
||||||
}}>
|
setIsFilterDropDownOpen(value);
|
||||||
<DropdownMenuTrigger asChild>
|
}}>
|
||||||
<PopoverTriggerButton isOpen={isFilterDropDownOpen}>
|
<DropdownMenuTrigger>
|
||||||
{filterRange === getFilterDropDownLabels(t).CUSTOM_RANGE
|
<div className="flex min-w-[8rem] items-center justify-between rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
|
||||||
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
|
<span className="text-sm text-slate-700">
|
||||||
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
|
{filterRange === getFilterDropDownLabels(t).CUSTOM_RANGE
|
||||||
}`
|
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
|
||||||
: filterRange}
|
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
|
||||||
</PopoverTriggerButton>
|
}`
|
||||||
</DropdownMenuTrigger>
|
: filterRange}
|
||||||
<DropdownMenuContent>
|
</span>
|
||||||
<DropdownMenuItem
|
{isFilterDropDownOpen ? (
|
||||||
onClick={() => {
|
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||||
setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
|
) : (
|
||||||
setDateRange({ from: undefined, to: getTodayDate() });
|
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||||
}}>
|
)}
|
||||||
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuItem
|
<DropdownMenuContent>
|
||||||
onClick={() => {
|
<DropdownMenuItem
|
||||||
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
|
onClick={() => {
|
||||||
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
|
setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
|
||||||
}}>
|
setDateRange({ from: undefined, to: getTodayDate() });
|
||||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
|
}}>
|
||||||
</DropdownMenuItem>
|
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
|
||||||
<DropdownMenuItem
|
</DropdownMenuItem>
|
||||||
onClick={() => {
|
<DropdownMenuItem
|
||||||
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
|
onClick={() => {
|
||||||
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
|
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
|
||||||
}}>
|
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
|
||||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
|
}}>
|
||||||
</DropdownMenuItem>
|
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
|
||||||
<DropdownMenuItem
|
</DropdownMenuItem>
|
||||||
onClick={() => {
|
<DropdownMenuItem
|
||||||
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
|
onClick={() => {
|
||||||
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
|
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
|
||||||
}}>
|
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
|
||||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
|
}}>
|
||||||
</DropdownMenuItem>
|
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
|
||||||
<DropdownMenuItem
|
</DropdownMenuItem>
|
||||||
onClick={() => {
|
<DropdownMenuItem
|
||||||
setFilterRange(getFilterDropDownLabels(t).LAST_MONTH);
|
onClick={() => {
|
||||||
setDateRange({
|
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
|
||||||
from: startOfMonth(subMonths(new Date(), 1)),
|
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
|
||||||
to: endOfMonth(subMonths(getTodayDate(), 1)),
|
}}>
|
||||||
});
|
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
|
||||||
}}>
|
</DropdownMenuItem>
|
||||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={() => {
|
||||||
<DropdownMenuItem
|
setFilterRange(getFilterDropDownLabels(t).LAST_MONTH);
|
||||||
onClick={() => {
|
setDateRange({
|
||||||
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
|
from: startOfMonth(subMonths(new Date(), 1)),
|
||||||
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
|
to: endOfMonth(subMonths(getTodayDate(), 1)),
|
||||||
}}>
|
});
|
||||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
|
}}>
|
||||||
</DropdownMenuItem>
|
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
|
||||||
<DropdownMenuItem
|
</DropdownMenuItem>
|
||||||
onClick={() => {
|
<DropdownMenuItem
|
||||||
setFilterRange(getFilterDropDownLabels(t).LAST_QUARTER);
|
onClick={() => {
|
||||||
setDateRange({
|
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
|
||||||
from: startOfQuarter(subQuarters(new Date(), 1)),
|
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
|
||||||
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
|
}}>
|
||||||
});
|
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
|
||||||
}}>
|
</DropdownMenuItem>
|
||||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={() => {
|
||||||
<DropdownMenuItem
|
setFilterRange(getFilterDropDownLabels(t).LAST_QUARTER);
|
||||||
onClick={() => {
|
setDateRange({
|
||||||
setFilterRange(getFilterDropDownLabels(t).LAST_6_MONTHS);
|
from: startOfQuarter(subQuarters(new Date(), 1)),
|
||||||
setDateRange({
|
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
|
||||||
from: startOfMonth(subMonths(new Date(), 6)),
|
});
|
||||||
to: endOfMonth(getTodayDate()),
|
}}>
|
||||||
});
|
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
|
||||||
}}>
|
</DropdownMenuItem>
|
||||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={() => {
|
||||||
<DropdownMenuItem
|
setFilterRange(getFilterDropDownLabels(t).LAST_6_MONTHS);
|
||||||
onClick={() => {
|
setDateRange({
|
||||||
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
|
from: startOfMonth(subMonths(new Date(), 6)),
|
||||||
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
|
to: endOfMonth(getTodayDate()),
|
||||||
}}>
|
});
|
||||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
|
}}>
|
||||||
</DropdownMenuItem>
|
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
|
||||||
<DropdownMenuItem
|
</DropdownMenuItem>
|
||||||
onClick={() => {
|
<DropdownMenuItem
|
||||||
setFilterRange(getFilterDropDownLabels(t).LAST_YEAR);
|
onClick={() => {
|
||||||
setDateRange({
|
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
|
||||||
from: startOfYear(subYears(new Date(), 1)),
|
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
|
||||||
to: endOfYear(subYears(getTodayDate(), 1)),
|
}}>
|
||||||
});
|
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
|
||||||
}}>
|
</DropdownMenuItem>
|
||||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={() => {
|
||||||
<DropdownMenuItem
|
setFilterRange(getFilterDropDownLabels(t).LAST_YEAR);
|
||||||
onClick={() => {
|
setDateRange({
|
||||||
setIsDatePickerOpen(true);
|
from: startOfYear(subYears(new Date(), 1)),
|
||||||
setFilterRange(getFilterDropDownLabels(t).CUSTOM_RANGE);
|
to: endOfYear(subYears(getTodayDate(), 1)),
|
||||||
setSelectingDate(DateSelected.FROM);
|
});
|
||||||
}}>
|
}}>
|
||||||
<p className="text-sm text-slate-700 hover:ring-0">{getFilterDropDownLabels(t).CUSTOM_RANGE}</p>
|
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
<DropdownMenuItem
|
||||||
</DropdownMenu>
|
onClick={() => {
|
||||||
<DropdownMenu
|
setIsDatePickerOpen(true);
|
||||||
onOpenChange={(value) => {
|
setFilterRange(getFilterDropDownLabels(t).CUSTOM_RANGE);
|
||||||
value && handleDatePickerClose();
|
setSelectingDate(DateSelected.FROM);
|
||||||
setIsDownloadDropDownOpen(value);
|
}}>
|
||||||
}}>
|
<p className="text-sm text-slate-700 hover:ring-0">
|
||||||
<DropdownMenuTrigger asChild>
|
{getFilterDropDownLabels(t).CUSTOM_RANGE}
|
||||||
<PopoverTriggerButton isOpen={isDownloadDropDownOpen} disabled={isDownloading}>
|
</p>
|
||||||
<span className="flex items-center gap-2">
|
</DropdownMenuItem>
|
||||||
{t("common.download")}
|
</DropdownMenuContent>
|
||||||
{isDownloading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
</DropdownMenu>
|
||||||
</span>
|
<DropdownMenu
|
||||||
</PopoverTriggerButton>
|
onOpenChange={(value) => {
|
||||||
</DropdownMenuTrigger>
|
value && handleDatePickerClose();
|
||||||
|
}}>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
asChild
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-muted cursor-pointer outline-none",
|
||||||
|
isDownloading && "cursor-not-allowed opacity-50"
|
||||||
|
)}
|
||||||
|
disabled={isDownloading}
|
||||||
|
data-testid="fb__custom-filter-download-responses-button">
|
||||||
|
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
|
||||||
|
<div className="hidden w-full items-center justify-between sm:flex">
|
||||||
|
<span className="text-sm text-slate-700">{t("common.download")}</span>
|
||||||
|
{isDownloading ? (
|
||||||
|
<Loader2Icon className="ml-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DownloadIcon className="block h-4 sm:hidden" />
|
||||||
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
data-testid="fb__custom-filter-download-all-csv"
|
data-testid="fb__custom-filter-download-all-csv"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await handleDownloadResponses(FilterDownload.ALL, "csv");
|
await handleDownloadResponses(FilterDownload.ALL, "csv");
|
||||||
}}>
|
}}>
|
||||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
|
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
data-testid="fb__custom-filter-download-all-xlsx"
|
data-testid="fb__custom-filter-download-all-xlsx"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await handleDownloadResponses(FilterDownload.ALL, "xlsx");
|
await handleDownloadResponses(FilterDownload.ALL, "xlsx");
|
||||||
}}>
|
}}>
|
||||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
|
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
data-testid="fb__custom-filter-download-filtered-csv"
|
data-testid="fb__custom-filter-download-filtered-csv"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await handleDownloadResponses(FilterDownload.FILTER, "csv");
|
await handleDownloadResponses(FilterDownload.FILTER, "csv");
|
||||||
}}>
|
}}>
|
||||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
|
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
data-testid="fb__custom-filter-download-filtered-xlsx"
|
data-testid="fb__custom-filter-download-filtered-xlsx"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await handleDownloadResponses(FilterDownload.FILTER, "xlsx");
|
await handleDownloadResponses(FilterDownload.FILTER, "xlsx");
|
||||||
}}>
|
}}>
|
||||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p>
|
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
|
||||||
{isDatePickerOpen && (
|
|
||||||
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
|
|
||||||
<Calendar
|
|
||||||
autoFocus
|
|
||||||
mode="range"
|
|
||||||
defaultMonth={dateRange?.from}
|
|
||||||
selected={hoveredRange || dateRange}
|
|
||||||
numberOfMonths={2}
|
|
||||||
onDayClick={(date) => handleDateChange(date)}
|
|
||||||
onDayMouseEnter={handleDateHoveredChange}
|
|
||||||
onDayMouseLeave={() => setHoveredRange(null)}
|
|
||||||
classNames={{
|
|
||||||
day_today: "hover:bg-slate-200 bg-white",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{isDatePickerOpen && (
|
||||||
</div>
|
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
|
||||||
|
<Calendar
|
||||||
|
autoFocus
|
||||||
|
mode="range"
|
||||||
|
defaultMonth={dateRange?.from}
|
||||||
|
selected={hoveredRange ? hoveredRange : dateRange}
|
||||||
|
numberOfMonths={2}
|
||||||
|
onDayClick={(date) => handleDateChange(date)}
|
||||||
|
onDayMouseEnter={handleDateHoveredChange}
|
||||||
|
onDayMouseLeave={() => setHoveredRange(null)}
|
||||||
|
classNames={{
|
||||||
|
day_today: "hover:bg-slate-200 bg-white",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,319 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
|
||||||
import { useMemo, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TI18nString } from "@formbricks/types/i18n";
|
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
|
||||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementsComboBox";
|
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
|
||||||
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from "@/modules/ui/components/command";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
|
||||||
import { Input } from "@/modules/ui/components/input";
|
|
||||||
|
|
||||||
const DEFAULT_LANGUAGE_CODE = "default";
|
|
||||||
|
|
||||||
// Helper to get localized option value
|
|
||||||
const getOptionValue = (option: string | TI18nString): string => {
|
|
||||||
return typeof option === "object" && option !== null
|
|
||||||
? getLocalizedValue(option, DEFAULT_LANGUAGE_CODE)
|
|
||||||
: option;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ElementFilterComboBoxProps = {
|
|
||||||
filterOptions: (string | TI18nString)[] | undefined;
|
|
||||||
filterComboBoxOptions: (string | TI18nString)[] | undefined;
|
|
||||||
filterValue: string | undefined;
|
|
||||||
filterComboBoxValue: string | string[] | undefined;
|
|
||||||
onChangeFilterValue: (o: string) => void;
|
|
||||||
onChangeFilterComboBoxValue: (o: string | string[]) => void;
|
|
||||||
type?: TSurveyElementTypeEnum | Omit<OptionsType, OptionsType.ELEMENTS>;
|
|
||||||
handleRemoveMultiSelect: (value: string[]) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
fieldId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to check if multiple selection is allowed
|
|
||||||
const checkIsMultiple = (
|
|
||||||
type: TSurveyElementTypeEnum | Omit<OptionsType, OptionsType.ELEMENTS> | undefined,
|
|
||||||
filterValue: string | undefined
|
|
||||||
): boolean => {
|
|
||||||
const isMultiSelectType =
|
|
||||||
type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
|
|
||||||
type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
|
|
||||||
type === TSurveyElementTypeEnum.PictureSelection;
|
|
||||||
const isNPSIncludesEither = type === TSurveyElementTypeEnum.NPS && filterValue === "Includes either";
|
|
||||||
return isMultiSelectType || isNPSIncludesEither;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to check if combo box should be disabled
|
|
||||||
const checkIsDisabledComboBox = (
|
|
||||||
type: TSurveyElementTypeEnum | Omit<OptionsType, OptionsType.ELEMENTS> | undefined,
|
|
||||||
filterValue: string | undefined
|
|
||||||
): boolean => {
|
|
||||||
const isNPSOrRating = type === TSurveyElementTypeEnum.NPS || type === TSurveyElementTypeEnum.Rating;
|
|
||||||
const isSubmittedOrSkipped = filterValue === "Submitted" || filterValue === "Skipped";
|
|
||||||
return isNPSOrRating && isSubmittedOrSkipped;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ElementFilterComboBox = ({
|
|
||||||
filterComboBoxOptions,
|
|
||||||
filterComboBoxValue,
|
|
||||||
filterOptions,
|
|
||||||
filterValue,
|
|
||||||
onChangeFilterComboBoxValue,
|
|
||||||
onChangeFilterValue,
|
|
||||||
type,
|
|
||||||
handleRemoveMultiSelect,
|
|
||||||
disabled = false,
|
|
||||||
fieldId,
|
|
||||||
}: ElementFilterComboBoxProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const commandRef = useRef(null);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
useClickOutside(commandRef, () => setOpen(false));
|
|
||||||
|
|
||||||
const isMultiple = checkIsMultiple(type, filterValue);
|
|
||||||
|
|
||||||
// Filter out already selected options for multi-select
|
|
||||||
const options = useMemo(() => {
|
|
||||||
if (!isMultiple) return filterComboBoxOptions;
|
|
||||||
|
|
||||||
return filterComboBoxOptions?.filter((o) => {
|
|
||||||
const optionValue = getOptionValue(o);
|
|
||||||
return !filterComboBoxValue?.includes(optionValue);
|
|
||||||
});
|
|
||||||
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue]);
|
|
||||||
|
|
||||||
const isDisabledComboBox = checkIsDisabledComboBox(type, filterValue);
|
|
||||||
|
|
||||||
// Check if this is a text input field (URL meta field)
|
|
||||||
const isTextInputField = type === OptionsType.META && fieldId === "url";
|
|
||||||
|
|
||||||
// Filter options based on search query
|
|
||||||
const filteredOptions = useMemo(
|
|
||||||
() =>
|
|
||||||
options?.filter((o) => {
|
|
||||||
const optionValue = getOptionValue(o);
|
|
||||||
return optionValue.toLowerCase().includes(searchQuery.toLowerCase());
|
|
||||||
}),
|
|
||||||
[options, searchQuery]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCommandItemSelect = (o: string | TI18nString) => {
|
|
||||||
const value = getOptionValue(o);
|
|
||||||
|
|
||||||
if (isMultiple) {
|
|
||||||
const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value];
|
|
||||||
onChangeFilterComboBoxValue(newValue);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangeFilterComboBoxValue(value);
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isComboBoxDisabled = disabled || isDisabledComboBox || !filterValue;
|
|
||||||
const ChevronIcon = open ? ChevronUp : ChevronDown;
|
|
||||||
|
|
||||||
// Render filter options dropdown
|
|
||||||
const renderFilterOptionsDropdown = () => {
|
|
||||||
if (!filterOptions || filterOptions.length <= 1) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-9 max-w-fit items-center rounded-md rounded-r-none border-r border-slate-300 bg-white px-2 text-sm text-slate-600">
|
|
||||||
<p className="mr-1 max-w-[50px] truncate sm:max-w-[100px]">{filterValue}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<DropdownMenu
|
|
||||||
onOpenChange={(value) => {
|
|
||||||
if (value) setOpen(false);
|
|
||||||
}}>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
disabled={disabled}
|
|
||||||
className={clsx(
|
|
||||||
"flex h-9 max-w-fit items-center justify-between gap-2 rounded-md rounded-r-none border-r border-slate-300 bg-white px-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
|
|
||||||
disabled ? "opacity-50" : "cursor-pointer hover:bg-slate-50"
|
|
||||||
)}>
|
|
||||||
{filterValue ? (
|
|
||||||
<p className="max-w-[50px] truncate sm:max-w-[80px]">{filterValue}</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-slate-400">{t("common.select")}...</p>
|
|
||||||
)}
|
|
||||||
{filterOptions.length > 1 && <ChevronIcon className="h-4 w-4 flex-shrink-0 opacity-50" />}
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent className="bg-white">
|
|
||||||
{filterOptions.map((o, index) => {
|
|
||||||
const optionValue = getOptionValue(o);
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={`${optionValue}-${index}`}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => onChangeFilterValue(optionValue)}>
|
|
||||||
{optionValue}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenDropdown = () => {
|
|
||||||
if (isComboBoxDisabled) return;
|
|
||||||
setOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to filter out a specific value from the array
|
|
||||||
const getFilteredValues = (valueToRemove: string): string[] => {
|
|
||||||
if (!Array.isArray(filterComboBoxValue)) return [];
|
|
||||||
return filterComboBoxValue.filter((i) => i !== valueToRemove);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle removal of a multi-select tag
|
|
||||||
const handleRemoveTag = (e: React.MouseEvent, valueToRemove: string) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const filteredValues = getFilteredValues(valueToRemove);
|
|
||||||
handleRemoveMultiSelect(filteredValues);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render a single multi-select tag
|
|
||||||
const renderTag = (value: string, index: number) => (
|
|
||||||
<button
|
|
||||||
key={`${value}-${index}`}
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => handleRemoveTag(e, value)}
|
|
||||||
className="flex items-center gap-1 whitespace-nowrap rounded bg-slate-100 px-2 py-1 text-sm text-slate-600 hover:bg-slate-200">
|
|
||||||
{value}
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Render multi-select tags
|
|
||||||
const renderMultiSelectTags = () => {
|
|
||||||
if (!Array.isArray(filterComboBoxValue) || filterComboBoxValue.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="no-scrollbar flex grow gap-2 overflow-auto">
|
|
||||||
{filterComboBoxValue.map((value, index) => renderTag(value, index))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render the appropriate content based on filterComboBoxValue state
|
|
||||||
const renderComboBoxContent = () => {
|
|
||||||
if (!filterComboBoxValue || filterComboBoxValue.length === 0) {
|
|
||||||
return (
|
|
||||||
<p className={clsx("text-sm", isComboBoxDisabled ? "text-slate-300" : "text-slate-400")}>
|
|
||||||
{t("common.select")}...
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(filterComboBoxValue)) {
|
|
||||||
return renderMultiSelectTags();
|
|
||||||
}
|
|
||||||
|
|
||||||
return <p className="truncate text-sm text-slate-600">{filterComboBoxValue}</p>;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="inline-flex h-fit w-full flex-row rounded-md border border-slate-300 hover:border-slate-400">
|
|
||||||
{renderFilterOptionsDropdown()}
|
|
||||||
|
|
||||||
{isTextInputField ? (
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={typeof filterComboBoxValue === "string" ? filterComboBoxValue : ""}
|
|
||||||
onChange={(e) => onChangeFilterComboBoxValue(e.target.value)}
|
|
||||||
disabled={isComboBoxDisabled}
|
|
||||||
placeholder={t("common.enter_url")}
|
|
||||||
className="h-9 rounded-l-none border-none bg-white text-sm focus:ring-offset-0"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Command ref={commandRef} className="relative h-fit w-full min-w-0 overflow-visible bg-transparent">
|
|
||||||
{/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role */}
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={isComboBoxDisabled ? -1 : 0}
|
|
||||||
className={clsx(
|
|
||||||
"flex min-w-0 items-center gap-2 rounded-md rounded-l-none bg-white pl-2",
|
|
||||||
isComboBoxDisabled ? "opacity-50" : "cursor-pointer hover:bg-slate-50"
|
|
||||||
)}
|
|
||||||
onClick={handleOpenDropdown}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
const isActivationKey = e.key === "Enter" || e.key === " ";
|
|
||||||
if (isActivationKey && !isComboBoxDisabled) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleOpenDropdown();
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<div className="min-w-0 flex-1">{renderComboBoxContent()}</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (isComboBoxDisabled) return;
|
|
||||||
setOpen(!open);
|
|
||||||
}}
|
|
||||||
disabled={isComboBoxDisabled}
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
className="flex-shrink-0"
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-label={t("common.select")}>
|
|
||||||
<ChevronIcon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md bg-white shadow-md outline-none">
|
|
||||||
<CommandList className="max-h-52">
|
|
||||||
<CommandInput
|
|
||||||
value={searchQuery}
|
|
||||||
onValueChange={setSearchQuery}
|
|
||||||
placeholder={`${t("common.search")}...`}
|
|
||||||
className="border-none"
|
|
||||||
/>
|
|
||||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{filteredOptions?.map((o) => {
|
|
||||||
const optionValue = getOptionValue(o);
|
|
||||||
return (
|
|
||||||
<CommandItem
|
|
||||||
key={optionValue}
|
|
||||||
onSelect={() => handleCommandItemSelect(o)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
{optionValue}
|
|
||||||
</CommandItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Command>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {
|
|
||||||
AirplayIcon,
|
|
||||||
ArrowUpFromDotIcon,
|
|
||||||
CheckIcon,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
ContactIcon,
|
|
||||||
EyeOff,
|
|
||||||
FlagIcon,
|
|
||||||
GlobeIcon,
|
|
||||||
GridIcon,
|
|
||||||
HashIcon,
|
|
||||||
HomeIcon,
|
|
||||||
ImageIcon,
|
|
||||||
LanguagesIcon,
|
|
||||||
LinkIcon,
|
|
||||||
ListIcon,
|
|
||||||
ListOrderedIcon,
|
|
||||||
MessageSquareTextIcon,
|
|
||||||
MousePointerClickIcon,
|
|
||||||
PieChartIcon,
|
|
||||||
Rows3Icon,
|
|
||||||
SmartphoneIcon,
|
|
||||||
StarIcon,
|
|
||||||
User,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Fragment, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
|
||||||
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from "@/modules/ui/components/command";
|
|
||||||
import { NetPromoterScoreIcon } from "@/modules/ui/components/icons";
|
|
||||||
|
|
||||||
export enum OptionsType {
|
|
||||||
ELEMENTS = "Elements",
|
|
||||||
TAGS = "Tags",
|
|
||||||
ATTRIBUTES = "Attributes",
|
|
||||||
OTHERS = "Other Filters",
|
|
||||||
META = "Meta",
|
|
||||||
HIDDEN_FIELDS = "Hidden Fields",
|
|
||||||
QUOTAS = "Quotas",
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ElementOption = {
|
|
||||||
label: string;
|
|
||||||
elementType?: TSurveyElementTypeEnum;
|
|
||||||
type: OptionsType;
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
export type ElementOptions = {
|
|
||||||
header: OptionsType;
|
|
||||||
option: ElementOption[];
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ElementComboBoxProps {
|
|
||||||
options: ElementOptions[];
|
|
||||||
selected: Partial<ElementOption>;
|
|
||||||
onChangeValue: (option: ElementOption) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const elementIcons = {
|
|
||||||
// elements
|
|
||||||
[TSurveyElementTypeEnum.OpenText]: MessageSquareTextIcon,
|
|
||||||
[TSurveyElementTypeEnum.Rating]: StarIcon,
|
|
||||||
[TSurveyElementTypeEnum.CTA]: MousePointerClickIcon,
|
|
||||||
[TSurveyElementTypeEnum.MultipleChoiceMulti]: ListIcon,
|
|
||||||
[TSurveyElementTypeEnum.MultipleChoiceSingle]: Rows3Icon,
|
|
||||||
[TSurveyElementTypeEnum.NPS]: NetPromoterScoreIcon,
|
|
||||||
[TSurveyElementTypeEnum.Consent]: CheckIcon,
|
|
||||||
[TSurveyElementTypeEnum.PictureSelection]: ImageIcon,
|
|
||||||
[TSurveyElementTypeEnum.Matrix]: GridIcon,
|
|
||||||
[TSurveyElementTypeEnum.Ranking]: ListOrderedIcon,
|
|
||||||
[TSurveyElementTypeEnum.Address]: HomeIcon,
|
|
||||||
[TSurveyElementTypeEnum.ContactInfo]: ContactIcon,
|
|
||||||
|
|
||||||
// attributes
|
|
||||||
[OptionsType.ATTRIBUTES]: User,
|
|
||||||
|
|
||||||
// hidden fields
|
|
||||||
[OptionsType.HIDDEN_FIELDS]: EyeOff,
|
|
||||||
|
|
||||||
// meta
|
|
||||||
device: SmartphoneIcon,
|
|
||||||
os: AirplayIcon,
|
|
||||||
browser: GlobeIcon,
|
|
||||||
source: ArrowUpFromDotIcon,
|
|
||||||
action: MousePointerClickIcon,
|
|
||||||
country: FlagIcon,
|
|
||||||
url: LinkIcon,
|
|
||||||
|
|
||||||
// others
|
|
||||||
Language: LanguagesIcon,
|
|
||||||
|
|
||||||
// tags
|
|
||||||
[OptionsType.TAGS]: HashIcon,
|
|
||||||
|
|
||||||
// quotas
|
|
||||||
[OptionsType.QUOTAS]: PieChartIcon,
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIcon = (type: string) => {
|
|
||||||
const IconComponent = elementIcons[type];
|
|
||||||
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIconBackground = (type: OptionsType | string): string => {
|
|
||||||
const backgroundMap: Record<string, string> = {
|
|
||||||
[OptionsType.ATTRIBUTES]: "bg-indigo-500",
|
|
||||||
[OptionsType.ELEMENTS]: "bg-brand-dark",
|
|
||||||
[OptionsType.TAGS]: "bg-indigo-500",
|
|
||||||
[OptionsType.QUOTAS]: "bg-slate-500",
|
|
||||||
};
|
|
||||||
return backgroundMap[type] ?? "bg-amber-500";
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLabelClassName = (type: OptionsType | string, label?: string): string => {
|
|
||||||
if (type !== OptionsType.META) return "";
|
|
||||||
return label === "os" || label === "url" ? "uppercase" : "capitalize";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SelectedCommandItem = ({ label, elementType, type }: Partial<ElementOption>) => {
|
|
||||||
const getDisplayIcon = () => {
|
|
||||||
if (!type) return null;
|
|
||||||
if (type === OptionsType.ELEMENTS && elementType) return getIcon(elementType);
|
|
||||||
if (type === OptionsType.ATTRIBUTES) return getIcon(OptionsType.ATTRIBUTES);
|
|
||||||
if (type === OptionsType.HIDDEN_FIELDS) return getIcon(OptionsType.HIDDEN_FIELDS);
|
|
||||||
if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) return getIcon(label);
|
|
||||||
if (type === OptionsType.TAGS) return getIcon(OptionsType.TAGS);
|
|
||||||
if (type === OptionsType.QUOTAS) return getIcon(OptionsType.QUOTAS);
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full min-w-0 items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-white",
|
|
||||||
getIconBackground(type ?? "")
|
|
||||||
)}>
|
|
||||||
{getDisplayIcon()}
|
|
||||||
</span>
|
|
||||||
<p className={clsx("truncate text-sm text-slate-600", getLabelClassName(type ?? "", label))}>
|
|
||||||
{typeof label === "string" ? label : getLocalizedValue(label, "default")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementComboBoxProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const commandRef = useRef(null);
|
|
||||||
const [inputValue, setInputValue] = useState("");
|
|
||||||
useClickOutside(commandRef, () => setOpen(false));
|
|
||||||
|
|
||||||
const hasSelection = selected.hasOwnProperty("label");
|
|
||||||
const ChevronIcon = open ? ChevronUp : ChevronDown;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Command
|
|
||||||
ref={commandRef}
|
|
||||||
className="relative h-fit w-full overflow-visible rounded-md border border-slate-300 bg-white hover:border-slate-400">
|
|
||||||
{/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role */}
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
className="flex cursor-pointer items-center justify-between"
|
|
||||||
onClick={() => !open && setOpen(true)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
!open && setOpen(true);
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
{!open && hasSelection && <SelectedCommandItem {...selected} />}
|
|
||||||
{(open || !hasSelection) && (
|
|
||||||
<CommandInput
|
|
||||||
value={inputValue}
|
|
||||||
onValueChange={setInputValue}
|
|
||||||
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
|
|
||||||
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setOpen(!open);
|
|
||||||
}}
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
className="flex-shrink-0"
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-label={t("common.select")}>
|
|
||||||
<ChevronIcon className="h-4 w-4 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md shadow-md outline-none">
|
|
||||||
<CommandList className="max-h-[600px]">
|
|
||||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
|
||||||
{options?.map((data) => (
|
|
||||||
<Fragment key={data.header}>
|
|
||||||
{data?.option.length > 0 && (
|
|
||||||
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
|
|
||||||
{data?.option?.map((o) => (
|
|
||||||
<CommandItem
|
|
||||||
key={o.id}
|
|
||||||
onSelect={() => {
|
|
||||||
setInputValue("");
|
|
||||||
onChangeValue(o);
|
|
||||||
setOpen(false);
|
|
||||||
}}>
|
|
||||||
<SelectedCommandItem {...o} />
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</CommandList>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Command>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||||
|
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||||
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
|
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/modules/ui/components/command";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
|
||||||
|
type QuestionFilterComboBoxProps = {
|
||||||
|
filterOptions: string[] | undefined;
|
||||||
|
filterComboBoxOptions: string[] | undefined;
|
||||||
|
filterValue: string | undefined;
|
||||||
|
filterComboBoxValue: string | string[] | undefined;
|
||||||
|
onChangeFilterValue: (o: string) => void;
|
||||||
|
onChangeFilterComboBoxValue: (o: string | string[]) => void;
|
||||||
|
type?: TSurveyQuestionTypeEnum | Omit<OptionsType, OptionsType.QUESTIONS>;
|
||||||
|
handleRemoveMultiSelect: (value: string[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
fieldId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QuestionFilterComboBox = ({
|
||||||
|
filterComboBoxOptions,
|
||||||
|
filterComboBoxValue,
|
||||||
|
filterOptions,
|
||||||
|
filterValue,
|
||||||
|
onChangeFilterComboBoxValue,
|
||||||
|
onChangeFilterValue,
|
||||||
|
type,
|
||||||
|
handleRemoveMultiSelect,
|
||||||
|
disabled = false,
|
||||||
|
fieldId,
|
||||||
|
}: QuestionFilterComboBoxProps) => {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [openFilterValue, setOpenFilterValue] = React.useState<boolean>(false);
|
||||||
|
const commandRef = React.useRef(null);
|
||||||
|
const [searchQuery, setSearchQuery] = React.useState<string>("");
|
||||||
|
const defaultLanguageCode = "default";
|
||||||
|
useClickOutside(commandRef, () => setOpen(false));
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// multiple when question type is multi selection
|
||||||
|
const isMultiple =
|
||||||
|
type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
|
||||||
|
type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||||
|
type === TSurveyQuestionTypeEnum.PictureSelection ||
|
||||||
|
(type === TSurveyQuestionTypeEnum.NPS && filterValue === "Includes either");
|
||||||
|
|
||||||
|
// when question type is multi selection so we remove the option from the options which has been already selected
|
||||||
|
const options = isMultiple
|
||||||
|
? filterComboBoxOptions?.filter(
|
||||||
|
(o) =>
|
||||||
|
!filterComboBoxValue?.includes(
|
||||||
|
typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: filterComboBoxOptions;
|
||||||
|
|
||||||
|
// disable the combo box for selection of value when question type is nps or rating and selected value is submitted or skipped
|
||||||
|
const isDisabledComboBox =
|
||||||
|
(type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) &&
|
||||||
|
(filterValue === "Submitted" || filterValue === "Skipped");
|
||||||
|
|
||||||
|
// Check if this is a URL field with string comparison operations that require text input
|
||||||
|
const isTextInputField = type === OptionsType.META && fieldId === "url";
|
||||||
|
|
||||||
|
const filteredOptions = options?.filter((o) =>
|
||||||
|
(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o)
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterComboBoxItem = !Array.isArray(filterComboBoxValue) ? (
|
||||||
|
<p className="text-slate-600">{filterComboBoxValue}</p>
|
||||||
|
) : (
|
||||||
|
<div className="no-scrollbar flex w-[7rem] gap-3 overflow-auto md:w-[10rem] lg:w-[18rem]">
|
||||||
|
{typeof filterComboBoxValue !== "string" &&
|
||||||
|
filterComboBoxValue?.map((o, index) => (
|
||||||
|
<button
|
||||||
|
key={`${o}-${index}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
|
||||||
|
className="w-30 flex items-center whitespace-nowrap bg-slate-100 px-2 text-slate-600">
|
||||||
|
{o}
|
||||||
|
<X width={14} height={14} className="ml-2" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const commandItemOnSelect = (o: string) => {
|
||||||
|
if (!isMultiple) {
|
||||||
|
onChangeFilterComboBoxValue(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o);
|
||||||
|
} else {
|
||||||
|
onChangeFilterComboBoxValue(
|
||||||
|
Array.isArray(filterComboBoxValue)
|
||||||
|
? [...filterComboBoxValue, typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
|
||||||
|
: [typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isMultiple) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex w-full flex-row">
|
||||||
|
{filterOptions && filterOptions?.length <= 1 ? (
|
||||||
|
<div className="h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600">
|
||||||
|
<p className="mr-1 max-w-[50px] truncate text-slate-600 sm:max-w-[100px]">{filterValue}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DropdownMenu
|
||||||
|
onOpenChange={(value) => {
|
||||||
|
value && setOpen(false);
|
||||||
|
setOpenFilterValue(value);
|
||||||
|
}}>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
disabled={disabled}
|
||||||
|
className={clsx(
|
||||||
|
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
|
||||||
|
!disabled ? "cursor-pointer" : "opacity-50"
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{!filterValue ? (
|
||||||
|
<p className="text-slate-400">{t("common.select")}...</p>
|
||||||
|
) : (
|
||||||
|
<p className="mr-1 max-w-[50px] truncate text-slate-600 sm:max-w-[80px]">{filterValue}</p>
|
||||||
|
)}
|
||||||
|
{filterOptions && filterOptions.length > 1 && (
|
||||||
|
<>
|
||||||
|
{openFilterValue ? (
|
||||||
|
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="bg-white p-2">
|
||||||
|
{filterOptions?.map((o, index) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={`${o}-${index}`}
|
||||||
|
className="px-0.5 py-1 dark:bg-slate-700 dark:text-slate-300 dark:ring-slate-700"
|
||||||
|
onClick={() => onChangeFilterValue(o)}>
|
||||||
|
{o}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
{isTextInputField ? (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={typeof filterComboBoxValue === "string" ? filterComboBoxValue : ""}
|
||||||
|
onChange={(e) => onChangeFilterComboBoxValue(e.target.value)}
|
||||||
|
disabled={disabled || !filterValue}
|
||||||
|
className="h-9 rounded-l-none border-none bg-white text-sm focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
|
||||||
|
)}>
|
||||||
|
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
|
||||||
|
filterComboBoxItem
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||||
|
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||||
|
className={clsx(
|
||||||
|
"flex-1 text-left text-slate-400",
|
||||||
|
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||||
|
)}>
|
||||||
|
{t("common.select")}...
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||||
|
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||||
|
className={clsx(
|
||||||
|
"ml-2 flex items-center justify-center",
|
||||||
|
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||||
|
)}>
|
||||||
|
{open ? (
|
||||||
|
<ChevronUp className="h-4 w-4 opacity-50" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative mt-2 h-full">
|
||||||
|
{open && (
|
||||||
|
<div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
|
||||||
|
<CommandList>
|
||||||
|
<div className="p-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
placeholder={t("common.search") + "..."}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{filteredOptions?.map((o, index) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
|
||||||
|
onSelect={() => commandItemOnSelect(o)}
|
||||||
|
className="cursor-pointer">
|
||||||
|
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Command>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {
|
||||||
|
AirplayIcon,
|
||||||
|
ArrowUpFromDotIcon,
|
||||||
|
CheckIcon,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
ContactIcon,
|
||||||
|
EyeOff,
|
||||||
|
FlagIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
GridIcon,
|
||||||
|
HashIcon,
|
||||||
|
HomeIcon,
|
||||||
|
ImageIcon,
|
||||||
|
LanguagesIcon,
|
||||||
|
LinkIcon,
|
||||||
|
ListIcon,
|
||||||
|
ListOrderedIcon,
|
||||||
|
MessageSquareTextIcon,
|
||||||
|
MousePointerClickIcon,
|
||||||
|
PieChartIcon,
|
||||||
|
Rows3Icon,
|
||||||
|
SmartphoneIcon,
|
||||||
|
StarIcon,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Fragment, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||||
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
|
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/modules/ui/components/command";
|
||||||
|
import { NetPromoterScoreIcon } from "@/modules/ui/components/icons";
|
||||||
|
|
||||||
|
export enum OptionsType {
|
||||||
|
QUESTIONS = "Questions",
|
||||||
|
TAGS = "Tags",
|
||||||
|
ATTRIBUTES = "Attributes",
|
||||||
|
OTHERS = "Other Filters",
|
||||||
|
META = "Meta",
|
||||||
|
HIDDEN_FIELDS = "Hidden Fields",
|
||||||
|
QUOTAS = "Quotas",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QuestionOption = {
|
||||||
|
label: string;
|
||||||
|
questionType?: TSurveyQuestionTypeEnum;
|
||||||
|
type: OptionsType;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
export type QuestionOptions = {
|
||||||
|
header: OptionsType;
|
||||||
|
option: QuestionOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface QuestionComboBoxProps {
|
||||||
|
options: QuestionOptions[];
|
||||||
|
selected: Partial<QuestionOption>;
|
||||||
|
onChangeValue: (option: QuestionOption) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const questionIcons = {
|
||||||
|
// questions
|
||||||
|
[TSurveyQuestionTypeEnum.OpenText]: MessageSquareTextIcon,
|
||||||
|
[TSurveyQuestionTypeEnum.Rating]: StarIcon,
|
||||||
|
[TSurveyQuestionTypeEnum.CTA]: MousePointerClickIcon,
|
||||||
|
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: ListIcon,
|
||||||
|
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: Rows3Icon,
|
||||||
|
[TSurveyQuestionTypeEnum.NPS]: NetPromoterScoreIcon,
|
||||||
|
[TSurveyQuestionTypeEnum.Consent]: CheckIcon,
|
||||||
|
[TSurveyQuestionTypeEnum.PictureSelection]: ImageIcon,
|
||||||
|
[TSurveyQuestionTypeEnum.Matrix]: GridIcon,
|
||||||
|
[TSurveyQuestionTypeEnum.Ranking]: ListOrderedIcon,
|
||||||
|
[TSurveyQuestionTypeEnum.Address]: HomeIcon,
|
||||||
|
[TSurveyQuestionTypeEnum.ContactInfo]: ContactIcon,
|
||||||
|
|
||||||
|
// attributes
|
||||||
|
[OptionsType.ATTRIBUTES]: User,
|
||||||
|
|
||||||
|
// hidden fields
|
||||||
|
[OptionsType.HIDDEN_FIELDS]: EyeOff,
|
||||||
|
|
||||||
|
// meta
|
||||||
|
device: SmartphoneIcon,
|
||||||
|
os: AirplayIcon,
|
||||||
|
browser: GlobeIcon,
|
||||||
|
source: ArrowUpFromDotIcon,
|
||||||
|
action: MousePointerClickIcon,
|
||||||
|
country: FlagIcon,
|
||||||
|
url: LinkIcon,
|
||||||
|
|
||||||
|
// others
|
||||||
|
Language: LanguagesIcon,
|
||||||
|
|
||||||
|
// tags
|
||||||
|
[OptionsType.TAGS]: HashIcon,
|
||||||
|
|
||||||
|
// quotas
|
||||||
|
[OptionsType.QUOTAS]: PieChartIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIcon = (type: string) => {
|
||||||
|
const IconComponent = questionIcons[type];
|
||||||
|
return IconComponent ? <IconComponent width={18} height={18} className="text-white" /> : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
|
||||||
|
const getIconType = () => {
|
||||||
|
if (type) {
|
||||||
|
if (type === OptionsType.QUESTIONS && questionType) {
|
||||||
|
return getIcon(questionType);
|
||||||
|
} else if (type === OptionsType.ATTRIBUTES) {
|
||||||
|
return getIcon(OptionsType.ATTRIBUTES);
|
||||||
|
} else if (type === OptionsType.HIDDEN_FIELDS) {
|
||||||
|
return getIcon(OptionsType.HIDDEN_FIELDS);
|
||||||
|
} else if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) {
|
||||||
|
return getIcon(label);
|
||||||
|
} else if (type === OptionsType.TAGS) {
|
||||||
|
return getIcon(OptionsType.TAGS);
|
||||||
|
} else if (type === OptionsType.QUOTAS) {
|
||||||
|
return getIcon(OptionsType.QUOTAS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColor = () => {
|
||||||
|
if (type === OptionsType.ATTRIBUTES) {
|
||||||
|
return "bg-indigo-500";
|
||||||
|
} else if (type === OptionsType.QUESTIONS) {
|
||||||
|
return "bg-brand-dark";
|
||||||
|
} else if (type === OptionsType.TAGS) {
|
||||||
|
return "bg-indigo-500";
|
||||||
|
} else if (type === OptionsType.QUOTAS) {
|
||||||
|
return "bg-slate-500";
|
||||||
|
} else {
|
||||||
|
return "bg-amber-500";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLabelStyle = (): string | undefined => {
|
||||||
|
if (type !== OptionsType.META) return undefined;
|
||||||
|
return label === "os" || label === "url" ? "uppercase" : "capitalize";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-5 w-[12rem] items-center sm:w-4/5">
|
||||||
|
<span className={clsx("rounded-md p-1", getColor())}>{getIconType()}</span>
|
||||||
|
<p className={clsx("ml-3 truncate text-sm text-slate-600", getLabelStyle())}>
|
||||||
|
{typeof label === "string" ? label : getLocalizedValue(label, "default")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QuestionsComboBox = ({ options, selected, onChangeValue }: QuestionComboBoxProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const commandRef = useRef(null);
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
useClickOutside(commandRef, () => setOpen(false));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent hover:bg-slate-50">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="group flex cursor-pointer items-center justify-between rounded-md bg-white px-3 py-2 text-sm">
|
||||||
|
{!open && selected.hasOwnProperty("label") && (
|
||||||
|
<SelectedCommandItem
|
||||||
|
label={selected?.label}
|
||||||
|
type={selected?.type}
|
||||||
|
questionType={selected?.questionType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(open || !selected.hasOwnProperty("label")) && (
|
||||||
|
<CommandInput
|
||||||
|
value={inputValue}
|
||||||
|
onValueChange={setInputValue}
|
||||||
|
placeholder={t("common.search") + "..."}
|
||||||
|
className="h-5 border-none border-transparent p-0 shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
{open ? (
|
||||||
|
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div className="relative mt-2 h-full">
|
||||||
|
{open && (
|
||||||
|
<div className="animate-in absolute top-0 z-50 w-full overflow-auto rounded-md bg-white outline-none">
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||||
|
{options?.map((data) => (
|
||||||
|
<Fragment key={data.header}>
|
||||||
|
{data?.option.length > 0 && (
|
||||||
|
<CommandGroup
|
||||||
|
heading={<p className="text-sm font-normal text-slate-600">{data.header}</p>}>
|
||||||
|
{data?.option?.map((o, i) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`${o.label}-${i}`}
|
||||||
|
onSelect={() => {
|
||||||
|
setInputValue("");
|
||||||
|
onChangeValue(o);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer">
|
||||||
|
<SelectedCommandItem label={o.label} type={o.type} questionType={o.questionType} />
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Command>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,18 +4,15 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
|||||||
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
|
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TI18nString } from "@formbricks/types/i18n";
|
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
|
||||||
import {
|
import {
|
||||||
SelectedFilterValue,
|
SelectedFilterValue,
|
||||||
TResponseStatus,
|
TResponseStatus,
|
||||||
useResponseFilter,
|
useResponseFilter,
|
||||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||||
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||||
import { ElementFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementFilterComboBox";
|
import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox";
|
||||||
import { generateElementAndFilterOptions } from "@/app/lib/surveys/surveys";
|
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
|
||||||
import { 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 {
|
||||||
@@ -25,49 +22,15 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/modules/ui/components/select";
|
} from "@/modules/ui/components/select";
|
||||||
import { ElementOption, ElementsComboBox, OptionsType } from "./ElementsComboBox";
|
import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
|
||||||
|
|
||||||
export type ElementFilterOptions = {
|
export type QuestionFilterOptions = {
|
||||||
type:
|
type: TSurveyQuestionTypeEnum | "Attributes" | "Tags" | "Languages" | "Quotas";
|
||||||
| TSurveyElementTypeEnum
|
filterOptions: string[];
|
||||||
| "Attributes"
|
filterComboBoxOptions: string[];
|
||||||
| "Tags"
|
|
||||||
| "Languages"
|
|
||||||
| "Quotas"
|
|
||||||
| "Hidden Fields"
|
|
||||||
| "Meta"
|
|
||||||
| OptionsType.OTHERS;
|
|
||||||
filterOptions: (string | TI18nString)[];
|
|
||||||
filterComboBoxOptions: (string | TI18nString)[];
|
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PopoverTriggerButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
||||||
isOpen: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PopoverTriggerButton = React.forwardRef<HTMLButtonElement, PopoverTriggerButtonProps>(
|
|
||||||
({ isOpen, children, ...props }, ref) => (
|
|
||||||
<button
|
|
||||||
ref={ref}
|
|
||||||
type="button"
|
|
||||||
{...props}
|
|
||||||
className="flex min-w-[8rem] cursor-pointer items-center justify-between rounded-md border border-slate-300 bg-white p-2 hover:border-slate-400">
|
|
||||||
<span className="text-sm text-slate-700">{children}</span>
|
|
||||||
<div className="ml-3">
|
|
||||||
{isOpen ? (
|
|
||||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
PopoverTriggerButton.displayName = "PopoverTriggerButton";
|
|
||||||
|
|
||||||
interface ResponseFilterProps {
|
interface ResponseFilterProps {
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
}
|
}
|
||||||
@@ -80,12 +43,6 @@ 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 () => {
|
||||||
@@ -95,7 +52,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
|||||||
if (!surveyFilterData?.data) return;
|
if (!surveyFilterData?.data) return;
|
||||||
|
|
||||||
const { attributes, meta, environmentTags, hiddenFields, quotas } = surveyFilterData.data;
|
const { attributes, meta, environmentTags, hiddenFields, quotas } = surveyFilterData.data;
|
||||||
const { elementFilterOptions, elementOptions } = generateElementAndFilterOptions(
|
const { questionFilterOptions, questionOptions } = generateQuestionAndFilterOptions(
|
||||||
survey,
|
survey,
|
||||||
environmentTags,
|
environmentTags,
|
||||||
attributes,
|
attributes,
|
||||||
@@ -103,35 +60,34 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
|||||||
hiddenFields,
|
hiddenFields,
|
||||||
quotas
|
quotas
|
||||||
);
|
);
|
||||||
setSelectedOptions({ elementFilterOptions: elementFilterOptions, elementOptions: elementOptions });
|
setSelectedOptions({ questionFilterOptions, questionOptions });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleInitialData();
|
handleInitialData();
|
||||||
}, [isOpen, setSelectedOptions, survey]);
|
}, [isOpen, setSelectedOptions, survey]);
|
||||||
|
|
||||||
const handleOnChangeElementComboBoxValue = (value: ElementOption, index: number) => {
|
const handleOnChangeQuestionComboBoxValue = (value: QuestionOption, index: number) => {
|
||||||
const matchingFilterOption = selectedOptions.elementFilterOptions.find(
|
if (filterValue.filter[index].questionType) {
|
||||||
(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] = {
|
||||||
elementType: value,
|
questionType: value,
|
||||||
filterType: {
|
filterType: {
|
||||||
filterComboBoxValue: undefined,
|
filterComboBoxValue: undefined,
|
||||||
filterValue: defaultFilterValue,
|
filterValue: selectedOptions.questionFilterOptions.find(
|
||||||
|
(q) => q.type === value.type || q.type === value.questionType
|
||||||
|
)?.filterOptions[0],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
setFilterValue({ filter: [...filterValue.filter], responseStatus: filterValue.responseStatus });
|
setFilterValue({ filter: [...filterValue.filter], responseStatus: filterValue.responseStatus });
|
||||||
} else {
|
} else {
|
||||||
// Update the existing value at the specified index
|
// Update the existing value at the specified index
|
||||||
filterValue.filter[index].elementType = value;
|
filterValue.filter[index].questionType = value;
|
||||||
filterValue.filter[index].filterType = {
|
filterValue.filter[index].filterType = {
|
||||||
filterComboBoxValue: undefined,
|
filterComboBoxValue: undefined,
|
||||||
filterValue: defaultFilterValue,
|
filterValue: selectedOptions.questionFilterOptions.find(
|
||||||
|
(q) => q.type === value.type || q.type === value.questionType
|
||||||
|
)?.filterOptions[0],
|
||||||
};
|
};
|
||||||
setFilterValue({ ...filterValue });
|
setFilterValue({ ...filterValue });
|
||||||
}
|
}
|
||||||
@@ -141,8 +97,8 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
|||||||
const clearItem = () => {
|
const clearItem = () => {
|
||||||
setFilterValue({
|
setFilterValue({
|
||||||
filter: filterValue.filter.filter((s) => {
|
filter: filterValue.filter.filter((s) => {
|
||||||
// keep the filter if elementType is selected and filterComboBoxValue is selected
|
// keep the filter if questionType is selected and filterComboBoxValue is selected
|
||||||
return s.elementType.hasOwnProperty("label") && s.filterType.filterComboBoxValue?.length;
|
return s.questionType.hasOwnProperty("label") && s.filterType.filterComboBoxValue?.length;
|
||||||
}),
|
}),
|
||||||
responseStatus: filterValue.responseStatus,
|
responseStatus: filterValue.responseStatus,
|
||||||
});
|
});
|
||||||
@@ -152,6 +108,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
clearItem();
|
clearItem();
|
||||||
|
handleApplyFilters();
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
@@ -162,7 +119,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
|||||||
filter: [
|
filter: [
|
||||||
...filterValue.filter,
|
...filterValue.filter,
|
||||||
{
|
{
|
||||||
elementType: {},
|
questionType: {},
|
||||||
filterType: { filterComboBoxValue: undefined, filterValue: undefined },
|
filterType: { filterComboBoxValue: undefined, filterValue: undefined },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -170,9 +127,8 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClearAllFilters = () => {
|
const handleClearAllFilters = () => {
|
||||||
const clearedFilters = { filter: [], responseStatus: "all" as const };
|
setFilterValue((filterValue) => ({ ...filterValue, filter: [], responseStatus: "all" }));
|
||||||
setFilterValue(clearedFilters);
|
setSelectedFilter((selectedFilters) => ({ ...selectedFilters, filter: [], responseStatus: "all" }));
|
||||||
setSelectedFilter(clearedFilters);
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -214,10 +170,10 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// remove the filter which has already been selected
|
// remove the filter which has already been selected
|
||||||
const elementComboBoxOptions = selectedOptions.elementOptions.map((q) => {
|
const questionComboBoxOptions = selectedOptions.questionOptions.map((q) => {
|
||||||
return {
|
return {
|
||||||
...q,
|
...q,
|
||||||
option: q.option.filter((o) => !filterValue.filter.some((f) => f?.elementType?.id === o?.id)),
|
option: q.option.filter((o) => !filterValue.filter.some((f) => f?.questionType?.id === o?.id)),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -228,6 +184,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
handleApplyFilters();
|
||||||
|
}
|
||||||
setIsOpen(open);
|
setIsOpen(open);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -235,30 +194,38 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
|||||||
setFilterValue(selectedFilter);
|
setFilterValue(selectedFilter);
|
||||||
}, [selectedFilter]);
|
}, [selectedFilter]);
|
||||||
|
|
||||||
const activeFilterCount = filterValue.filter.length + (filterValue.responseStatus === "all" ? 0 : 1);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded border border-slate-200 bg-white p-3 text-sm text-slate-600 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
|
||||||
<PopoverTriggerButton isOpen={isOpen}>
|
<span>
|
||||||
Filter <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
|
Filter <b>{filterValue.filter.length > 0 && `(${filterValue.filter.length})`}</b>
|
||||||
</PopoverTriggerButton>
|
</span>
|
||||||
|
<div className="ml-3">
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
align="start"
|
align="start"
|
||||||
className="w-[300px] rounded-lg border-slate-200 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]"
|
className="w-[300px] border-slate-200 bg-slate-100 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]"
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}>
|
onOpenAutoFocus={(event) => event.preventDefault()}>
|
||||||
<div className="mb-6 flex flex-wrap items-start justify-between gap-2">
|
<div className="mb-8 flex flex-wrap items-start justify-between gap-2">
|
||||||
<p className="font-semibold text-slate-800">
|
<p className="text-slate800 hidden text-lg font-semibold sm:block">
|
||||||
{t("environments.surveys.summary.show_all_responses_that_match")}
|
{t("environments.surveys.summary.show_all_responses_that_match")}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="block text-base text-slate-500 sm:hidden">
|
||||||
|
{t("environments.surveys.summary.show_all_responses_where")}
|
||||||
|
</p>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Select
|
<Select
|
||||||
value={filterValue.responseStatus ?? "all"}
|
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
handleResponseStatusChange(val as TResponseStatus);
|
handleResponseStatusChange(val as TResponseStatus);
|
||||||
}}>
|
}}
|
||||||
<SelectTrigger className="w-full bg-white text-slate-700">
|
defaultValue={filterValue.responseStatus}>
|
||||||
|
<SelectTrigger className="w-full bg-white">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent position="popper">
|
<SelectContent position="popper">
|
||||||
@@ -280,76 +247,73 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
|||||||
<div className="flex w-full flex-wrap gap-3 md:flex-nowrap">
|
<div className="flex w-full flex-wrap gap-3 md:flex-nowrap">
|
||||||
<div
|
<div
|
||||||
className="grid w-full grid-cols-1 items-center gap-3 md:grid-cols-2"
|
className="grid w-full grid-cols-1 items-center gap-3 md:grid-cols-2"
|
||||||
key={`${s.elementType.id}-${i}-${s.elementType.label}`}>
|
key={`${s.questionType.id}-${i}-${s.questionType.label}`}>
|
||||||
<ElementsComboBox
|
<QuestionsComboBox
|
||||||
key={`${s.elementType.label}-${i}-${s.elementType.id}`}
|
key={`${s.questionType.label}-${i}-${s.questionType.id}`}
|
||||||
options={elementComboBoxOptions}
|
options={questionComboBoxOptions}
|
||||||
selected={s.elementType}
|
selected={s.questionType}
|
||||||
onChangeValue={(value) => handleOnChangeElementComboBoxValue(value, i)}
|
onChangeValue={(value) => handleOnChangeQuestionComboBoxValue(value, i)}
|
||||||
/>
|
/>
|
||||||
<ElementFilterComboBox
|
<QuestionFilterComboBox
|
||||||
key={`${s.elementType.id}-${i}`}
|
key={`${s.questionType.id}-${i}`}
|
||||||
filterOptions={
|
filterOptions={
|
||||||
selectedOptions.elementFilterOptions.find(
|
selectedOptions.questionFilterOptions.find(
|
||||||
(q) =>
|
(q) =>
|
||||||
(q.type === s.elementType.elementType || q.type === s.elementType.type) &&
|
(q.type === s.questionType.questionType || q.type === s.questionType.type) &&
|
||||||
q.id === s.elementType.id
|
q.id === s.questionType.id
|
||||||
)?.filterOptions
|
)?.filterOptions
|
||||||
}
|
}
|
||||||
filterComboBoxOptions={
|
filterComboBoxOptions={
|
||||||
selectedOptions.elementFilterOptions.find(
|
selectedOptions.questionFilterOptions.find(
|
||||||
(q) =>
|
(q) =>
|
||||||
(q.type === s.elementType.elementType || q.type === s.elementType.type) &&
|
(q.type === s.questionType.questionType || q.type === s.questionType.type) &&
|
||||||
q.id === s.elementType.id
|
q.id === s.questionType.id
|
||||||
)?.filterComboBoxOptions
|
)?.filterComboBoxOptions
|
||||||
}
|
}
|
||||||
filterValue={filterValue.filter[i].filterType.filterValue}
|
filterValue={filterValue.filter[i].filterType.filterValue}
|
||||||
filterComboBoxValue={filterValue.filter[i].filterType.filterComboBoxValue}
|
filterComboBoxValue={filterValue.filter[i].filterType.filterComboBoxValue}
|
||||||
type={
|
type={
|
||||||
s?.elementType?.type === OptionsType.ELEMENTS
|
s?.questionType?.type === OptionsType.QUESTIONS
|
||||||
? s?.elementType?.elementType
|
? s?.questionType?.questionType
|
||||||
: s?.elementType?.type
|
: s?.questionType?.type
|
||||||
}
|
}
|
||||||
fieldId={s?.elementType?.id}
|
fieldId={s?.questionType?.id}
|
||||||
handleRemoveMultiSelect={(value) => handleRemoveMultiSelect(value, i)}
|
handleRemoveMultiSelect={(value) => handleRemoveMultiSelect(value, i)}
|
||||||
onChangeFilterComboBoxValue={(value) => handleOnChangeFilterComboBoxValue(value, i)}
|
onChangeFilterComboBoxValue={(value) => handleOnChangeFilterComboBoxValue(value, i)}
|
||||||
onChangeFilterValue={(value) => handleOnChangeFilterValue(value, i)}
|
onChangeFilterValue={(value) => handleOnChangeFilterValue(value, i)}
|
||||||
disabled={!s?.elementType?.label}
|
disabled={!s?.questionType?.label}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full items-center justify-end gap-1 md:w-auto">
|
<div className="flex w-full items-center justify-end gap-1 md:w-auto">
|
||||||
<Button
|
<p className="block font-light text-slate-500 md:hidden">Delete</p>
|
||||||
variant="secondary"
|
<TrashIcon
|
||||||
size="icon"
|
className="w-4 cursor-pointer text-slate-500 md:text-black"
|
||||||
onClick={() => handleDeleteFilter(i)}
|
onClick={() => handleDeleteFilter(i)}
|
||||||
aria-label={t("common.delete")}>
|
/>
|
||||||
<TrashIcon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{i !== filterValue.filter.length - 1 && (
|
{i !== filterValue.filter.length - 1 && (
|
||||||
<div className="my-4 flex items-center">
|
<div className="my-6 flex items-center">
|
||||||
<p className="mr-4 font-semibold text-slate-800">and</p>
|
<p className="mr-6 text-base text-slate-600">And</p>
|
||||||
<hr className="w-full text-slate-600" />
|
<hr className="w-full text-slate-600" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 flex items-center justify-between">
|
<div className="mt-8 flex items-center justify-between">
|
||||||
|
<Button size="sm" variant="secondary" onClick={handleAddNewFilter}>
|
||||||
|
{t("common.add_filter")}
|
||||||
|
<Plus width={18} height={18} className="ml-2" />
|
||||||
|
</Button>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" variant="secondary" onClick={handleAddNewFilter}>
|
|
||||||
{t("common.add_filter")}
|
|
||||||
<Plus />
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={handleApplyFilters}>
|
<Button size="sm" onClick={handleApplyFilters}>
|
||||||
{t("common.apply_filters")}
|
{t("common.apply_filters")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={handleClearAllFilters}>
|
||||||
|
{t("common.clear_all")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="destructive" onClick={handleClearAllFilters}>
|
|
||||||
{t("common.clear_all")}
|
|
||||||
<TrashIcon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { ChatwootWidget } from "@/app/chatwoot/ChatwootWidget";
|
import { Suspense } from "react";
|
||||||
import { CHATWOOT_BASE_URL, CHATWOOT_WEBSITE_TOKEN, IS_CHATWOOT_CONFIGURED } from "@/lib/constants";
|
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
|
||||||
|
import { IS_POSTHOG_CONFIGURED, POSTHOG_API_HOST, POSTHOG_API_KEY } from "@/lib/constants";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
||||||
|
import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client";
|
||||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||||
|
|
||||||
const AppLayout = async ({ children }) => {
|
const AppLayout = async ({ children }) => {
|
||||||
@@ -19,17 +21,20 @@ const AppLayout = async ({ children }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NoMobileOverlay />
|
<NoMobileOverlay />
|
||||||
{IS_CHATWOOT_CONFIGURED && (
|
<Suspense>
|
||||||
<ChatwootWidget
|
<PostHogPageview
|
||||||
userEmail={user?.email}
|
posthogEnabled={IS_POSTHOG_CONFIGURED}
|
||||||
userName={user?.name}
|
postHogApiHost={POSTHOG_API_HOST}
|
||||||
userId={user?.id}
|
postHogApiKey={POSTHOG_API_KEY}
|
||||||
chatwootWebsiteToken={CHATWOOT_WEBSITE_TOKEN}
|
|
||||||
chatwootBaseUrl={CHATWOOT_BASE_URL}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</Suspense>
|
||||||
<ToasterClient />
|
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
|
||||||
{children}
|
<>
|
||||||
|
<IntercomClientWrapper user={user} />
|
||||||
|
<ToasterClient />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
</PHProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
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,8 +23,12 @@ import {
|
|||||||
TIntegrationSlackCredential,
|
TIntegrationSlackCredential,
|
||||||
} from "@formbricks/types/integration/slack";
|
} from "@formbricks/types/integration/slack";
|
||||||
import { TResponse, TResponseMeta } from "@formbricks/types/responses";
|
import { TResponse, TResponseMeta } from "@formbricks/types/responses";
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import {
|
||||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
TSurvey,
|
||||||
|
TSurveyOpenTextQuestion,
|
||||||
|
TSurveyPictureSelectionQuestion,
|
||||||
|
TSurveyQuestionTypeEnum,
|
||||||
|
} from "@formbricks/types/surveys/types";
|
||||||
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||||
import { writeData as airtableWriteData } from "@/lib/airtable/service";
|
import { writeData as airtableWriteData } from "@/lib/airtable/service";
|
||||||
import { writeData as googleSheetWriteData } from "@/lib/googleSheet/service";
|
import { writeData as googleSheetWriteData } from "@/lib/googleSheet/service";
|
||||||
@@ -97,47 +101,33 @@ const mockPipelineInput = {
|
|||||||
const mockSurvey = {
|
const mockSurvey = {
|
||||||
id: surveyId,
|
id: surveyId,
|
||||||
name: "Test Survey",
|
name: "Test Survey",
|
||||||
blocks: [
|
questions: [
|
||||||
{
|
{
|
||||||
id: "block1",
|
id: questionId1,
|
||||||
name: "Block 1",
|
type: TSurveyQuestionTypeEnum.OpenText,
|
||||||
elements: [
|
headline: { default: "Question 1 {{recall:q2}}" },
|
||||||
{
|
required: true,
|
||||||
id: questionId1,
|
} as unknown as TSurveyOpenTextQuestion,
|
||||||
type: TSurveyElementTypeEnum.OpenText,
|
{
|
||||||
headline: { default: "Question 1 {{recall:q2}}" },
|
id: questionId2,
|
||||||
required: true,
|
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||||
inputType: "text",
|
headline: { default: "Question 2" },
|
||||||
charLimit: 1000,
|
required: true,
|
||||||
subheader: { default: "" },
|
choices: [
|
||||||
placeholder: { default: "" },
|
{ id: "choice1", label: { default: "Choice 1" } },
|
||||||
},
|
{ id: "choice2", label: { default: "Choice 2" } },
|
||||||
{
|
|
||||||
id: questionId2,
|
|
||||||
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
|
|
||||||
headline: { default: "Question 2" },
|
|
||||||
required: true,
|
|
||||||
choices: [
|
|
||||||
{ id: "choice1", label: { default: "Choice 1" } },
|
|
||||||
{ id: "choice2", label: { default: "Choice 2" } },
|
|
||||||
],
|
|
||||||
shuffleOption: "none",
|
|
||||||
subheader: { default: "" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: questionId3,
|
|
||||||
type: TSurveyElementTypeEnum.PictureSelection,
|
|
||||||
headline: { default: "Question 3" },
|
|
||||||
required: true,
|
|
||||||
choices: [
|
|
||||||
{ id: "picChoice1", imageUrl: "http://image.com/1" },
|
|
||||||
{ id: "picChoice2", imageUrl: "http://image.com/2" },
|
|
||||||
],
|
|
||||||
allowMultiple: false,
|
|
||||||
subheader: { default: "" },
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: questionId3,
|
||||||
|
type: TSurveyQuestionTypeEnum.PictureSelection,
|
||||||
|
headline: { default: "Question 3" },
|
||||||
|
required: true,
|
||||||
|
choices: [
|
||||||
|
{ id: "picChoice1", imageUrl: "http://image.com/1" },
|
||||||
|
{ id: "picChoice2", imageUrl: "http://image.com/2" },
|
||||||
|
],
|
||||||
|
} as unknown as TSurveyPictureSelectionQuestion,
|
||||||
],
|
],
|
||||||
hiddenFields: {
|
hiddenFields: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -172,7 +162,7 @@ const mockAirtableIntegration: TIntegrationAirtable = {
|
|||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
surveyId: surveyId,
|
surveyId: surveyId,
|
||||||
elementIds: [questionId1, questionId2],
|
questionIds: [questionId1, questionId2],
|
||||||
baseId: "base1",
|
baseId: "base1",
|
||||||
tableId: "table1",
|
tableId: "table1",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -196,8 +186,8 @@ const mockGoogleSheetsIntegration: TIntegrationGoogleSheets = {
|
|||||||
surveyId: surveyId,
|
surveyId: surveyId,
|
||||||
spreadsheetId: "sheet1",
|
spreadsheetId: "sheet1",
|
||||||
spreadsheetName: "Sheet Name",
|
spreadsheetName: "Sheet Name",
|
||||||
elementIds: [questionId1],
|
questionIds: [questionId1],
|
||||||
elements: "What is Q1?",
|
questions: "What is Q1?",
|
||||||
createdAt: new Date("2024-01-01T00:00:00.000Z"),
|
createdAt: new Date("2024-01-01T00:00:00.000Z"),
|
||||||
includeHiddenFields: false,
|
includeHiddenFields: false,
|
||||||
includeMetadata: false,
|
includeMetadata: false,
|
||||||
@@ -219,8 +209,8 @@ const mockSlackIntegration: TIntegrationSlack = {
|
|||||||
surveyId: surveyId,
|
surveyId: surveyId,
|
||||||
channelId: "channel1",
|
channelId: "channel1",
|
||||||
channelName: "Channel 1",
|
channelName: "Channel 1",
|
||||||
elementIds: [questionId1, questionId2, questionId3],
|
questionIds: [questionId1, questionId2, questionId3],
|
||||||
elements: "Q1, Q2, Q3",
|
questions: "Q1, Q2, Q3",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
includeHiddenFields: true,
|
includeHiddenFields: true,
|
||||||
includeMetadata: true,
|
includeMetadata: true,
|
||||||
@@ -249,19 +239,19 @@ const mockNotionIntegration: TIntegrationNotion = {
|
|||||||
databaseName: "DB 1",
|
databaseName: "DB 1",
|
||||||
mapping: [
|
mapping: [
|
||||||
{
|
{
|
||||||
element: { id: questionId1, name: "Question 1", type: TSurveyQuestionTypeEnum.OpenText },
|
question: { id: questionId1, name: "Question 1", type: TSurveyQuestionTypeEnum.OpenText },
|
||||||
column: { id: "col1", name: "Column 1", type: "rich_text" },
|
column: { id: "col1", name: "Column 1", type: "rich_text" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
element: { id: questionId3, name: "Question 3", type: TSurveyQuestionTypeEnum.PictureSelection },
|
question: { id: questionId3, name: "Question 3", type: TSurveyQuestionTypeEnum.PictureSelection },
|
||||||
column: { id: "col3", name: "Column 3", type: "url" },
|
column: { id: "col3", name: "Column 3", type: "url" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
element: { id: "metadata", name: "Metadata", type: "metadata" },
|
question: { id: "metadata", name: "Metadata", type: "metadata" },
|
||||||
column: { id: "col_meta", name: "Metadata Col", type: "rich_text" },
|
column: { id: "col_meta", name: "Metadata Col", type: "rich_text" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
element: { id: "createdAt", name: "Created At", type: "createdAt" },
|
question: { id: "createdAt", name: "Created At", type: "createdAt" },
|
||||||
column: { id: "col_created", name: "Created Col", type: "date" },
|
column: { id: "col_created", name: "Created Col", type: "date" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -351,14 +341,16 @@ describe("handleIntegrations", () => {
|
|||||||
mockAirtableIntegration.config.key,
|
mockAirtableIntegration.config.key,
|
||||||
mockAirtableIntegration.config.data[0],
|
mockAirtableIntegration.config.data[0],
|
||||||
[
|
[
|
||||||
"Answer 1",
|
[
|
||||||
"Choice 1, Choice 2",
|
"Answer 1",
|
||||||
"Hidden Value",
|
"Choice 1, Choice 2",
|
||||||
expectedMetadataString,
|
"Hidden Value",
|
||||||
"Variable Value",
|
expectedMetadataString,
|
||||||
"2024-01-01 12:00",
|
"Variable Value",
|
||||||
], // responses + hidden + meta + var + created
|
"2024-01-01 12:00",
|
||||||
["Question 1 {{recall:q2}}", "Question 2", hiddenFieldId, "Metadata", "Variable 1", "Created At"] // elements (raw headline for Airtable) + hidden + meta + var + created
|
], // responses + hidden + meta + var + created
|
||||||
|
["Question 1 {{recall:q2}}", "Question 2", hiddenFieldId, "Metadata", "Variable 1", "Created At"], // questions (raw headline for Airtable) + hidden + meta + var + created
|
||||||
|
]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -393,8 +385,10 @@ describe("handleIntegrations", () => {
|
|||||||
expect(googleSheetWriteData).toHaveBeenCalledWith(
|
expect(googleSheetWriteData).toHaveBeenCalledWith(
|
||||||
expectedIntegrationData,
|
expectedIntegrationData,
|
||||||
mockGoogleSheetsIntegration.config.data[0].spreadsheetId,
|
mockGoogleSheetsIntegration.config.data[0].spreadsheetId,
|
||||||
["Answer 1"], // responses
|
[
|
||||||
["Question 1 {{recall:q2}}"] // elements (raw headline for Google Sheets)
|
["Answer 1"], // responses
|
||||||
|
["Question 1 {{recall:q2}}"], // questions (raw headline for Google Sheets)
|
||||||
|
]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
|||||||
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||||
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
||||||
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
||||||
import { TResponseDataValue, TResponseMeta } from "@formbricks/types/responses";
|
import { TResponseMeta } from "@formbricks/types/responses";
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||||
import { writeData as airtableWriteData } from "@/lib/airtable/service";
|
import { writeData as airtableWriteData } from "@/lib/airtable/service";
|
||||||
@@ -17,7 +16,6 @@ import { getLocalizedValue } from "@/lib/i18n/utils";
|
|||||||
import { writeData as writeNotionData } from "@/lib/notion/service";
|
import { writeData as writeNotionData } from "@/lib/notion/service";
|
||||||
import { processResponseData } from "@/lib/responses";
|
import { processResponseData } from "@/lib/responses";
|
||||||
import { writeDataToSlack } from "@/lib/slack/service";
|
import { writeDataToSlack } from "@/lib/slack/service";
|
||||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
|
||||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||||
import { truncateText } from "@/lib/utils/strings";
|
import { truncateText } from "@/lib/utils/strings";
|
||||||
@@ -44,40 +42,33 @@ const processDataForIntegration = async (
|
|||||||
includeMetadata: boolean,
|
includeMetadata: boolean,
|
||||||
includeHiddenFields: boolean,
|
includeHiddenFields: boolean,
|
||||||
includeCreatedAt: boolean,
|
includeCreatedAt: boolean,
|
||||||
elementIds: string[]
|
questionIds: string[]
|
||||||
): Promise<{
|
): Promise<string[][]> => {
|
||||||
responses: string[];
|
|
||||||
elements: string[];
|
|
||||||
}> => {
|
|
||||||
const ids =
|
const ids =
|
||||||
includeHiddenFields && survey.hiddenFields.fieldIds
|
includeHiddenFields && survey.hiddenFields.fieldIds
|
||||||
? [...elementIds, ...survey.hiddenFields.fieldIds]
|
? [...questionIds, ...survey.hiddenFields.fieldIds]
|
||||||
: elementIds;
|
: questionIds;
|
||||||
const { responses, elements } = await extractResponses(integrationType, data, ids, survey);
|
const values = await extractResponses(integrationType, data, ids, survey);
|
||||||
|
|
||||||
if (includeMetadata) {
|
if (includeMetadata) {
|
||||||
responses.push(convertMetaObjectToString(data.response.meta));
|
values[0].push(convertMetaObjectToString(data.response.meta));
|
||||||
elements.push("Metadata");
|
values[1].push("Metadata");
|
||||||
}
|
}
|
||||||
if (includeVariables) {
|
if (includeVariables) {
|
||||||
survey.variables?.forEach((variable) => {
|
survey.variables.forEach((variable) => {
|
||||||
const value = data.response.variables[variable.id];
|
const value = data.response.variables[variable.id];
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
responses.push(String(data.response.variables[variable.id]));
|
values[0].push(String(data.response.variables[variable.id]));
|
||||||
elements.push(variable.name);
|
values[1].push(variable.name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (includeCreatedAt) {
|
if (includeCreatedAt) {
|
||||||
const date = new Date(data.response.createdAt);
|
const date = new Date(data.response.createdAt);
|
||||||
responses.push(`${getFormattedDateTimeString(date)}`);
|
values[0].push(`${getFormattedDateTimeString(date)}`);
|
||||||
elements.push("Created At");
|
values[1].push("Created At");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return values;
|
||||||
responses,
|
|
||||||
elements,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleIntegrations = async (
|
export const handleIntegrations = async (
|
||||||
@@ -140,9 +131,9 @@ const handleAirtableIntegration = async (
|
|||||||
!!element.includeMetadata,
|
!!element.includeMetadata,
|
||||||
!!element.includeHiddenFields,
|
!!element.includeHiddenFields,
|
||||||
!!element.includeCreatedAt,
|
!!element.includeCreatedAt,
|
||||||
element.elementIds
|
element.questionIds
|
||||||
);
|
);
|
||||||
await airtableWriteData(integration.config.key, element, values.responses, values.elements);
|
await airtableWriteData(integration.config.key, element, values);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,14 +167,14 @@ const handleGoogleSheetsIntegration = async (
|
|||||||
!!element.includeMetadata,
|
!!element.includeMetadata,
|
||||||
!!element.includeHiddenFields,
|
!!element.includeHiddenFields,
|
||||||
!!element.includeCreatedAt,
|
!!element.includeCreatedAt,
|
||||||
element.elementIds
|
element.questionIds
|
||||||
);
|
);
|
||||||
const integrationData = structuredClone(integration);
|
const integrationData = structuredClone(integration);
|
||||||
integrationData.config.data.forEach((data) => {
|
integrationData.config.data.forEach((data) => {
|
||||||
data.createdAt = new Date(data.createdAt);
|
data.createdAt = new Date(data.createdAt);
|
||||||
});
|
});
|
||||||
|
|
||||||
await writeData(integrationData, element.spreadsheetId, values.responses, values.elements);
|
await writeData(integrationData, element.spreadsheetId, values);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,15 +208,9 @@ const handleSlackIntegration = async (
|
|||||||
!!element.includeMetadata,
|
!!element.includeMetadata,
|
||||||
!!element.includeHiddenFields,
|
!!element.includeHiddenFields,
|
||||||
!!element.includeCreatedAt,
|
!!element.includeCreatedAt,
|
||||||
element.elementIds
|
element.questionIds
|
||||||
);
|
|
||||||
await writeDataToSlack(
|
|
||||||
integration.config.key,
|
|
||||||
element.channelId,
|
|
||||||
values.responses,
|
|
||||||
values.elements,
|
|
||||||
survey?.name
|
|
||||||
);
|
);
|
||||||
|
await writeDataToSlack(integration.config.key, element.channelId, values, survey?.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,81 +227,63 @@ const handleSlackIntegration = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to process a single element's response for integrations
|
|
||||||
const processElementResponse = (
|
|
||||||
element: ReturnType<typeof getElementsFromBlocks>[number],
|
|
||||||
responseValue: TResponseDataValue
|
|
||||||
): string => {
|
|
||||||
if (responseValue === undefined) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element.type === TSurveyElementTypeEnum.PictureSelection) {
|
|
||||||
const selectedChoiceIds = responseValue as string[];
|
|
||||||
return element.choices
|
|
||||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
|
||||||
.map((choice) => choice.imageUrl)
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
return processResponseData(responseValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to create empty response object for non-slack integrations
|
|
||||||
const createEmptyResponseObject = (responseData: Record<string, unknown>): Record<string, string> => {
|
|
||||||
return Object.keys(responseData).reduce(
|
|
||||||
(acc, key) => {
|
|
||||||
acc[key] = "";
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, string>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractResponses = async (
|
const extractResponses = async (
|
||||||
integrationType: TIntegrationType,
|
integrationType: TIntegrationType,
|
||||||
pipelineData: TPipelineInput,
|
pipelineData: TPipelineInput,
|
||||||
elementIds: string[],
|
questionIds: string[],
|
||||||
survey: TSurvey
|
survey: TSurvey
|
||||||
): Promise<{
|
): Promise<string[][]> => {
|
||||||
responses: string[];
|
|
||||||
elements: string[];
|
|
||||||
}> => {
|
|
||||||
const responses: string[] = [];
|
const responses: string[] = [];
|
||||||
const elements: string[] = [];
|
const questions: string[] = [];
|
||||||
const surveyElements = getElementsFromBlocks(survey.blocks);
|
|
||||||
const emptyResponseObject = createEmptyResponseObject(pipelineData.response.data);
|
|
||||||
|
|
||||||
for (const elementId of elementIds) {
|
for (const questionId of questionIds) {
|
||||||
// Check for hidden field Ids
|
//check for hidden field Ids
|
||||||
if (survey.hiddenFields.fieldIds?.includes(elementId)) {
|
if (survey.hiddenFields.fieldIds?.includes(questionId)) {
|
||||||
responses.push(processResponseData(pipelineData.response.data[elementId]));
|
responses.push(processResponseData(pipelineData.response.data[questionId]));
|
||||||
elements.push(elementId);
|
questions.push(questionId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const question = survey?.questions.find((q) => q.id === questionId);
|
||||||
|
if (!question) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const element = surveyElements.find((q) => q.id === elementId);
|
const responseValue = pipelineData.response.data[questionId];
|
||||||
if (!element) {
|
|
||||||
continue;
|
if (responseValue !== undefined) {
|
||||||
|
let answer: typeof responseValue;
|
||||||
|
if (question.type === TSurveyQuestionTypeEnum.PictureSelection) {
|
||||||
|
const selectedChoiceIds = responseValue as string[];
|
||||||
|
answer = question?.choices
|
||||||
|
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||||
|
.map((choice) => choice.imageUrl)
|
||||||
|
.join("\n");
|
||||||
|
} else {
|
||||||
|
answer = responseValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
responses.push(processResponseData(answer));
|
||||||
|
} else {
|
||||||
|
responses.push("");
|
||||||
}
|
}
|
||||||
|
// Create emptyResponseObject with same keys but empty string values
|
||||||
const responseValue = pipelineData.response.data[elementId];
|
const emptyResponseObject = Object.keys(pipelineData.response.data).reduce(
|
||||||
responses.push(processElementResponse(element, responseValue));
|
(acc, key) => {
|
||||||
|
acc[key] = "";
|
||||||
const responseDataForRecall =
|
return acc;
|
||||||
integrationType === "slack" ? pipelineData.response.data : emptyResponseObject;
|
},
|
||||||
const variablesForRecall = integrationType === "slack" ? pipelineData.response.variables : {};
|
{} as Record<string, string>
|
||||||
|
);
|
||||||
elements.push(
|
questions.push(
|
||||||
parseRecallInfo(
|
parseRecallInfo(
|
||||||
getTextContent(getLocalizedValue(element.headline, "default")),
|
getTextContent(getLocalizedValue(question?.headline, "default")),
|
||||||
responseDataForRecall,
|
integrationType === "slack" ? pipelineData.response.data : emptyResponseObject,
|
||||||
variablesForRecall
|
integrationType === "slack" ? pipelineData.response.variables : {}
|
||||||
) || ""
|
) || ""
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { responses, elements };
|
return [responses, questions];
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNotionIntegration = async (
|
const handleNotionIntegration = async (
|
||||||
@@ -354,34 +321,32 @@ const buildNotionPayloadProperties = (
|
|||||||
const properties: any = {};
|
const properties: any = {};
|
||||||
const responses = data.response.data;
|
const responses = data.response.data;
|
||||||
|
|
||||||
const surveyElements = getElementsFromBlocks(surveyData.blocks);
|
const mappingQIds = mapping
|
||||||
|
.filter((m) => m.question.type === TSurveyQuestionTypeEnum.PictureSelection)
|
||||||
const mappingElementIds = mapping
|
.map((m) => m.question.id);
|
||||||
.filter((m) => m.element.type === TSurveyElementTypeEnum.PictureSelection)
|
|
||||||
.map((m) => m.element.id);
|
|
||||||
|
|
||||||
Object.keys(responses).forEach((resp) => {
|
Object.keys(responses).forEach((resp) => {
|
||||||
if (mappingElementIds.find((elementId) => elementId === resp)) {
|
if (mappingQIds.find((qId) => qId === resp)) {
|
||||||
const selectedChoiceIds = responses[resp] as string[];
|
const selectedChoiceIds = responses[resp] as string[];
|
||||||
const pictureElement = surveyElements.find((el) => el.id === resp);
|
const pictureQuestion = surveyData.questions.find((q) => q.id === resp);
|
||||||
|
|
||||||
responses[resp] = (pictureElement as any)?.choices
|
responses[resp] = (pictureQuestion as any)?.choices
|
||||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||||
.map((choice) => choice.imageUrl);
|
.map((choice) => choice.imageUrl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
mapping.forEach((map) => {
|
mapping.forEach((map) => {
|
||||||
if (map.element.id === "metadata") {
|
if (map.question.id === "metadata") {
|
||||||
properties[map.column.name] = {
|
properties[map.column.name] = {
|
||||||
[map.column.type]: getValue(map.column.type, convertMetaObjectToString(data.response.meta)) || null,
|
[map.column.type]: getValue(map.column.type, convertMetaObjectToString(data.response.meta)) || null,
|
||||||
};
|
};
|
||||||
} else if (map.element.id === "createdAt") {
|
} else if (map.question.id === "createdAt") {
|
||||||
properties[map.column.name] = {
|
properties[map.column.name] = {
|
||||||
[map.column.type]: getValue(map.column.type, data.response.createdAt) || null,
|
[map.column.type]: getValue(map.column.type, data.response.createdAt) || null,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const value = responses[map.element.id];
|
const value = responses[map.question.id];
|
||||||
properties[map.column.name] = {
|
properties[map.column.name] = {
|
||||||
[map.column.type]: getValue(map.column.type, value) || null,
|
[map.column.type]: getValue(map.column.type, value) || null,
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user