mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-24 17:33:26 -05:00
Compare commits
12 Commits
fix/types
...
testing/ts
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
449b56fd40 | ||
|
|
2b1ffa90f9 | ||
|
|
d595794fb6 | ||
|
|
2d3dec7834 | ||
|
|
bbfdba7615 | ||
|
|
681c559c79 | ||
|
|
4e39f45446 | ||
|
|
62c514acf2 | ||
|
|
48638e8ca2 | ||
|
|
cb44b575c2 | ||
|
|
1565fd33f7 | ||
|
|
2bf04e9818 |
BIN
apps/docs/app/global/hidden-fields/filled-hidden-fields.webp
Normal file
BIN
apps/docs/app/global/hidden-fields/filled-hidden-fields.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
BIN
apps/docs/app/global/hidden-fields/hidden-field-responses.webp
Normal file
BIN
apps/docs/app/global/hidden-fields/hidden-field-responses.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
apps/docs/app/global/hidden-fields/hidden-fields.webp
Normal file
BIN
apps/docs/app/global/hidden-fields/hidden-fields.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
apps/docs/app/global/hidden-fields/input-hidden-fields.webp
Normal file
BIN
apps/docs/app/global/hidden-fields/input-hidden-fields.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
79
apps/docs/app/global/hidden-fields/page.mdx
Normal file
79
apps/docs/app/global/hidden-fields/page.mdx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
|
||||
import FilledHiddenFields from "./filled-hidden-fields.webp";
|
||||
import HiddenFieldResponses from "./hidden-field-responses.webp";
|
||||
import HiddenFields from "./hidden-fields.webp";
|
||||
import InputHiddenFields from "./input-hidden-fields.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Hidden Fields",
|
||||
description: "Add hidden fields to your surveys to capture additional data without requiring user inputs!",
|
||||
};
|
||||
|
||||
# Hidden Fields
|
||||
|
||||
Hidden fields are a powerful feature in Formbricks that allows you to add data to a submission without asking the user to type it in. This feature is especially useful when you already have information about a user that you want to use in the analysis of the survey results (e.g. `payment plan` or `email`)
|
||||
|
||||
<Note>Hidden fields are now available in the Formbricks in-app and website surveys as well</Note>
|
||||
|
||||
## How to Add Hidden Fields
|
||||
|
||||
### Enable them in the Survey Builder
|
||||
|
||||
1. Edit the survey you want to add hidden fields to & switch to the Questions tab and scroll down to the bottom of the page. You will see a section called **Hidden Fields**. Make sure to enable it by toggling the switch.
|
||||
|
||||
<MdxImage
|
||||
src={HiddenFields}
|
||||
alt="Enable Hidden Fields"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
2. Now click on it to add a new hidden field ID. You can add as many hidden fields as you want.
|
||||
|
||||
<MdxImage
|
||||
src={InputHiddenFields}
|
||||
alt="Add Hidden Fields"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
<MdxImage
|
||||
src={FilledHiddenFields}
|
||||
alt="Filled Hidden Fields"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### Set Hidden Field
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Example Screen from which the User filled it">
|
||||
|
||||
```sh
|
||||
formbricks.track("my event", {
|
||||
hiddenFields: {
|
||||
screen: "landing_page",
|
||||
job: "Founder"
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
## View Hidden Fields in Responses
|
||||
|
||||
These hidden fields will now be visible in the responses tab just like other fields in the Summary as well as the Response Cards, and you can use them to filter and analyze your responses.
|
||||
|
||||
<MdxImage
|
||||
src={HiddenFieldResponses}
|
||||
alt="Hidden Field Responses"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **User Metadata**: You can add hidden fields to capture user metadata such as user ID, email, or any other user-specific information.
|
||||
- **Survey Metadata**: You can add hidden fields to capture other metadata, e.g. the screen from which the survey was filled, or any other app specific information.
|
||||
@@ -4,7 +4,6 @@ import FilledHiddenFields from "./filled-hidden-fields.webp";
|
||||
import HiddenFieldResponses from "./hidden-field-responses.webp";
|
||||
import HiddenFields from "./hidden-fields.webp";
|
||||
import InputHiddenFields from "./input-hidden-fields.webp";
|
||||
import SettingsPage from "./settings.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Hidden Fields",
|
||||
@@ -21,16 +20,7 @@ Hidden fields are a powerful feature in Formbricks that allows you to add data t
|
||||
|
||||
### Enable them in the Survey Builder
|
||||
|
||||
1. Edit the survey you want to add hidden fields to & open it's settings, make sure it's selected as a **Link Survey**.
|
||||
|
||||
<MdxImage
|
||||
src={SettingsPage}
|
||||
alt="Select the Survey Type as Link Survey"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
2. Switch to the Questions tab and scroll down to the bottom of the page. You will see a section called **Hidden Fields**. Make sure to enable it by toggling the switch.
|
||||
1. Edit the survey you want to add hidden fields to & switch to the Questions tab and scroll down to the bottom of the page. You will see a section called **Hidden Fields**. Make sure to enable it by toggling the switch.
|
||||
|
||||
<MdxImage
|
||||
src={HiddenFields}
|
||||
@@ -39,7 +29,7 @@ Hidden fields are a powerful feature in Formbricks that allows you to add data t
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
3. Now click on it to add a new hidden field ID. You can add as many hidden fields as you want.
|
||||
2. Now click on it to add a new hidden field ID. You can add as many hidden fields as you want.
|
||||
|
||||
<MdxImage
|
||||
src={InputHiddenFields}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 39 KiB |
140
apps/docs/app/self-hosting/custom-ssl/page.mdx
Normal file
140
apps/docs/app/self-hosting/custom-ssl/page.mdx
Normal file
@@ -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.
|
||||
@@ -35,6 +35,7 @@ export const navigation: Array<NavGroup> = [
|
||||
{ title: "Advanced Targeting", href: "/app-surveys/advanced-targeting" },
|
||||
{ title: "Show Survey to % of users", href: "/global/show-survey-to-percent-of-users" }, // app and website
|
||||
{ title: "Recontact Options", href: "/app-surveys/recontact" },
|
||||
{ title: "Hidden Fields", href: "/global/hidden-fields" }, // global
|
||||
{ title: "Multi Language Surveys", href: "/global/multi-language-surveys" }, // global
|
||||
{ title: "User Metadata", href: "/global/metadata" }, // global
|
||||
{ title: "Custom Styling", href: "/global/overwrite-styling" }, // global
|
||||
@@ -57,6 +58,7 @@ export const navigation: Array<NavGroup> = [
|
||||
{ title: "Actions & Targeting", href: "/website-surveys/actions-and-targeting" },
|
||||
{ title: "Show Survey to % of users", href: "/global/show-survey-to-percent-of-users" }, // app and website
|
||||
{ title: "Recontact Options", href: "/app-surveys/recontact" },
|
||||
{ title: "Hidden Fields", href: "/global/hidden-fields" }, // global
|
||||
{ title: "Multi Language Surveys", href: "/global/multi-language-surveys" }, // global
|
||||
{ title: "User Metadata", href: "/global/metadata" }, // global
|
||||
{ title: "Custom Styling", href: "/global/overwrite-styling" }, // global
|
||||
@@ -107,6 +109,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" },
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
|
||||
import { mixColor } from "@formbricks/lib/utils";
|
||||
import { mixColor } from "@formbricks/lib/utils/colors";
|
||||
import { TProductStyling } from "@formbricks/types/product";
|
||||
import { TSurveyStyling } from "@formbricks/types/surveys";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -377,14 +383,12 @@ export const QuestionsView = ({
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
|
||||
{localSurvey.type === "link" ? (
|
||||
<HiddenFieldsCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
/>
|
||||
) : null}
|
||||
<HiddenFieldsCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
/>
|
||||
|
||||
<MultiLanguageCard
|
||||
localSurvey={localSurvey}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -14,13 +14,13 @@ import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import { TBaseFilter, TSegment, TSegmentCreateInput, TSegmentUpdateInput } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { AlertDialog } from "@formbricks/ui/AlertDialog";
|
||||
import { BasicAddFilterModal } from "@formbricks/ui/BasicAddFilterModal";
|
||||
import { BasicSegmentEditor } from "@formbricks/ui/BasicSegmentEditor";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { BasicAddFilterModal } from "@formbricks/ui/Targeting/BasicAddFilterModal";
|
||||
import { BasicSegmentEditor } from "@formbricks/ui/Targeting/BasicSegmentEditor";
|
||||
import { LoadSegmentModal } from "@formbricks/ui/Targeting/LoadSegmentModal";
|
||||
import { SaveAsNewSegmentModal } from "@formbricks/ui/Targeting/SaveAsNewSegmentModal";
|
||||
import { SegmentTitle } from "@formbricks/ui/Targeting/SegmentTitle";
|
||||
import { TargetingIndicator } from "@formbricks/ui/Targeting/TargetingIndicator";
|
||||
import { LoadSegmentModal } from "@formbricks/ui/LoadSegmentModal";
|
||||
import { SaveAsNewSegmentModal } from "@formbricks/ui/SaveAsNewSegmentModal";
|
||||
import { SegmentTitle } from "@formbricks/ui/SegmentTitle";
|
||||
import { TargetingIndicator } from "@formbricks/ui/TargetingIndicator";
|
||||
import { UpgradePlanNotice } from "@formbricks/ui/UpgradePlanNotice";
|
||||
|
||||
import {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { TagIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/strings";
|
||||
import { convertDateTimeStringShort } from "@formbricks/lib/time";
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getAttributes } from "@formbricks/lib/attribute/service";
|
||||
import { getPerson } from "@formbricks/lib/person/service";
|
||||
import { getResponsesByPersonId } from "@formbricks/lib/response/service";
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/strings";
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
||||
|
||||
export const AttributesSection = async ({ personId }: { personId: string }) => {
|
||||
const [person, attributes] = await Promise.all([getPerson(personId), getAttributes(personId)]);
|
||||
|
||||
@@ -9,11 +9,11 @@ import { createSegmentAction } from "@formbricks/ee/advancedTargeting/lib/action
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import { TBaseFilter, TSegment, ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import { BasicAddFilterModal } from "@formbricks/ui/BasicAddFilterModal";
|
||||
import { BasicSegmentEditor } from "@formbricks/ui/BasicSegmentEditor";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Modal } from "@formbricks/ui/Modal";
|
||||
import { BasicAddFilterModal } from "@formbricks/ui/Targeting/BasicAddFilterModal";
|
||||
import { BasicSegmentEditor } from "@formbricks/ui/Targeting/BasicSegmentEditor";
|
||||
import { UpgradePlanNotice } from "@formbricks/ui/UpgradePlanNotice";
|
||||
|
||||
type TCreateSegmentModalProps = {
|
||||
|
||||
@@ -9,11 +9,11 @@ import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { isAdvancedSegment } from "@formbricks/lib/segment/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import { TBaseFilter, TSegment, TSegmentWithSurveyNames, ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import { BasicAddFilterModal } from "@formbricks/ui/BasicAddFilterModal";
|
||||
import { BasicSegmentEditor } from "@formbricks/ui/BasicSegmentEditor";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { ConfirmDeleteSegmentModal } from "@formbricks/ui/ConfirmDeleteSegmentModal";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { BasicAddFilterModal } from "@formbricks/ui/Targeting/BasicAddFilterModal";
|
||||
import { BasicSegmentEditor } from "@formbricks/ui/Targeting/BasicSegmentEditor";
|
||||
import { ConfirmDeleteSegmentModal } from "@formbricks/ui/Targeting/ConfirmDeleteSegmentModal";
|
||||
import { UpgradePlanNotice } from "@formbricks/ui/UpgradePlanNotice";
|
||||
|
||||
import { deleteBasicSegmentAction, updateBasicSegmentAction } from "../actions";
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/strings";
|
||||
import { convertDateTimeStringShort } from "@formbricks/lib/time";
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
@@ -29,7 +29,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { capitalizeFirstLetter, truncate } from "@formbricks/lib/strings";
|
||||
import { capitalizeFirstLetter, truncate } from "@formbricks/lib/utils/strings";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/strings";
|
||||
import { convertDateTimeStringShort } from "@formbricks/lib/time";
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { TWebhook } from "@formbricks/types/webhooks";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/strings";
|
||||
import { timeSinceConditionally } from "@formbricks/lib/time";
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { TWebhook } from "@formbricks/types/webhooks";
|
||||
import { Badge } from "@formbricks/ui/Badge";
|
||||
|
||||
@@ -4,8 +4,8 @@ import { FilesIcon, TrashIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/strings";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
||||
import { TApiKey } from "@formbricks/types/apiKeys";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { truncate } from "@formbricks/lib/strings";
|
||||
import { truncate } from "@formbricks/lib/utils/strings";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
const LoadingCard = ({ title, description, skeletonLines }) => {
|
||||
return (
|
||||
<div className="my-4 w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 shadow-sm">
|
||||
<div className="grid content-center border-b border-slate-200 px-4 pb-4 text-left text-slate-900">
|
||||
<h3 className="text-lg font-medium leading-6">{title}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{description}</p>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="rounded-lg px-6 py-5">
|
||||
{skeletonLines.map((line, index) => (
|
||||
<div key={index} className="mt-4">
|
||||
<div className={`animate-pulse rounded-full bg-slate-200 ${line.classes}`}></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Loading = () => {
|
||||
const cards = [
|
||||
{
|
||||
title: "Email alerts (Surveys)",
|
||||
description: "Set up an alert to get an email on new responses.",
|
||||
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
|
||||
},
|
||||
{
|
||||
title: "Weekly summary (Products)",
|
||||
description: "Stay up-to-date with a Weekly every Monday.",
|
||||
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
|
||||
},
|
||||
];
|
||||
|
||||
const pages = ["Profile", "Notifications"];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between space-x-4 pb-4">
|
||||
<h1 className="text-3xl font-bold text-slate-800">Account Settings</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 border-b border-slate-200">
|
||||
<div className="grid h-10 w-full grid-cols-[auto,1fr] ">
|
||||
<nav className="flex h-full min-w-full items-center space-x-4" aria-label="Tabs">
|
||||
{pages.map((navElem) => (
|
||||
<div
|
||||
key={navElem}
|
||||
className="flex h-full items-center border-b-2 border-transparent px-3 text-sm font-medium text-slate-500 transition-all duration-150 ease-in-out hover:border-slate-300 hover:text-slate-700">
|
||||
{navElem}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="justify-self-end"></div>
|
||||
</div>
|
||||
</div>
|
||||
{cards.map((card, index) => (
|
||||
<LoadingCard key={index} {...card} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
@@ -1,111 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
removeAvatarAction,
|
||||
updateAvatarAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions";
|
||||
import { handleFileUpload } from "@/app/lib/fileUpload";
|
||||
import { Session } from "next-auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { ProfileAvatar } from "@formbricks/ui/Avatars";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
|
||||
export const EditAvatar = ({ session, environmentId }: { session: Session; environmentId: string }) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleUpload = async (file: File, environmentId: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (session?.user.imageUrl) {
|
||||
// If avatar image already exist, then remove it before update action
|
||||
await removeAvatarAction(environmentId);
|
||||
}
|
||||
const { url, error } = await handleFileUpload(file, environmentId);
|
||||
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await updateAvatarAction(url);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
toast.error("Avatar update failed. Please try again.");
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await removeAvatarAction(environmentId);
|
||||
} catch (err) {
|
||||
toast.error("Avatar update failed. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative h-10 w-10 overflow-hidden rounded-full">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30">
|
||||
<svg className="h-7 w-7 animate-spin text-slate-200" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProfileAvatar userId={session.user.id} imageUrl={session.user.imageUrl} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
className="mr-2"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
inputRef.current?.click();
|
||||
}}>
|
||||
{session?.user.imageUrl ? "Change Image" : "Upload Image"}
|
||||
<input
|
||||
type="file"
|
||||
id="hiddenFileInput"
|
||||
ref={inputRef}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
await handleUpload(file, environmentId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
{session?.user?.imageUrl && (
|
||||
<Button className="mr-2" variant="warn" size="sm" onClick={handleRemove}>
|
||||
Remove Image
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
import { updateUserAction } from "../actions";
|
||||
|
||||
type FormData = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const EditName = ({ user }: { user: TUser }) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
watch,
|
||||
} = useForm<FormData>();
|
||||
|
||||
const nameValue = watch("name", user.name || "");
|
||||
const isNotEmptySpaces = (value: string) => value.trim() !== "";
|
||||
|
||||
const onSubmit: SubmitHandler<FormData> = async (data) => {
|
||||
try {
|
||||
data.name = data.name.trim();
|
||||
if (!isNotEmptySpaces(data.name)) {
|
||||
toast.error("Please enter at least one character");
|
||||
return;
|
||||
}
|
||||
if (data.name === user.name) {
|
||||
toast.success("This is already your name");
|
||||
return;
|
||||
}
|
||||
await updateUserAction({ name: data.name });
|
||||
toast.success("Your name was updated successfully");
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className="w-full max-w-sm items-center" onSubmit={handleSubmit(onSubmit)}>
|
||||
<Label htmlFor="fullname">Full Name</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="fullname"
|
||||
defaultValue={user.name || ""}
|
||||
{...register("name", { required: true })}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input type="email" id="fullname" defaultValue={user.email} disabled />
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
disabled={nameValue === "" || isSubmitting}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
removeAvatarAction,
|
||||
updateAvatarAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions";
|
||||
import { handleFileUpload } from "@/app/lib/fileUpload";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Session } from "next-auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ProfileAvatar } from "@formbricks/ui/Avatars";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { FormError, FormField, FormItem, FormProvider } from "@formbricks/ui/Form";
|
||||
|
||||
interface EditProfileAvatarFormProps {
|
||||
session: Session;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const EditProfileAvatarForm = ({ session, environmentId }: EditProfileAvatarFormProps) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const fileSchema =
|
||||
typeof window !== "undefined"
|
||||
? z
|
||||
.instanceof(FileList)
|
||||
.refine((files) => files.length === 1, "You must select a file.")
|
||||
.refine((files) => {
|
||||
const file = files[0];
|
||||
const allowedTypes = ["image/jpeg", "image/png"];
|
||||
return allowedTypes.includes(file.type);
|
||||
}, "Invalid file type. Only JPEG and PNG are allowed.")
|
||||
.refine((files) => {
|
||||
const file = files[0];
|
||||
const maxSize = 10 * 1024 * 1024;
|
||||
return file.size <= maxSize;
|
||||
}, "File size must be less than 10MB.")
|
||||
: z.any();
|
||||
|
||||
const formSchema = z.object({
|
||||
file: fileSchema,
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const handleUpload = async (file: File, environmentId: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (session?.user.imageUrl) {
|
||||
// If avatar image already exists, then remove it before update action
|
||||
await removeAvatarAction(environmentId);
|
||||
}
|
||||
const { url, error } = await handleFileUpload(file, environmentId);
|
||||
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await updateAvatarAction(url);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
toast.error("Avatar update failed. Please try again.");
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await removeAvatarAction(environmentId);
|
||||
} catch (err) {
|
||||
toast.error("Avatar update failed. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
form.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
const file = data.file[0];
|
||||
if (file) {
|
||||
await handleUpload(file, environmentId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative h-10 w-10 overflow-hidden rounded-full">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30">
|
||||
<svg className="h-7 w-7 animate-spin text-slate-200" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProfileAvatar userId={session.user.id} imageUrl={session.user.imageUrl} />
|
||||
</div>
|
||||
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4">
|
||||
<FormField
|
||||
name="file"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<div className="flex">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="mr-2"
|
||||
variant={!!fieldState.error?.message ? "warn" : "secondary"}
|
||||
onClick={() => {
|
||||
inputRef.current?.click();
|
||||
}}>
|
||||
{session?.user.imageUrl ? "Change Image" : "Upload Image"}
|
||||
<input
|
||||
type="file"
|
||||
id="hiddenFileInput"
|
||||
ref={(e) => {
|
||||
field.ref(e);
|
||||
// @ts-expect-error
|
||||
inputRef.current = e;
|
||||
}}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.files);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{session?.user?.imageUrl && (
|
||||
<Button type="button" className="mr-2" variant="warn" size="sm" onClick={handleRemove}>
|
||||
Remove Image
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TUser, ZUser } from "@formbricks/types/user";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel, FormProvider } from "@formbricks/ui/Form";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
import { updateUserAction } from "../actions";
|
||||
|
||||
const ZEditProfileNameFormSchema = ZUser.pick({ name: true });
|
||||
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
|
||||
|
||||
export const EditProfileDetailsForm = ({ user }: { user: TUser }) => {
|
||||
const form = useForm<TEditProfileNameForm>({
|
||||
defaultValues: { name: user.name },
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(ZEditProfileNameFormSchema),
|
||||
});
|
||||
|
||||
const { isSubmitting, isDirty } = form.formState;
|
||||
|
||||
const onSubmit: SubmitHandler<TEditProfileNameForm> = async (data) => {
|
||||
try {
|
||||
const name = data.name.trim();
|
||||
await updateUserAction({ name });
|
||||
toast.success("Your name was updated successfully");
|
||||
|
||||
form.reset({ name });
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form className="w-full max-w-sm items-center" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
placeholder="Full Name"
|
||||
required
|
||||
isInvalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* disabled */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input type="email" id="fullname" defaultValue={user.email} disabled />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
const LoadingCard = ({ title, description, skeletonLines }) => {
|
||||
return (
|
||||
<div className="my-4 rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-100 px-6 py-5 text-left text-slate-900">
|
||||
<div className="my-4 w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 shadow-sm">
|
||||
<div className="grid content-center border-b border-slate-200 px-4 pb-4 text-left text-slate-900">
|
||||
<h3 className="text-lg font-medium leading-6">{title}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{description}</p>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="rounded-lg px-6 py-5 hover:bg-slate-100">
|
||||
<div className="rounded-lg px-6 py-5">
|
||||
{skeletonLines.map((line, index) => (
|
||||
<div key={index} className="mt-4">
|
||||
<div className={`animate-pulse rounded-full bg-slate-200 ${line.classes}`}></div>
|
||||
@@ -28,7 +28,6 @@ const Loading = () => {
|
||||
{ classes: "h-6 w-64" },
|
||||
{ classes: "h-4 w-28" },
|
||||
{ classes: "h-6 w-64" },
|
||||
{ classes: "h-8 w-24" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -48,9 +47,29 @@ const Loading = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const pages = ["Profile", "Notifications"];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">Profile</h2>
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between space-x-4 pb-4">
|
||||
<h1 className="text-3xl font-bold text-slate-800">Account Settings</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 border-b border-slate-200">
|
||||
<div className="grid h-10 w-full grid-cols-[auto,1fr] ">
|
||||
<nav className="flex h-full min-w-full items-center space-x-4" aria-label="Tabs">
|
||||
{pages.map((navElem) => (
|
||||
<div
|
||||
key={navElem}
|
||||
className="flex h-full items-center border-b-2 border-transparent px-3 text-sm font-medium text-slate-500 transition-all duration-150 ease-in-out hover:border-slate-300 hover:text-slate-700">
|
||||
{navElem}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="justify-self-end"></div>
|
||||
</div>
|
||||
</div>
|
||||
{cards.map((card, index) => (
|
||||
<LoadingCard key={index} {...card} />
|
||||
))}
|
||||
|
||||
@@ -11,8 +11,8 @@ import { SettingsId } from "@formbricks/ui/SettingsId";
|
||||
|
||||
import { SettingsCard } from "../../components/SettingsCard";
|
||||
import { DeleteAccount } from "./components/DeleteAccount";
|
||||
import { EditAvatar } from "./components/EditAvatar";
|
||||
import { EditName } from "./components/EditName";
|
||||
import { EditProfileAvatarForm } from "./components/EditProfileAvatarForm";
|
||||
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
|
||||
|
||||
const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
const { environmentId } = params;
|
||||
@@ -30,12 +30,12 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
{user && (
|
||||
<div>
|
||||
<SettingsCard title="Personal information" description="Update your personal information.">
|
||||
<EditName user={user} />
|
||||
<EditProfileDetailsForm user={user} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Avatar"
|
||||
description="Assist your organization in identifying you on Formbricks.">
|
||||
<EditAvatar session={session} environmentId={environmentId} />
|
||||
<EditProfileAvatarForm session={session} environmentId={environmentId} />
|
||||
</SettingsCard>
|
||||
{user.identityProvider === "email" && (
|
||||
<SettingsCard title="Security" description="Manage your password and other security settings.">
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
const pages = ["Members", "Billing & Plan"];
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">Billing & Plan</h2>
|
||||
<div className="grid grid-cols-2 gap-4 rounded-lg p-8">
|
||||
<div className=" h-[75vh] animate-pulse rounded-md bg-slate-200 "></div>
|
||||
<div className=" h-96 animate-pulse rounded-md bg-slate-200"></div>
|
||||
<div className="col-span-2 h-96 bg-slate-200 p-8"></div>
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between space-x-4 pb-4">
|
||||
<h1 className="text-3xl font-bold text-slate-800">Organization Settings</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 border-b border-slate-200">
|
||||
<div className="grid h-10 w-full grid-cols-[auto,1fr] ">
|
||||
<nav className="flex h-full min-w-full items-center space-x-4" aria-label="Tabs">
|
||||
{pages.map((navElem) => (
|
||||
<div
|
||||
key={navElem}
|
||||
className="flex h-full items-center border-b-2 border-transparent px-3 text-sm font-medium text-slate-500 transition-all duration-150 ease-in-out hover:border-slate-300 hover:text-slate-700">
|
||||
{navElem}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="justify-self-end"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className=" my-8 h-64 animate-pulse rounded-xl bg-slate-200 "></div>
|
||||
<div className=" my-8 h-96 animate-pulse rounded-md bg-slate-200"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
const pages = ["Members", "Enterprise License"];
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">Enterprise License</h2>
|
||||
<div className="grid grid-cols-2 gap-4 rounded-lg p-8">
|
||||
<div className=" h-[75vh] animate-pulse rounded-md bg-slate-200 "></div>
|
||||
<div className=" h-96 animate-pulse rounded-md bg-slate-200"></div>
|
||||
<div className="col-span-2 h-96 bg-slate-200 p-8"></div>
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between space-x-4 pb-4">
|
||||
<h1 className="text-3xl font-bold text-slate-800">Organization Settings</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 border-b border-slate-200">
|
||||
<div className="grid h-10 w-full grid-cols-[auto,1fr] ">
|
||||
<nav className="flex h-full min-w-full items-center space-x-4" aria-label="Tabs">
|
||||
{pages.map((navElem) => (
|
||||
<div
|
||||
key={navElem}
|
||||
className="flex h-full items-center border-b-2 border-transparent px-3 text-sm font-medium text-slate-500 transition-all duration-150 ease-in-out hover:border-slate-300 hover:text-slate-700">
|
||||
{navElem}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="justify-self-end"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className=" my-8 h-64 animate-pulse rounded-xl bg-slate-200 "></div>
|
||||
<div className=" my-8 h-96 animate-pulse rounded-md bg-slate-200"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
interface EditOrganizationNameForm {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface EditOrganizationNameProps {
|
||||
environmentId: string;
|
||||
organization: TOrganization;
|
||||
membershipRole?: TMembershipRole;
|
||||
}
|
||||
|
||||
export const EditOrganizationName = ({ organization, membershipRole }: EditOrganizationNameProps) => {
|
||||
const router = useRouter();
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<EditOrganizationNameForm>({
|
||||
defaultValues: {
|
||||
name: organization.name,
|
||||
},
|
||||
});
|
||||
const [isUpdatingOrganization, setIsUpdatingOrganization] = useState(false);
|
||||
const { isViewer } = getAccessFlags(membershipRole);
|
||||
|
||||
const organizationName = useWatch({
|
||||
control,
|
||||
name: "name",
|
||||
});
|
||||
|
||||
const isOrganizationNameInputEmpty = !organizationName?.trim();
|
||||
const currentOrganizationName = organizationName?.trim().toLowerCase() ?? "";
|
||||
const previousOrganizationName = organization?.name?.trim().toLowerCase() ?? "";
|
||||
|
||||
const handleUpdateOrganizationName: SubmitHandler<EditOrganizationNameForm> = async (data) => {
|
||||
try {
|
||||
data.name = data.name.trim();
|
||||
setIsUpdatingOrganization(true);
|
||||
await updateOrganizationNameAction(organization.id, data.name);
|
||||
|
||||
setIsUpdatingOrganization(false);
|
||||
toast.success("Organization name updated successfully.");
|
||||
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setIsUpdatingOrganization(false);
|
||||
toast.error(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return isViewer ? (
|
||||
<p className="text-sm text-red-700">You are not authorized to perform this action.</p>
|
||||
) : (
|
||||
<form className="w-full max-w-sm items-center" onSubmit={handleSubmit(handleUpdateOrganizationName)}>
|
||||
<Label htmlFor="organizationname">Organization Name</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="organizationname"
|
||||
defaultValue={organization?.name ?? ""}
|
||||
{...register("name", {
|
||||
required: {
|
||||
message: "Organization name is required.",
|
||||
value: true,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
{errors?.name?.message && <p className="text-xs text-red-500">{errors.name.message}</p>}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4"
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
loading={isUpdatingOrganization}
|
||||
disabled={isOrganizationNameInputEmpty || currentOrganizationName === previousOrganizationName}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization, ZOrganization } from "@formbricks/types/organizations";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel, FormProvider } from "@formbricks/ui/Form";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
|
||||
interface EditOrganizationNameProps {
|
||||
environmentId: string;
|
||||
organization: TOrganization;
|
||||
membershipRole?: TMembershipRole;
|
||||
}
|
||||
|
||||
const ZEditOrganizationNameFormSchema = ZOrganization.pick({ name: true });
|
||||
type EditOrganizationNameForm = z.infer<typeof ZEditOrganizationNameFormSchema>;
|
||||
|
||||
export const EditOrganizationNameForm = ({ organization, membershipRole }: EditOrganizationNameProps) => {
|
||||
const form = useForm<EditOrganizationNameForm>({
|
||||
defaultValues: {
|
||||
name: organization.name,
|
||||
},
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(ZEditOrganizationNameFormSchema),
|
||||
});
|
||||
|
||||
const { isViewer } = getAccessFlags(membershipRole);
|
||||
|
||||
const { isSubmitting, isDirty } = form.formState;
|
||||
|
||||
const handleUpdateOrganizationName: SubmitHandler<EditOrganizationNameForm> = async (data) => {
|
||||
try {
|
||||
const name = data.name.trim();
|
||||
const updatedOrg = await updateOrganizationNameAction(organization.id, name);
|
||||
|
||||
toast.success("Organization name updated successfully.");
|
||||
form.reset({ name: updatedOrg.name });
|
||||
} catch (err) {
|
||||
toast.error(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return isViewer ? (
|
||||
<p className="text-sm text-red-700">You are not authorized to perform this action.</p>
|
||||
) : (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
className="w-full max-w-sm items-center"
|
||||
onSubmit={form.handleSubmit(handleUpdateOrganizationName)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Organization Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
isInvalid={!!fieldState.error?.message}
|
||||
placeholder="Organization Name"
|
||||
required
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4"
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,77 +1,68 @@
|
||||
import { Skeleton } from "@formbricks/ui/Skeleton";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
|
||||
const LoadingCard = ({
|
||||
title,
|
||||
description,
|
||||
skeleton,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
skeleton: React.ReactNode;
|
||||
}) => {
|
||||
const LoadingCard = ({ title, description, skeletonLines }) => {
|
||||
return (
|
||||
<div className="my-4 rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-100 px-6 py-5 text-left text-slate-900">
|
||||
<div className="my-4 w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 shadow-sm">
|
||||
<div className="grid content-center border-b border-slate-200 px-4 pb-4 text-left text-slate-900">
|
||||
<h3 className="text-lg font-medium leading-6">{title}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{description}</p>
|
||||
</div>
|
||||
<div className="w-full">{skeleton}</div>
|
||||
<div className="w-full">
|
||||
<div className="rounded-lg px-6">
|
||||
{skeletonLines.map((line, index) => (
|
||||
<div key={index} className="mt-4">
|
||||
<div className={`animate-pulse rounded-full bg-slate-200 ${line.classes}`}></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: "Manage members",
|
||||
description: "Add or remove members in your organization.",
|
||||
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-8 w-80" }, { classes: "h-8 w-80" }],
|
||||
},
|
||||
{
|
||||
title: "Organization Name",
|
||||
description: "Give your organization a descriptive name.",
|
||||
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-8 w-80" }],
|
||||
},
|
||||
{
|
||||
title: "Delete Organization",
|
||||
description:
|
||||
"Delete organization with all its products including all surveys, responses, people, actions and attributes",
|
||||
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-8 w-80" }],
|
||||
},
|
||||
];
|
||||
|
||||
const pages = ["Members", IS_FORMBRICKS_CLOUD ? "Billing & Plan" : "Enterprise License"];
|
||||
|
||||
const Loading = () => {
|
||||
const cards = [
|
||||
{
|
||||
title: "Manage members",
|
||||
description: "Add or remove members in your organization",
|
||||
skeleton: (
|
||||
<div className="flex flex-col space-y-4 p-4">
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<Skeleton className="h-12 w-40 rounded-lg" />
|
||||
<Skeleton className="h-12 w-40 rounded-lg" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<div className="grid-cols-20 grid h-12 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-2"></div>
|
||||
<div className="col-span-5">Fullname</div>
|
||||
<div className="col-span-5">Email</div>
|
||||
<div className="col-span-3">Role</div>
|
||||
<div className="col-span-5"></div>
|
||||
</div>
|
||||
|
||||
<div className="h-10"></div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Organization Name",
|
||||
description: "Give your organization a descriptive name",
|
||||
skeleton: (
|
||||
<div className="flex flex-col p-4">
|
||||
<Skeleton className="mb-2 h-5 w-32" />
|
||||
<Skeleton className="mb-4 h-12 w-96 rounded-lg" />
|
||||
<Skeleton className="h-12 w-36 rounded-lg" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Delete account",
|
||||
description: "Delete your account with all of your personal information and data.",
|
||||
skeleton: (
|
||||
<div className="flex flex-col p-4">
|
||||
<Skeleton className="mb-2 h-5 w-full" />
|
||||
<Skeleton className="h-12 w-36 rounded-lg" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">Profile</h2>
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between space-x-4 pb-4">
|
||||
<h1 className="text-3xl font-bold text-slate-800">Organization Settings</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 border-b border-slate-200">
|
||||
<div className="grid h-10 w-full grid-cols-[auto,1fr] ">
|
||||
<nav className="flex h-full min-w-full items-center space-x-4" aria-label="Tabs">
|
||||
{pages.map((navElem) => (
|
||||
<div
|
||||
key={navElem}
|
||||
className="flex h-full items-center border-b-2 border-transparent px-3 text-sm font-medium text-slate-500 transition-all duration-150 ease-in-out hover:border-slate-300 hover:text-slate-700">
|
||||
{navElem}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="justify-self-end"></div>
|
||||
</div>
|
||||
</div>
|
||||
{cards.map((card, index) => (
|
||||
<LoadingCard key={index} {...card} />
|
||||
))}
|
||||
|
||||
@@ -15,34 +15,19 @@ import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/ser
|
||||
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||
import { SettingsId } from "@formbricks/ui/SettingsId";
|
||||
import { Skeleton } from "@formbricks/ui/Skeleton";
|
||||
|
||||
import { SettingsCard } from "../../components/SettingsCard";
|
||||
import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||
import { EditMemberships } from "./components/EditMemberships";
|
||||
import { EditOrganizationName } from "./components/EditOrganizationName";
|
||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||
|
||||
const MembersLoading = () => (
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<div className="grid-cols-20 grid h-12 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-2"></div>
|
||||
<div className="col-span-5">Fullname</div>
|
||||
<div className="col-span-5">Email</div>
|
||||
<div className="col-span-3">Role</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid-cols-20 grid h-12 content-center rounded-t-lg bg-white p-4 text-left text-sm font-semibold text-slate-900">
|
||||
<Skeleton className="col-span-2 h-10 w-10 rounded-full" />
|
||||
<Skeleton className="col-span-5 h-8 w-24" />
|
||||
<Skeleton className="col-span-5 h-8 w-24" />
|
||||
<Skeleton className="col-span-3 h-8 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-2">
|
||||
{Array.from(Array(2)).map((_, index) => (
|
||||
<div key={index} className="mt-4">
|
||||
<div className={`h-8 w-80 animate-pulse rounded-full bg-slate-200`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -104,7 +89,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
)}
|
||||
</SettingsCard>
|
||||
<SettingsCard title="Organization Name" description="Give your organization a descriptive name.">
|
||||
<EditOrganizationName
|
||||
<EditOrganizationNameForm
|
||||
organization={organization}
|
||||
environmentId={params.environmentId}
|
||||
membershipRole={currentUserMembership?.role}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -11,7 +11,7 @@ import { ResponseQueue } from "@formbricks/lib/responseQueue";
|
||||
import { SurveyState } from "@formbricks/lib/surveyState";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TResponse, TResponseData, TResponseUpdate } from "@formbricks/types/responses";
|
||||
import { TResponse, TResponseHiddenFieldValue, TResponseUpdate } from "@formbricks/types/responses";
|
||||
import { TUploadFileConfig } from "@formbricks/types/storage";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { ClientLogo } from "@formbricks/ui/ClientLogo";
|
||||
@@ -123,20 +123,17 @@ export const LinkSurvey = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const hiddenFieldsRecord = useMemo<TResponseData | undefined>(() => {
|
||||
const fieldsRecord: TResponseData = {};
|
||||
let fieldsSet = false;
|
||||
const hiddenFieldsRecord = useMemo<TResponseHiddenFieldValue>(() => {
|
||||
const fieldsRecord: TResponseHiddenFieldValue = {};
|
||||
|
||||
survey.hiddenFields?.fieldIds?.forEach((field) => {
|
||||
const answer = searchParams?.get(field);
|
||||
if (answer) {
|
||||
fieldsRecord[field] = answer;
|
||||
fieldsSet = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Only return the record if at least one field was set.
|
||||
return fieldsSet ? fieldsRecord : undefined;
|
||||
return fieldsRecord;
|
||||
}, [searchParams, survey.hiddenFields?.fieldIds]);
|
||||
|
||||
const getVerifiedEmail = useMemo<Record<string, string> | null>(() => {
|
||||
@@ -255,7 +252,6 @@ export const LinkSurvey = ({
|
||||
responseQueue.add({
|
||||
data: {
|
||||
...responseUpdate.data,
|
||||
...hiddenFieldsRecord,
|
||||
...getVerifiedEmail,
|
||||
},
|
||||
ttc: responseUpdate.ttc,
|
||||
@@ -266,6 +262,7 @@ export const LinkSurvey = ({
|
||||
url: window.location.href,
|
||||
source: sourceParam || "",
|
||||
},
|
||||
...(Object.keys(hiddenFieldsRecord).length > 0 && { hiddenFields: hiddenFieldsRecord }),
|
||||
});
|
||||
}}
|
||||
onFileUpload={async (file: File, params: TUploadFileConfig) => {
|
||||
@@ -285,7 +282,6 @@ export const LinkSurvey = ({
|
||||
setQuestionId = f;
|
||||
}}
|
||||
startAtQuestionId={startAt && isStartAtValid ? startAt : undefined}
|
||||
hiddenFieldsRecord={hiddenFieldsRecord}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Made the column `name` on table `User` required. This step will fail if there are existing NULL values in that column.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "name" SET NOT NULL;
|
||||
@@ -569,7 +569,7 @@ model User {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String?
|
||||
name String
|
||||
email String @unique
|
||||
emailVerified DateTime? @map(name: "email_verified")
|
||||
imageUrl String?
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/strings";
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { Badge } from "@formbricks/ui/Badge";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
|
||||
@@ -15,10 +15,10 @@ import { TBaseFilter, TSegment, TSegmentCreateInput, TSegmentUpdateInput } from
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { AlertDialog } from "@formbricks/ui/AlertDialog";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { LoadSegmentModal } from "@formbricks/ui/Targeting/LoadSegmentModal";
|
||||
import { SaveAsNewSegmentModal } from "@formbricks/ui/Targeting/SaveAsNewSegmentModal";
|
||||
import { SegmentTitle } from "@formbricks/ui/Targeting/SegmentTitle";
|
||||
import { TargetingIndicator } from "@formbricks/ui/Targeting/TargetingIndicator";
|
||||
import { LoadSegmentModal } from "@formbricks/ui/LoadSegmentModal";
|
||||
import { SaveAsNewSegmentModal } from "@formbricks/ui/SaveAsNewSegmentModal";
|
||||
import { SegmentTitle } from "@formbricks/ui/SegmentTitle";
|
||||
import { TargetingIndicator } from "@formbricks/ui/TargetingIndicator";
|
||||
|
||||
import {
|
||||
cloneSegmentAction,
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
updatePersonIdentifierInFilter,
|
||||
updateSegmentIdInFilter,
|
||||
} from "@formbricks/lib/segment/utils";
|
||||
import { isCapitalized } from "@formbricks/lib/utils/strings";
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import {
|
||||
@@ -80,8 +81,6 @@ type TSegmentFilterProps = {
|
||||
viewOnly?: boolean;
|
||||
};
|
||||
|
||||
const isCapitalized = (str: string) => str.charAt(0) === str.charAt(0).toUpperCase();
|
||||
|
||||
const SegmentFilterItemConnector = ({
|
||||
connector,
|
||||
segment,
|
||||
|
||||
@@ -11,8 +11,8 @@ import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import { TBaseFilter, TSegment, TSegmentWithSurveyNames, ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { ConfirmDeleteSegmentModal } from "@formbricks/ui/ConfirmDeleteSegmentModal";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { ConfirmDeleteSegmentModal } from "@formbricks/ui/Targeting/ConfirmDeleteSegmentModal";
|
||||
|
||||
import { deleteSegmentAction, updateSegmentAction } from "../lib/actions";
|
||||
import { AddFilterModal } from "./AddFilterModal";
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
},
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [".", "../ui/Targeting/TargetingIndicator.tsx"],
|
||||
"include": [".", "../ui/TargetingIndicator.tsx"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import React from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
|
||||
import { isLight, mixColor } from "@formbricks/lib/utils";
|
||||
import { isLight, mixColor } from "@formbricks/lib/utils/colors";
|
||||
import { TSurvey, TSurveyQuestionType, TSurveyStyling } from "@formbricks/types/surveys";
|
||||
import { RatingSmiley } from "@formbricks/ui/RatingSmiley";
|
||||
|
||||
|
||||
@@ -29,6 +29,17 @@
|
||||
"import": "./dist/website.js",
|
||||
"require": "./dist/website.umd.cjs",
|
||||
"types": "./dist/website.d.ts"
|
||||
},
|
||||
"./*": "./dist/*"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"app": [
|
||||
"./dist/app.d.ts"
|
||||
],
|
||||
"website": [
|
||||
"./dist/website.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TJsAppConfigInput } from "@formbricks/types/js";
|
||||
import { TJsAppConfigInput, TJsTrackProperties } from "@formbricks/types/js";
|
||||
|
||||
import { CommandQueue } from "../shared/commandQueue";
|
||||
import { ErrorHandler } from "../shared/errors";
|
||||
@@ -41,7 +41,7 @@ const reset = async (): Promise<void> => {
|
||||
await queue.wait();
|
||||
};
|
||||
|
||||
const track = async (name: string, properties: any = {}): Promise<void> => {
|
||||
const track = async (name: string, properties?: TJsTrackProperties): Promise<void> => {
|
||||
queue.add<any>(true, "app", trackCodeAction, name, properties);
|
||||
await queue.wait();
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
import { TJsActionInput } from "@formbricks/types/js";
|
||||
import { TJsActionInput, TJsTrackProperties } from "@formbricks/types/js";
|
||||
|
||||
import { InvalidCodeError, NetworkError, Result, err, okVoid } from "../../shared/errors";
|
||||
import { Logger } from "../../shared/logger";
|
||||
@@ -13,7 +13,11 @@ const inAppConfig = AppConfig.getInstance();
|
||||
|
||||
const intentsToNotCreateOnApp = ["Exit Intent (Desktop)", "50% Scroll"];
|
||||
|
||||
export const trackAction = async (name: string, alias?: string): Promise<Result<void, NetworkError>> => {
|
||||
export const trackAction = async (
|
||||
name: string,
|
||||
alias?: string,
|
||||
properties?: TJsTrackProperties
|
||||
): Promise<Result<void, NetworkError>> => {
|
||||
const aliasName = alias || name;
|
||||
const { userId } = inAppConfig.get();
|
||||
|
||||
@@ -71,7 +75,7 @@ export const trackAction = async (name: string, alias?: string): Promise<Result<
|
||||
for (const survey of activeSurveys) {
|
||||
for (const trigger of survey.triggers) {
|
||||
if (trigger.actionClass.name === name) {
|
||||
await triggerSurvey(survey, name);
|
||||
await triggerSurvey(survey, name, properties);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +87,8 @@ export const trackAction = async (name: string, alias?: string): Promise<Result<
|
||||
};
|
||||
|
||||
export const trackCodeAction = (
|
||||
code: string
|
||||
code: string,
|
||||
properties?: TJsTrackProperties
|
||||
): Promise<Result<void, NetworkError>> | Result<void, InvalidCodeError> => {
|
||||
const {
|
||||
state: { actionClasses = [] },
|
||||
@@ -99,7 +104,7 @@ export const trackCodeAction = (
|
||||
});
|
||||
}
|
||||
|
||||
return trackAction(action.name, code);
|
||||
return trackAction(action.name, code, properties);
|
||||
};
|
||||
|
||||
export const trackNoCodeAction = (name: string): Promise<Result<void, NetworkError>> => {
|
||||
|
||||
@@ -2,12 +2,13 @@ import { FormbricksAPI } from "@formbricks/api";
|
||||
import { ResponseQueue } from "@formbricks/lib/responseQueue";
|
||||
import { SurveyState } from "@formbricks/lib/surveyState";
|
||||
import { getStyling } from "@formbricks/lib/utils/styling";
|
||||
import { TResponseUpdate } from "@formbricks/types/responses";
|
||||
import { TJsTrackProperties } from "@formbricks/types/js";
|
||||
import { TResponseHiddenFieldValue, TResponseUpdate } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
import { ErrorHandler } from "../../shared/errors";
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { getDefaultLanguageCode, getLanguageCode } from "../../shared/utils";
|
||||
import { getDefaultLanguageCode, getLanguageCode, handleHiddenFields } from "../../shared/utils";
|
||||
import { AppConfig } from "./config";
|
||||
import { putFormbricksInErrorState } from "./initialize";
|
||||
import { sync } from "./sync";
|
||||
@@ -30,7 +31,11 @@ const shouldDisplayBasedOnPercentage = (displayPercentage: number) => {
|
||||
return randomNum <= displayPercentage;
|
||||
};
|
||||
|
||||
export const triggerSurvey = async (survey: TSurvey, action?: string): Promise<void> => {
|
||||
export const triggerSurvey = async (
|
||||
survey: TSurvey,
|
||||
action?: string,
|
||||
properties?: TJsTrackProperties
|
||||
): Promise<void> => {
|
||||
// Check if the survey should be displayed based on displayPercentage
|
||||
if (survey.displayPercentage) {
|
||||
const shouldDisplaySurvey = shouldDisplayBasedOnPercentage(survey.displayPercentage);
|
||||
@@ -39,10 +44,20 @@ export const triggerSurvey = async (survey: TSurvey, action?: string): Promise<v
|
||||
return; // skip displaying the survey
|
||||
}
|
||||
}
|
||||
await renderWidget(survey, action);
|
||||
|
||||
const hiddenFieldsObject: TResponseHiddenFieldValue = handleHiddenFields(
|
||||
survey.hiddenFields,
|
||||
properties?.hiddenFields
|
||||
);
|
||||
|
||||
await renderWidget(survey, action, hiddenFieldsObject);
|
||||
};
|
||||
|
||||
const renderWidget = async (survey: TSurvey, action?: string) => {
|
||||
const renderWidget = async (
|
||||
survey: TSurvey,
|
||||
action?: string,
|
||||
hiddenFields: TResponseHiddenFieldValue = {}
|
||||
) => {
|
||||
if (isSurveyRunning) {
|
||||
logger.debug("A survey is already running. Skipping.");
|
||||
return;
|
||||
@@ -95,8 +110,8 @@ const renderWidget = async (survey: TSurvey, action?: string) => {
|
||||
|
||||
setTimeout(() => {
|
||||
formbricksSurveys.renderSurveyModal({
|
||||
survey: survey,
|
||||
isBrandingEnabled: isBrandingEnabled,
|
||||
survey,
|
||||
isBrandingEnabled,
|
||||
clickOutside,
|
||||
darkOverlay,
|
||||
languageCode,
|
||||
@@ -144,6 +159,7 @@ const renderWidget = async (survey: TSurvey, action?: string) => {
|
||||
url: window.location.href,
|
||||
action,
|
||||
},
|
||||
hiddenFields,
|
||||
});
|
||||
},
|
||||
onClose: closeSurvey,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { TAttributes } from "@formbricks/types/attributes";
|
||||
import { TJsTrackProperties } from "@formbricks/types/js";
|
||||
import { TResponseHiddenFieldValue } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
import { Logger } from "../shared/logger";
|
||||
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
export const getIsDebug = () => window.location.search.includes("formbricksDebug=true");
|
||||
|
||||
export const getLanguageCode = (survey: TSurvey, attributes: TAttributes): string | undefined => {
|
||||
@@ -34,3 +40,34 @@ export const getDefaultLanguageCode = (survey: TSurvey) => {
|
||||
});
|
||||
if (defaultSurveyLanguage) return defaultSurveyLanguage.language.code;
|
||||
};
|
||||
|
||||
export const handleHiddenFields = (
|
||||
hiddenFieldsConfig: TSurvey["hiddenFields"],
|
||||
hiddenFields: TJsTrackProperties["hiddenFields"]
|
||||
): TResponseHiddenFieldValue => {
|
||||
const { enabled: enabledHiddenFields, fieldIds: hiddenFieldIds } = hiddenFieldsConfig || {};
|
||||
|
||||
let hiddenFieldsObject: TResponseHiddenFieldValue = {};
|
||||
|
||||
if (!enabledHiddenFields) {
|
||||
logger.error("Hidden fields are not enabled for this survey");
|
||||
} else if (hiddenFieldIds && hiddenFields) {
|
||||
const unknownHiddenFields: string[] = [];
|
||||
hiddenFieldsObject = Object.keys(hiddenFields).reduce((acc, key) => {
|
||||
if (hiddenFieldIds?.includes(key)) {
|
||||
acc[key] = hiddenFields?.[key];
|
||||
} else {
|
||||
unknownHiddenFields.push(key);
|
||||
}
|
||||
return acc;
|
||||
}, {} as TResponseHiddenFieldValue);
|
||||
|
||||
if (unknownHiddenFields.length > 0) {
|
||||
logger.error(
|
||||
`Unknown hidden fields: ${unknownHiddenFields.join(", ")}. Please add them to the survey hidden fields.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return hiddenFieldsObject;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TJsWebsiteConfigInput } from "@formbricks/types/js";
|
||||
import { TJsTrackProperties, TJsWebsiteConfigInput } from "@formbricks/types/js";
|
||||
|
||||
// Shared imports
|
||||
import { CommandQueue } from "../shared/commandQueue";
|
||||
@@ -26,7 +26,7 @@ const reset = async (): Promise<void> => {
|
||||
await queue.wait();
|
||||
};
|
||||
|
||||
const track = async (name: string, properties: any = {}): Promise<void> => {
|
||||
const track = async (name: string, properties?: TJsTrackProperties): Promise<void> => {
|
||||
queue.add<any>(true, "website", trackCodeAction, name, properties);
|
||||
await queue.wait();
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { TJsTrackProperties } from "@formbricks/types/js";
|
||||
|
||||
import { InvalidCodeError, NetworkError, Result, err, okVoid } from "../../shared/errors";
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { WebsiteConfig } from "./config";
|
||||
@@ -6,7 +8,11 @@ import { triggerSurvey } from "./widget";
|
||||
const logger = Logger.getInstance();
|
||||
const websiteConfig = WebsiteConfig.getInstance();
|
||||
|
||||
export const trackAction = async (name: string, alias?: string): Promise<Result<void, NetworkError>> => {
|
||||
export const trackAction = async (
|
||||
name: string,
|
||||
alias?: string,
|
||||
properties?: TJsTrackProperties
|
||||
): Promise<Result<void, NetworkError>> => {
|
||||
const aliasName = alias || name;
|
||||
logger.debug(`Formbricks: Action "${aliasName}" tracked`);
|
||||
|
||||
@@ -17,7 +23,7 @@ export const trackAction = async (name: string, alias?: string): Promise<Result<
|
||||
for (const survey of activeSurveys) {
|
||||
for (const trigger of survey.triggers) {
|
||||
if (trigger.actionClass.name === name) {
|
||||
await triggerSurvey(survey, name);
|
||||
await triggerSurvey(survey, name, properties);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +35,8 @@ export const trackAction = async (name: string, alias?: string): Promise<Result<
|
||||
};
|
||||
|
||||
export const trackCodeAction = (
|
||||
code: string
|
||||
code: string,
|
||||
properties?: TJsTrackProperties
|
||||
): Promise<Result<void, NetworkError>> | Result<void, InvalidCodeError> => {
|
||||
const {
|
||||
state: { actionClasses = [] },
|
||||
@@ -45,7 +52,7 @@ export const trackCodeAction = (
|
||||
});
|
||||
}
|
||||
|
||||
return trackAction(action.name, code);
|
||||
return trackAction(action.name, code, properties);
|
||||
};
|
||||
|
||||
export const trackNoCodeAction = (name: string): Promise<Result<void, NetworkError>> => {
|
||||
|
||||
@@ -2,12 +2,12 @@ import { FormbricksAPI } from "@formbricks/api";
|
||||
import { ResponseQueue } from "@formbricks/lib/responseQueue";
|
||||
import { SurveyState } from "@formbricks/lib/surveyState";
|
||||
import { getStyling } from "@formbricks/lib/utils/styling";
|
||||
import { TJSWebsiteStateDisplay } from "@formbricks/types/js";
|
||||
import { TResponseUpdate } from "@formbricks/types/responses";
|
||||
import { TJSWebsiteStateDisplay, TJsTrackProperties } from "@formbricks/types/js";
|
||||
import { TResponseHiddenFieldValue, TResponseUpdate } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { getDefaultLanguageCode, getLanguageCode } from "../../shared/utils";
|
||||
import { getDefaultLanguageCode, getLanguageCode, handleHiddenFields } from "../../shared/utils";
|
||||
import { WebsiteConfig } from "./config";
|
||||
import { filterPublicSurveys } from "./sync";
|
||||
|
||||
@@ -29,7 +29,11 @@ const shouldDisplayBasedOnPercentage = (displayPercentage: number) => {
|
||||
return randomNum <= displayPercentage;
|
||||
};
|
||||
|
||||
export const triggerSurvey = async (survey: TSurvey, action?: string): Promise<void> => {
|
||||
export const triggerSurvey = async (
|
||||
survey: TSurvey,
|
||||
action?: string,
|
||||
properties?: TJsTrackProperties
|
||||
): Promise<void> => {
|
||||
// Check if the survey should be displayed based on displayPercentage
|
||||
if (survey.displayPercentage) {
|
||||
const shouldDisplaySurvey = shouldDisplayBasedOnPercentage(survey.displayPercentage);
|
||||
@@ -38,10 +42,20 @@ export const triggerSurvey = async (survey: TSurvey, action?: string): Promise<v
|
||||
return; // skip displaying the survey
|
||||
}
|
||||
}
|
||||
await renderWidget(survey, action);
|
||||
|
||||
const hiddenFieldsObject: TResponseHiddenFieldValue = handleHiddenFields(
|
||||
survey.hiddenFields,
|
||||
properties?.hiddenFields
|
||||
);
|
||||
|
||||
await renderWidget(survey, action, hiddenFieldsObject);
|
||||
};
|
||||
|
||||
const renderWidget = async (survey: TSurvey, action?: string) => {
|
||||
const renderWidget = async (
|
||||
survey: TSurvey,
|
||||
action?: string,
|
||||
hiddenFields: TResponseHiddenFieldValue = {}
|
||||
) => {
|
||||
if (isSurveyRunning) {
|
||||
logger.debug("A survey is already running. Skipping.");
|
||||
return;
|
||||
@@ -94,8 +108,8 @@ const renderWidget = async (survey: TSurvey, action?: string) => {
|
||||
|
||||
setTimeout(() => {
|
||||
formbricksSurveys.renderSurveyModal({
|
||||
survey: survey,
|
||||
isBrandingEnabled: isBrandingEnabled,
|
||||
survey,
|
||||
isBrandingEnabled,
|
||||
clickOutside,
|
||||
darkOverlay,
|
||||
languageCode,
|
||||
@@ -175,6 +189,7 @@ const renderWidget = async (survey: TSurvey, action?: string) => {
|
||||
url: window.location.href,
|
||||
action,
|
||||
},
|
||||
hiddenFields,
|
||||
});
|
||||
},
|
||||
onClose: closeSurvey,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@formbricks/js",
|
||||
"license": "MIT",
|
||||
"version": "2.1.0-beta.0",
|
||||
"version": "2.0.1",
|
||||
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
|
||||
"homepage": "https://formbricks.com",
|
||||
"repository": {
|
||||
@@ -28,7 +28,8 @@
|
||||
"import": "./dist/website.js",
|
||||
"require": "./dist/website.cjs",
|
||||
"types": "./dist/website.d.ts"
|
||||
}
|
||||
},
|
||||
"./*": "./dist/*"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite build --watch --mode dev",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TFormbricksApp } from "@formbricks/js-core/app";
|
||||
import { TFormbricksWebsite } from "@formbricks/js-core/website";
|
||||
import { TFormbricksApp } from "@formbricks/js-core/dist/app";
|
||||
import { TFormbricksWebsite } from "@formbricks/js-core/dist/website";
|
||||
|
||||
import { loadFormbricksToProxy } from "./shared/loadFormbricks";
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"include": ["src", "package.json"],
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"module": "ESNext",
|
||||
"declaration": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
|
||||
@@ -28,9 +28,9 @@ import {
|
||||
|
||||
import { getLocalizedValue } from "../i18n/utils";
|
||||
import { processResponseData } from "../responses";
|
||||
import { sanitizeString } from "../strings";
|
||||
import { getTodaysDateTimeFormatted } from "../time";
|
||||
import { evaluateCondition } from "../utils/evaluateLogic";
|
||||
import { sanitizeString } from "../utils/strings";
|
||||
|
||||
export const calculateTtcTotal = (ttc: TResponseTtc) => {
|
||||
const result = { ...ttc };
|
||||
|
||||
@@ -2,7 +2,7 @@ import { FormbricksAPI } from "@formbricks/api";
|
||||
import { TResponseUpdate } from "@formbricks/types/responses";
|
||||
|
||||
import { SurveyState } from "./surveyState";
|
||||
import { delay } from "./utils";
|
||||
import { delay } from "./utils/promises";
|
||||
|
||||
interface QueueConfig {
|
||||
apiHost: string;
|
||||
@@ -87,6 +87,7 @@ export class ResponseQueue {
|
||||
surveyId: this.surveyState.surveyId,
|
||||
userId: this.surveyState.userId || null,
|
||||
singleUseId: this.surveyState.singleUseId || null,
|
||||
data: { ...responseUpdate.data, ...responseUpdate.hiddenFields },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Could not create response");
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
export const delay = (ms: number) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
|
||||
export const hexToRGBA = (hex: string | undefined, opacity: number): string | undefined => {
|
||||
// return undefined if hex is undefined, this is important for adding the default values to the CSS variables
|
||||
// TODO: find a better way to handle this
|
||||
3
packages/lib/utils/promises.ts
Normal file
3
packages/lib/utils/promises.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const delay = (ms: number) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
@@ -18,3 +18,5 @@ export const truncate = (str: string, length: number) => {
|
||||
export const sanitizeString = (str: string, delimiter: string = "_", length: number = 255) => {
|
||||
return str.replace(/[^0-9a-zA-Z\-._]+/g, delimiter).substring(0, length);
|
||||
};
|
||||
|
||||
export const isCapitalized = (str: string) => str.charAt(0) === str.charAt(0).toUpperCase();
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMemo, useState } from "preact/hooks";
|
||||
// @ts-expect-error
|
||||
import { JSXInternal } from "preact/src/jsx";
|
||||
|
||||
import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils";
|
||||
@@ -101,6 +100,7 @@ export const FileInput = ({
|
||||
const handleDragOver = (e: JSXInternal.TargetedDragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// @ts-expect-error
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
|
||||
@@ -108,6 +108,7 @@ export const FileInput = ({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// @ts-expect-error
|
||||
handleFileSelection(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ export const Survey = ({
|
||||
onFileUpload,
|
||||
responseCount,
|
||||
startAtQuestionId,
|
||||
hiddenFieldsRecord,
|
||||
clickOutside,
|
||||
shouldResetQuestionId,
|
||||
}: SurveyBaseProps) => {
|
||||
@@ -58,7 +57,7 @@ export const Survey = ({
|
||||
|
||||
const [loadingElement, setLoadingElement] = useState(false);
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [responseData, setResponseData] = useState<TResponseData>(hiddenFieldsRecord ?? {});
|
||||
const [responseData, setResponseData] = useState<TResponseData>({});
|
||||
const [ttc, setTtc] = useState<TResponseTtc>({});
|
||||
const cardArrangement = useMemo(() => {
|
||||
if (survey.type === "link") {
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import preflight from "@/styles/preflight.css?inline";
|
||||
import calendarCss from "react-calendar/dist/Calendar.css?inline";
|
||||
import datePickerCss from "react-date-picker/dist/DatePicker.css?inline";
|
||||
|
||||
import { isLight, mixColor } from "@formbricks/lib/utils";
|
||||
import { isLight, mixColor } from "@formbricks/lib/utils/colors";
|
||||
import { TProductStyling } from "@formbricks/types/product";
|
||||
import { TSurveyStyling } from "@formbricks/types/surveys";
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"module": "esnext",
|
||||
"target": "ES2021",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["es2022", "dom", "dom.iterable"],
|
||||
"module": "ESNext",
|
||||
"target": "ES2022",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -24,7 +24,6 @@ export interface SurveyBaseProps {
|
||||
responseCount?: number;
|
||||
isCardBorderVisible?: boolean;
|
||||
startAtQuestionId?: string;
|
||||
hiddenFieldsRecord?: TResponseData;
|
||||
clickOutside?: boolean;
|
||||
shouldResetQuestionId?: boolean;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ZActionClass } from "./actionClasses";
|
||||
import { ZAttributes } from "./attributes";
|
||||
import { ZPerson } from "./people";
|
||||
import { ZProduct } from "./product";
|
||||
import { ZResponseHiddenFieldValue } from "./responses";
|
||||
import { ZSurvey } from "./surveys";
|
||||
|
||||
export const ZJsPerson = z.object({
|
||||
@@ -252,3 +253,9 @@ export type TSettings = z.infer<typeof ZJsSettings>;
|
||||
export const ZJsPackageType = z.union([z.literal("app"), z.literal("website")]);
|
||||
|
||||
export type TJsPackageType = z.infer<typeof ZJsPackageType>;
|
||||
|
||||
export const ZJsTrackProperties = z.object({
|
||||
hiddenFields: ZResponseHiddenFieldValue.optional(),
|
||||
});
|
||||
|
||||
export type TJsTrackProperties = z.infer<typeof ZJsTrackProperties>;
|
||||
|
||||
@@ -27,7 +27,10 @@ export const ZOrganization = z.object({
|
||||
id: z.string().cuid2(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
name: z.string(),
|
||||
name: z
|
||||
.string({ message: "Name is required" })
|
||||
.trim()
|
||||
.min(1, { message: "Name must be at least 1 character long" }),
|
||||
billing: ZOrganizationBilling,
|
||||
});
|
||||
|
||||
|
||||
@@ -292,6 +292,9 @@ export const ZResponseWithSurvey = ZResponse.extend({
|
||||
|
||||
export type TResponseWithSurvey = z.infer<typeof ZResponseWithSurvey>;
|
||||
|
||||
export const ZResponseHiddenFieldValue = z.record(z.union([z.string(), z.number(), z.array(z.string())]));
|
||||
export type TResponseHiddenFieldValue = z.infer<typeof ZResponseHiddenFieldValue>;
|
||||
|
||||
export const ZResponseUpdate = z.object({
|
||||
finished: z.boolean(),
|
||||
data: ZResponseData,
|
||||
@@ -304,6 +307,7 @@ export const ZResponseUpdate = z.object({
|
||||
action: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
hiddenFields: ZResponseHiddenFieldValue.optional(),
|
||||
});
|
||||
|
||||
export type TResponseUpdate = z.infer<typeof ZResponseUpdate>;
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,10 @@ export type TUserNotificationSettings = z.infer<typeof ZUserNotificationSettings
|
||||
|
||||
export const ZUser = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullable(),
|
||||
name: z
|
||||
.string({ message: "Name is required" })
|
||||
.trim()
|
||||
.min(1, { message: "Name should be at least 1 character long" }),
|
||||
email: z.string().email(),
|
||||
emailVerified: z.date().nullable(),
|
||||
imageUrl: z.string().url().nullable(),
|
||||
@@ -40,7 +43,7 @@ export const ZUser = z.object({
|
||||
export type TUser = z.infer<typeof ZUser>;
|
||||
|
||||
export const ZUserUpdateInput = z.object({
|
||||
name: z.string().nullish(),
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
emailVerified: z.date().nullish(),
|
||||
onboardingCompleted: z.boolean().optional(),
|
||||
@@ -53,7 +56,10 @@ export const ZUserUpdateInput = z.object({
|
||||
export type TUserUpdateInput = z.infer<typeof ZUserUpdateInput>;
|
||||
|
||||
export const ZUserCreateInput = z.object({
|
||||
name: z.string().optional(),
|
||||
name: z
|
||||
.string({ message: "Name is required" })
|
||||
.trim()
|
||||
.min(1, { message: "Name should be at least 1 character long" }),
|
||||
email: z.string().email(),
|
||||
emailVerified: z.date().optional(),
|
||||
onboardingCompleted: z.boolean().optional(),
|
||||
|
||||
@@ -1,81 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { FingerprintIcon, TagIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import { TBaseFilter, TSegmentAttributeFilter, TSegmentPersonFilter } from "@formbricks/types/segment";
|
||||
import { TBaseFilter } from "@formbricks/types/segment";
|
||||
|
||||
import { Input } from "../Input";
|
||||
import { Modal } from "../Modal";
|
||||
import { handleAddFilter } from "./lib/utils";
|
||||
|
||||
const handleAddFilter = ({
|
||||
type,
|
||||
attributeClassName,
|
||||
isUserId = false,
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
}: {
|
||||
type: "person" | "attribute";
|
||||
attributeClassName?: string;
|
||||
isUserId?: boolean;
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
}) => {
|
||||
if (type === "person") {
|
||||
const newResource: TSegmentPersonFilter = {
|
||||
id: createId(),
|
||||
root: { type: "person", personIdentifier: "userId" },
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "",
|
||||
};
|
||||
|
||||
const newFilter: TBaseFilter = {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
resource: newResource,
|
||||
};
|
||||
|
||||
onAddFilter(newFilter);
|
||||
setOpen(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!attributeClassName) return;
|
||||
|
||||
const newFilterResource: TSegmentAttributeFilter = {
|
||||
id: createId(),
|
||||
root: {
|
||||
type: "attribute",
|
||||
attributeClassName,
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "",
|
||||
...(isUserId && { meta: { isUserId } }),
|
||||
};
|
||||
|
||||
const newFilter: TBaseFilter = {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
resource: newFilterResource,
|
||||
};
|
||||
|
||||
onAddFilter(newFilter);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
type TBasicAddFilterModalProps = {
|
||||
interface TBasicAddFilterModalProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
attributeClasses: TAttributeClass[];
|
||||
};
|
||||
}
|
||||
|
||||
export const BasicAddFilterModal = ({
|
||||
onAddFilter,
|
||||
63
packages/ui/BasicAddFilterModal/lib/utils.ts
Normal file
63
packages/ui/BasicAddFilterModal/lib/utils.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
|
||||
import { TBaseFilter, TSegmentAttributeFilter, TSegmentPersonFilter } from "@formbricks/types/segment";
|
||||
|
||||
export const handleAddFilter = ({
|
||||
type,
|
||||
attributeClassName,
|
||||
isUserId = false,
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
}: {
|
||||
type: "person" | "attribute";
|
||||
attributeClassName?: string;
|
||||
isUserId?: boolean;
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
}) => {
|
||||
if (type === "person") {
|
||||
const newResource: TSegmentPersonFilter = {
|
||||
id: createId(),
|
||||
root: { type: "person", personIdentifier: "userId" },
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "",
|
||||
};
|
||||
|
||||
const newFilter: TBaseFilter = {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
resource: newResource,
|
||||
};
|
||||
|
||||
onAddFilter(newFilter);
|
||||
setOpen(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!attributeClassName) return;
|
||||
|
||||
const newFilterResource: TSegmentAttributeFilter = {
|
||||
id: createId(),
|
||||
root: {
|
||||
type: "attribute",
|
||||
attributeClassName,
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "",
|
||||
...(isUserId && { meta: { isUserId } }),
|
||||
};
|
||||
|
||||
const newFilter: TBaseFilter = {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
resource: newFilterResource,
|
||||
};
|
||||
|
||||
onAddFilter(newFilter);
|
||||
setOpen(false);
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import { TagIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import {
|
||||
convertOperatorToText,
|
||||
convertOperatorToTitle,
|
||||
updateAttributeClassNameInFilter,
|
||||
updateOperatorInFilter,
|
||||
} from "@formbricks/lib/segment/utils";
|
||||
import { isCapitalized } from "@formbricks/lib/utils/strings";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import {
|
||||
ARITHMETIC_OPERATORS,
|
||||
ATTRIBUTE_OPERATORS,
|
||||
TArithmeticOperator,
|
||||
TAttributeOperator,
|
||||
TSegment,
|
||||
TSegmentAttributeFilter,
|
||||
TSegmentConnector,
|
||||
TSegmentFilterValue,
|
||||
} from "@formbricks/types/segment";
|
||||
|
||||
import { Input } from "../../Input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../Select";
|
||||
import { SegmentFilterItemConnector } from "./SegmentFilterItemConnector";
|
||||
import { SegmentFilterItemContextMenu } from "./SegmentFilterItemContextMenu";
|
||||
|
||||
interface AttributeSegmentFilterProps {
|
||||
connector: TSegmentConnector;
|
||||
environmentId: string;
|
||||
segment: TSegment;
|
||||
attributeClasses: TAttributeClass[];
|
||||
setSegment: (segment: TSegment) => void;
|
||||
onDeleteFilter: (filterId: string) => void;
|
||||
onMoveFilter: (filterId: string, direction: "up" | "down") => void;
|
||||
viewOnly?: boolean;
|
||||
resource: TSegmentAttributeFilter;
|
||||
updateValueInLocalSurvey: (filterId: string, newValue: TSegmentFilterValue) => void;
|
||||
}
|
||||
|
||||
export const AttributeSegmentFilter = ({
|
||||
connector,
|
||||
resource,
|
||||
onDeleteFilter,
|
||||
onMoveFilter,
|
||||
updateValueInLocalSurvey,
|
||||
segment,
|
||||
setSegment,
|
||||
attributeClasses,
|
||||
viewOnly,
|
||||
}: AttributeSegmentFilterProps) => {
|
||||
const { attributeClassName } = resource.root;
|
||||
const operatorText = convertOperatorToText(resource.qualifier.operator);
|
||||
|
||||
const [valueError, setValueError] = useState("");
|
||||
|
||||
// when the operator changes, we need to check if the value is valid
|
||||
useEffect(() => {
|
||||
const { operator } = resource.qualifier;
|
||||
|
||||
if (ARITHMETIC_OPERATORS.includes(operator as TArithmeticOperator)) {
|
||||
const isNumber = z.coerce.number().safeParse(resource.value);
|
||||
|
||||
if (isNumber.success) {
|
||||
setValueError("");
|
||||
} else {
|
||||
setValueError("Value must be a number");
|
||||
}
|
||||
}
|
||||
}, [resource.qualifier, resource.value]);
|
||||
|
||||
const operatorArr = ATTRIBUTE_OPERATORS.map((operator) => {
|
||||
return {
|
||||
id: operator,
|
||||
name: convertOperatorToText(operator),
|
||||
};
|
||||
});
|
||||
|
||||
const attributeClass = attributeClasses?.find((attrClass) => attrClass?.name === attributeClassName)?.name;
|
||||
|
||||
const updateOperatorInLocalSurvey = (filterId: string, newOperator: TAttributeOperator) => {
|
||||
const updatedSegment = structuredClone(segment);
|
||||
if (updatedSegment.filters) {
|
||||
updateOperatorInFilter(updatedSegment.filters, filterId, newOperator);
|
||||
}
|
||||
|
||||
setSegment(updatedSegment);
|
||||
};
|
||||
|
||||
const updateAttributeClassNameInLocalSurvey = (filterId: string, newAttributeClassName: string) => {
|
||||
const updatedSegment = structuredClone(segment);
|
||||
if (updatedSegment.filters) {
|
||||
updateAttributeClassNameInFilter(updatedSegment.filters, filterId, newAttributeClassName);
|
||||
}
|
||||
|
||||
setSegment(updatedSegment);
|
||||
};
|
||||
|
||||
const checkValueAndUpdate = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = e.target;
|
||||
updateValueInLocalSurvey(resource.id, value);
|
||||
|
||||
if (!value) {
|
||||
setValueError("Value cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
const { operator } = resource.qualifier;
|
||||
|
||||
if (ARITHMETIC_OPERATORS.includes(operator as TArithmeticOperator)) {
|
||||
const isNumber = z.coerce.number().safeParse(value);
|
||||
|
||||
if (isNumber.success) {
|
||||
setValueError("");
|
||||
updateValueInLocalSurvey(resource.id, parseInt(value, 10));
|
||||
} else {
|
||||
setValueError("Value must be a number");
|
||||
updateValueInLocalSurvey(resource.id, value);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setValueError("");
|
||||
updateValueInLocalSurvey(resource.id, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<SegmentFilterItemConnector
|
||||
key={connector}
|
||||
connector={connector}
|
||||
filterId={resource.id}
|
||||
setSegment={setSegment}
|
||||
segment={segment}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={attributeClass}
|
||||
onValueChange={(value) => {
|
||||
updateAttributeClassNameInLocalSurvey(resource.id, value);
|
||||
}}
|
||||
disabled={viewOnly}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
|
||||
hideArrow>
|
||||
<SelectValue>
|
||||
<div
|
||||
className={cn("flex items-center gap-1", !isCapitalized(attributeClass ?? "") && "lowercase")}>
|
||||
<TagIcon className="h-4 w-4 text-sm" />
|
||||
<p>{attributeClass}</p>
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{attributeClasses
|
||||
?.filter((attributeClass) => !attributeClass.archived)
|
||||
?.map((attrClass) => (
|
||||
<SelectItem value={attrClass.name} key={attrClass.id}>
|
||||
{attrClass.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={operatorText}
|
||||
onValueChange={(operator: TAttributeOperator) => {
|
||||
updateOperatorInLocalSurvey(resource.id, operator);
|
||||
}}
|
||||
disabled={viewOnly}>
|
||||
<SelectTrigger className="flex w-auto items-center justify-center bg-white text-center" hideArrow>
|
||||
<SelectValue>
|
||||
<p>{operatorText}</p>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{operatorArr.map((operator) => (
|
||||
<SelectItem value={operator.id} title={convertOperatorToTitle(operator.id)}>
|
||||
{operator.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{!["isSet", "isNotSet"].includes(resource.qualifier.operator) && (
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<Input
|
||||
disabled={viewOnly}
|
||||
value={resource.value}
|
||||
onChange={(e) => {
|
||||
checkValueAndUpdate(e);
|
||||
}}
|
||||
className={cn("w-auto bg-white", valueError && "border border-red-500 focus:border-red-500")}
|
||||
/>
|
||||
|
||||
{valueError && (
|
||||
<p className="absolute right-2 -mt-1 rounded-md bg-white px-2 text-xs text-red-500">
|
||||
{valueError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SegmentFilterItemContextMenu
|
||||
filterId={resource.id}
|
||||
onDeleteFilter={onDeleteFilter}
|
||||
onMoveFilter={onMoveFilter}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user