Compare commits
7 Commits
mattinannt
...
feat/3214-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edd1b6bc0d | ||
|
|
2cd51e18f4 | ||
|
|
b13472540e | ||
|
|
101c856496 | ||
|
|
22744ee185 | ||
|
|
8adf9be9d3 | ||
|
|
666f699858 |
@@ -1,4 +1,4 @@
|
||||
version: "3.8"
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
@@ -22,11 +22,11 @@ services:
|
||||
# Uncomment the next line to use a non-root user for all processes.
|
||||
# user: node
|
||||
|
||||
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
|
||||
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
|
||||
# (Adding the "ports" property to this file will not forward from a Codespace.)
|
||||
|
||||
db:
|
||||
image: pgvector/pgvector:pg17
|
||||
image: postgres:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
@@ -41,11 +41,12 @@ services:
|
||||
image: mailhog/mailhog
|
||||
network_mode: service:app
|
||||
logging:
|
||||
driver:
|
||||
"none" # disable saving logs
|
||||
driver: "none" # disable saving logs
|
||||
# ports:
|
||||
# - 8025:8025 # web ui
|
||||
# 1025:1025 # smtp server
|
||||
|
||||
|
||||
|
||||
volumes:
|
||||
postgres-data: null
|
||||
|
||||
@@ -180,9 +180,3 @@ UNSPLASH_ACCESS_KEY=
|
||||
|
||||
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
|
||||
# CUSTOM_CACHE_DISABLED=1
|
||||
|
||||
# Azure AI settings
|
||||
# AI_AZURE_RESSOURCE_NAME=
|
||||
# AI_AZURE_API_KEY=
|
||||
# AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID=
|
||||
# AI_AZURE_LLM_DEPLOYMENT_ID=
|
||||
3
.github/workflows/e2e.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg17
|
||||
image: postgres:latest
|
||||
env:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
@@ -50,7 +50,6 @@ jobs:
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env
|
||||
echo "" >> .env
|
||||
echo "E2E_TESTING=1" >> .env
|
||||
shell: bash
|
||||
|
||||
|
||||
2
LICENSE
@@ -2,7 +2,7 @@ Copyright (c) 2024 Formbricks GmbH
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
- All content that resides under the "packages/ee/", "apps/web/modules/ee" & "apps/web/app/(ee)" directories of this repository, if these directories exist, is licensed under the license defined in "packages/ee/LICENSE".
|
||||
- All content that resides under the "packages/ee/" & "apps/web/app/(ee)" directories of this repository, if these directories exist, is licensed under the license defined in "packages/ee/LICENSE".
|
||||
- All content that resides under the "packages/js/", "packages/react-native/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
|
||||
- All third party components incorporated into the Formbricks Software are licensed under the original license provided by the owner of the applicable component.
|
||||
- Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below.
|
||||
|
||||
|
Before Width: | Height: | Size: 10 KiB |
@@ -1,36 +0,0 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import SurveyEmbed from "@/components/SurveyEmbed";
|
||||
import Address from "./images/address.webp";
|
||||
|
||||
#### Question Type
|
||||
|
||||
# Address
|
||||
|
||||
The Address question type allows respondents to input their address details, including multiple fields such as address lines, city, state, and country. You can configure the question by adding a title, an optional description, and toggling specific fields to be required.
|
||||
|
||||
<SurveyEmbed surveyUrl="https://app.formbricks.com/s/m8w91e8wi52pdao8un1f4twu" />
|
||||
|
||||
## Elements
|
||||
|
||||
<MdxImage
|
||||
src={Address}
|
||||
alt="Overview of Address question type"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### Question
|
||||
Provide a question to describe the address information you are requesting.
|
||||
|
||||
### Description
|
||||
Optionally, add a description to guide the user.
|
||||
|
||||
### Toggle Fields
|
||||
You can choose to show and require any or all of the following fields in the form:
|
||||
- Address Line 1
|
||||
- Address Line 2
|
||||
- City
|
||||
- State
|
||||
- Zip Code
|
||||
- Country
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB |
@@ -1,28 +0,0 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import SurveyEmbed from "@/components/SurveyEmbed";
|
||||
import Consent from "./images/consent.webp";
|
||||
|
||||
#### Question Type
|
||||
|
||||
# Consent
|
||||
|
||||
The Consent card is used to obtain user agreement regarding a product, service, or policy. It features a bold statement or question as the title, followed by a brief description. At the end of the card, users can confirm their consent by checking a checkbox to indicate their agreement.
|
||||
|
||||
<SurveyEmbed surveyUrl="https://app.formbricks.com/s/orxp15pca6x2nfr3v8pttpwm" />
|
||||
|
||||
## Elements
|
||||
<MdxImage
|
||||
src={Consent}
|
||||
alt="Overview of Consent question type"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### Title
|
||||
A bold statement or question asking for user consent, displayed prominently at the top of the card.
|
||||
|
||||
### Description
|
||||
A short explanation or additional context for the consent request, displayed below the title. The text can be formatted, and hyperlinks are allowed within the description.
|
||||
|
||||
### Checkbox
|
||||
At the bottom of the card, users can confirm their agreement by checking the box, indicating their consent to the question or statement above. The label for the checkbox is also editable.
|
||||
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,36 +0,0 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import SurveyEmbed from "@/components/SurveyEmbed";
|
||||
import Contact from "./images/contact.webp";
|
||||
|
||||
#### Question Type
|
||||
|
||||
# Contact Info
|
||||
|
||||
The Contact Info question type allows respondents to provide their basic contact information such as name, email, and phone number. You can customize the form with a title, an optional description, and control which fields to display and require.
|
||||
|
||||
<SurveyEmbed surveyUrl="https://app.formbricks.com/s/z2zjoonfeythx5n6z5qijbsg" />
|
||||
|
||||
## Elements
|
||||
<MdxImage
|
||||
src={Contact}
|
||||
alt="Overview of Contact Info question type"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### Title
|
||||
|
||||
Specify a title to describe the information you're collecting.
|
||||
|
||||
### Description
|
||||
|
||||
Optionally, add a description to give additional context.
|
||||
|
||||
### Toggle Fields
|
||||
|
||||
You can choose to show and require any or all of the following fields:
|
||||
- First Name
|
||||
- Last Name
|
||||
- Email
|
||||
- Phone Number
|
||||
- Company
|
||||
|
Before Width: | Height: | Size: 8.4 KiB |
@@ -1,32 +0,0 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import SurveyEmbed from "@/components/SurveyEmbed";
|
||||
import Date from "./images/date.webp";
|
||||
|
||||
#### Question Type
|
||||
|
||||
# Date
|
||||
|
||||
The Date question type allows respondents to provide a date, such as when they are available or when an event is scheduled. It features a title to guide the respondent on what date to enter, and an optional description to provide further details or context.
|
||||
|
||||
<SurveyEmbed surveyUrl="https://app.formbricks.com/s/rk844spc8ffls25vzkxzzhse" />
|
||||
|
||||
## Elements
|
||||
<MdxImage
|
||||
src={Date}
|
||||
alt="Overview of Date question type"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### Title
|
||||
Add a clear title to inform the respondent what date you are asking for.
|
||||
|
||||
### Description
|
||||
Provide an optional description with further instructions.
|
||||
|
||||
### Date Format
|
||||
Choose from multiple date formats for the input:
|
||||
- MM-DD-YYYY
|
||||
- DD-MM-YYYY
|
||||
- YYYY-MM-DD
|
||||
|
||||
|
Before Width: | Height: | Size: 9.2 KiB |
@@ -1,34 +0,0 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import SurveyEmbed from "@/components/SurveyEmbed";
|
||||
import FileUpload from "./images/file-upload.webp";
|
||||
|
||||
#### Questions Type
|
||||
|
||||
# File Upload
|
||||
|
||||
The File Upload question type allows respondents to upload files related to your survey, such as production documents or requirement specifications. It features a title to guide the user on what to upload and an optional description to provide additional context.
|
||||
|
||||
<SurveyEmbed surveyUrl="https://app.formbricks.com/s/oo4e6vva48w0trn01ht8krwo" />
|
||||
|
||||
## Elements
|
||||
<MdxImage
|
||||
src={FileUpload}
|
||||
alt="Overview of Fill Upload question type"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### Title
|
||||
Add a clear title that informs the respondent about the purpose of the file upload.
|
||||
|
||||
### Description
|
||||
Provide an optional description to give respondents more details or instructions about what files they need to upload.
|
||||
|
||||
### Allow Multiple Files
|
||||
Enable this option to allow respondents to upload multiple files at once.
|
||||
|
||||
### Max File Size
|
||||
You can set a maximum file size limit, and an input box will appear to specify the size in MB.
|
||||
|
||||
### File Type Restrictions
|
||||
You can restrict the allowed file types. An input box will appear where you can specify the file formats, such as `.pdf`, `.jpg`, `.docx`, etc.
|
||||
|
Before Width: | Height: | Size: 11 KiB |
@@ -1,43 +0,0 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import SurveyEmbed from "@/components/SurveyEmbed";
|
||||
import FreeText from "./images/free-text.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Free Text",
|
||||
description: "Free text questions allow respondents to enter a custom answer.",
|
||||
};
|
||||
|
||||
#### Question Type
|
||||
|
||||
# Free Text
|
||||
|
||||
Free text questions allow respondents to enter a custom answer. Displays a title and an input field for the respondent to type in.
|
||||
|
||||
<SurveyEmbed surveyUrl="https://app.formbricks.com/s/cm2b2eftv000012b0l3htbu0a" />
|
||||
|
||||
## Elements
|
||||
<MdxImage
|
||||
src={FreeText}
|
||||
alt="Overview of Free Text question type"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### Title
|
||||
Add a clear title to inform the respondent what information you are asking for.
|
||||
|
||||
### Description
|
||||
Provide an optional description with further instructions.
|
||||
|
||||
### Placeholder
|
||||
Specify a placeholder text to display in the input field.
|
||||
|
||||
### Input Type
|
||||
|
||||
Choose the type of input field to display. Options include:
|
||||
|
||||
- **Text**: A text area input. This can be converted to a single line input field if needed, by toggling the _"Long answer"_ switch at the bottom of the question segment.
|
||||
- **Email**: A single-line text input that validates the input as an email address.
|
||||
- **URL**: A single-line text input that validates the input as a URL.
|
||||
- **Number**: A single-line text input that validates the input as a number and shows "increase" and "decrease" buttons.
|
||||
- **Phone**: A single-line text input that validates the input as a phone number.
|
||||
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,36 +0,0 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import SurveyEmbed from "@/components/SurveyEmbed";
|
||||
import Matrix from "./images/matrix.webp";
|
||||
|
||||
#### Question Type
|
||||
|
||||
# Matrix
|
||||
|
||||
Matrix questions allow respondents to select a value for each option presented in rows. The values range from 0 to a user-defined maximum (e.g., 0 to X). The selection is made using radio buttons, and users can choose any value within the defined range, including 0.
|
||||
|
||||
<SurveyEmbed surveyUrl="https://app.formbricks.com/s/obqeey0574jig4lo2gqyv51e" />
|
||||
|
||||
## Elements
|
||||
<MdxImage
|
||||
src={Matrix}
|
||||
alt="Overview of Matrix question type"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### Title
|
||||
Add a clear title to inform the respondent what information you are asking for.
|
||||
|
||||
### Description
|
||||
Provide an optional description with further instructions.
|
||||
|
||||
### Rows
|
||||
Define the options shown on the left side of the matrix. These represent the items for which users will select a value.
|
||||
|
||||
### Columns
|
||||
Represent the range of values from 0 to X (right side of the screen). Users can choose any value, including 0, using radio buttons.
|
||||
|
||||
### Select ordering
|
||||
|
||||
- Keep current order: This will keep the order of options the same for all respondents.
|
||||
- Randomize all: This will randomize the options for each respondent.
|
||||
|
Before Width: | Height: | Size: 16 KiB |
@@ -1,36 +0,0 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import SurveyEmbed from "@/components/SurveyEmbed";
|
||||
import MultiSelect from "./images/multi-select.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Multi Select",
|
||||
description: "Multi select questions allow respondents to select several answers from a list",
|
||||
};
|
||||
|
||||
#### Question Type
|
||||
|
||||
# Multi Select
|
||||
|
||||
Multi select questions allow respondents to select several answers from a list. Displays a title and a list of checkboxes for the respondent to choose from.
|
||||
|
||||
<SurveyEmbed surveyUrl="https://app.formbricks.com/s/jhyo6lwzf6eh3fyplhlp7h5f" />
|
||||
|
||||
## Elements
|
||||
<MdxImage
|
||||
src={MultiSelect}
|
||||
alt="Overview of Multi Select question type"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### Title
|
||||
Add a clear title to inform the respondent what information you are asking for.
|
||||
|
||||
### Description
|
||||
Provide an optional description with further instructions.
|
||||
|
||||
### Options
|
||||
Define the options shown in the list. These represent the items for which users will select.
|
||||
|
||||
Other than the fact that respondents can select multiple options, multi select questions are similar to [single select](/global/question-types/single-select) questions.
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,31 +0,0 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import SurveyEmbed from "@/components/SurveyEmbed";
|
||||
import NetPromoterScore from "./images/net-promoter-score.webp";
|
||||
|
||||
#### Question Type
|
||||
|
||||
# Net Promoter Score
|
||||
|
||||
Net Promoter Score questions allow respondents to rate a question on a scale from 0 to 10. Displays a title and a list of radio buttons for the respondent to choose from.
|
||||
|
||||
<SurveyEmbed surveyUrl="https://app.formbricks.com/s/vqmpasmnt5qcpsa4enheips0" />
|
||||
|
||||
## Elements
|
||||
<MdxImage
|
||||
src={NetPromoterScore}
|
||||
alt="Overview of Net Promoter Score question type"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### Title
|
||||
Add a clear title to inform the respondent what information you are asking for.
|
||||
|
||||
### Description
|
||||
Provide an optional description with further instructions.
|
||||
|
||||
### Labels
|
||||
Add labels for the lower and upper bounds of the score. The default is "Not at all likely" and "Extremely likely".
|
||||
|
||||
### Add color coding
|
||||
Add color coding to the score. This will show a color bar above the score.
|
||||
|
Before Width: | Height: | Size: 16 KiB |
@@ -1,36 +0,0 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import SurveyEmbed from "@/components/SurveyEmbed";
|
||||
import PictureSelection from "./images/picture-selection.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Picture Selection",
|
||||
description: "Picture selection questions allow respondents to select one or more images from a list",
|
||||
};
|
||||
|
||||
#### Question Type
|
||||
|
||||
# Picture Selection
|
||||
|
||||
Picture selection questions allow respondents to select one or more images from a list. Displays a title and a list of images for the respondent to choose from.
|
||||
|
||||
<SurveyEmbed surveyUrl="https://app.formbricks.com/s/xtgmwxlk7jxxr4oi6ym7odki" />
|
||||
|
||||
## Elements
|
||||
<MdxImage
|
||||
src={PictureSelection}
|
||||
alt="Overview of Picture Selection question type"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### Title
|
||||
Add a clear title to inform the respondent what information you are asking for.
|
||||
|
||||
### Description
|
||||
Provide an optional description with further instructions.
|
||||
|
||||
### Images
|
||||
Images can be uploaded via click or drag and drop. At least two images are required.
|
||||
|
||||
### Allow Multi Select
|
||||
This option allows user to select more than one image.
|
||||
|
Before Width: | Height: | Size: 10 KiB |
@@ -1,32 +0,0 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import SurveyEmbed from "@/components/SurveyEmbed";
|
||||
import Ranking from "./images/ranking.webp";
|
||||
|
||||
#### Question Type
|
||||
|
||||
# Ranking
|
||||
|
||||
Ranking questions let respondents select options in order from 1 to the total number of options. As they make their choices, the list is automatically rearranged in numerical order.
|
||||
|
||||
<SurveyEmbed surveyUrl="https://app.formbricks.com/s/z6s84x9wbyk0yqqtfaz238px" />
|
||||
|
||||
## Elements
|
||||
<MdxImage
|
||||
src={Ranking}
|
||||
alt="Overview of Ranking question type"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### Title
|
||||
Add a clear title to inform the respondent what information you are asking for.
|
||||
|
||||
### Description
|
||||
Provide an optional description with further instructions.
|
||||
|
||||
### Options
|
||||
You need to add at least two options so that users can rearrange them in numerical order based on their selection.
|
||||
|
||||
### Select ordering
|
||||
- Keep current order: This will keep the order of options the same for all respondents.
|
||||
- Randomize all: This will randomize the options for each respondent.
|
||||
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,39 +0,0 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import SurveyEmbed from "@/components/SurveyEmbed";
|
||||
import Rating from "./images/rating.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Rating",
|
||||
description: "Rating questions allow respondents to rate questions on a scale",
|
||||
};
|
||||
|
||||
#### Question Type
|
||||
|
||||
# Rating
|
||||
|
||||
Rating questions allow respondents to rate questions on a scale. Displays a title and a rating scale consisting of a number of images and labels for the lower and upper ends of the scale.
|
||||
|
||||
<SurveyEmbed surveyUrl="https://app.formbricks.com/s/cx7u4n6hwvc3nztuk4vdezl9" />
|
||||
|
||||
## Elements
|
||||
<MdxImage
|
||||
src={Rating}
|
||||
alt="Overview of Rating question type"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### Title
|
||||
Add a clear title to inform the respondent what information you are asking for.
|
||||
|
||||
### Description
|
||||
Provide an optional description with further instructions.
|
||||
|
||||
### Scale
|
||||
Select the icon to be used for the rating scale. The options include: stars, numbers or smileys. The default is stars.
|
||||
|
||||
### Range
|
||||
Select the range of the rating scale. the options include: 3, 4, 5, 7 or 10. The default is 5.
|
||||
|
||||
### Labels
|
||||
Add labels for the lower and upper bounds of the rating scale. The default is "Not good" and "Very good".
|
||||
|
Before Width: | Height: | Size: 10 KiB |
@@ -1,31 +0,0 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import SurveyEmbed from "@/components/SurveyEmbed";
|
||||
import ScheduleCall from "./images/schedule-call.webp";
|
||||
|
||||
#### Question Type
|
||||
|
||||
# Schedule A Meeting
|
||||
|
||||
The Schedule A Meeting question type allows respondents to book a meeting by selecting a date and time. It includes a title to guide the respondent, along with an optional description to provide additional context for the meeting setup.
|
||||
|
||||
<SurveyEmbed surveyUrl="https://app.formbricks.com/s/hx08x27c2aghywh57rroe6fi" />
|
||||
|
||||
## Elements
|
||||
<MdxImage
|
||||
src={ScheduleCall}
|
||||
alt="Overview of Schedule question type"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### Title
|
||||
Add a clear title to inform the respondent what information you are asking for.
|
||||
|
||||
### Description
|
||||
Provide an optional description with further instructions.
|
||||
|
||||
### Cal.com Username/Event
|
||||
Add an input box where users can enter their [`cal.com`](https://cal.com/) username and event URL (e.g., `username/event`).
|
||||
|
||||
### Custom Hostname (Optional)
|
||||
Enable an input box for adding a custom hostname, which is necessary if using a self-hosted instance of [`cal.com`](https://cal.com/docs/self-hosting/installation).
|
||||
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,44 +0,0 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import SurveyEmbed from "@/components/SurveyEmbed";
|
||||
import SingleSelect from "./images/single-select.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Single Select",
|
||||
description: "Single select questions allow respondents to select one answer from a list",
|
||||
};
|
||||
|
||||
#### Question Type
|
||||
|
||||
# Single Select
|
||||
|
||||
Single select questions allow respondents to select one answer from a list. Displays a title and a list of radio buttons for the respondent to choose from.
|
||||
|
||||
<SurveyEmbed surveyUrl="https://app.formbricks.com/s/wybd3v3cxpdfve4472fu3lhi" />
|
||||
|
||||
## Elements
|
||||
<MdxImage
|
||||
src={SingleSelect}
|
||||
alt="Overview of Single Select question type"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### Title
|
||||
Add a clear title to inform the respondent what information you are asking for.
|
||||
|
||||
### Description
|
||||
Provide an optional description with further instructions.
|
||||
|
||||
### Options
|
||||
The list of answers the respondent can choose from.
|
||||
|
||||
### Additional Actions
|
||||
|
||||
- Add "Other": Adds an "Other" option to allow respondents to enter a custom answer. This will show two inputs, one for the label text and one for the placeholder.
|
||||
|
||||
- Convert to Multiple Select: Converts the question to a multiple select question. This will show checkboxes instead of radio buttons.
|
||||
|
||||
- Order dropdown: Allows you to choose the order in which the options are displayed.
|
||||
- Keep current order: Options will be displayed in the order you added them.
|
||||
- Randomize all: Options will be displayed in a random order.
|
||||
- Randomize all except last option: Options will be displayed in a random order, except for the last one.
|
||||
|
Before Width: | Height: | Size: 13 KiB |
@@ -1,29 +0,0 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import SurveyEmbed from "@/components/SurveyEmbed";
|
||||
import StatementCTA from "./images/statement-CTA.webp";
|
||||
|
||||
#### Question Type
|
||||
|
||||
# Statement (Call to Action)
|
||||
|
||||
The Statement question type allows you to display descriptive information in your survey, such as a message or instruction. It consists of a title (can be Question or Short Note) and a description, which can be a brief note(realted to CTA) or guideline. Instead of collecting input, this type includes a call to action button for further steps, such as booking an interview call.
|
||||
|
||||
<SurveyEmbed surveyUrl="https://app.formbricks.com/s/k3p7r7riyy504u4zziqat8zj" />
|
||||
|
||||
## Elements
|
||||
<MdxImage
|
||||
src={StatementCTA}
|
||||
alt="Overview of Statement question type"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### Title
|
||||
This is the main question or heading that appears at the top of the card.
|
||||
|
||||
### Description
|
||||
A brief note or instruction displayed under the title, typically used to provide context or instructions for the next step.
|
||||
|
||||
### Button Options
|
||||
- Button to continue in survey: This will continue respondent with the survey, form or fillups.
|
||||
- Button to link to external URL: Selecting this option will open-up URL input box below when us set URL the button will redirect to your setted link.
|
||||
@@ -1,25 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
interface SurveyEmbedProps {
|
||||
surveyUrl: string;
|
||||
}
|
||||
|
||||
const SurveyEmbed: React.FC<SurveyEmbedProps> = ({ surveyUrl }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
height: "90vh",
|
||||
maxHeight: "100vh",
|
||||
overflow: "auto",
|
||||
borderRadius: "12px",
|
||||
}}>
|
||||
<iframe
|
||||
src={surveyUrl}
|
||||
style={{ position: "absolute", left: 0, top: 0, width: "100%", height: "100%", border: 0 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SurveyEmbed;
|
||||
@@ -83,26 +83,6 @@ export const navigation: Array<NavGroup> = [
|
||||
{
|
||||
title: "Core Features",
|
||||
links: [
|
||||
{
|
||||
title: "Question Types",
|
||||
children: [
|
||||
{ title: "Free Text", href: "/global/question-type/free-text" },
|
||||
{ title: "Select Single", href: "/global/question-type/single-select" },
|
||||
{ title: "Select Multiple", href: "/global/question-type/multi-select" },
|
||||
{ title: "Select Picture", href: "/global/question-type/picture-selection" },
|
||||
{ title: "Rating", href: "/global/question-type/rating" },
|
||||
{ title: "Net Promoter Score", href: "/global/question-type/net-promoter-score" },
|
||||
{ title: "Ranking", href: "/global/question-type/ranking" },
|
||||
{ title: "Matrix", href: "/global/question-type/matrix" },
|
||||
{ title: "Statement (Call to Action)", href: "/global/question-type/statement-cta" },
|
||||
{ title: "Consent", href: "/global/question-type/consent" },
|
||||
{ title: "File Upload", href: "/global/question-type/file-upload" },
|
||||
{ title: "Date", href: "/global/question-type/date" },
|
||||
{ title: "Schedule a Meeting", href: "/global/question-type/schedule" },
|
||||
{ title: "Address", href: "/global/question-type/address" },
|
||||
{ title: "Contact Info", href: "/global/question-type/contact" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Integrations",
|
||||
children: [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LucideProps } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ForwardRefExoticComponent, RefAttributes } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { OptionCard } from "@formbricks/ui/components/OptionCard";
|
||||
|
||||
interface OnboardingOptionsContainerProps {
|
||||
@@ -39,7 +40,11 @@ export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContain
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-5xl flex-wrap justify-center gap-8 text-center">
|
||||
<div
|
||||
className={cn({
|
||||
"flex w-5/6 justify-center gap-8 text-center md:flex-row lg:w-2/3": options.length >= 3,
|
||||
"flex justify-center gap-8": options.length < 3,
|
||||
})}>
|
||||
{options.map((option) =>
|
||||
option.href ? (
|
||||
<Link key={option.title} href={option.href}>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { DevEnvironmentBanner } from "@formbricks/ui/components/DevEnvironmentBanner";
|
||||
import { ToasterClient } from "@formbricks/ui/components/ToasterClient";
|
||||
|
||||
const SurveyEditorEnvironmentLayout = async ({ children, params }) => {
|
||||
const EnvLayout = async ({ children, params }) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || !session.user) {
|
||||
return redirect(`/auth/login`);
|
||||
@@ -61,4 +61,4 @@ const SurveyEditorEnvironmentLayout = async ({ children, params }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default SurveyEditorEnvironmentLayout;
|
||||
export default EnvLayout;
|
||||
|
||||
@@ -113,7 +113,7 @@ export function ConditionalLogic({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="mt-2">
|
||||
<Label className="flex gap-2">
|
||||
Conditional Logic
|
||||
<SplitIcon className="h-4 w-4 rotate-90" />
|
||||
|
||||
@@ -13,12 +13,7 @@ import { cn } from "@formbricks/lib/cn";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyEndScreenCard,
|
||||
TSurveyQuestionId,
|
||||
TSurveyRedirectUrlCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyEndScreenCard, TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
|
||||
import { OptionsSwitch } from "@formbricks/ui/components/OptionsSwitch";
|
||||
import { TooltipRenderer } from "@formbricks/ui/components/Tooltip";
|
||||
|
||||
@@ -27,7 +22,7 @@ interface EditEndingCardProps {
|
||||
endingCardIndex: number;
|
||||
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
|
||||
setActiveQuestionId: (id: string | null) => void;
|
||||
activeQuestionId: TSurveyQuestionId | null;
|
||||
activeQuestionId: string | null;
|
||||
isInvalid: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useState } from "react";
|
||||
import { LocalizedEditor } from "@formbricks/ee/multi-language/components/localized-editor";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyQuestionId, TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
|
||||
import { FileInput } from "@formbricks/ui/components/FileInput";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
@@ -17,7 +17,7 @@ interface EditWelcomeCardProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: (survey: TSurvey) => void;
|
||||
setActiveQuestionId: (id: string | null) => void;
|
||||
activeQuestionId: TSurveyQuestionId | null;
|
||||
activeQuestionId: string | null;
|
||||
isInvalid: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { extractRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
import { TSurvey, TSurveyHiddenFields, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyHiddenFields } from "@formbricks/types/surveys/types";
|
||||
import { validateId } from "@formbricks/types/surveys/validation";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Input } from "@formbricks/ui/components/Input";
|
||||
@@ -18,8 +18,8 @@ import { Tag } from "@formbricks/ui/components/Tag";
|
||||
interface HiddenFieldsCardProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: (survey: TSurvey) => void;
|
||||
activeQuestionId: TSurveyQuestionId | null;
|
||||
setActiveQuestionId: (questionId: TSurveyQuestionId | null) => void;
|
||||
activeQuestionId: string | null;
|
||||
setActiveQuestionId: (questionId: string | null) => void;
|
||||
}
|
||||
|
||||
export const HiddenFieldsCard = ({
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
@@ -47,8 +46,8 @@ interface QuestionCardProps {
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
deleteQuestion: (questionIdx: number) => void;
|
||||
duplicateQuestion: (questionIdx: number) => void;
|
||||
activeQuestionId: TSurveyQuestionId | null;
|
||||
setActiveQuestionId: (questionId: TSurveyQuestionId | null) => void;
|
||||
activeQuestionId: string | null;
|
||||
setActiveQuestionId: (questionId: string | null) => void;
|
||||
lastQuestion: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { QuestionCard } from "./QuestionCard";
|
||||
|
||||
interface QuestionsDraggableProps {
|
||||
@@ -11,8 +11,8 @@ interface QuestionsDraggableProps {
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
deleteQuestion: (questionIdx: number) => void;
|
||||
duplicateQuestion: (questionIdx: number) => void;
|
||||
activeQuestionId: TSurveyQuestionId | null;
|
||||
setActiveQuestionId: (questionId: TSurveyQuestionId | null) => void;
|
||||
activeQuestionId: string | null;
|
||||
setActiveQuestionId: (questionId: string | null) => void;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
invalidQuestions: string[] | null;
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
TSingleCondition,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicAction,
|
||||
TSurveyQuestionId,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { findQuestionsWithCyclicLogic } from "@formbricks/types/surveys/validation";
|
||||
@@ -48,8 +47,8 @@ import { QuestionsDroppable } from "./QuestionsDroppable";
|
||||
interface QuestionsViewProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: React.Dispatch<SetStateAction<TSurvey>>;
|
||||
activeQuestionId: TSurveyQuestionId | null;
|
||||
setActiveQuestionId: (questionId: TSurveyQuestionId | null) => void;
|
||||
activeQuestionId: string | null;
|
||||
setActiveQuestionId: (questionId: string | null) => void;
|
||||
product: TProduct;
|
||||
invalidQuestions: string[] | null;
|
||||
setInvalidQuestions: React.Dispatch<SetStateAction<string[] | null>>;
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { FileDigitIcon } from "lucide-react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { SurveyVariablesCardItem } from "./SurveyVariablesCardItem";
|
||||
|
||||
interface SurveyVariablesCardProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: (survey: TSurvey) => void;
|
||||
activeQuestionId: TSurveyQuestionId | null;
|
||||
setActiveQuestionId: (id: TSurveyQuestionId | null) => void;
|
||||
activeQuestionId: string | null;
|
||||
setActiveQuestionId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const variablesCardId = `fb-variables-${Date.now()}`;
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
TSurveyLogicAction,
|
||||
TSurveyLogicConditionsOperator,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyVariable,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
@@ -1024,7 +1023,7 @@ const isUsedInRightOperand = (
|
||||
}
|
||||
};
|
||||
|
||||
export const findQuestionUsedInLogic = (survey: TSurvey, questionId: TSurveyQuestionId): number => {
|
||||
export const findQuestionUsedInLogic = (survey: TSurvey, questionId: string): number => {
|
||||
const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => {
|
||||
if (isConditionGroup(condition)) {
|
||||
// It's a TConditionGroup
|
||||
@@ -1054,11 +1053,7 @@ export const findQuestionUsedInLogic = (survey: TSurvey, questionId: TSurveyQues
|
||||
);
|
||||
};
|
||||
|
||||
export const findOptionUsedInLogic = (
|
||||
survey: TSurvey,
|
||||
questionId: TSurveyQuestionId,
|
||||
optionId: string
|
||||
): number => {
|
||||
export const findOptionUsedInLogic = (survey: TSurvey, questionId: string, optionId: string): number => {
|
||||
const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => {
|
||||
if (isConditionGroup(condition)) {
|
||||
// It's a TConditionGroup
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { generateObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { llmModel } from "@formbricks/lib/aiModels";
|
||||
import { getOrganization } from "@formbricks/lib/organization/service";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
|
||||
import { createSurvey } from "@formbricks/lib/survey/service";
|
||||
import { getIsAIEnabled } from "@formbricks/lib/utils/ai";
|
||||
import { ZId, ZString } from "@formbricks/types/common";
|
||||
import { ZSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
|
||||
const ZCreateAISurveyAction = z.object({
|
||||
environmentId: ZId,
|
||||
prompt: ZString,
|
||||
});
|
||||
|
||||
export const createAISurveyAction = authenticatedActionClient
|
||||
.schema(ZCreateAISurveyAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
|
||||
await checkAuthorization({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
rules: ["survey", "create"],
|
||||
});
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const isAIEnabled = await getIsAIEnabled(organization.billing.plan);
|
||||
|
||||
if (!isAIEnabled) {
|
||||
throw new Error("AI is not enabled for this organization");
|
||||
}
|
||||
|
||||
const { object } = await generateObject({
|
||||
model: llmModel,
|
||||
schema: z.object({
|
||||
name: z.string(),
|
||||
questions: z.array(
|
||||
z.object({
|
||||
headline: z.string(),
|
||||
subheader: z.string(),
|
||||
type: z.enum(["openText", "multipleChoiceSingle", "multipleChoiceMulti"]),
|
||||
choices: z
|
||||
.array(z.string())
|
||||
.min(2, { message: "Multiple Choice Question must have at least two choices" })
|
||||
.optional(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
system: `You are a survey AI. Create a survey with 3 questions max that fits the schema and user input.`,
|
||||
prompt: parsedInput.prompt,
|
||||
experimental_telemetry: { isEnabled: true },
|
||||
});
|
||||
|
||||
const parsedQuestions = object.questions.map((question) => {
|
||||
return ZSurveyQuestion.parse({
|
||||
id: createId(),
|
||||
headline: { default: question.headline },
|
||||
subheader: { default: question.subheader },
|
||||
type: question.type,
|
||||
choices: question.choices
|
||||
? question.choices.map((choice) => ({ id: createId(), label: { default: choice } }))
|
||||
: undefined,
|
||||
required: true,
|
||||
});
|
||||
});
|
||||
|
||||
return await createSurvey(parsedInput.environmentId, { name: object.name, questions: parsedQuestions });
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createAISurveyAction } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/actions";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@formbricks/ui/components/Card";
|
||||
import { Textarea } from "@formbricks/ui/components/Textarea";
|
||||
|
||||
interface FormbricksAICardProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const FormbricksAICard = ({ environmentId }: FormbricksAICardProps) => {
|
||||
const router = useRouter();
|
||||
const [aiPrompt, setAiPrompt] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
// Here you would typically send the data to your backend
|
||||
const createSurveyResponse = await createAISurveyAction({
|
||||
environmentId,
|
||||
prompt: aiPrompt,
|
||||
});
|
||||
|
||||
if (createSurveyResponse?.data) {
|
||||
router.push(`/environments/${environmentId}/surveys/${createSurveyResponse.data.id}/edit`);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createSurveyResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
// Reset form field after submission
|
||||
setAiPrompt("");
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mx-auto w-full bg-gradient-to-tr from-slate-100 to-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Formbricks AI</CardTitle>
|
||||
<CardDescription>
|
||||
Describe your survey and let Formbricks AI create the survey for you
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Textarea
|
||||
className="bg-slate-50"
|
||||
id="ai-prompt"
|
||||
placeholder="Enter survey information (e.g. key topics to cover)"
|
||||
value={aiPrompt}
|
||||
onChange={(e) => setAiPrompt(e.target.value)}
|
||||
required
|
||||
aria-label="AI Prompt"
|
||||
/>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
className="w-full shadow-sm"
|
||||
type="submit"
|
||||
onClick={handleSubmit}
|
||||
variant="secondary"
|
||||
loading={isLoading}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Generate
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { FormbricksAICard } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/FormbricksAICard";
|
||||
import { MenuBar } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/MenuBar";
|
||||
import { useState } from "react";
|
||||
import { customSurvey } from "@formbricks/lib/templates";
|
||||
@@ -10,7 +9,6 @@ import type { TTemplate, TTemplateRole } from "@formbricks/types/templates";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { PreviewSurvey } from "@formbricks/ui/components/PreviewSurvey";
|
||||
import { SearchBar } from "@formbricks/ui/components/SearchBar";
|
||||
import { Separator } from "@formbricks/ui/components/Separator";
|
||||
import { TemplateList } from "@formbricks/ui/components/TemplateList";
|
||||
import { minimalSurvey } from "../../lib/minimalSurvey";
|
||||
|
||||
@@ -20,7 +18,6 @@ type TemplateContainerWithPreviewProps = {
|
||||
environment: TEnvironment;
|
||||
user: TUser;
|
||||
prefilledFilters: (TProductConfigChannel | TProductConfigIndustry | TTemplateRole | null)[];
|
||||
isAIEnabled: boolean;
|
||||
};
|
||||
|
||||
export const TemplateContainerWithPreview = ({
|
||||
@@ -28,7 +25,6 @@ export const TemplateContainerWithPreview = ({
|
||||
environment,
|
||||
user,
|
||||
prefilledFilters,
|
||||
isAIEnabled,
|
||||
}: TemplateContainerWithPreviewProps) => {
|
||||
const initialTemplate = customSurvey;
|
||||
const [activeTemplate, setActiveTemplate] = useState<TTemplate>(initialTemplate);
|
||||
@@ -52,15 +48,6 @@ export const TemplateContainerWithPreview = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAIEnabled && (
|
||||
<>
|
||||
<div className="px-6">
|
||||
<FormbricksAICard environmentId={environment.id} />
|
||||
</div>
|
||||
<Separator className="mt-4" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<TemplateList
|
||||
environment={environment}
|
||||
product={product}
|
||||
|
||||
@@ -65,8 +65,6 @@ const Page = async ({ params, searchParams }: SurveyTemplateProps) => {
|
||||
environment={environment}
|
||||
product={product}
|
||||
prefilledFilters={prefilledFilters}
|
||||
// AI Survey Creation -- Need improvement
|
||||
isAIEnabled={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||
import { getIsAIEnabled } from "@/app/lib/utils";
|
||||
import type { Session } from "next-auth";
|
||||
import { getEnterpriseLicense } from "@formbricks/ee/lib/service";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
@@ -64,8 +63,6 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
]);
|
||||
}
|
||||
|
||||
const isAIEnabled = await getIsAIEnabled(organization);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
|
||||
<DevEnvironmentBanner environment={environment} />
|
||||
@@ -96,7 +93,6 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
membershipRole={currentUserMembership?.role}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
isAIEnabled={isAIEnabled}
|
||||
/>
|
||||
<div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50">
|
||||
<TopControlBar
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
ChevronRightIcon,
|
||||
Cog,
|
||||
CreditCardIcon,
|
||||
GaugeIcon,
|
||||
GlobeIcon,
|
||||
GlobeLockIcon,
|
||||
KeyIcon,
|
||||
@@ -68,7 +67,6 @@ interface NavigationProps {
|
||||
isMultiOrgEnabled: boolean;
|
||||
isFormbricksCloud?: boolean;
|
||||
membershipRole?: TMembershipRole;
|
||||
isAIEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const MainNavigation = ({
|
||||
@@ -80,7 +78,6 @@ export const MainNavigation = ({
|
||||
isMultiOrgEnabled,
|
||||
isFormbricksCloud = true,
|
||||
membershipRole,
|
||||
isAIEnabled = false,
|
||||
}: NavigationProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -161,13 +158,6 @@ export const MainNavigation = ({
|
||||
|
||||
const mainNavigation = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: "Experience",
|
||||
href: `/environments/${environment.id}/experience`,
|
||||
icon: GaugeIcon,
|
||||
isActive: pathname?.includes("/experience"),
|
||||
isHidden: !isAIEnabled,
|
||||
},
|
||||
{
|
||||
name: "Surveys",
|
||||
href: `/environments/${environment.id}/surveys`,
|
||||
@@ -216,7 +206,7 @@ export const MainNavigation = ({
|
||||
},
|
||||
{
|
||||
label: "Organization",
|
||||
href: `/environments/${environment.id}/settings/general`,
|
||||
href: `/environments/${environment.id}/settings/members`,
|
||||
icon: UsersIcon,
|
||||
},
|
||||
{
|
||||
@@ -332,6 +322,7 @@ export const MainNavigation = ({
|
||||
</p>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { ExperiencePage } from "@/modules/ee/insights/experience/page";
|
||||
|
||||
export default ExperiencePage;
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
TIntegrationGoogleSheetsConfigData,
|
||||
TIntegrationGoogleSheetsInput,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { AdditionalIntegrationSettings } from "@formbricks/ui/components/AdditionalIntegrationSettings";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Checkbox } from "@formbricks/ui/components/Checkbox";
|
||||
@@ -150,7 +150,7 @@ export const AddIntegrationModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (questionId: TSurveyQuestionId) => {
|
||||
const handleCheckboxChange = (questionId: string) => {
|
||||
setSelectedQuestions((prevValues) =>
|
||||
prevValues.includes(questionId)
|
||||
? prevValues.filter((value) => value !== questionId)
|
||||
|
||||
@@ -17,8 +17,8 @@ import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getWebhookCountBySource } from "@formbricks/lib/webhook/service";
|
||||
import { TIntegrationType } from "@formbricks/types/integration";
|
||||
import { Card } from "@formbricks/ui/components/Card";
|
||||
import { ErrorComponent } from "@formbricks/ui/components/ErrorComponent";
|
||||
import { Card } from "@formbricks/ui/components/IntegrationCard";
|
||||
import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/components/PageHeader";
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
TIntegrationSlackConfigData,
|
||||
TIntegrationSlackInput,
|
||||
} from "@formbricks/types/integration/slack";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { AdditionalIntegrationSettings } from "@formbricks/ui/components/AdditionalIntegrationSettings";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Checkbox } from "@formbricks/ui/components/Checkbox";
|
||||
@@ -136,7 +136,7 @@ export const AddChannelMappingModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (questionId: TSurveyQuestionId) => {
|
||||
const handleCheckboxChange = (questionId: string) => {
|
||||
setSelectedQuestions((prevValues) =>
|
||||
prevValues.includes(questionId)
|
||||
? prevValues.filter((value) => value !== questionId)
|
||||
|
||||
@@ -14,7 +14,7 @@ import { FormbricksClient } from "../../components/FormbricksClient";
|
||||
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
|
||||
import { PosthogIdentify } from "./components/PosthogIdentify";
|
||||
|
||||
export const EnvLayout = async ({ children, params }) => {
|
||||
const EnvLayout = async ({ children, params }) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || !session.user) {
|
||||
return redirect(`/auth/login`);
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
import { getIsAIEnabled } from "@/app/lib/utils";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
|
||||
const Page = async ({ params }) => {
|
||||
const organization = await getOrganizationByEnvironmentId(params.environmentId);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const isAIEnabled = await getIsAIEnabled(organization);
|
||||
|
||||
if (isAIEnabled) {
|
||||
return redirect(`/environments/${params.environmentId}/experience`);
|
||||
}
|
||||
|
||||
const Page = ({ params }) => {
|
||||
return redirect(`/environments/${params.environmentId}/surveys`);
|
||||
};
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ export const EditAlerts = ({
|
||||
)}
|
||||
<p className="pb-3 pl-4 text-xs text-slate-400">
|
||||
Want to loop in organization mates?{" "}
|
||||
<Link className="font-semibold" href={`/environments/${environmentId}/settings/general`}>
|
||||
<Link className="font-semibold" href={`/environments/${environmentId}/settings/members`}>
|
||||
Invite them.
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const EditWeeklySummary = ({ memberships, user, environmentId }: EditAler
|
||||
</div>
|
||||
<p className="pb-3 pl-4 text-xs text-slate-400">
|
||||
Want to loop in organization mates?{" "}
|
||||
<Link className="font-semibold" href={`/environments/${environmentId}/settings/general`}>
|
||||
<Link className="font-semibold" href={`/environments/${environmentId}/settings/members`}>
|
||||
Invite them.
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -121,20 +121,13 @@ export const DisableTwoFactorModal = ({ open, setOpen }: TDisableTwoFactorModalP
|
||||
<Controller
|
||||
name="code"
|
||||
control={control}
|
||||
render={({ field, formState: { errors } }) => (
|
||||
<>
|
||||
<OTPInput
|
||||
value={field.value}
|
||||
valueLength={6}
|
||||
onChange={field.onChange}
|
||||
containerClassName="justify-start mt-4"
|
||||
/>
|
||||
{errors.code && (
|
||||
<p className="mt-2 text-sm text-red-600" id="code-error">
|
||||
{errors.code.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
render={({ field }) => (
|
||||
<OTPInput
|
||||
value={field.value}
|
||||
valueLength={6}
|
||||
onChange={field.onChange}
|
||||
containerClassName="justify-start mt-4"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -148,7 +148,7 @@ type TEnableCodeProps = {
|
||||
refreshData: () => void;
|
||||
};
|
||||
const EnterCode = ({ setCurrentStep, setOpen, refreshData }: TEnableCodeProps) => {
|
||||
const { control, handleSubmit, setError } = useForm<TEnterCodeFormState>({
|
||||
const { control, handleSubmit } = useForm<TEnterCodeFormState>({
|
||||
defaultValues: {
|
||||
code: "",
|
||||
},
|
||||
@@ -163,9 +163,6 @@ const EnterCode = ({ setCurrentStep, setOpen, refreshData }: TEnableCodeProps) =
|
||||
|
||||
// refresh data to update the UI
|
||||
refreshData();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(enableTwoFactorAuthResponse);
|
||||
setError("code", { message: errorMessage });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
|
||||
@@ -25,11 +25,11 @@ export const OrganizationSettingsNavbar = ({
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
id: "general",
|
||||
label: "General",
|
||||
href: `/environments/${environmentId}/settings/general`,
|
||||
id: "members",
|
||||
label: "Members",
|
||||
href: `/environments/${environmentId}/settings/members`,
|
||||
icon: <UsersIcon className="h-5 w-5" />,
|
||||
current: pathname?.includes("/general"),
|
||||
current: pathname?.includes("/members"),
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { updateOrganizationAIEnabledAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { Switch } from "@formbricks/ui/components/Switch";
|
||||
|
||||
interface AIToggleProps {
|
||||
environmentId: string;
|
||||
organization: TOrganization;
|
||||
isAdminOrOwner: boolean;
|
||||
}
|
||||
|
||||
export const AIToggle = ({ organization, isAdminOrOwner }: AIToggleProps) => {
|
||||
const [isAIEnabled, setIsAIEnabled] = useState(organization.isAIEnabled);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleUpdateOrganization = async (data) => {
|
||||
try {
|
||||
setIsAIEnabled(data.enabled);
|
||||
setIsSubmitting(true);
|
||||
const updatedOrganizationResponse = await updateOrganizationAIEnabledAction({
|
||||
organizationId: organization.id,
|
||||
data: {
|
||||
isAIEnabled: data.enabled,
|
||||
},
|
||||
});
|
||||
|
||||
if (updatedOrganizationResponse?.data) {
|
||||
toast.success(`Formbricks AI ${data.enabled ? "enabled" : "disabled"} successfully.`);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedOrganizationResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(`Error: ${err.message}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
if (typeof window !== "undefined") {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return !isAdminOrOwner ? (
|
||||
<p className="text-sm text-red-700">You are not authorized to perform this action.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="formbricks-ai-toggle" className="cursor-pointer">
|
||||
{isAIEnabled ? "Disable" : "Enable"} Formbricks AI
|
||||
</Label>
|
||||
<Switch
|
||||
id="formbricks-ai-toggle"
|
||||
disabled={isSubmitting}
|
||||
checked={isAIEnabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUpdateOrganization({ enabled: !organization.isAIEnabled });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-slate-600">
|
||||
By activating Formbricks AI, you agree to the updated{" "}
|
||||
<Link
|
||||
className="underline"
|
||||
href={"https://formbricks.com/privacy-policy"}
|
||||
rel="noreferrer"
|
||||
target="_blank">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -38,25 +38,6 @@ export const updateOrganizationNameAction = authenticatedActionClient
|
||||
return await updateOrganization(parsedInput.organizationId, parsedInput.data);
|
||||
});
|
||||
|
||||
const ZUpdateOrganizationAIEnabledAction = z.object({
|
||||
organizationId: ZId,
|
||||
data: ZOrganizationUpdateInput.pick({ isAIEnabled: true }),
|
||||
});
|
||||
|
||||
export const updateOrganizationAIEnabledAction = authenticatedActionClient
|
||||
.schema(ZUpdateOrganizationAIEnabledAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
await checkAuthorization({
|
||||
schema: ZOrganizationUpdateInput.pick({ isAIEnabled: true }),
|
||||
data: parsedInput.data,
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
rules: ["organization", "update"],
|
||||
});
|
||||
|
||||
return await updateOrganization(parsedInput.organizationId, parsedInput.data);
|
||||
});
|
||||
|
||||
const ZDeleteInviteAction = z.object({
|
||||
inviteId: ZUuid,
|
||||
organizationId: ZId,
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { deleteOrganizationAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
||||
import { deleteOrganizationAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/members/actions";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Dispatch, SetStateAction, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MembersInfo } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditMemberships/MembersInfo";
|
||||
import { MembersInfo } from "@/app/(app)/environments/[environmentId]/settings/(organization)/members/components/EditMemberships/MembersInfo";
|
||||
import { getRoleManagementPermission } from "@formbricks/ee/lib/service";
|
||||
import { getInvitesByOrganizationId } from "@formbricks/lib/invite/service";
|
||||
import { getMembersByOrganizationId } from "@formbricks/lib/membership/service";
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
deleteInviteAction,
|
||||
deleteMembershipAction,
|
||||
resendInviteAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
||||
import { ShareInviteModal } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/ShareInviteModal";
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(organization)/members/actions";
|
||||
import { ShareInviteModal } from "@/app/(app)/environments/[environmentId]/settings/(organization)/members/components/ShareInviteModal";
|
||||
import { SendHorizonalIcon, ShareIcon, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useMemo, useState } from "react";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MemberActions } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditMemberships/MemberActions";
|
||||
import { MemberActions } from "@/app/(app)/environments/[environmentId]/settings/(organization)/members/components/EditMemberships/MemberActions";
|
||||
import { isInviteExpired } from "@/app/lib/utils";
|
||||
import { EditMembershipRole } from "@formbricks/ee/role-management/components/edit-membership-role";
|
||||
import { TInvite } from "@formbricks/types/invites";
|
||||
@@ -3,8 +3,8 @@
|
||||
import {
|
||||
inviteUserAction,
|
||||
leaveOrganizationAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
||||
import { AddMemberModal } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AddMemberModal";
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(organization)/members/actions";
|
||||
import { AddMemberModal } from "@/app/(app)/environments/[environmentId]/settings/(organization)/members/components/AddMemberModal";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { updateOrganizationNameAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
||||
import { updateOrganizationNameAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/members/actions";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { SubmitHandler, useForm, useWatch } from "react-hook-form";
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { updateOrganizationNameAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
||||
import { updateOrganizationNameAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/members/actions";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -27,7 +27,7 @@ const Loading = () => {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="Organization Settings">
|
||||
<OrganizationSettingsNavbar isFormbricksCloud={IS_FORMBRICKS_CLOUD} activeId="general" loading />
|
||||
<OrganizationSettingsNavbar isFormbricksCloud={IS_FORMBRICKS_CLOUD} activeId="members" loading />
|
||||
</PageHeader>
|
||||
{cards.map((card, index) => (
|
||||
<LoadingCard key={index} {...card} />
|
||||
@@ -1,7 +1,5 @@
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { AIToggle } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle";
|
||||
import { OrganizationActions } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditMemberships/OrganizationActions";
|
||||
import { getIsOrganizationAIReady } from "@/app/lib/utils";
|
||||
import { OrganizationActions } from "@/app/(app)/environments/[environmentId]/settings/(organization)/members/components/EditMemberships/OrganizationActions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { Suspense } from "react";
|
||||
import { getIsMultiOrgEnabled, getRoleManagementPermission } from "@formbricks/ee/lib/service";
|
||||
@@ -54,8 +52,6 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
const isLeaveOrganizationDisabled = userMemberships.length <= 1;
|
||||
const isUserAdminOrOwner = isAdmin || isOwner;
|
||||
|
||||
const isOrganizationAIReady = await getIsOrganizationAIReady(organization.billing.plan);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="Organization Settings">
|
||||
@@ -63,7 +59,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
environmentId={params.environmentId}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
membershipRole={currentUserMembership?.role}
|
||||
activeId="general"
|
||||
activeId="members"
|
||||
/>
|
||||
</PageHeader>
|
||||
<SettingsCard title="Manage members" description="Add or remove members in your organization.">
|
||||
@@ -99,17 +95,6 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
membershipRole={currentUserMembership?.role}
|
||||
/>
|
||||
</SettingsCard>
|
||||
{isOrganizationAIReady && (
|
||||
<SettingsCard
|
||||
title="Formbricks AI"
|
||||
description="Get personalised insights from your survey responses with Formbricks AI">
|
||||
<AIToggle
|
||||
environmentId={params.environmentId}
|
||||
organization={organization}
|
||||
isAdminOrOwner={isUserAdminOrOwner}
|
||||
/>
|
||||
</SettingsCard>
|
||||
)}
|
||||
{isMultiOrgEnabled && (
|
||||
<SettingsCard
|
||||
title="Delete Organization"
|
||||
@@ -1,15 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { generateInsightsForSurvey } from "@/app/api/(internal)/insights/lib/utils";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { getOrganizationIdFromSurveyId } from "@formbricks/lib/organization/utils";
|
||||
import { getResponseCountBySurveyId, getResponses } from "@formbricks/lib/response/service";
|
||||
import { getResponseCountBySurveyId, getResponses, getSurveySummary } from "@formbricks/lib/response/service";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
||||
import { getSurveySummary } from "./summary/lib/surveySummary";
|
||||
|
||||
export const revalidateSurveyIdPath = async (environmentId: string, surveyId: string) => {
|
||||
revalidatePath(`/environments/${environmentId}/surveys/${surveyId}`);
|
||||
@@ -72,19 +70,3 @@ export const getResponseCountAction = authenticatedActionClient
|
||||
|
||||
return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria);
|
||||
});
|
||||
|
||||
const ZGenerateInsightsForSurveyAction = z.object({
|
||||
surveyId: ZId,
|
||||
});
|
||||
|
||||
export const generateInsightsForSurveyAction = authenticatedActionClient
|
||||
.schema(ZGenerateInsightsForSurveyAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
rules: ["survey", "update"],
|
||||
});
|
||||
|
||||
generateInsightsForSurvey(parsedInput.surveyId);
|
||||
});
|
||||
|
||||
@@ -182,7 +182,7 @@ export const ResponseTable = ({
|
||||
/>
|
||||
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-200">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Table className="w-full" style={{ tableLayout: "fixed" }} id="response-table">
|
||||
<Table className="w-full" style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { EnableInsightsBanner } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/EnableInsightsBanner";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { needsInsightsGeneration } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||
import { getIsAIEnabled } from "@/app/lib/utils";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import {
|
||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION,
|
||||
RESPONSES_PER_PAGE,
|
||||
WEBAPP_URL,
|
||||
} from "@formbricks/lib/constants";
|
||||
import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
@@ -60,9 +53,6 @@ const Page = async ({ params }) => {
|
||||
|
||||
const { isViewer } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
const isAIEnabled = await getIsAIEnabled(organization);
|
||||
const shouldGenerateInsights = needsInsightsGeneration(survey);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader
|
||||
@@ -76,14 +66,6 @@ const Page = async ({ params }) => {
|
||||
user={user}
|
||||
/>
|
||||
}>
|
||||
{isAIEnabled && shouldGenerateInsights && (
|
||||
<EnableInsightsBanner
|
||||
surveyId={survey.id}
|
||||
surveyResponseCount={totalResponseCount}
|
||||
maxResponseCount={MAX_RESPONSES_FOR_INSIGHT_GENERATION}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SurveyAnalysisNavigation
|
||||
environmentId={environment.id}
|
||||
survey={survey}
|
||||
|
||||
@@ -19,7 +19,7 @@ export const CTASummary = ({ questionSummary, survey, attributeClasses }: CTASum
|
||||
questionSummary={questionSummary}
|
||||
showResponses={false}
|
||||
attributeClasses={attributeClasses}
|
||||
additionalInfo={
|
||||
insights={
|
||||
<>
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -2,7 +2,6 @@ import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryConsent,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
@@ -15,7 +14,7 @@ interface ConsentSummaryProps {
|
||||
survey: TSurvey;
|
||||
attributeClasses: TAttributeClass[];
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
questionId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { generateInsightsForSurveyAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { SparklesIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@formbricks/ui/components/Alert";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
|
||||
interface EnableInsightsBannerProps {
|
||||
surveyId: string;
|
||||
maxResponseCount: number;
|
||||
surveyResponseCount: number;
|
||||
}
|
||||
|
||||
export const EnableInsightsBanner = ({
|
||||
surveyId,
|
||||
surveyResponseCount,
|
||||
maxResponseCount,
|
||||
}: EnableInsightsBannerProps) => {
|
||||
const [isGeneratingInsights, setIsGeneratingInsights] = useState(false);
|
||||
|
||||
const handleInsightGeneration = async () => {
|
||||
setIsGeneratingInsights(true);
|
||||
toast.success("Generating insights for this survey. Please check back in a few minutes.");
|
||||
generateInsightsForSurveyAction({ surveyId });
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert className="w-1/2 bg-white">
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
<span>Ready to enable insights?</span>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="flex items-start justify-between gap-4">
|
||||
<span>
|
||||
You can enable the new insights feature for the survey to get AI-based insights for your open-text
|
||||
responses.
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={handleInsightGeneration}
|
||||
disabled={surveyResponseCount > maxResponseCount || isGeneratingInsights}
|
||||
tooltip={
|
||||
surveyResponseCount > maxResponseCount
|
||||
? "Kindly contact us at hola@formbricks.com to generate insights for this survey"
|
||||
: undefined
|
||||
}>
|
||||
Enable Insights
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,6 @@ import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryMatrix,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
@@ -14,7 +13,7 @@ interface MatrixQuestionSummaryProps {
|
||||
survey: TSurvey;
|
||||
attributeClasses: TAttributeClass[];
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
questionId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryMultipleChoice,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyType,
|
||||
@@ -23,7 +22,7 @@ interface MultipleChoiceSummaryProps {
|
||||
survey: TSurvey;
|
||||
attributeClasses: TAttributeClass[];
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
questionId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryNps,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
@@ -15,7 +14,7 @@ interface NPSSummaryProps {
|
||||
survey: TSurvey;
|
||||
attributeClasses: TAttributeClass[];
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
questionId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { InsightView } from "@/modules/ee/insights/components/insights-view";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
|
||||
@@ -7,15 +6,6 @@ import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys/types";
|
||||
import { PersonAvatar } from "@formbricks/ui/components/Avatars";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { SecondaryNavigation } from "@formbricks/ui/components/SecondaryNavigation";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@formbricks/ui/components/Table";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface OpenTextSummaryProps {
|
||||
@@ -23,8 +13,6 @@ interface OpenTextSummaryProps {
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
attributeClasses: TAttributeClass[];
|
||||
isAIEnabled: boolean;
|
||||
documentsPerPage?: number;
|
||||
}
|
||||
|
||||
export const OpenTextSummary = ({
|
||||
@@ -32,14 +20,8 @@ export const OpenTextSummary = ({
|
||||
environmentId,
|
||||
survey,
|
||||
attributeClasses,
|
||||
isAIEnabled,
|
||||
documentsPerPage,
|
||||
}: OpenTextSummaryProps) => {
|
||||
const isInsightsEnabled = isAIEnabled && questionSummary.insightsEnabled;
|
||||
const [visibleResponses, setVisibleResponses] = useState(10);
|
||||
const [activeTab, setActiveTab] = useState<"insights" | "responses">(
|
||||
isInsightsEnabled && questionSummary.insights.length ? "insights" : "responses"
|
||||
);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||
@@ -48,96 +30,61 @@ export const OpenTextSummary = ({
|
||||
);
|
||||
};
|
||||
|
||||
const tabNavigation = [
|
||||
{
|
||||
id: "insights",
|
||||
label: "Insights",
|
||||
onClick: () => setActiveTab("insights"),
|
||||
},
|
||||
{
|
||||
id: "responses",
|
||||
label: "Responses",
|
||||
onClick: () => setActiveTab("responses"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
additionalInfo={
|
||||
isAIEnabled && questionSummary.insightsEnabled === false ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">Insights disabled</div>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{isInsightsEnabled && (
|
||||
<div className="ml-4">
|
||||
<SecondaryNavigation activeId={activeTab} navigation={tabNavigation} />
|
||||
<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">User</div>
|
||||
<div className="col-span-2 pl-4 md:pl-6">Response</div>
|
||||
<div className="px-4 md:px-6">Time</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-slate-200"></div>
|
||||
<div className="max-h-[40vh] overflow-y-auto">
|
||||
{activeTab === "insights" ? (
|
||||
<InsightView
|
||||
insights={questionSummary.insights}
|
||||
questionId={questionSummary.question.id}
|
||||
surveyId={survey.id}
|
||||
documentsPerPage={documentsPerPage}
|
||||
/>
|
||||
) : activeTab === "responses" ? (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader className="bg-slate-100">
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Response</TableHead>
|
||||
<TableHead>Time</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<TableRow key={response.id}>
|
||||
<TableCell>
|
||||
{response.person ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/people/${response.person.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.person.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getPersonIdentifier(response.person, response.personAttributes)}
|
||||
</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">Anonymous</p>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{response.value}</TableCell>
|
||||
<TableCell>{timeSince(new Date(response.updatedAt).toISOString())}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{visibleResponses < questionSummary.samples.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
Load more
|
||||
</Button>
|
||||
<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.person ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/people/${response.person.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.person.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getPersonIdentifier(response.person, response.personAttributes)}
|
||||
</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">Anonymous</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold" dir="auto">
|
||||
{response.value}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString())}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{visibleResponses < questionSummary.samples.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryPictureSelection,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
@@ -16,7 +15,7 @@ interface PictureChoiceSummaryProps {
|
||||
survey: TSurvey;
|
||||
attributeClasses: TAttributeClass[];
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
questionId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
|
||||
@@ -7,14 +7,14 @@ import { TSurvey, TSurveyQuestionSummary } from "@formbricks/types/surveys/types
|
||||
interface HeadProps {
|
||||
questionSummary: TSurveyQuestionSummary;
|
||||
showResponses?: boolean;
|
||||
additionalInfo?: JSX.Element;
|
||||
insights?: JSX.Element;
|
||||
survey: TSurvey;
|
||||
attributeClasses: TAttributeClass[];
|
||||
}
|
||||
|
||||
export const QuestionSummaryHeader = ({
|
||||
questionSummary,
|
||||
additionalInfo,
|
||||
insights,
|
||||
showResponses = true,
|
||||
survey,
|
||||
attributeClasses,
|
||||
@@ -62,7 +62,7 @@ export const QuestionSummaryHeader = ({
|
||||
{`${questionSummary.responseCount} Responses`}
|
||||
</div>
|
||||
)}
|
||||
{additionalInfo}
|
||||
{insights}
|
||||
{!questionSummary.question.required && (
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryRating,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
@@ -18,7 +17,7 @@ interface RatingSummaryProps {
|
||||
survey: TSurvey;
|
||||
attributeClasses: TAttributeClass[];
|
||||
setFilter: (
|
||||
questionId: TSurveyQuestionId,
|
||||
questionId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
@@ -45,7 +44,7 @@ export const RatingSummary = ({
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
additionalInfo={
|
||||
insights={
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
{getIconBasedOnScale}
|
||||
<div>Overall: {questionSummary.average.toFixed(2)}</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ const ScrollToTop: React.FC<ScrollToTopProps> = ({ containerId }) => {
|
||||
return (
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className={`fixed bottom-4 right-4 z-[1] flex h-10 w-10 justify-center rounded-md bg-slate-500 p-2 text-white transition-opacity ${
|
||||
className={`fixed bottom-4 right-4 flex h-10 w-10 justify-center rounded-md bg-slate-500 p-2 text-white transition-opacity ${
|
||||
showButton ? "opacity-80" : "opacity-0"
|
||||
}`}>
|
||||
↑
|
||||
|
||||
@@ -25,7 +25,7 @@ import { toast } from "react-hot-toast";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TI18nString, TSurveyQuestionId, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TI18nString, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { EmptySpaceFiller } from "@formbricks/ui/components/EmptySpaceFiller";
|
||||
@@ -39,8 +39,6 @@ interface SummaryListProps {
|
||||
survey: TSurvey;
|
||||
totalResponseCount: number;
|
||||
attributeClasses: TAttributeClass[];
|
||||
isAIEnabled: boolean;
|
||||
documentsPerPage?: number;
|
||||
}
|
||||
|
||||
export const SummaryList = ({
|
||||
@@ -50,13 +48,11 @@ export const SummaryList = ({
|
||||
survey,
|
||||
totalResponseCount,
|
||||
attributeClasses,
|
||||
isAIEnabled,
|
||||
documentsPerPage,
|
||||
}: SummaryListProps) => {
|
||||
const { setSelectedFilter, selectedFilter } = useResponseFilter();
|
||||
|
||||
const setFilter = (
|
||||
questionId: TSurveyQuestionId,
|
||||
questionId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
@@ -130,8 +126,6 @@ export const SummaryList = ({
|
||||
environmentId={environment.id}
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
isAIEnabled={isAIEnabled}
|
||||
documentsPerPage={documentsPerPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,8 +48,6 @@ interface SummaryPageProps {
|
||||
user?: TUser;
|
||||
totalResponseCount: number;
|
||||
attributeClasses: TAttributeClass[];
|
||||
isAIEnabled: boolean;
|
||||
documentsPerPage?: number;
|
||||
}
|
||||
|
||||
export const SummaryPage = ({
|
||||
@@ -59,8 +57,6 @@ export const SummaryPage = ({
|
||||
webAppUrl,
|
||||
totalResponseCount,
|
||||
attributeClasses,
|
||||
isAIEnabled,
|
||||
documentsPerPage,
|
||||
}: SummaryPageProps) => {
|
||||
const params = useParams();
|
||||
const sharingKey = params.sharingKey as string;
|
||||
@@ -180,8 +176,6 @@ export const SummaryPage = ({
|
||||
environment={environment}
|
||||
totalResponseCount={totalResponseCount}
|
||||
attributeClasses={attributeClasses}
|
||||
isAIEnabled={isAIEnabled}
|
||||
documentsPerPage={documentsPerPage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -156,16 +156,14 @@ export const SurveyAnalysisCTA = ({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="mr-8 w-40">
|
||||
<DropdownMenuGroup>
|
||||
{survey.type === "link" && (
|
||||
<DropdownMenuItem>
|
||||
<button onClick={handleCopyLink} className="flex w-full items-center">
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
Copy Link
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<button onClick={handleCopyLink} className="flex w-full items-center">
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
Copy Link
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleModalState("panel")(true);
|
||||
@@ -177,7 +175,7 @@ export const SurveyAnalysisCTA = ({
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/environments/${survey.environmentId}/settings/notifications`}
|
||||
className="flex w-full items-center"
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { documentCache } from "@/lib/cache/document";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { INSIGHTS_PER_PAGE } from "@formbricks/lib/constants";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TInsight } from "@formbricks/types/insights";
|
||||
import { TSurveyQuestionId, ZSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const getInsightsBySurveyIdQuestionId = reactCache(
|
||||
(surveyId: string, questionId: TSurveyQuestionId, limit?: number, offset?: number): Promise<TInsight[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([surveyId, ZId], [questionId, ZSurveyQuestionId]);
|
||||
|
||||
limit = limit ?? INSIGHTS_PER_PAGE;
|
||||
try {
|
||||
const insights = await prisma.insight.findMany({
|
||||
where: {
|
||||
documentInsights: {
|
||||
some: {
|
||||
document: {
|
||||
surveyId,
|
||||
questionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
documentInsights: {
|
||||
where: {
|
||||
document: {
|
||||
surveyId,
|
||||
questionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
documentInsights: {
|
||||
_count: "desc",
|
||||
},
|
||||
},
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
],
|
||||
take: limit ? limit : undefined,
|
||||
skip: offset ? offset : undefined,
|
||||
});
|
||||
|
||||
return insights;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getInsightsBySurveyIdQuestionId-${surveyId}-${limit}-${offset}`],
|
||||
{
|
||||
tags: [documentCache.tag.bySurveyId(surveyId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -1,916 +0,0 @@
|
||||
import "server-only";
|
||||
import { getInsightsBySurveyIdQuestionId } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/insights";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { displayCache } from "@formbricks/lib/display/cache";
|
||||
import { getDisplayCountBySurveyId } from "@formbricks/lib/display/service";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { responseCache } from "@formbricks/lib/response/cache";
|
||||
import { getResponseCountBySurveyId, getResponses } from "@formbricks/lib/response/service";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { evaluateLogic, performActions } from "@formbricks/lib/surveyLogic/utils";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
TResponse,
|
||||
TResponseData,
|
||||
TResponseFilterCriteria,
|
||||
TResponseVariables,
|
||||
ZResponseFilterCriteria,
|
||||
} from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyContactInfoQuestion,
|
||||
TSurveyLanguage,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryAddress,
|
||||
TSurveyQuestionSummaryDate,
|
||||
TSurveyQuestionSummaryFileUpload,
|
||||
TSurveyQuestionSummaryHiddenFields,
|
||||
TSurveyQuestionSummaryMultipleChoice,
|
||||
TSurveyQuestionSummaryOpenText,
|
||||
TSurveyQuestionSummaryPictureSelection,
|
||||
TSurveyQuestionSummaryRanking,
|
||||
TSurveyQuestionSummaryRating,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveySummary,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { convertFloatTo2Decimal } from "./utils";
|
||||
|
||||
export const getSurveySummaryMeta = (
|
||||
responses: TResponse[],
|
||||
displayCount: number
|
||||
): TSurveySummary["meta"] => {
|
||||
const completedResponses = responses.filter((response) => response.finished).length;
|
||||
|
||||
let ttcResponseCount = 0;
|
||||
const ttcSum = responses.reduce((acc, response) => {
|
||||
if (response.ttc?._total) {
|
||||
ttcResponseCount++;
|
||||
return acc + response.ttc._total;
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
const responseCount = responses.length;
|
||||
|
||||
const startsPercentage = displayCount > 0 ? (responseCount / displayCount) * 100 : 0;
|
||||
const completedPercentage = displayCount > 0 ? (completedResponses / displayCount) * 100 : 0;
|
||||
const dropOffCount = responseCount - completedResponses;
|
||||
const dropOffPercentage = responseCount > 0 ? (dropOffCount / responseCount) * 100 : 0;
|
||||
const ttcAverage = ttcResponseCount > 0 ? ttcSum / ttcResponseCount : 0;
|
||||
|
||||
return {
|
||||
displayCount: displayCount || 0,
|
||||
totalResponses: responseCount,
|
||||
startsPercentage: convertFloatTo2Decimal(startsPercentage),
|
||||
completedResponses,
|
||||
completedPercentage: convertFloatTo2Decimal(completedPercentage),
|
||||
dropOffCount,
|
||||
dropOffPercentage: convertFloatTo2Decimal(dropOffPercentage),
|
||||
ttcAverage: convertFloatTo2Decimal(ttcAverage),
|
||||
};
|
||||
};
|
||||
|
||||
const evaluateLogicAndGetNextQuestionId = (
|
||||
localSurvey: TSurvey,
|
||||
data: TResponseData,
|
||||
localVariables: TResponseVariables,
|
||||
currentQuestionIndex: number,
|
||||
currQuesTemp: TSurveyQuestion,
|
||||
selectedLanguage: string | null
|
||||
): {
|
||||
nextQuestionId: TSurveyQuestionId | undefined;
|
||||
updatedSurvey: TSurvey;
|
||||
updatedVariables: TResponseVariables;
|
||||
} => {
|
||||
const questions = localSurvey.questions;
|
||||
|
||||
let updatedSurvey = { ...localSurvey };
|
||||
let updatedVariables = { ...localVariables };
|
||||
|
||||
let firstJumpTarget: string | undefined;
|
||||
|
||||
if (currQuesTemp.logic && currQuesTemp.logic.length > 0) {
|
||||
for (const logic of currQuesTemp.logic) {
|
||||
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
|
||||
const { jumpTarget, requiredQuestionIds, calculations } = performActions(
|
||||
updatedSurvey,
|
||||
logic.actions,
|
||||
data,
|
||||
updatedVariables
|
||||
);
|
||||
|
||||
if (requiredQuestionIds.length > 0) {
|
||||
updatedSurvey.questions = updatedSurvey.questions.map((q) =>
|
||||
requiredQuestionIds.includes(q.id) ? { ...q, required: true } : q
|
||||
);
|
||||
}
|
||||
updatedVariables = { ...updatedVariables, ...calculations };
|
||||
|
||||
if (jumpTarget && !firstJumpTarget) {
|
||||
firstJumpTarget = jumpTarget;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the first jump target if found, otherwise go to the next question
|
||||
const nextQuestionId = firstJumpTarget || questions[currentQuestionIndex + 1]?.id || undefined;
|
||||
|
||||
return { nextQuestionId, updatedSurvey, updatedVariables };
|
||||
};
|
||||
|
||||
export const getSurveySummaryDropOff = (
|
||||
survey: TSurvey,
|
||||
responses: TResponse[],
|
||||
displayCount: number
|
||||
): TSurveySummary["dropOff"] => {
|
||||
const initialTtc = survey.questions.reduce((acc: Record<string, number>, question) => {
|
||||
acc[question.id] = 0;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
let totalTtc = { ...initialTtc };
|
||||
let responseCounts = { ...initialTtc };
|
||||
|
||||
let dropOffArr = new Array(survey.questions.length).fill(0) as number[];
|
||||
let impressionsArr = new Array(survey.questions.length).fill(0) as number[];
|
||||
let dropOffPercentageArr = new Array(survey.questions.length).fill(0) as number[];
|
||||
|
||||
const surveyVariablesData = survey.variables?.reduce(
|
||||
(acc, variable) => {
|
||||
acc[variable.id] = variable.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string | number>
|
||||
);
|
||||
|
||||
responses.forEach((response) => {
|
||||
// Calculate total time-to-completion
|
||||
Object.keys(totalTtc).forEach((questionId) => {
|
||||
if (response.ttc && response.ttc[questionId]) {
|
||||
totalTtc[questionId] += response.ttc[questionId];
|
||||
responseCounts[questionId]++;
|
||||
}
|
||||
});
|
||||
|
||||
let localSurvey = structuredClone(survey);
|
||||
let localResponseData: TResponseData = { ...response.data };
|
||||
let localVariables: TResponseVariables = {
|
||||
...surveyVariablesData,
|
||||
};
|
||||
|
||||
let currQuesIdx = 0;
|
||||
|
||||
while (currQuesIdx < localSurvey.questions.length) {
|
||||
const currQues = localSurvey.questions[currQuesIdx];
|
||||
if (!currQues) break;
|
||||
|
||||
// question is not answered and required
|
||||
if (response.data[currQues.id] === undefined && currQues.required) {
|
||||
dropOffArr[currQuesIdx]++;
|
||||
impressionsArr[currQuesIdx]++;
|
||||
break;
|
||||
}
|
||||
|
||||
impressionsArr[currQuesIdx]++;
|
||||
|
||||
const { nextQuestionId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextQuestionId(
|
||||
localSurvey,
|
||||
localResponseData,
|
||||
localVariables,
|
||||
currQuesIdx,
|
||||
currQues,
|
||||
response.language
|
||||
);
|
||||
|
||||
localSurvey = updatedSurvey;
|
||||
localVariables = updatedVariables;
|
||||
|
||||
if (nextQuestionId) {
|
||||
const nextQuesIdx = survey.questions.findIndex((q) => q.id === nextQuestionId);
|
||||
if (!response.data[nextQuestionId] && !response.finished) {
|
||||
dropOffArr[nextQuesIdx]++;
|
||||
impressionsArr[nextQuesIdx]++;
|
||||
break;
|
||||
}
|
||||
currQuesIdx = nextQuesIdx;
|
||||
} else {
|
||||
currQuesIdx++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate the average time for each question
|
||||
Object.keys(totalTtc).forEach((questionId) => {
|
||||
totalTtc[questionId] =
|
||||
responseCounts[questionId] > 0 ? totalTtc[questionId] / responseCounts[questionId] : 0;
|
||||
});
|
||||
|
||||
if (!survey.welcomeCard.enabled) {
|
||||
dropOffArr[0] = displayCount - impressionsArr[0];
|
||||
if (impressionsArr[0] > displayCount) dropOffPercentageArr[0] = 0;
|
||||
|
||||
dropOffPercentageArr[0] =
|
||||
impressionsArr[0] - displayCount >= 0
|
||||
? 0
|
||||
: ((displayCount - impressionsArr[0]) / displayCount) * 100 || 0;
|
||||
|
||||
impressionsArr[0] = displayCount;
|
||||
} else {
|
||||
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
|
||||
}
|
||||
|
||||
for (let i = 1; i < survey.questions.length; i++) {
|
||||
if (impressionsArr[i] !== 0) {
|
||||
dropOffPercentageArr[i] = (dropOffArr[i] / impressionsArr[i]) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
const dropOff = survey.questions.map((question, index) => {
|
||||
return {
|
||||
questionId: question.id,
|
||||
headline: getLocalizedValue(question.headline, "default"),
|
||||
ttc: convertFloatTo2Decimal(totalTtc[question.id]) || 0,
|
||||
impressions: impressionsArr[index] || 0,
|
||||
dropOffCount: dropOffArr[index] || 0,
|
||||
dropOffPercentage: convertFloatTo2Decimal(dropOffPercentageArr[index]) || 0,
|
||||
};
|
||||
});
|
||||
|
||||
return dropOff;
|
||||
};
|
||||
|
||||
const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: string | null) => {
|
||||
if (!surveyLanguages?.length || !languageCode) return "default";
|
||||
const language = surveyLanguages.find((surveyLanguage) => surveyLanguage.language.code === languageCode);
|
||||
return language?.default ? "default" : language?.language.code || "default";
|
||||
};
|
||||
|
||||
const checkForI18n = (response: TResponse, id: string, survey: TSurvey, languageCode: string) => {
|
||||
const question = survey.questions.find((question) => question.id === id);
|
||||
|
||||
if (question?.type === "multipleChoiceMulti" || question?.type === "ranking") {
|
||||
// Initialize an array to hold the choice values
|
||||
let choiceValues = [] as string[];
|
||||
|
||||
(typeof response.data[id] === "string"
|
||||
? ([response.data[id]] as string[])
|
||||
: (response.data[id] as string[])
|
||||
)?.forEach((data) => {
|
||||
choiceValues.push(
|
||||
getLocalizedValue(
|
||||
question.choices.find((choice) => choice.label[languageCode] === data)?.label,
|
||||
"default"
|
||||
) || data
|
||||
);
|
||||
});
|
||||
|
||||
// Return the array of localized choice values of multiSelect multi questions
|
||||
return choiceValues;
|
||||
}
|
||||
|
||||
// Return the localized value of the choice fo multiSelect single question
|
||||
const choice = (question as TSurveyMultipleChoiceQuestion)?.choices.find(
|
||||
(choice) => choice.label[languageCode] === response.data[id]
|
||||
);
|
||||
|
||||
return getLocalizedValue(choice?.label, "default") || response.data[id];
|
||||
};
|
||||
|
||||
export const getQuestionSummary = async (
|
||||
survey: TSurvey,
|
||||
responses: TResponse[],
|
||||
dropOff: TSurveySummary["dropOff"]
|
||||
): Promise<TSurveySummary["summary"]> => {
|
||||
const VALUES_LIMIT = 50;
|
||||
let summary: TSurveySummary["summary"] = [];
|
||||
|
||||
for (const question of survey.questions) {
|
||||
switch (question.type) {
|
||||
case TSurveyQuestionTypeEnum.OpenText: {
|
||||
let values: TSurveyQuestionSummaryOpenText["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (answer && typeof answer === "string") {
|
||||
values.push({
|
||||
id: response.id,
|
||||
updatedAt: response.updatedAt,
|
||||
value: answer,
|
||||
person: response.person,
|
||||
personAttributes: response.personAttributes,
|
||||
});
|
||||
}
|
||||
});
|
||||
const insights = await getInsightsBySurveyIdQuestionId(survey.id, question.id, 50);
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: values.length,
|
||||
samples: values.slice(0, VALUES_LIMIT),
|
||||
insights,
|
||||
insightsEnabled: question.insightsEnabled,
|
||||
});
|
||||
|
||||
values;
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti: {
|
||||
let values: TSurveyQuestionSummaryMultipleChoice["choices"] = [];
|
||||
// check last choice is others or not
|
||||
const lastChoice = question.choices[question.choices.length - 1];
|
||||
const isOthersEnabled = lastChoice.id === "other";
|
||||
|
||||
const questionChoices = question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
|
||||
if (isOthersEnabled) {
|
||||
questionChoices.pop();
|
||||
}
|
||||
|
||||
const choiceCountMap = questionChoices.reduce((acc: Record<string, number>, choice) => {
|
||||
acc[choice] = 0;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const otherValues: TSurveyQuestionSummaryMultipleChoice["choices"][number]["others"] = [];
|
||||
responses.forEach((response) => {
|
||||
const responseLanguageCode = getLanguageCode(survey.languages, response.language);
|
||||
|
||||
const answer =
|
||||
responseLanguageCode === "default"
|
||||
? response.data[question.id]
|
||||
: checkForI18n(response, question.id, survey, responseLanguageCode);
|
||||
|
||||
if (Array.isArray(answer)) {
|
||||
answer.forEach((value) => {
|
||||
if (questionChoices.includes(value)) {
|
||||
choiceCountMap[value]++;
|
||||
} else {
|
||||
otherValues.push({
|
||||
value,
|
||||
person: response.person,
|
||||
personAttributes: response.personAttributes,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (typeof answer === "string") {
|
||||
if (questionChoices.includes(answer)) {
|
||||
choiceCountMap[answer]++;
|
||||
} else {
|
||||
otherValues.push({
|
||||
value: answer,
|
||||
person: response.person,
|
||||
personAttributes: response.personAttributes,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(choiceCountMap).map(([label, count]) => {
|
||||
values.push({
|
||||
value: label,
|
||||
count,
|
||||
percentage: responses.length > 0 ? convertFloatTo2Decimal((count / responses.length) * 100) : 0,
|
||||
});
|
||||
});
|
||||
|
||||
if (isOthersEnabled) {
|
||||
values.push({
|
||||
value: getLocalizedValue(lastChoice.label, "default") || "Other",
|
||||
count: otherValues.length,
|
||||
percentage: convertFloatTo2Decimal((otherValues.length / responses.length) * 100),
|
||||
others: otherValues.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
}
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: responses.length,
|
||||
choices: values,
|
||||
});
|
||||
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.PictureSelection: {
|
||||
let values: TSurveyQuestionSummaryPictureSelection["choices"] = [];
|
||||
const choiceCountMap: Record<string, number> = {};
|
||||
|
||||
question.choices.forEach((choice) => {
|
||||
choiceCountMap[choice.id] = 0;
|
||||
});
|
||||
let totalResponseCount = 0;
|
||||
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (Array.isArray(answer)) {
|
||||
answer.forEach((value) => {
|
||||
totalResponseCount++;
|
||||
choiceCountMap[value]++;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
question.choices.forEach((choice) => {
|
||||
values.push({
|
||||
id: choice.id,
|
||||
imageUrl: choice.imageUrl,
|
||||
count: choiceCountMap[choice.id],
|
||||
percentage:
|
||||
totalResponseCount > 0
|
||||
? convertFloatTo2Decimal((choiceCountMap[choice.id] / totalResponseCount) * 100)
|
||||
: 0,
|
||||
});
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: totalResponseCount,
|
||||
choices: values,
|
||||
});
|
||||
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Rating: {
|
||||
let values: TSurveyQuestionSummaryRating["choices"] = [];
|
||||
const choiceCountMap: Record<number, number> = {};
|
||||
const range = question.range;
|
||||
|
||||
for (let i = 1; i <= range; i++) {
|
||||
choiceCountMap[i] = 0;
|
||||
}
|
||||
|
||||
let totalResponseCount = 0;
|
||||
let totalRating = 0;
|
||||
let dismissed = 0;
|
||||
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (typeof answer === "number") {
|
||||
totalResponseCount++;
|
||||
choiceCountMap[answer]++;
|
||||
totalRating += answer;
|
||||
} else if (response.ttc && response.ttc[question.id] > 0) {
|
||||
dismissed++;
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(choiceCountMap).map(([label, count]) => {
|
||||
values.push({
|
||||
rating: parseInt(label),
|
||||
count,
|
||||
percentage:
|
||||
totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
|
||||
});
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
average: convertFloatTo2Decimal(totalRating / totalResponseCount) || 0,
|
||||
responseCount: totalResponseCount,
|
||||
choices: values,
|
||||
dismissed: {
|
||||
count: dismissed,
|
||||
},
|
||||
});
|
||||
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.NPS: {
|
||||
const data = {
|
||||
promoters: 0,
|
||||
passives: 0,
|
||||
detractors: 0,
|
||||
dismissed: 0,
|
||||
total: 0,
|
||||
score: 0,
|
||||
};
|
||||
|
||||
responses.forEach((response) => {
|
||||
const value = response.data[question.id];
|
||||
if (typeof value === "number") {
|
||||
data.total++;
|
||||
if (value >= 9) {
|
||||
data.promoters++;
|
||||
} else if (value >= 7) {
|
||||
data.passives++;
|
||||
} else {
|
||||
data.detractors++;
|
||||
}
|
||||
} else if (response.ttc && response.ttc[question.id] > 0) {
|
||||
data.total++;
|
||||
data.dismissed++;
|
||||
}
|
||||
});
|
||||
|
||||
data.score =
|
||||
data.total > 0
|
||||
? convertFloatTo2Decimal(((data.promoters - data.detractors) / data.total) * 100)
|
||||
: 0;
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: data.total,
|
||||
total: data.total,
|
||||
score: data.score,
|
||||
promoters: {
|
||||
count: data.promoters,
|
||||
percentage: data.total > 0 ? convertFloatTo2Decimal((data.promoters / data.total) * 100) : 0,
|
||||
},
|
||||
passives: {
|
||||
count: data.passives,
|
||||
percentage: data.total > 0 ? convertFloatTo2Decimal((data.passives / data.total) * 100) : 0,
|
||||
},
|
||||
detractors: {
|
||||
count: data.detractors,
|
||||
percentage: data.total > 0 ? convertFloatTo2Decimal((data.detractors / data.total) * 100) : 0,
|
||||
},
|
||||
dismissed: {
|
||||
count: data.dismissed,
|
||||
percentage: data.total > 0 ? convertFloatTo2Decimal((data.dismissed / data.total) * 100) : 0,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.CTA: {
|
||||
const data = {
|
||||
clicked: 0,
|
||||
dismissed: 0,
|
||||
};
|
||||
|
||||
responses.forEach((response) => {
|
||||
const value = response.data[question.id];
|
||||
if (value === "clicked") {
|
||||
data.clicked++;
|
||||
} else if (value === "dismissed") {
|
||||
data.dismissed++;
|
||||
}
|
||||
});
|
||||
|
||||
const totalResponses = data.clicked + data.dismissed;
|
||||
const idx = survey.questions.findIndex((q) => q.id === question.id);
|
||||
const impressions = dropOff[idx].impressions;
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
impressionCount: impressions,
|
||||
clickCount: data.clicked,
|
||||
skipCount: data.dismissed,
|
||||
responseCount: totalResponses,
|
||||
ctr: {
|
||||
count: data.clicked,
|
||||
percentage: impressions > 0 ? convertFloatTo2Decimal((data.clicked / impressions) * 100) : 0,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Consent: {
|
||||
const data = {
|
||||
accepted: 0,
|
||||
dismissed: 0,
|
||||
};
|
||||
|
||||
responses.forEach((response) => {
|
||||
const value = response.data[question.id];
|
||||
if (value === "accepted") {
|
||||
data.accepted++;
|
||||
} else if (response.ttc && response.ttc[question.id] > 0) {
|
||||
data.dismissed++;
|
||||
}
|
||||
});
|
||||
|
||||
const totalResponses = data.accepted + data.dismissed;
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: totalResponses,
|
||||
accepted: {
|
||||
count: data.accepted,
|
||||
percentage:
|
||||
totalResponses > 0 ? convertFloatTo2Decimal((data.accepted / totalResponses) * 100) : 0,
|
||||
},
|
||||
dismissed: {
|
||||
count: data.dismissed,
|
||||
percentage:
|
||||
totalResponses > 0 ? convertFloatTo2Decimal((data.dismissed / totalResponses) * 100) : 0,
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Date: {
|
||||
let values: TSurveyQuestionSummaryDate["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (answer && typeof answer === "string") {
|
||||
values.push({
|
||||
id: response.id,
|
||||
updatedAt: response.updatedAt,
|
||||
value: answer,
|
||||
person: response.person,
|
||||
personAttributes: response.personAttributes,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: values.length,
|
||||
samples: values.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.FileUpload: {
|
||||
let values: TSurveyQuestionSummaryFileUpload["files"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (Array.isArray(answer)) {
|
||||
values.push({
|
||||
id: response.id,
|
||||
updatedAt: response.updatedAt,
|
||||
value: answer,
|
||||
person: response.person,
|
||||
personAttributes: response.personAttributes,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: values.length,
|
||||
files: values.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Cal: {
|
||||
const data = {
|
||||
booked: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
|
||||
responses.forEach((response) => {
|
||||
const value = response.data[question.id];
|
||||
if (value === "booked") {
|
||||
data.booked++;
|
||||
} else if (response.ttc && response.ttc[question.id] > 0) {
|
||||
data.skipped++;
|
||||
}
|
||||
});
|
||||
const totalResponses = data.booked + data.skipped;
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: totalResponses,
|
||||
booked: {
|
||||
count: data.booked,
|
||||
percentage: totalResponses > 0 ? convertFloatTo2Decimal((data.booked / totalResponses) * 100) : 0,
|
||||
},
|
||||
skipped: {
|
||||
count: data.skipped,
|
||||
percentage:
|
||||
totalResponses > 0 ? convertFloatTo2Decimal((data.skipped / totalResponses) * 100) : 0,
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Matrix: {
|
||||
const rows = question.rows.map((row) => getLocalizedValue(row, "default"));
|
||||
const columns = question.columns.map((column) => getLocalizedValue(column, "default"));
|
||||
let totalResponseCount = 0;
|
||||
|
||||
// Initialize count object
|
||||
const countMap: Record<string, string> = rows.reduce((acc, row) => {
|
||||
acc[row] = columns.reduce((colAcc, col) => {
|
||||
colAcc[col] = 0;
|
||||
return colAcc;
|
||||
}, {});
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
responses.forEach((response) => {
|
||||
const selectedResponses = response.data[question.id] as Record<string, string>;
|
||||
const responseLanguageCode = getLanguageCode(survey.languages, response.language);
|
||||
if (selectedResponses) {
|
||||
totalResponseCount++;
|
||||
question.rows.forEach((row) => {
|
||||
const localizedRow = getLocalizedValue(row, responseLanguageCode);
|
||||
const colValue = question.columns.find((column) => {
|
||||
return getLocalizedValue(column, responseLanguageCode) === selectedResponses[localizedRow];
|
||||
});
|
||||
const colValueInDefaultLanguage = getLocalizedValue(colValue, "default");
|
||||
if (colValueInDefaultLanguage && columns.includes(colValueInDefaultLanguage)) {
|
||||
countMap[getLocalizedValue(row, "default")][colValueInDefaultLanguage] += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const matrixSummary = rows.map((row) => {
|
||||
let totalResponsesForRow = 0;
|
||||
columns.forEach((col) => {
|
||||
totalResponsesForRow += countMap[row][col];
|
||||
});
|
||||
|
||||
const columnPercentages = columns.reduce((acc, col) => {
|
||||
const count = countMap[row][col];
|
||||
const percentage =
|
||||
totalResponsesForRow > 0 ? ((count / totalResponsesForRow) * 100).toFixed(2) : "0.00";
|
||||
acc[col] = percentage;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return { rowLabel: row, columnPercentages, totalResponsesForRow };
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: totalResponseCount,
|
||||
data: matrixSummary,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Address:
|
||||
case TSurveyQuestionTypeEnum.ContactInfo: {
|
||||
let values: TSurveyQuestionSummaryAddress["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (Array.isArray(answer) && answer.length > 0) {
|
||||
values.push({
|
||||
id: response.id,
|
||||
updatedAt: response.updatedAt,
|
||||
value: answer,
|
||||
person: response.person,
|
||||
personAttributes: response.personAttributes,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: question.type as TSurveyQuestionTypeEnum.ContactInfo,
|
||||
question: question as TSurveyContactInfoQuestion,
|
||||
responseCount: values.length,
|
||||
samples: values.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Ranking: {
|
||||
let values: TSurveyQuestionSummaryRanking["choices"] = [];
|
||||
const questionChoices = question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
|
||||
let totalResponseCount = 0;
|
||||
const choiceRankSums: Record<string, number> = {};
|
||||
const choiceCountMap: Record<string, number> = {};
|
||||
questionChoices.forEach((choice) => {
|
||||
choiceRankSums[choice] = 0;
|
||||
choiceCountMap[choice] = 0;
|
||||
});
|
||||
|
||||
responses.forEach((response) => {
|
||||
const responseLanguageCode = getLanguageCode(survey.languages, response.language);
|
||||
|
||||
const answer =
|
||||
responseLanguageCode === "default"
|
||||
? response.data[question.id]
|
||||
: checkForI18n(response, question.id, survey, responseLanguageCode);
|
||||
|
||||
if (Array.isArray(answer)) {
|
||||
totalResponseCount++;
|
||||
answer.forEach((value, index) => {
|
||||
const ranking = index + 1; // Calculate ranking based on index
|
||||
if (questionChoices.includes(value)) {
|
||||
choiceRankSums[value] += ranking;
|
||||
choiceCountMap[value]++;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
questionChoices.forEach((choice) => {
|
||||
const count = choiceCountMap[choice];
|
||||
const avgRanking = count > 0 ? choiceRankSums[choice] / count : 0;
|
||||
values.push({
|
||||
value: choice,
|
||||
count,
|
||||
avgRanking: convertFloatTo2Decimal(avgRanking),
|
||||
});
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: totalResponseCount,
|
||||
choices: values,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
survey.hiddenFields?.fieldIds?.forEach((hiddenFieldId) => {
|
||||
let values: TSurveyQuestionSummaryHiddenFields["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[hiddenFieldId];
|
||||
if (answer && typeof answer === "string") {
|
||||
values.push({
|
||||
updatedAt: response.updatedAt,
|
||||
value: answer,
|
||||
person: response.person,
|
||||
personAttributes: response.personAttributes,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
summary.push({
|
||||
type: "hiddenField",
|
||||
id: hiddenFieldId,
|
||||
responseCount: values.length,
|
||||
samples: values.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
|
||||
values = [];
|
||||
});
|
||||
|
||||
return summary;
|
||||
};
|
||||
|
||||
export const getSurveySummary = reactCache(
|
||||
(surveyId: string, filterCriteria?: TResponseFilterCriteria): Promise<TSurveySummary> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([surveyId, ZId], [filterCriteria, ZResponseFilterCriteria.optional()]);
|
||||
|
||||
try {
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
const batchSize = 3000;
|
||||
const totalResponseCount = await getResponseCountBySurveyId(surveyId);
|
||||
const filteredResponseCount = await getResponseCountBySurveyId(surveyId, filterCriteria);
|
||||
|
||||
const hasFilter = totalResponseCount !== filteredResponseCount;
|
||||
|
||||
const pages = Math.ceil(filteredResponseCount / batchSize);
|
||||
|
||||
const responsesArray = await Promise.all(
|
||||
Array.from({ length: pages }, (_, i) => {
|
||||
return getResponses(surveyId, batchSize, i * batchSize, filterCriteria);
|
||||
})
|
||||
);
|
||||
const responses = responsesArray.flat();
|
||||
|
||||
const responseIds = hasFilter ? responses.map((response) => response.id) : [];
|
||||
|
||||
const displayCount = await getDisplayCountBySurveyId(surveyId, {
|
||||
createdAt: filterCriteria?.createdAt,
|
||||
...(hasFilter && { responseIds }),
|
||||
});
|
||||
|
||||
const dropOff = getSurveySummaryDropOff(survey, responses, displayCount);
|
||||
const meta = getSurveySummaryMeta(responses, displayCount);
|
||||
const questionWiseSummary = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
return { meta, dropOff, summary: questionWiseSummary };
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getSurveySummary-${surveyId}-${JSON.stringify(filterCriteria)}`],
|
||||
{
|
||||
tags: [
|
||||
surveyCache.tag.byId(surveyId),
|
||||
responseCache.tag.bySurveyId(surveyId),
|
||||
displayCache.tag.bySurveyId(surveyId),
|
||||
],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -1,18 +1,14 @@
|
||||
import { TSurvey, TSurveyQuestionId, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const convertFloatToNDecimal = (num: number, N: number = 2) => {
|
||||
return Math.round(num * Math.pow(10, N)) / Math.pow(10, N);
|
||||
};
|
||||
|
||||
export const convertFloatTo2Decimal = (num: number) => {
|
||||
return Math.round(num * 100) / 100;
|
||||
};
|
||||
|
||||
export const constructToastMessage = (
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
survey: TSurvey,
|
||||
questionId: TSurveyQuestionId,
|
||||
questionId: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => {
|
||||
const questionIdx = survey.questions.findIndex((question) => question.id === questionId);
|
||||
@@ -24,12 +20,3 @@ export const constructToastMessage = (
|
||||
return `Added filter for responses where answer to question ${questionIdx + 1} ${filterValue} ${Array.isArray(filterComboBoxValue) ? filterComboBoxValue.join(",") : filterComboBoxValue}`;
|
||||
}
|
||||
};
|
||||
|
||||
export const needsInsightsGeneration = (survey: TSurvey): boolean => {
|
||||
const openTextQuestions = survey.questions.filter((question) => question.type === "openText");
|
||||
const questionWithoutInsightsEnabled = openTextQuestions.some(
|
||||
(question) => question.type === "openText" && typeof question.insightsEnabled === "undefined"
|
||||
);
|
||||
|
||||
return openTextQuestions.length > 0 && questionWithoutInsightsEnabled;
|
||||
};
|
||||
|
||||