mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-22 19:39:01 -05:00
Merge branch 'formbricks:main' into feature/docs-in-page-section-nav
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
export const metadata = {
|
||||
title: "Add Custom SSL Certificate to Formbricks",
|
||||
description: "Learn how to add a custom SSL certificate to your Formbricks self-hosted instance.",
|
||||
};
|
||||
|
||||
# Using Formbricks One-Click Setup with a Custom SSL Certificate
|
||||
|
||||
<Note>
|
||||
Formbricks One-Click setup already comes with a valid SSL certificate using Let's Encrypt. This guide is
|
||||
only if you already have a valid SSL certificate that you need to use due to company policy or other
|
||||
requirements.
|
||||
</Note>
|
||||
|
||||
## Introduction
|
||||
|
||||
While Formbricks' One-Click setup can automatically create a valid SSL certificate using Let's Encrypt, there are scenarios where a custom SSL certificate is necessary. This is particularly relevant for environments like intranets or other setups with specific certificate requirements, where an internal or custom certificate authority (CA) might be used.
|
||||
|
||||
### Step 1: Navigate to the Formbricks Folder
|
||||
|
||||
Navigate into the "formbricks" folder that contains all the files from the Formbricks One-Click setup.
|
||||
|
||||
```sh
|
||||
cd formbricks
|
||||
```
|
||||
|
||||
### Step 2: Create a Folder for SSL Certificates
|
||||
|
||||
Create a new folder named "certs" within the "formbricks" folder. Place your SSL certificate files (`fullchain.crt` and `cert.key`) in this directory.
|
||||
|
||||
```sh
|
||||
mkdir certs
|
||||
# Move your SSL certificate files to the certs folder
|
||||
mv /path/to/your/fullchain.crt certs/
|
||||
mv /path/to/your/cert.key certs/
|
||||
```
|
||||
|
||||
### Step 3: Understand SSL Certificate Files
|
||||
|
||||
For a custom SSL setup, you need the following files:
|
||||
|
||||
- **fullchain.crt**: This file contains your SSL certificate along with the entire certificate chain. The certificate chain includes intermediate certificates that link your SSL certificate to a trusted root certificate.
|
||||
- **cert.key**: This is your private key file. It is used to encrypt and decrypt data sent between your server and clients.
|
||||
|
||||
### Step 4: Update File Permissions
|
||||
|
||||
Ensure the directory and files have appropriate permissions:
|
||||
|
||||
```sh
|
||||
sudo chown root:root certs/*
|
||||
sudo chmod 600 certs/*
|
||||
```
|
||||
|
||||
### Step 5: Update `traefik.yaml`
|
||||
|
||||
Update your `traefik.yaml` file to define entry points for HTTP and HTTPS traffic and set up a provider for Traefik to use Docker and a file-based configuration.
|
||||
|
||||
```yaml
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
http:
|
||||
redirections:
|
||||
entryPoint:
|
||||
to: websecure
|
||||
scheme: https
|
||||
permanent: true
|
||||
websecure:
|
||||
address: ":443"
|
||||
|
||||
providers:
|
||||
docker:
|
||||
watch: true
|
||||
exposedByDefault: false
|
||||
file:
|
||||
directory: /etc/traefik/dynamic
|
||||
```
|
||||
|
||||
### Step 6: Create `certs-traefik.yaml`
|
||||
|
||||
Create a `certs-traefik.yaml` file that specifies the path to your custom SSL certificate and key.
|
||||
|
||||
```yaml
|
||||
tls:
|
||||
certificates:
|
||||
- certFile: /certs/fullchain.crt
|
||||
keyFile: /certs/cert.key
|
||||
```
|
||||
|
||||
### Step 7: Update `docker-compose.yml`
|
||||
|
||||
Update your `docker-compose.yml` file to enforce TLS and link to your custom SSL certificate. Here's an example configuration for both the Formbricks and Traefik services. The rest of the configuration should remain the same as the One-Click setup:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
formbricks:
|
||||
restart: always
|
||||
image: ghcr.io/formbricks/formbricks:latest
|
||||
depends_on:
|
||||
- postgres
|
||||
labels:
|
||||
- "traefik.enable=true" # Enable Traefik for this service
|
||||
- "traefik.http.routers.formbricks.rule=Host(`my-domain.com`)" # Use your actual domain or IP
|
||||
- "traefik.http.routers.formbricks.entrypoints=websecure" # Use the websecure entrypoint (port 443 with TLS)
|
||||
- "traefik.http.routers.formbricks.tls=true" # Enable TLS
|
||||
- "traefik.http.services.formbricks.loadbalancer.server.port=3000" # Forward traffic to Formbricks on port 3000
|
||||
ports:
|
||||
- 3000:3000
|
||||
volumes:
|
||||
- uploads:/home/nextjs/apps/web/uploads/
|
||||
<<: *environment
|
||||
|
||||
traefik:
|
||||
image: "traefik:v2.7"
|
||||
restart: always
|
||||
container_name: "traefik"
|
||||
depends_on:
|
||||
- formbricks
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./traefik.yaml:/traefik.yaml
|
||||
- ./acme.json:/acme.json
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./certs:/certs
|
||||
- ./certs-traefik.yaml:/etc/traefik/dynamic/certs-traefik.yaml
|
||||
```
|
||||
|
||||
### Summary
|
||||
|
||||
1. **Navigate to the Formbricks Folder**: Move into the "formbricks" directory.
|
||||
2. **Create a Folder for SSL Certificates**: Create a "certs" folder and place your `fullchain.crt` and `cert.key` files inside it.
|
||||
3. **Understand SSL Certificate Files**: Ensure you have the `fullchain.crt` and `cert.key` files.
|
||||
4. **Update File Permissions**: Ensure the certificate files have the correct permissions.
|
||||
5. **Update `traefik.yaml`**: Define entry points and remove certificate resolvers.
|
||||
6. **Create `certs-traefik.yaml`**: Specify the paths to your SSL certificate and key.
|
||||
7. **Update `docker-compose.yml`**: Configure Traefik labels to enforce TLS and mount the certificate directory.
|
||||
|
||||
This setup ensures that Formbricks uses your custom SSL certificate for secure communications, suitable for environments with special certificate requirements.
|
||||
@@ -107,6 +107,7 @@ export const navigation: Array<NavGroup> = [
|
||||
links: [
|
||||
{ title: "Overview", href: "/self-hosting/overview" },
|
||||
{ title: "One-Click Setup", href: "/self-hosting/one-click" },
|
||||
{ title: "Custom SSL Certificate", href: "/self-hosting/custom-ssl" },
|
||||
{ title: "Docker Setup", href: "/self-hosting/docker" },
|
||||
{ title: "Migration Guide", href: "/self-hosting/migration-guide" },
|
||||
{ title: "Configuration", href: "/self-hosting/configuration" },
|
||||
|
||||
+2
@@ -40,6 +40,7 @@ export const AddressQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
value={question.headline}
|
||||
label={"Question*"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
@@ -56,6 +57,7 @@ export const AddressQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="subheader"
|
||||
value={question.subheader}
|
||||
label={"Description"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
|
||||
+17
-16
@@ -40,6 +40,7 @@ export const CTAQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
value={question.headline}
|
||||
label={"Question*"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
@@ -91,6 +92,7 @@ export const CTAQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
value={question.buttonLabel}
|
||||
label={`"Next" Button Label`}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
@@ -106,6 +108,7 @@ export const CTAQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="backButtonLabel"
|
||||
value={question.backButtonLabel}
|
||||
label={`"Back" Button Label`}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
@@ -136,22 +139,20 @@ export const CTAQuestionForm = ({
|
||||
)}
|
||||
|
||||
{!question.required && (
|
||||
<div className="mt-3 flex-1">
|
||||
<Label htmlFor="buttonLabel">Skip Button Label</Label>
|
||||
<div className="mt-2">
|
||||
<QuestionFormInput
|
||||
id="dismissButtonLabel"
|
||||
value={question.dismissButtonLabel}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
placeholder={"skip"}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<QuestionFormInput
|
||||
id="dismissButtonLabel"
|
||||
value={question.dismissButtonLabel}
|
||||
label={"Skip Button Label"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
placeholder={"skip"}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
+2
@@ -39,6 +39,7 @@ export const CalQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
value={question.headline}
|
||||
label={"Question*"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
@@ -54,6 +55,7 @@ export const CalQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="subheader"
|
||||
value={question.subheader}
|
||||
label={"Description"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
|
||||
+2
-1
@@ -35,6 +35,7 @@ export const ConsentQuestionForm = ({
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
label="Question*"
|
||||
value={question.headline}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
@@ -65,7 +66,7 @@ export const ConsentQuestionForm = ({
|
||||
|
||||
<QuestionFormInput
|
||||
id="label"
|
||||
label="Checkbox Label"
|
||||
label="Checkbox Label*"
|
||||
placeholder="I agree to the terms and conditions"
|
||||
value={question.label}
|
||||
localSurvey={localSurvey}
|
||||
|
||||
+2
@@ -54,6 +54,7 @@ export const DateQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
value={question.headline}
|
||||
label={"Question*"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
@@ -69,6 +70,7 @@ export const DateQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="subheader"
|
||||
value={question.subheader}
|
||||
label={"Description"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
|
||||
+2
-1
@@ -112,7 +112,7 @@ export const EditThankYouCard = ({
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
label="Headline"
|
||||
label="Note*"
|
||||
value={localSurvey?.thankYouCard?.headline}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={localSurvey.questions.length}
|
||||
@@ -126,6 +126,7 @@ export const EditThankYouCard = ({
|
||||
<QuestionFormInput
|
||||
id="subheader"
|
||||
value={localSurvey.thankYouCard.subheader}
|
||||
label={"Description"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={localSurvey.questions.length}
|
||||
isInvalid={isInvalid}
|
||||
|
||||
+2
-1
@@ -126,7 +126,7 @@ export const EditWelcomeCard = ({
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
value={localSurvey.welcomeCard.headline}
|
||||
label="Headline"
|
||||
label="Note*"
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={-1}
|
||||
isInvalid={isInvalid}
|
||||
@@ -169,6 +169,7 @@ export const EditWelcomeCard = ({
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
label={`"Next" Button Label`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
@@ -118,6 +118,7 @@ export const FileUploadQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
value={question.headline}
|
||||
label={"Question*"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
@@ -133,6 +134,7 @@ export const FileUploadQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="subheader"
|
||||
value={question.subheader}
|
||||
label={"Description"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
|
||||
+4
@@ -107,6 +107,7 @@ export const MatrixQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
value={question.headline}
|
||||
label={"Question*"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
@@ -122,6 +123,7 @@ export const MatrixQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="subheader"
|
||||
value={question.subheader}
|
||||
label={"Description"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
@@ -163,6 +165,7 @@ export const MatrixQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
key={`row-${index}`}
|
||||
id={`row-${index}`}
|
||||
label={""}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
value={question.rows[index]}
|
||||
@@ -205,6 +208,7 @@ export const MatrixQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
key={`column-${index}`}
|
||||
id={`column-${index}`}
|
||||
label={""}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
value={question.columns[index]}
|
||||
|
||||
+3
-1
@@ -187,6 +187,7 @@ export const MultipleChoiceQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
value={question.headline}
|
||||
label={"Question*"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
@@ -203,6 +204,7 @@ export const MultipleChoiceQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="subheader"
|
||||
value={question.subheader}
|
||||
label={"Description"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
@@ -242,7 +244,7 @@ export const MultipleChoiceQuestionForm = ({
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="choices">Options</Label>
|
||||
<Label htmlFor="choices">Options*</Label>
|
||||
<div className="mt-2" id="choices">
|
||||
<DndContext
|
||||
onDragEnd={(event) => {
|
||||
|
||||
+5
@@ -39,6 +39,7 @@ export const NPSQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
value={question.headline}
|
||||
label={"Question*"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
@@ -55,6 +56,7 @@ export const NPSQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="subheader"
|
||||
value={question.subheader}
|
||||
label={"Description"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
@@ -98,6 +100,7 @@ export const NPSQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="lowerLabel"
|
||||
value={question.lowerLabel}
|
||||
label={"Lower Label"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
@@ -111,6 +114,7 @@ export const NPSQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="upperLabel"
|
||||
value={question.upperLabel}
|
||||
label={"Upper Label"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
@@ -127,6 +131,7 @@ export const NPSQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
value={question.buttonLabel}
|
||||
label={`"Next" Button Label`}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
|
||||
+3
@@ -77,6 +77,7 @@ export const OpenQuestionForm = ({
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
label={"Question*"}
|
||||
/>
|
||||
|
||||
<div>
|
||||
@@ -93,6 +94,7 @@ export const OpenQuestionForm = ({
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
label={"Description"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -137,6 +139,7 @@ export const OpenQuestionForm = ({
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
label={"Placeholder"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
+2
@@ -42,6 +42,7 @@ export const PictureSelectionForm = ({
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
label={"Question*"}
|
||||
value={question.headline}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
@@ -58,6 +59,7 @@ export const PictureSelectionForm = ({
|
||||
<QuestionFormInput
|
||||
id="subheader"
|
||||
value={question.subheader}
|
||||
label={"Description"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
|
||||
+16
-49
@@ -1,27 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { getTSurveyQuestionTypeName } from "@/app/lib/questions";
|
||||
import { QUESTIONS_ICON_MAP, getTSurveyQuestionTypeName } from "@/app/lib/questions";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import {
|
||||
ArrowUpFromLineIcon,
|
||||
CalendarDaysIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
Grid3X3Icon,
|
||||
GripIcon,
|
||||
HomeIcon,
|
||||
ImageIcon,
|
||||
ListIcon,
|
||||
MessageSquareTextIcon,
|
||||
MousePointerClickIcon,
|
||||
PhoneIcon,
|
||||
PresentationIcon,
|
||||
Rows3Icon,
|
||||
StarIcon,
|
||||
} from "lucide-react";
|
||||
import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
@@ -45,7 +28,7 @@ import { MultipleChoiceQuestionForm } from "./MultipleChoiceQuestionForm";
|
||||
import { NPSQuestionForm } from "./NPSQuestionForm";
|
||||
import { OpenQuestionForm } from "./OpenQuestionForm";
|
||||
import { PictureSelectionForm } from "./PictureSelectionForm";
|
||||
import { QuestionDropdown } from "./QuestionMenu";
|
||||
import { QuestionMenu } from "./QuestionMenu";
|
||||
import { RatingQuestionForm } from "./RatingQuestionForm";
|
||||
|
||||
interface QuestionCardProps {
|
||||
@@ -64,6 +47,7 @@ interface QuestionCardProps {
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
isInvalid: boolean;
|
||||
attributeClasses: TAttributeClass[];
|
||||
addQuestion: (question: any, index?: number) => void;
|
||||
}
|
||||
|
||||
export const QuestionCard = ({
|
||||
@@ -82,6 +66,7 @@ export const QuestionCard = ({
|
||||
setSelectedLanguageCode,
|
||||
isInvalid,
|
||||
attributeClasses,
|
||||
addQuestion,
|
||||
}: QuestionCardProps) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: question.id,
|
||||
@@ -154,7 +139,8 @@ export const QuestionCard = ({
|
||||
"flex flex-row rounded-lg bg-white transition-all duration-300 ease-in-out"
|
||||
)}
|
||||
ref={setNodeRef}
|
||||
style={style}>
|
||||
style={style}
|
||||
id={question.id}>
|
||||
<div
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
@@ -186,33 +172,7 @@ export const QuestionCard = ({
|
||||
<div>
|
||||
<div className="inline-flex">
|
||||
<div className="-ml-0.5 mr-3 h-6 min-w-[1.5rem] text-slate-400">
|
||||
{question.type === TSurveyQuestionType.FileUpload ? (
|
||||
<ArrowUpFromLineIcon className="h-5 w-5" />
|
||||
) : question.type === TSurveyQuestionType.OpenText ? (
|
||||
<MessageSquareTextIcon className="h-5 w-5" />
|
||||
) : question.type === TSurveyQuestionType.MultipleChoiceSingle ? (
|
||||
<Rows3Icon className="h-5 w-5" />
|
||||
) : question.type === TSurveyQuestionType.MultipleChoiceMulti ? (
|
||||
<ListIcon className="h-5 w-5" />
|
||||
) : question.type === TSurveyQuestionType.NPS ? (
|
||||
<PresentationIcon className="h-5 w-5" />
|
||||
) : question.type === TSurveyQuestionType.CTA ? (
|
||||
<MousePointerClickIcon className="h-5 w-5" />
|
||||
) : question.type === TSurveyQuestionType.Rating ? (
|
||||
<StarIcon className="h-5 w-5" />
|
||||
) : question.type === TSurveyQuestionType.Consent ? (
|
||||
<CheckIcon className="h-5 w-5" />
|
||||
) : question.type === TSurveyQuestionType.PictureSelection ? (
|
||||
<ImageIcon className="h-5 w-5" />
|
||||
) : question.type === TSurveyQuestionType.Date ? (
|
||||
<CalendarDaysIcon className="h-5 w-5" />
|
||||
) : question.type === TSurveyQuestionType.Cal ? (
|
||||
<PhoneIcon className="h-5 w-5" />
|
||||
) : question.type === TSurveyQuestionType.Matrix ? (
|
||||
<Grid3X3Icon className="h-5 w-5" />
|
||||
) : question.type === TSurveyQuestionType.Address ? (
|
||||
<HomeIcon className="h-5 w-5" />
|
||||
) : null}
|
||||
{QUESTIONS_ICON_MAP[question.type]}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">
|
||||
@@ -241,12 +201,16 @@ export const QuestionCard = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<QuestionDropdown
|
||||
<QuestionMenu
|
||||
questionIdx={questionIdx}
|
||||
lastQuestion={lastQuestion}
|
||||
duplicateQuestion={duplicateQuestion}
|
||||
deleteQuestion={deleteQuestion}
|
||||
moveQuestion={moveQuestion}
|
||||
question={question}
|
||||
product={product}
|
||||
updateQuestion={updateQuestion}
|
||||
addQuestion={addQuestion}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -429,6 +393,7 @@ export const QuestionCard = ({
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
value={question.buttonLabel}
|
||||
label={`"Next" Button Label`}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
@@ -454,6 +419,7 @@ export const QuestionCard = ({
|
||||
<QuestionFormInput
|
||||
id="backButtonLabel"
|
||||
value={question.backButtonLabel}
|
||||
label={`"Back" Button Label`}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
@@ -474,6 +440,7 @@ export const QuestionCard = ({
|
||||
<QuestionFormInput
|
||||
id="backButtonLabel"
|
||||
value={question.backButtonLabel}
|
||||
label={`"Back" Button Label`}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
|
||||
+196
-24
@@ -1,6 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, TrashIcon } from "lucide-react";
|
||||
import { QUESTIONS_ICON_MAP, QUESTIONS_NAME_MAP, getQuestionDefaults } from "@/app/lib/questions";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { ConfirmationModal } from "@formbricks/ui/ConfirmationModal";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
|
||||
interface QuestionDropdownProps {
|
||||
questionIdx: number;
|
||||
@@ -8,39 +24,87 @@ interface QuestionDropdownProps {
|
||||
duplicateQuestion: (questionIdx: number) => void;
|
||||
deleteQuestion: (questionIdx: number) => void;
|
||||
moveQuestion: (questionIdx: number, up: boolean) => void;
|
||||
question: TSurveyQuestion;
|
||||
product: TProduct;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
addQuestion: (question: any, index?: number) => void;
|
||||
}
|
||||
|
||||
export const QuestionDropdown = ({
|
||||
export const QuestionMenu = ({
|
||||
questionIdx,
|
||||
lastQuestion,
|
||||
duplicateQuestion,
|
||||
deleteQuestion,
|
||||
moveQuestion,
|
||||
product,
|
||||
question,
|
||||
updateQuestion,
|
||||
addQuestion,
|
||||
}: QuestionDropdownProps) => {
|
||||
const [logicWarningModal, setLogicWarningModal] = useState(false);
|
||||
const [changeToType, setChangeToType] = useState(question.type);
|
||||
|
||||
const changeQuestionType = (type: TSurveyQuestionType) => {
|
||||
const { headline, required, subheader, imageUrl, videoUrl, buttonLabel, backButtonLabel } = question;
|
||||
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
|
||||
// if going from single select to multi select or vice versa, we need to keep the choices as well
|
||||
|
||||
if (
|
||||
(type === TSurveyQuestionType.MultipleChoiceSingle &&
|
||||
question.type === TSurveyQuestionType.MultipleChoiceMulti) ||
|
||||
(type === TSurveyQuestionType.MultipleChoiceMulti &&
|
||||
question.type === TSurveyQuestionType.MultipleChoiceSingle)
|
||||
) {
|
||||
updateQuestion(questionIdx, {
|
||||
choices: question.choices,
|
||||
type,
|
||||
logic: undefined,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
...questionDefaults,
|
||||
type,
|
||||
headline,
|
||||
subheader,
|
||||
required,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
logic: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const addQuestionBelow = (type: TSurveyQuestionType) => {
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
|
||||
addQuestion(
|
||||
{
|
||||
...questionDefaults,
|
||||
type,
|
||||
id: createId(),
|
||||
required: true,
|
||||
},
|
||||
questionIdx + 1
|
||||
);
|
||||
|
||||
// scroll to the new question
|
||||
const section = document.getElementById(`${question.id}`);
|
||||
section?.scrollIntoView({ behavior: "smooth", block: "end", inline: "end" });
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
changeQuestionType(changeToType);
|
||||
setLogicWarningModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
<ArrowUpIcon
|
||||
className={`h-4 cursor-pointer text-slate-500 hover:text-slate-600 ${
|
||||
questionIdx === 0 ? "opacity-50" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (questionIdx !== 0) {
|
||||
e.stopPropagation();
|
||||
moveQuestion(questionIdx, true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ArrowDownIcon
|
||||
className={`h-4 cursor-pointer text-slate-500 hover:text-slate-600 ${
|
||||
lastQuestion ? "opacity-50" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (!lastQuestion) {
|
||||
e.stopPropagation();
|
||||
moveQuestion(questionIdx, false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CopyIcon
|
||||
className="h-4 cursor-pointer text-slate-500 hover:text-slate-600"
|
||||
onClick={(e) => {
|
||||
@@ -55,6 +119,114 @@ export const QuestionDropdown = ({
|
||||
deleteQuestion(questionIdx);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<EllipsisIcon className="h-4 w-4 text-slate-500 hover:text-slate-600" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<div className="flex flex-col">
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<div className="cursor-pointer text-slate-500 hover:text-slate-600">
|
||||
<span className="text-xs text-slate-500">Change question type</span>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent className="ml-4 border border-slate-200">
|
||||
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
|
||||
if (type === question.type) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
className="min-h-8 cursor-pointer text-slate-500"
|
||||
onClick={() => {
|
||||
setChangeToType(type as TSurveyQuestionType);
|
||||
if (question.logic) {
|
||||
setLogicWarningModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
changeQuestionType(type as TSurveyQuestionType);
|
||||
}}>
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyQuestionType]}
|
||||
<span className="ml-2">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<div className="cursor-pointer text-slate-500 hover:text-slate-600">
|
||||
<span className="text-xs text-slate-500">Add question below</span>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent className="ml-4 border border-slate-200">
|
||||
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
|
||||
if (type === question.type) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
className="min-h-8 cursor-pointer text-slate-500"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
addQuestionBelow(type as TSurveyQuestionType);
|
||||
}}>
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyQuestionType]}
|
||||
<span className="ml-2">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem
|
||||
className={`flex min-h-8 cursor-pointer justify-between text-slate-500 hover:text-slate-600 ${
|
||||
questionIdx === 0 ? "opacity-50" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (questionIdx !== 0) {
|
||||
e.stopPropagation();
|
||||
moveQuestion(questionIdx, true);
|
||||
}
|
||||
}}
|
||||
disabled={questionIdx === 0}>
|
||||
<span className="text-xs text-slate-500">Move up</span>
|
||||
<ArrowUpIcon className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className={`flex min-h-8 cursor-pointer justify-between text-slate-500 hover:text-slate-600 ${
|
||||
lastQuestion ? "opacity-50" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (!lastQuestion) {
|
||||
e.stopPropagation();
|
||||
moveQuestion(questionIdx, false);
|
||||
}
|
||||
}}
|
||||
disabled={lastQuestion}>
|
||||
<span className="text-xs text-slate-500">Move down</span>
|
||||
<ArrowDownIcon className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<ConfirmationModal
|
||||
open={logicWarningModal}
|
||||
setOpen={setLogicWarningModal}
|
||||
title="Changing will cause logic errors"
|
||||
text="Changing the question type will remove the logic conditions from this question"
|
||||
buttonText="Change anyway"
|
||||
onConfirm={onConfirm}
|
||||
buttonVariant="darkCTA"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+3
@@ -20,6 +20,7 @@ interface QuestionsDraggableProps {
|
||||
invalidQuestions: string[] | null;
|
||||
internalQuestionIdMap: Record<string, string>;
|
||||
attributeClasses: TAttributeClass[];
|
||||
addQuestion: (question: any, index?: number) => void;
|
||||
}
|
||||
|
||||
export const QuestionsDroppable = ({
|
||||
@@ -36,6 +37,7 @@ export const QuestionsDroppable = ({
|
||||
updateQuestion,
|
||||
internalQuestionIdMap,
|
||||
attributeClasses,
|
||||
addQuestion,
|
||||
}: QuestionsDraggableProps) => {
|
||||
return (
|
||||
<div className="group mb-5 grid w-full gap-5">
|
||||
@@ -58,6 +60,7 @@ export const QuestionsDroppable = ({
|
||||
lastQuestion={questionIdx === localSurvey.questions.length - 1}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(question.id) : false}
|
||||
attributeClasses={attributeClasses}
|
||||
addQuestion={addQuestion}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
+18
-12
@@ -148,17 +148,17 @@ export const QuestionsView = ({
|
||||
setbackButtonLabel(updatedAttributes.backButtonLabel);
|
||||
}
|
||||
}
|
||||
// If the value of buttonLabel is equal to {default:""}, then delete buttonLabel key
|
||||
if ("buttonLabel" in updatedAttributes) {
|
||||
const currentButtonLabel = updatedSurvey.questions[questionIdx].buttonLabel;
|
||||
if (
|
||||
currentButtonLabel &&
|
||||
Object.keys(currentButtonLabel).length === 1 &&
|
||||
currentButtonLabel["default"].trim() === ""
|
||||
) {
|
||||
delete updatedSurvey.questions[questionIdx].buttonLabel;
|
||||
const attributesToCheck = ["buttonLabel", "upperLabel", "lowerLabel"];
|
||||
|
||||
// If the value of buttonLabel, lowerLabel or upperLabel is equal to {default:""}, then delete buttonLabel key
|
||||
attributesToCheck.forEach((attribute) => {
|
||||
if (Object.keys(updatedAttributes).includes(attribute)) {
|
||||
const currentLabel = updatedSurvey.questions[questionIdx][attribute];
|
||||
if (currentLabel && Object.keys(currentLabel).length === 1 && currentLabel["default"].trim() === "") {
|
||||
delete updatedSurvey.questions[questionIdx][attribute];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
setLocalSurvey(updatedSurvey);
|
||||
validateSurveyQuestion(updatedSurvey.questions[questionIdx]);
|
||||
};
|
||||
@@ -216,14 +216,19 @@ export const QuestionsView = ({
|
||||
toast.success("Question duplicated.");
|
||||
};
|
||||
|
||||
const addQuestion = (question: any) => {
|
||||
const addQuestion = (question: any, index?: number) => {
|
||||
const updatedSurvey = { ...localSurvey };
|
||||
if (backButtonLabel) {
|
||||
question.backButtonLabel = backButtonLabel;
|
||||
}
|
||||
const languageSymbols = extractLanguageCodes(localSurvey.languages);
|
||||
const translatedQuestion = translateQuestion(question, languageSymbols);
|
||||
updatedSurvey.questions.push({ ...translatedQuestion, isDraft: true });
|
||||
|
||||
if (index) {
|
||||
updatedSurvey.questions.splice(index, 0, { ...translatedQuestion, isDraft: true });
|
||||
} else {
|
||||
updatedSurvey.questions.push({ ...translatedQuestion, isDraft: true });
|
||||
}
|
||||
|
||||
setLocalSurvey(updatedSurvey);
|
||||
setActiveQuestionId(question.id);
|
||||
@@ -361,6 +366,7 @@ export const QuestionsView = ({
|
||||
invalidQuestions={invalidQuestions}
|
||||
internalQuestionIdMap={internalQuestionIdMap}
|
||||
attributeClasses={attributeClasses}
|
||||
addQuestion={addQuestion}
|
||||
/>
|
||||
</DndContext>
|
||||
|
||||
|
||||
+5
@@ -40,6 +40,7 @@ export const RatingQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
value={question.headline}
|
||||
label={"Question*"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
@@ -56,6 +57,7 @@ export const RatingQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="subheader"
|
||||
value={question.subheader}
|
||||
label={"Description"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
@@ -134,6 +136,7 @@ export const RatingQuestionForm = ({
|
||||
id="lowerLabel"
|
||||
placeholder="Not good"
|
||||
value={question.lowerLabel}
|
||||
label={"Lower Label"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
@@ -148,6 +151,7 @@ export const RatingQuestionForm = ({
|
||||
id="upperLabel"
|
||||
placeholder="Very satisfied"
|
||||
value={question.upperLabel}
|
||||
label={"Upper Label"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
@@ -165,6 +169,7 @@ export const RatingQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
value={question.buttonLabel}
|
||||
label={`"Next" Button Label`}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
placeholder={"skip"}
|
||||
|
||||
+2
@@ -84,6 +84,7 @@ export const SelectQuestionChoice = ({
|
||||
key={choice.id}
|
||||
id={`choice-${choiceIdx}`}
|
||||
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
|
||||
label={""}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
value={choice.label}
|
||||
@@ -110,6 +111,7 @@ export const SelectQuestionChoice = ({
|
||||
id="otherOptionPlaceholder"
|
||||
localSurvey={localSurvey}
|
||||
placeholder={"Please specify"}
|
||||
label={""}
|
||||
questionIdx={questionIdx}
|
||||
value={
|
||||
question.otherOptionPlaceholder
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ export const SurveyEditor = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
/>
|
||||
<div className="relative z-0 flex flex-1 overflow-hidden">
|
||||
<main className="relative z-0 flex-1 overflow-y-auto focus:outline-none" ref={surveyEditorRef}>
|
||||
<main className="relative z-0 flex-1 overflow-y-auto focus:outline-none" ref={surveyEditorRef}>
|
||||
<QuestionsAudienceTabs
|
||||
activeId={activeView}
|
||||
setActiveId={setActiveView}
|
||||
|
||||
+5
-1
@@ -124,7 +124,11 @@ export const validationRules = {
|
||||
}
|
||||
|
||||
for (const field of fieldsToValidate) {
|
||||
if (question[field] && typeof question[field][defaultLanguageCode] !== "undefined") {
|
||||
if (
|
||||
question[field] &&
|
||||
typeof question[field][defaultLanguageCode] !== "undefined" &&
|
||||
question[field][defaultLanguageCode].trim() !== ""
|
||||
) {
|
||||
isValid = isValid && isLabelValidForAllLanguages(question[field], languages);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -263,11 +263,14 @@ export const AddIntegrationModal = ({
|
||||
</>
|
||||
);
|
||||
case ERRORS.MAPPING:
|
||||
const question = questionTypes.find((qt) => qt.id === ques.type);
|
||||
if (!question) return null;
|
||||
return (
|
||||
<>
|
||||
- <i>"{ques.name}"</i> of type{" "}
|
||||
<b>{questionTypes.find((qt) => qt.id === ques.type)?.label}</b> can't be mapped to the
|
||||
column <i>"{col.name}"</i> of type <b>{col.type}</b>
|
||||
- <i>"{ques.name}"</i> of type <b>{question.label}</b> can't be mapped to the
|
||||
column <i>"{col.name}"</i> of type <b>{col.type}</b>. Instead use column of type{" "}
|
||||
{""}
|
||||
<b>{TYPE_MAPPING[question.id].join(" ,")}.</b>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
|
||||
@@ -22,6 +22,10 @@ export const TYPE_MAPPING = {
|
||||
[TSurveyQuestionType.Rating]: ["number"],
|
||||
[TSurveyQuestionType.PictureSelection]: ["url"],
|
||||
[TSurveyQuestionType.FileUpload]: ["url"],
|
||||
[TSurveyQuestionType.Date]: ["date"],
|
||||
[TSurveyQuestionType.Address]: ["rich_text"],
|
||||
[TSurveyQuestionType.Matrix]: ["rich_text"],
|
||||
[TSurveyQuestionType.Cal]: ["checkbox"],
|
||||
};
|
||||
|
||||
export const UNSUPPORTED_TYPES_BY_NOTION = [
|
||||
|
||||
@@ -165,7 +165,7 @@ const buildNotionPayloadProperties = (
|
||||
const value = responses[map.question.id];
|
||||
|
||||
properties[map.column.name] = {
|
||||
[map.column.type]: getValue(map.column.type, value),
|
||||
[map.column.type]: getValue(map.column.type, processResponseData(value)),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import {
|
||||
ArrowUpFromLine,
|
||||
ArrowUpFromLineIcon,
|
||||
CalendarDaysIcon,
|
||||
CheckIcon,
|
||||
Grid3X3Icon,
|
||||
@@ -28,12 +28,13 @@ import {
|
||||
TSurveyNPSQuestion,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyPictureSelectionQuestion,
|
||||
TSurveyQuestionType,
|
||||
TSurveyRatingQuestion,
|
||||
} from "@formbricks/types/surveys";
|
||||
|
||||
import { replaceQuestionPresetPlaceholders } from "./templates";
|
||||
|
||||
export type TSurveyQuestionType = {
|
||||
export type TQuestion = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
@@ -41,7 +42,7 @@ export type TSurveyQuestionType = {
|
||||
preset: any;
|
||||
};
|
||||
|
||||
export const questionTypes: TSurveyQuestionType[] = [
|
||||
export const questionTypes: TQuestion[] = [
|
||||
{
|
||||
id: QuestionId.OpenText,
|
||||
label: "Free text",
|
||||
@@ -172,7 +173,7 @@ export const questionTypes: TSurveyQuestionType[] = [
|
||||
id: QuestionId.FileUpload,
|
||||
label: "File Upload",
|
||||
description: "Allow respondents to upload a file",
|
||||
icon: ArrowUpFromLine,
|
||||
icon: ArrowUpFromLineIcon,
|
||||
preset: {
|
||||
headline: { default: "File Upload" },
|
||||
allowMultipleFiles: false,
|
||||
@@ -217,6 +218,22 @@ export const questionTypes: TSurveyQuestionType[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const QUESTIONS_ICON_MAP = questionTypes.reduce(
|
||||
(prev, curr) => ({
|
||||
...prev,
|
||||
[curr.id]: <curr.icon className="h-5 w-5" />,
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
export const QUESTIONS_NAME_MAP = questionTypes.reduce(
|
||||
(prev, curr) => ({
|
||||
...prev,
|
||||
[curr.id]: curr.label,
|
||||
}),
|
||||
{}
|
||||
) as Record<TSurveyQuestionType, string>;
|
||||
|
||||
export const universalQuestionPresets = {
|
||||
required: true,
|
||||
};
|
||||
@@ -280,8 +280,8 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
// Fill welcome card in german
|
||||
await page.locator(".editor-input").click();
|
||||
await page.locator(".editor-input").fill(surveys.germanCreate.welcomeCard.description);
|
||||
await page.getByLabel("Headline").click();
|
||||
await page.getByLabel("Headline").fill(surveys.germanCreate.welcomeCard.headline);
|
||||
await page.getByLabel("Note*").click();
|
||||
await page.getByLabel("Note*").fill(surveys.germanCreate.welcomeCard.headline);
|
||||
|
||||
// Fill Open text question in german
|
||||
await page.getByRole("main").getByText("Free text").click();
|
||||
|
||||
@@ -140,14 +140,14 @@ export const createSurvey = async (
|
||||
await expect(page.locator("#welcome-toggle")).toBeVisible();
|
||||
await page.getByText("Welcome Card").click();
|
||||
await page.locator("#welcome-toggle").check();
|
||||
await page.getByLabel("Headline").fill(params.welcomeCard.headline);
|
||||
await page.getByLabel("Note*").fill(params.welcomeCard.headline);
|
||||
await page.locator("form").getByText("Thanks for providing your").fill(params.welcomeCard.description);
|
||||
await page.getByText("Welcome CardOn").click();
|
||||
|
||||
// Open Text Question
|
||||
await page.getByRole("main").getByText("What would you like to know?").click();
|
||||
|
||||
await page.getByLabel("Question").fill(params.openTextQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.openTextQuestion.question);
|
||||
await page.getByLabel("Description").fill(params.openTextQuestion.description);
|
||||
await page.getByLabel("Placeholder").fill(params.openTextQuestion.placeholder);
|
||||
|
||||
@@ -160,7 +160,7 @@ export const createSurvey = async (
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Single-Select" }).click();
|
||||
await page.getByLabel("Question").fill(params.singleSelectQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.singleSelectQuestion.question);
|
||||
await page.getByLabel("Description").fill(params.singleSelectQuestion.description);
|
||||
await page.getByPlaceholder("Option 1").fill(params.singleSelectQuestion.options[0]);
|
||||
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
|
||||
@@ -173,7 +173,7 @@ export const createSurvey = async (
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Multi-Select" }).click();
|
||||
await page.getByLabel("Question").fill(params.multiSelectQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.multiSelectQuestion.question);
|
||||
await page.getByRole("button", { name: "Add Description", exact: true }).click();
|
||||
await page.getByLabel("Description").fill(params.multiSelectQuestion.description);
|
||||
await page.getByPlaceholder("Option 1").fill(params.multiSelectQuestion.options[0]);
|
||||
@@ -187,7 +187,7 @@ export const createSurvey = async (
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Rating" }).click();
|
||||
await page.getByLabel("Question").fill(params.ratingQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.ratingQuestion.question);
|
||||
await page.getByLabel("Description").fill(params.ratingQuestion.description);
|
||||
await page.getByPlaceholder("Not good").fill(params.ratingQuestion.lowLabel);
|
||||
await page.getByPlaceholder("Very satisfied").fill(params.ratingQuestion.highLabel);
|
||||
@@ -199,7 +199,7 @@ export const createSurvey = async (
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Net Promoter Score (NPS)" }).click();
|
||||
await page.getByLabel("Question").fill(params.npsQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.npsQuestion.question);
|
||||
await page.getByLabel("Lower label").fill(params.npsQuestion.lowLabel);
|
||||
await page.getByLabel("Upper label").fill(params.npsQuestion.highLabel);
|
||||
|
||||
@@ -220,7 +220,7 @@ export const createSurvey = async (
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Consent" }).click();
|
||||
await page.getByLabel("Question").fill(params.consentQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.consentQuestion.question);
|
||||
await page.getByPlaceholder("I agree to the terms and").fill(params.consentQuestion.checkboxLabel);
|
||||
|
||||
// Picture Select Question
|
||||
@@ -230,7 +230,7 @@ export const createSurvey = async (
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Picture Selection" }).click();
|
||||
await page.getByLabel("Question").fill(params.pictureSelectQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.pictureSelectQuestion.question);
|
||||
await page.getByLabel("Description").fill(params.pictureSelectQuestion.description);
|
||||
|
||||
// File Upload Question
|
||||
@@ -240,7 +240,7 @@ export const createSurvey = async (
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "File Upload" }).click();
|
||||
await page.getByLabel("Question").fill(params.fileUploadQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.fileUploadQuestion.question);
|
||||
|
||||
// Fill Matrix question in german
|
||||
// File Upload Question
|
||||
@@ -250,7 +250,7 @@ export const createSurvey = async (
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Matrix" }).click();
|
||||
await page.getByLabel("Question").fill(params.matrix.question);
|
||||
await page.getByLabel("Question*").fill(params.matrix.question);
|
||||
await page.getByLabel("Description").fill(params.matrix.description);
|
||||
await page.locator("#row-0").click();
|
||||
await page.locator("#row-0").fill(params.matrix.rows[0]);
|
||||
@@ -274,10 +274,10 @@ export const createSurvey = async (
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Address" }).click();
|
||||
await page.getByLabel("Question").fill(params.address.question);
|
||||
await page.getByLabel("Question*").fill(params.address.question);
|
||||
|
||||
// Thank You Card
|
||||
await page.getByText("Thank You CardShown").click();
|
||||
await page.getByLabel("Headline").fill(params.thankYouCard.headline);
|
||||
await page.getByLabel("Note*").fill(params.thankYouCard.headline);
|
||||
await page.getByLabel("Description").fill(params.thankYouCard.description);
|
||||
};
|
||||
|
||||
@@ -159,7 +159,7 @@ export const RatingQuestion = ({
|
||||
className={cn(
|
||||
number <= hoveredNumber || number <= (value as number)
|
||||
? "text-amber-400"
|
||||
: "text-input-bg-selected",
|
||||
: "text-[#8696AC]",
|
||||
hoveredNumber === number ? "text-amber-400 " : "",
|
||||
"relative flex max-h-16 min-h-9 cursor-pointer justify-center focus:outline-none"
|
||||
)}
|
||||
|
||||
@@ -325,8 +325,8 @@ export type TSurveyMultipleChoiceQuestion = z.infer<typeof ZSurveyMultipleChoice
|
||||
|
||||
export const ZSurveyNPSQuestion = ZSurveyQuestionBase.extend({
|
||||
type: z.literal(TSurveyQuestionType.NPS),
|
||||
lowerLabel: ZI18nString,
|
||||
upperLabel: ZI18nString,
|
||||
lowerLabel: ZI18nString.optional(),
|
||||
upperLabel: ZI18nString.optional(),
|
||||
logic: z.array(ZSurveyNPSLogic).optional(),
|
||||
});
|
||||
|
||||
@@ -347,8 +347,8 @@ export const ZSurveyRatingQuestion = ZSurveyQuestionBase.extend({
|
||||
type: z.literal(TSurveyQuestionType.Rating),
|
||||
scale: z.enum(["number", "smiley", "star"]),
|
||||
range: z.union([z.literal(5), z.literal(3), z.literal(4), z.literal(7), z.literal(10)]),
|
||||
lowerLabel: ZI18nString,
|
||||
upperLabel: ZI18nString,
|
||||
lowerLabel: ZI18nString.optional(),
|
||||
upperLabel: ZI18nString.optional(),
|
||||
logic: z.array(ZSurveyRatingLogic).optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
getCardText,
|
||||
getChoiceLabel,
|
||||
getIndex,
|
||||
getLabelById,
|
||||
getMatrixLabel,
|
||||
getPlaceHolderById,
|
||||
isValueIncomplete,
|
||||
@@ -57,7 +56,7 @@ interface QuestionFormInputProps {
|
||||
isInvalid: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
label?: string;
|
||||
label: string;
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
ref?: RefObject<HTMLInputElement>;
|
||||
@@ -394,7 +393,7 @@ export const QuestionFormInput = ({
|
||||
<div className="w-full">
|
||||
<div className="w-full">
|
||||
<div className="mb-2 mt-3">
|
||||
<Label htmlFor={id}>{label || getLabelById(id)}</Label>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 bg-white">
|
||||
@@ -447,7 +446,7 @@ export const QuestionFormInput = ({
|
||||
placeholder={placeholder ? placeholder : getPlaceHolderById(id)}
|
||||
id={id}
|
||||
name={id}
|
||||
aria-label={label || getLabelById(id)}
|
||||
aria-label={label}
|
||||
autoComplete={showRecallItemSelect ? "off" : "on"}
|
||||
value={
|
||||
recallToHeadline(text, localSurvey, false, selectedLanguageCode, attributeClasses)[
|
||||
|
||||
@@ -62,27 +62,6 @@ export const determineImageUploaderVisibility = (questionIdx: number, localSurve
|
||||
}
|
||||
};
|
||||
|
||||
export const getLabelById = (id: string) => {
|
||||
switch (id) {
|
||||
case "headline":
|
||||
return "Question";
|
||||
case "subheader":
|
||||
return "Description";
|
||||
case "placeholder":
|
||||
return "Placeholder";
|
||||
case "buttonLabel":
|
||||
return `"Next" Button Label`;
|
||||
case "backButtonLabel":
|
||||
return `"Back" Button Label`;
|
||||
case "lowerLabel":
|
||||
return "Lower Label";
|
||||
case "upperLabel":
|
||||
return "Upper Label";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export const getPlaceHolderById = (id: string) => {
|
||||
switch (id) {
|
||||
case "headline":
|
||||
|
||||
Reference in New Issue
Block a user