Compare commits

..

19 Commits

Author SHA1 Message Date
pandeymangg
449b56fd40 build fix 2024-06-05 18:44:24 +05:30
pandeymangg
2b1ffa90f9 build fix 2024-06-05 18:20:34 +05:30
pandeymangg
d595794fb6 refactors 2024-06-05 14:08:08 +05:30
pandeymangg
2d3dec7834 fix: somethings 2024-06-05 10:07:33 +05:30
Piyush Gupta
bbfdba7615 feat: Add hiddenFields to app & website surveys (#2628)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2024-06-04 11:19:47 +00:00
Anshuman Pandey
681c559c79 fix: targeting ui dir structure (#2708)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2024-06-04 04:56:38 +00:00
Anshuman Pandey
4e39f45446 fix: settings forms (#2700)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-06-03 15:26:30 +00:00
Hicham El Bouaaichi
62c514acf2 feat: rework loading in Settings pages (#2650)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2024-06-03 15:10:46 +00:00
Dhruwang Jariwala
48638e8ca2 fix: added support for date, matrix, address and cal question to notion integration (#2726) 2024-06-03 15:06:50 +00:00
Dhruwang Jariwala
cb44b575c2 fix: Question card fixes (#2714) 2024-06-03 14:36:57 +00:00
Matti Nannt
1565fd33f7 docs: add tutorial for custom SSL certificates (#2724) 2024-06-03 12:02:48 +02:00
Anshuman Pandey
2bf04e9818 feat: change question type (#2646)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-06-03 08:53:12 +00:00
Piyush Gupta
a5f6ecb992 docs: adds documentation table for team roles (#2709)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-05-31 08:09:19 +00:00
Anshuman Pandey
a211e64f0e fix: product styling form (#2696) 2024-05-30 12:00:14 +00:00
Piyush Gupta
9d33aa034a feat: Filter Responses by hidden field values (#2662) 2024-05-30 11:00:09 +00:00
Dhruwang Jariwala
a91c9db4e0 chore: optimized survey card animation (#2707) 2024-05-30 08:43:34 +00:00
Dhruwang Jariwala
291f628415 feat: Added recall highlighting to summary header (#2672)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-05-29 12:51:42 +00:00
Piyush Gupta
d53ceaaaac docs: adds skipPrefilled docs (#2705) 2024-05-29 12:47:15 +00:00
Piyush Gupta
aa981fd891 chore: Increase maxDuration for cron functions to 180 seconds (#2706) 2024-05-29 12:44:07 +00:00
172 changed files with 3658 additions and 2687 deletions

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -1,41 +1,89 @@
import { MdxImage } from "@/components/MdxImage";
import AddMember from "./images/add-member.webp";
import BulkInvite from "./images/bulk-invite.webp";
import IndvInvite from "./images/individual-invite.webp";
import MenuItem from "./images/team-settings-menu.webp";
import MenuItem from "./images/organization-settings-menu.webp";
export const metadata = {
title: "Team Access Roles",
title: "Organization Access Roles",
description:
"Assign different roles to team members to grant them specific rights like creating surveys, viewing responses, or managing team members.",
"Assign different roles to organization members to grant them specific rights like creating surveys, viewing responses, or managing organization members.",
};
# Team Access Roles
# Organization Access Roles
Assign different roles to team members to grant them specific rights like creating surveys, viewing responses, or managing team members.
Assign different roles to organization members to grant them specific rights like creating surveys, viewing responses, or managing organization members.
<Note>Access Roles is a feature of the **Enterprise Edition**. In the **Community Edition** and on the **Free** and **Startup** plan in the Cloud you can invite unlimited team members as `Admins`.</Note>
<Note>
Access Roles is a feature of the **Enterprise Edition**. In the **Community Edition** and on the **Free**
and **Startup** plan in the Cloud you can invite unlimited organization members as `Admins`.
</Note>
Here are the different access permissions, ranked from highest to lowest access
| Role | Rights |
| --- | --- |
| Owner | Full rights; there can only one owner per team. Ownership can be transferred. |
| Admin | Full access rights incl. managing team members |
| Developer | Full product access to setup and run surveys incl. global styling, actions and attribute management, etc |
| Editor | Create and edit surveys. No access to features related to setting up or maintaining Formbricks. |
| Viewer | View survey results only. |
1. Owner
2. Admin
3. Developer
4. Editor
5. Viewer
## Inviting team members
For more information on user roles & permissions, see below:
There are two ways to invite team members: One by one or in bulk.
| | Owner | Admin | Editor | Developer | Viewer |
| -------------------------------- | ----- | ----- | ------ | --------- | ------ |
| **Organization** | | | | | |
| Update organization | ✅ | ✅ | ❌ | ❌ | ❌ |
| Delete organization | ✅ | ❌ | ❌ | ❌ | ❌ |
| Add new Member | ✅ | ✅ | ❌ | ❌ | ❌ |
| Delete Member | ✅ | ✅ | ❌ | ❌ | ❌ |
| Update Member Access | ✅ | ✅ | ❌ | ❌ | ❌ |
| Update Billing | ✅ | ✅ | ❌ | ❌ | ❌ |
| **Product** | | | | | |
| Create Product | ✅ | ✅ | ✅ | ✅ | ❌ |
| Update Product Name | ✅ | ✅ | ✅ | ❌ | ❌ |
| Update Product Name | ✅ | ✅ | ✅ | ❌ | ❌ |
| Update Product Recontact Options | ✅ | ✅ | ✅ | ✅ | ❌ |
| Update Look & Feel | ✅ | ✅ | ✅ | ✅ | ❌ |
| Update Survey Languages | ✅ | ✅ | ✅ | ✅ | ❌ |
| Delete Product | ✅ | ✅ | ✅ | ✅ | ❌ |
| **Surveys** | | | | | |
| Create New Survey | ✅ | ✅ | ✅ | ✅ | ❌ |
| Edit Survey | ✅ | ✅ | ✅ | ✅ | ❌ |
| Delete Survey | ✅ | ✅ | ✅ | ✅ | ❌ |
| View survey results | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Response** | | | | | |
| Delete response | ✅ | ✅ | ✅ | ✅ | ❌ |
| Add tags on response | ✅ | ✅ | ✅ | ✅ | ❌ |
| Edit tags on response | ✅ | ✅ | ✅ | ✅ | ❌ |
| **Actions** | | | | | |
| Create Action | ✅ | ✅ | ✅ | ✅ | ❌ |
| Update Action | ✅ | ✅ | ✅ | ✅ | ❌ |
| Delete Action | ✅ | ✅ | ✅ | ✅ | ❌ |
| **API Keys** | | | | | |
| Create API key | ✅ | ✅ | ✅ | ✅ | ❌ |
| Update API key | ✅ | ✅ | ✅ | ✅ | ❌ |
| Delete API key | ✅ | ✅ | ✅ | ✅ | ❌ |
| **Tags** | | | | | |
| Create tags | ✅ | ✅ | ✅ | ✅ | ❌ |
| Update tags | ✅ | ✅ | ✅ | ✅ | ❌ |
| Delete tags | ✅ | ✅ | ✅ | ✅ | ❌ |
| **People** | | | | | |
| Delete Person | ✅ | ✅ | ✅ | ✅ | ❌ |
| **Integrations** | | | | | |
| Manage Integrations | ✅ | ✅ | ✅ | ✅ | ❌ |
### Invite team members one by one
## Inviting organization members
1. Go to the `Team Settings` page via the menu in the lower right corner:
There are two ways to invite organization members: One by one or in bulk.
<MdxImage
src={MenuItem}
alt="Where to find the Menu Item for Team Settings"
### Invite organization members one by one
1. Go to the `Organization Settings` page via the menu in the lower right corner:
<MdxImage
src={MenuItem}
alt="Where to find the Menu Item for Organization Settings"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl "
/>
@@ -49,7 +97,7 @@ src={MenuItem}
className="max-w-full rounded-lg sm:max-w-3xl "
/>
3. In the modal, add the Name, Email and Role of the team member you want to invite:
3. In the modal, add the Name, Email and Role of the organization member you want to invite:
<MdxImage
src={IndvInvite}
@@ -58,19 +106,20 @@ src={MenuItem}
className="max-w-full rounded-lg sm:max-w-3xl "
/>
<Note>Access Roles is a feature of the **Enterprise Edition**. In the **Community Edition** and on the **Free** and **Startup** plan in the Cloud you can invite unlimited team members as `Admins`.</Note>
<Note>
Access Roles is a feature of the **Enterprise Edition**. In the **Community Edition** and on the **Free**
and **Startup** plan in the Cloud you can invite unlimited organization members as `Admins`.
</Note>
Formbricks sends an email to the organization member with an invitation link. The organization member can accept the invitation or create a new account by clicking on the link.
Formbricks sends an email to the team member with an invitation link. The team member can accept the invitation or create a new account by clicking on the link.
### Invite organization members in bulk
### Invite team members in bulk
1. Go to the `Team Settings` page via the menu in the lower right corner:
1. Go to the `Organization Settings` page via the menu in the lower right corner:
<MdxImage
src={MenuItem}
alt="Where to find the Menu Item for Team Settings"
alt="Where to find the Menu Item for Organization Settings"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl "
/>
@@ -84,7 +133,7 @@ Formbricks sends an email to the team member with an invitation link. The team m
className="max-w-full rounded-lg sm:max-w-3xl "
/>
3. In the modal, switch to `Bulk Invite`. You can download an example .CSV file to fill in the Name, Email and Role of the team members you want to invite:
3. In the modal, switch to `Bulk Invite`. You can download an example .CSV file to fill in the Name, Email and Role of the organization members you want to invite:
<MdxImage
src={BulkInvite}
@@ -93,8 +142,8 @@ Formbricks sends an email to the team member with an invitation link. The team m
className="max-w-full rounded-lg sm:max-w-3xl "
/>
4. Upload the filled .CSV file and invite the team members in bulk ✅
4. Upload the filled .CSV file and invite the organization members in bulk ✅
Formbricks sends an email to each team member in the CSV. The member can accept the invitation or create a new account by clicking on the link.
Formbricks sends an email to each organization member in the CSV. The member can accept the invitation or create a new account by clicking on the link.
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View 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.

View File

@@ -4,27 +4,22 @@ export const metadata = {
"Open source software beats proprietary software in every aspect - except for value capture. We're investing in growing the value creation of our open source platform because it directly translates into business with large organisations.",
};
#### Introduction
#### Introduction
# Why is Formbricks open source?
A lot has been written on why open source software beats proprietary software in all aspects - except for value capture for the company investing into its development. While this definitely poses a challenge for a profit-oriented organisation, it's also an interesting opportunity: Due to the open nature of our platform, it's usage is significantly higher. Capturing a small part of the value our platform generates translates into a decently-sized business.
| Advantage | Open Source Software | Proprietary Software |
|----------------------|----------------------------------------------------|----------------------------------------------------------|
| **Data Privacy** | Self-host for maximum control over data | Dependent on thrid party data processor. |
| **Cost** | Often free or significantly lower cost. | Typically requires a purchase or subscription. |
| **Customizability** | Code can be modified to meet specific needs. | Limited customization, restricted to developer's features.|
| **Security**| Frequent community reviews identify vulnerabilities quickly. | Security updates depend on vendor's schedule and interest.|
| **Flexibility**| Supports a wide range of applications and integrations. | Designed for specific environments and integrations. |
| **Community Support**| Large, active communities offer free support and resources. | Paid customer support with limited community help. |
| **Innovation** | Fosters rapid innovation through community contributions. | Innovations depend on vendor's vision and development team.|
| **Licensing** | Permissive licenses allow broad usage and modification. | Strict licensing with limited redistribution rights. |
| **Independence** | Not dependent on a single vendor or developer. | Vendor lock-in can limit future choices. |
| **Transparency** | Full visibility into the code base and development. | Closed-source, code is hidden from users. |
| **Interoperability**| Supports open standards, ensuring interoperability. | Often requires additional software or plugins for compatibility. |
| Advantage | Open Source Software | Proprietary Software |
| --------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------- |
| **Data Privacy** | Self-host for maximum control over data | Dependent on thrid party data processor. |
| **Cost** | Often free or significantly lower cost. | Typically requires a purchase or subscription. |
| **Customizability** | Code can be modified to meet specific needs. | Limited customization, restricted to developer's features. |
| **Security** | Frequent community reviews identify vulnerabilities quickly. | Security updates depend on vendor's schedule and interest. |
| **Flexibility** | Supports a wide range of applications and integrations. | Designed for specific environments and integrations. |
| **Community Support** | Large, active communities offer free support and resources. | Paid customer support with limited community help. |
| **Innovation** | Fosters rapid innovation through community contributions. | Innovations depend on vendor's vision and development team. |
| **Licensing** | Permissive licenses allow broad usage and modification. | Strict licensing with limited redistribution rights. |
| **Independence** | Not dependent on a single vendor or developer. | Vendor lock-in can limit future choices. |
| **Transparency** | Full visibility into the code base and development. | Closed-source, code is hidden from users. |
| **Interoperability** | Supports open standards, ensuring interoperability. | Often requires additional software or plugins for compatibility. |

View File

@@ -40,6 +40,10 @@ To prefill the questions of a survey, add query parameters to the survey URL. Th
<Note>Please make sure the answer is [URL encoded](https://www.urlencoder.org/).</Note>
## Prefilling Customisation
You can customize the prefilling behavior using the `skipPrefilled` parameter in the URL. If you want to skip the prefilled questions and show the next available question, you can add `skipPrefilled=true` to the URL. By default, the `skipPrefilled` parameter is set to `false`.
## Prefilling multiple values
Formbricks let's you prefill as many values as you want. You can combine multiple values in the URL using `&` so for example `name=Bernadette&age=18`. The order of the query parameters does not matter so you can always move around questions or add new ones without having to worry about the order of the query parameters.

View File

@@ -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

View 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.

View File

@@ -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" },

View File

@@ -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}

View File

@@ -2,11 +2,13 @@
import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon } from "lucide-react";
import { UseFormReturn } from "react-hook-form";
import { cn } from "@formbricks/lib/cn";
import { TProductStyling } from "@formbricks/types/product";
import { TSurveyBackgroundBgType, TSurveyStyling } from "@formbricks/types/surveys";
import { TSurveyStyling } from "@formbricks/types/surveys";
import { Badge } from "@formbricks/ui/Badge";
import { FormControl, FormDescription, FormField, FormItem, FormLabel } from "@formbricks/ui/Form";
import { Slider } from "@formbricks/ui/Slider";
import { SurveyBgSelectorTab } from "./SurveyBgSelectorTab";
@@ -14,52 +16,24 @@ import { SurveyBgSelectorTab } from "./SurveyBgSelectorTab";
interface BackgroundStylingCardProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
styling: TSurveyStyling | TProductStyling | null;
setStyling: React.Dispatch<React.SetStateAction<TSurveyStyling | TProductStyling>>;
colors: string[];
isSettingsPage?: boolean;
disabled?: boolean;
environmentId: string;
isUnsplashConfigured: boolean;
form: UseFormReturn<TProductStyling | TSurveyStyling>;
}
export const BackgroundStylingCard = ({
open,
setOpen,
styling,
setStyling,
colors,
isSettingsPage = false,
disabled,
environmentId,
isUnsplashConfigured,
form,
}: BackgroundStylingCardProps) => {
const { bgType, brightness } = styling?.background ?? {};
const handleBgChange = (color: string, type: TSurveyBackgroundBgType) => {
const { background } = styling ?? {};
setStyling({
...styling,
background: {
...background,
bg: color,
bgType: type,
brightness: 100,
},
});
};
const handleBrightnessChange = (percent: number) => {
setStyling((prev) => ({
...prev,
background: {
...prev.background,
brightness: percent,
},
}));
};
return (
<Collapsible.Root
open={open}
@@ -101,48 +75,66 @@ export const BackgroundStylingCard = ({
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent>
<hr className="py-1 text-slate-600" />
<div className="flex flex-col gap-3 p-3">
{/* Background */}
<div className="p-3">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Change background</h3>
<p className="text-xs font-normal text-slate-500">
Pick a background from our library or upload your own.
</p>
</div>
<SurveyBgSelectorTab
styling={styling}
handleBgChange={handleBgChange}
colors={colors}
bgType={bgType}
environmentId={environmentId}
isUnsplashConfigured={isUnsplashConfigured}
/>
</div>
{/* Overlay */}
<div className="flex flex-col gap-4 p-3">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Background overlay</h3>
<p className="text-xs font-normal text-slate-500">
Darken or lighten background of your choice.
</p>
</div>
<div>
<div className="ml-2 flex flex-col justify-center">
<div className="flex flex-col gap-4">
<div className="flex flex-col justify-center rounded-lg border bg-slate-50 p-6">
<Slider
value={[brightness ?? 100]}
max={200}
onValueChange={(value) => {
handleBrightnessChange(value[0]);
}}
/>
</div>
<hr className="pt-1 text-slate-600" />
<div className="flex flex-col gap-6 p-6 pt-2">
<FormField
control={form.control}
name="background"
render={({ field }) => (
<FormItem>
<div>
<FormLabel>Change background</FormLabel>
<FormDescription>Pick a background from our library or upload your own.</FormDescription>
</div>
<FormControl>
<SurveyBgSelectorTab
bg={field.value?.bg ?? ""}
handleBgChange={(bg: string, bgType: string) => {
field.onChange({
...field.value,
bg,
bgType,
brightness: 100,
});
}}
colors={colors}
bgType={field.value?.bgType ?? "color"}
environmentId={environmentId}
isUnsplashConfigured={isUnsplashConfigured}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex flex-col justify-center">
<div className="flex flex-col gap-4">
<div className="flex flex-col justify-center ">
<FormField
control={form.control}
name="background.brightness"
render={({ field }) => (
<FormItem>
<div>
<FormLabel>Brightness</FormLabel>
<FormDescription>Darken or lighten background of your choice.</FormDescription>
</div>
<FormControl>
<div className="rounded-lg border bg-slate-50 p-6">
<Slider
value={[field.value ?? 100]}
max={200}
onValueChange={(value) => {
field.onChange(value[0]);
}}
/>
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
</div>

View File

@@ -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>

View File

@@ -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}

View File

@@ -2,157 +2,47 @@
import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon } from "lucide-react";
import React, { useMemo } from "react";
import React from "react";
import { UseFormReturn } from "react-hook-form";
import { cn } from "@formbricks/lib/cn";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { TProduct, TProductStyling } from "@formbricks/types/product";
import { TCardArrangementOptions } from "@formbricks/types/styling";
import { TSurveyStyling, TSurveyType } from "@formbricks/types/surveys";
import { Badge } from "@formbricks/ui/Badge";
import { CardArrangementTabs } from "@formbricks/ui/CardArrangementTabs";
import { ColorPicker } from "@formbricks/ui/ColorPicker";
import { Label } from "@formbricks/ui/Label";
import { ColorSelector } from "@formbricks/ui/ColorSelector";
import { FormControl, FormDescription, FormField, FormItem, FormLabel } from "@formbricks/ui/Form";
import { Slider } from "@formbricks/ui/Slider";
import { CardArrangement, ColorSelectorWithLabel } from "@formbricks/ui/Styling";
import { Switch } from "@formbricks/ui/Switch";
type CardStylingSettingsProps = {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
styling: TSurveyStyling | TProductStyling | null;
setStyling: React.Dispatch<React.SetStateAction<TSurveyStyling | TProductStyling>>;
isSettingsPage?: boolean;
surveyType?: TSurveyType;
disabled?: boolean;
localProduct: TProduct;
product: TProduct;
form: UseFormReturn<TProductStyling | TSurveyStyling>;
};
export const CardStylingSettings = ({
setStyling,
styling,
isSettingsPage = false,
surveyType,
disabled,
open,
localProduct,
product,
setOpen,
form,
}: CardStylingSettingsProps) => {
const isAppSurvey = surveyType === "app" || surveyType === "website";
const cardBgColor = styling?.cardBackgroundColor?.light || COLOR_DEFAULTS.cardBackgroundColor;
const surveyTypeDerived = isAppSurvey ? "App / Website" : "Link";
const isLogoVisible = !!product.logo?.url;
const isLogoHidden = styling?.isLogoHidden ?? false;
const isLogoVisible = !!localProduct.logo?.url;
const linkSurveyCardArrangement = styling?.cardArrangement?.linkSurveys ?? "straight";
const inAppSurveyCardArrangement = styling?.cardArrangement?.appSurveys ?? "straight";
const setCardBgColor = (color: string) => {
setStyling((prev) => ({
...prev,
cardBackgroundColor: {
...(prev.cardBackgroundColor ?? {}),
light: color,
},
}));
};
const cardBorderColor = styling?.cardBorderColor?.light || COLOR_DEFAULTS.cardBorderColor;
const setCardBorderColor = (color: string) => {
setStyling((prev) => ({
...prev,
cardBorderColor: {
...(prev.cardBorderColor ?? {}),
light: color,
},
}));
};
const cardShadowColor = styling?.cardShadowColor?.light || COLOR_DEFAULTS.cardShadowColor;
const setCardShadowColor = (color: string) => {
setStyling((prev) => ({
...prev,
cardShadowColor: {
...(prev.cardShadowColor ?? {}),
light: color,
},
}));
};
const isHighlightBorderAllowed = !!styling?.highlightBorderColor;
const setIsHighlightBorderAllowed = (open: boolean) => {
if (!open) {
const { highlightBorderColor, ...rest } = styling ?? {};
setStyling({
...rest,
});
} else {
setStyling((prev) => ({
...prev,
highlightBorderColor: {
...(prev.highlightBorderColor ?? {}),
light: COLOR_DEFAULTS.highlightBorderColor,
},
}));
}
};
const highlightBorderColor = styling?.highlightBorderColor?.light || COLOR_DEFAULTS.highlightBorderColor;
const setHighlightBorderColor = (color: string) => {
setStyling((prev) => ({
...prev,
highlightBorderColor: {
...(prev.highlightBorderColor ?? {}),
light: color,
},
}));
};
const roundness = styling?.roundness ?? 8;
const setRoundness = (value: number) => {
setStyling((prev) => ({
...prev,
roundness: value,
}));
};
const setCardArrangement = (arrangement: TCardArrangementOptions, surveyType: TSurveyType) => {
const newCardArrangement = {
linkSurveys: linkSurveyCardArrangement,
appSurveys: inAppSurveyCardArrangement,
};
if (surveyType === "link") {
newCardArrangement.linkSurveys = arrangement;
} else if (surveyType === "app" || surveyType === "website") {
newCardArrangement.appSurveys = arrangement;
}
setStyling((prev) => ({
...prev,
cardArrangement: newCardArrangement,
}));
};
const toggleProgressBarVisibility = (hideProgressBar: boolean) => {
setStyling({
...styling,
hideProgressBar,
});
};
const toggleLogoVisibility = () => {
setStyling((prev) => ({
...prev,
isLogoHidden: !prev.isLogoHidden,
}));
};
const hideProgressBar = useMemo(() => {
return styling?.hideProgressBar;
}, [styling]);
const linkCardArrangement = form.watch("cardArrangement.linkSurveys") ?? "simple";
const appCardArrangement = form.watch("cardArrangement.appSurveys") ?? "simple";
const roundness = form.watch("roundness") ?? 8;
return (
<Collapsible.Root
@@ -194,107 +84,225 @@ export const CardStylingSettings = ({
<hr className="py-1 text-slate-600" />
<div className="flex flex-col gap-6 p-6 pt-2">
<div className="flex max-w-xs flex-col gap-4">
<div className="flex flex-col">
<h3 className="text-sm font-semibold text-slate-700">Roundness</h3>
<p className="text-xs text-slate-500">Change the border radius of the card and the inputs.</p>
</div>
<div className="flex flex-col justify-center rounded-lg border bg-slate-50 p-6">
<Slider value={[roundness]} max={22} onValueChange={(value) => setRoundness(value[0])} />
</div>
</div>
<ColorSelectorWithLabel
label="Card background color"
color={cardBgColor}
setColor={setCardBgColor}
description="Change the background color of the card."
/>
<ColorSelectorWithLabel
label="Card border color"
color={cardBorderColor}
setColor={setCardBorderColor}
description="Change the border color of the card."
/>
<ColorSelectorWithLabel
label="Card shadow color"
color={cardShadowColor}
setColor={setCardShadowColor}
description="Change the shadow color of the card."
/>
<CardArrangement
surveyType={isAppSurvey ? "app" : "link"}
activeCardArrangement={isAppSurvey ? inAppSurveyCardArrangement : linkSurveyCardArrangement}
setActiveCardArrangement={setCardArrangement}
/>
<>
<div className="flex items-center space-x-1">
<Switch
id="hideProgressBar"
checked={!!hideProgressBar}
onCheckedChange={(checked) => toggleProgressBarVisibility(checked)}
/>
<Label htmlFor="hideProgressBar" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Hide progress bar</h3>
<p className="text-xs font-normal text-slate-500">
Disable the visibility of survey progress.
</p>
</div>
</Label>
</div>
{isLogoVisible && (!surveyType || surveyType === "link") && !isSettingsPage && (
<div className="flex items-center space-x-1">
<Switch id="isLogoHidden" checked={isLogoHidden} onCheckedChange={toggleLogoVisibility} />
<Label htmlFor="isLogoHidden" className="cursor-pointer">
<div className="ml-2 flex flex-col">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-slate-700">Hide logo</h3>
<Badge text="Link Surveys" type="gray" size="normal" />
</div>
<p className="text-xs font-normal text-slate-500">
Hides the logo in this specific survey
</p>
<div className="flex flex-col justify-center">
<FormField
control={form.control}
name="roundness"
render={() => (
<FormItem>
<div>
<FormLabel>Roundness</FormLabel>
<FormDescription>Change the border radius of the card and the inputs.</FormDescription>
</div>
</Label>
</div>
)}
{(!surveyType || isAppSurvey) && (
<div className="flex max-w-xs flex-col gap-4">
<div className="flex items-center gap-2">
<Switch checked={isHighlightBorderAllowed} onCheckedChange={setIsHighlightBorderAllowed} />
<div className="flex flex-col">
<div className="flex items-center gap-2">
<h3 className="whitespace-nowrap text-sm font-semibold text-slate-700">
Add highlight border
</h3>
<Badge
text="App & Website Surveys"
type="gray"
size="normal"
className="whitespace-nowrap"
<FormControl>
<div className="rounded-lg border bg-slate-50 p-6">
<Slider
value={[roundness]}
max={22}
onValueChange={(value) => {
form.setValue("roundness", value[0]);
}}
/>
</div>
<p className="text-xs text-slate-500">Add an outer border to your survey card.</p>
</div>
</FormControl>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="cardBackgroundColor.light"
render={({ field }) => (
<FormItem className="space-y-4">
<div>
<FormLabel>Card background color</FormLabel>
<FormDescription>Change the background color of the card.</FormDescription>
</div>
{isHighlightBorderAllowed && (
<ColorPicker
color={highlightBorderColor}
onChange={setHighlightBorderColor}
containerClass="my-0"
<FormControl>
<ColorSelector
color={field.value || COLOR_DEFAULTS.cardBackgroundColor}
setColor={(color) => field.onChange(color)}
/>
)}
</div>
</FormControl>
</FormItem>
)}
</>
/>
<FormField
control={form.control}
name="cardBorderColor.light"
render={({ field }) => (
<FormItem className="space-y-4">
<div>
<FormLabel>Card border color</FormLabel>
<FormDescription>Change the border color of the card.</FormDescription>
</div>
<FormControl>
<ColorSelector
color={field.value || COLOR_DEFAULTS.cardBorderColor}
setColor={(color) => field.onChange(color)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="cardShadowColor.light"
render={({ field }) => (
<FormItem className="space-y-4">
<div>
<FormLabel>Card shadow color</FormLabel>
<FormDescription>Change the shadow color of the card.</FormDescription>
</div>
<FormControl>
<ColorSelector
color={field.value || COLOR_DEFAULTS.cardShadowColor}
setColor={(color) => field.onChange(color)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={"cardArrangement"}
render={() => (
<FormItem>
<div>
<FormLabel>Card Arrangement for {surveyTypeDerived} Surveys</FormLabel>
<FormDescription>
How funky do you want your cards in {surveyTypeDerived} Surveys
</FormDescription>
</div>
<FormControl>
<CardArrangementTabs
key={isAppSurvey ? "app" : "link"}
surveyType={isAppSurvey ? "app" : "link"}
activeCardArrangement={isAppSurvey ? appCardArrangement : linkCardArrangement}
setActiveCardArrangement={(value, type) => {
type === "app"
? form.setValue("cardArrangement.appSurveys", value)
: form.setValue("cardArrangement.linkSurveys", value);
}}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex items-center space-x-1">
<FormField
control={form.control}
name="hideProgressBar"
render={({ field }) => (
<FormItem className="flex w-full items-center gap-2 space-y-0">
<FormControl>
<Switch
id="hideProgressBar"
checked={!!field.value}
onCheckedChange={(checked) => field.onChange(checked)}
/>
</FormControl>
<div>
<FormLabel>Hide progress bar</FormLabel>
<FormDescription>Disable the visibility of survey progress.</FormDescription>
</div>
</FormItem>
)}
/>
</div>
{isLogoVisible && (!surveyType || surveyType === "link") && !isSettingsPage && (
<div className="flex items-center space-x-1">
<FormField
control={form.control}
name="isLogoHidden"
render={({ field }) => (
<FormItem className="flex w-full items-center gap-2 space-y-0">
<FormControl>
<Switch
id="isLogoHidden"
checked={!!field.value}
onCheckedChange={(checked) => field.onChange(checked)}
/>
</FormControl>
<div>
<FormLabel>
Hide logo
<Badge text="Link Surveys" type="gray" size="normal" />
</FormLabel>
<FormDescription>Hides the logo in this specific survey</FormDescription>
</div>
</FormItem>
)}
/>
</div>
)}
{(!surveyType || isAppSurvey) && (
<div className="flex max-w-xs flex-col gap-4">
<div className="flex items-center space-x-1">
<FormField
control={form.control}
name="highlightBorderColor"
render={({ field }) => (
<FormItem className="flex w-full flex-col gap-2 space-y-0">
<div className="flex items-center gap-2">
<FormControl>
<Switch
id="highlightBorderColor"
checked={!!field.value}
onCheckedChange={(checked) => {
if (!checked) {
field.onChange(null);
return;
}
field.onChange({
light: COLOR_DEFAULTS.highlightBorderColor,
});
}}
/>
</FormControl>
<div>
<FormLabel>Add highlight border</FormLabel>
<FormDescription className="text-xs font-normal text-slate-500">
Add an outer border to your survey card.
</FormDescription>
</div>
</div>
{!!field.value && (
<FormControl>
<ColorPicker
color={field.value?.light ?? COLOR_DEFAULTS.highlightBorderColor}
onChange={(color: string) =>
field.onChange({
...field.value,
light: color,
})
}
containerClass="my-0"
/>
</FormControl>
)}
</FormItem>
)}
/>
</div>
</div>
)}
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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}

View File

@@ -3,74 +3,50 @@
import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon, SparklesIcon } from "lucide-react";
import React from "react";
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";
import { ColorSelectorWithLabel } from "@formbricks/ui/Styling";
import { ColorSelector } from "@formbricks/ui/ColorSelector";
import { FormControl, FormDescription, FormField, FormItem, FormLabel } from "@formbricks/ui/Form";
type FormStylingSettingsProps = {
styling: TSurveyStyling | TProductStyling | null;
setStyling: React.Dispatch<React.SetStateAction<TSurveyStyling | TProductStyling>>;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
isSettingsPage?: boolean;
disabled?: boolean;
form: UseFormReturn<TProductStyling | TSurveyStyling>;
};
export const FormStylingSettings = ({
styling,
setStyling,
open,
isSettingsPage = false,
disabled = false,
setOpen,
form,
}: FormStylingSettingsProps) => {
const brandColor = styling?.brandColor?.light || COLOR_DEFAULTS.brandColor;
const setBrandColor = (color: string) => {
setStyling((prev) => ({
...prev,
brandColor: {
...(prev.brandColor ?? {}),
light: color,
},
}));
};
const brandColor = form.watch("brandColor.light") || COLOR_DEFAULTS.brandColor;
const background = form.watch("background");
const highlightBorderColor = form.watch("highlightBorderColor");
const questionColor = styling?.questionColor?.light || COLOR_DEFAULTS.questionColor;
const setQuestionColor = (color: string) => {
setStyling((prev) => ({
...prev,
questionColor: {
...(prev.questionColor ?? {}),
light: color,
},
}));
const setQuestionColor = (color: string) => form.setValue("questionColor.light", color);
const setInputColor = (color: string) => form.setValue("inputColor.light", color);
const setInputBorderColor = (color: string) => form.setValue("inputBorderColor.light", color);
const setCardBackgroundColor = (color: string) => form.setValue("cardBackgroundColor.light", color);
const setCardBorderColor = (color: string) => form.setValue("cardBorderColor.light", color);
const setCardShadowColor = (color: string) => form.setValue("cardShadowColor.light", color);
const setBackgroundColor = (color: string) => {
form.setValue("background", {
bg: color,
bgType: "color",
});
};
const inputColor = styling?.inputColor?.light || COLOR_DEFAULTS.inputColor;
const setInputColor = (color: string) => {
setStyling((prev) => ({
...prev,
inputColor: {
...(prev.inputColor ?? {}),
light: color,
},
}));
};
const inputBorderColor = styling?.inputBorderColor?.light || COLOR_DEFAULTS.inputBorderColor;
const setInputBorderColor = (color: string) => {
setStyling((prev) => ({
...prev,
inputBorderColor: {
...(prev.inputBorderColor ?? {}),
light: color,
},
}));
const setHighlightBorderColor = (color: string) => {
form.setValue("highlightBorderColor", { light: mixColor(color, "#ffffff", 0.25) });
};
const suggestColors = () => {
@@ -79,42 +55,16 @@ export const FormStylingSettings = ({
setInputColor(mixColor(brandColor, "#ffffff", 0.92));
setInputBorderColor(mixColor(brandColor, "#ffffff", 0.6));
// card background, border and shadow colors
setStyling((prev) => ({
...prev,
cardBackgroundColor: {
...(prev.cardBackgroundColor ?? {}),
light: mixColor(brandColor, "#ffffff", 0.97),
},
cardBorderColor: {
...(prev.cardBorderColor ?? {}),
light: mixColor(brandColor, "#ffffff", 0.8),
},
cardShadowColor: {
...(prev.cardShadowColor ?? {}),
light: brandColor,
},
}));
setCardBackgroundColor(mixColor(brandColor, "#ffffff", 0.97));
setCardBorderColor(mixColor(brandColor, "#ffffff", 0.8));
setCardShadowColor(brandColor);
if (!styling?.background || styling?.background?.bgType === "color") {
setStyling((prev) => ({
...prev,
background: {
...(prev.background ?? {}),
bg: mixColor(brandColor, "#ffffff", 0.855),
bgType: "color",
},
}));
if (!background || background?.bgType === "color") {
setBackgroundColor(mixColor(brandColor, "#ffffff", 0.855));
}
if (styling?.highlightBorderColor) {
setStyling((prev) => ({
...prev,
highlightBorderColor: {
...(prev.highlightBorderColor ?? {}),
light: mixColor(brandColor, "#ffffff", 0.25),
},
}));
if (!highlightBorderColor) {
setHighlightBorderColor(brandColor);
}
};
@@ -159,14 +109,28 @@ export const FormStylingSettings = ({
<div className="flex flex-col gap-6 p-6 pt-2">
<div className="flex flex-col gap-2">
<ColorSelectorWithLabel
label="Brand color"
color={brandColor}
setColor={setBrandColor}
description="Change the brand color of the survey"
<FormField
control={form.control}
name="brandColor.light"
render={({ field }) => (
<FormItem className="space-y-4">
<div>
<FormLabel>Brand color</FormLabel>
<FormDescription>Change the brand color of the survey.</FormDescription>
</div>
<FormControl>
<ColorSelector
color={field.value || COLOR_DEFAULTS.brandColor}
setColor={(color) => field.onChange(color)}
/>
</FormControl>
</FormItem>
)}
/>
<Button
type="button"
variant="secondary"
size="sm"
EndIcon={SparklesIcon}
@@ -176,25 +140,64 @@ export const FormStylingSettings = ({
</Button>
</div>
<ColorSelectorWithLabel
label="Text color"
color={questionColor}
setColor={setQuestionColor}
description="Change the text color of the questions, descriptions and answer options."
<FormField
control={form.control}
name="questionColor.light"
render={({ field }) => (
<FormItem className="space-y-4">
<div>
<FormLabel>Question color</FormLabel>
<FormDescription>Change the question color of the survey.</FormDescription>
</div>
<FormControl>
<ColorSelector
color={field.value || COLOR_DEFAULTS.questionColor}
setColor={(color) => field.onChange(color)}
/>
</FormControl>
</FormItem>
)}
/>
<ColorSelectorWithLabel
label="Input color"
color={inputColor}
setColor={setInputColor}
description="Change the background color of the input fields."
<FormField
control={form.control}
name="inputColor.light"
render={({ field }) => (
<FormItem className="space-y-4">
<div>
<FormLabel>Input color</FormLabel>
<FormDescription>Change the background color of the input fields.</FormDescription>
</div>
<FormControl>
<ColorSelector
color={field.value || COLOR_DEFAULTS.inputColor}
setColor={(color: string) => field.onChange(color)}
/>
</FormControl>
</FormItem>
)}
/>
<ColorSelectorWithLabel
label="Input border color"
color={inputBorderColor}
setColor={setInputBorderColor}
description="Change the border color of the input fields."
<FormField
control={form.control}
name="inputBorderColor.light"
render={({ field }) => (
<FormItem className="space-y-4">
<div>
<FormLabel>Input border color</FormLabel>
<FormDescription>Change the border color of the input fields.</FormDescription>
</div>
<FormControl>
<ColorSelector
color={field.value || COLOR_DEFAULTS.inputBorderColor}
setColor={(color: string) => field.onChange(color)}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</Collapsible.CollapsibleContent>

View File

@@ -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]}

View File

@@ -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) => {

View File

@@ -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}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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}

View File

@@ -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"}

View File

@@ -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

View File

@@ -1,13 +1,24 @@
import { RotateCcwIcon } from "lucide-react";
import Link from "next/link";
import React, { useEffect, useMemo, useState } from "react";
import { UseFormReturn, useForm, useWatch } from "react-hook-form";
import toast from "react-hot-toast";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { TEnvironment } from "@formbricks/types/environment";
import { TProduct } from "@formbricks/types/product";
import { TProduct, TProductStyling } from "@formbricks/types/product";
import { TBaseStyling } from "@formbricks/types/styling";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys";
import { AlertDialog } from "@formbricks/ui/AlertDialog";
import { Button } from "@formbricks/ui/Button";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@formbricks/ui/Form";
import { Switch } from "@formbricks/ui/Switch";
import { BackgroundStylingCard } from "./BackgroundStylingCard";
@@ -39,9 +50,54 @@ export const StylingView = ({
setLocalStylingChanges,
isUnsplashConfigured,
}: StylingViewProps) => {
const [overwriteThemeStyling, setOverwriteThemeStyling] = useState(
localSurvey?.styling?.overwriteThemeStyling ?? false
);
const stylingDefaults: TBaseStyling = useMemo(() => {
let stylingDefaults: TBaseStyling;
const isOverwriteEnabled = localSurvey.styling?.overwriteThemeStyling ?? false;
if (isOverwriteEnabled) {
const { overwriteThemeStyling, ...baseSurveyStyles } = localSurvey.styling ?? {};
stylingDefaults = baseSurveyStyles;
} else {
const { allowStyleOverwrite, ...baseProductStyles } = product.styling ?? {};
stylingDefaults = baseProductStyles;
}
return {
brandColor: { light: stylingDefaults.brandColor?.light ?? COLOR_DEFAULTS.brandColor },
questionColor: { light: stylingDefaults.questionColor?.light ?? COLOR_DEFAULTS.questionColor },
inputColor: { light: stylingDefaults.inputColor?.light ?? COLOR_DEFAULTS.inputColor },
inputBorderColor: { light: stylingDefaults.inputBorderColor?.light ?? COLOR_DEFAULTS.inputBorderColor },
cardBackgroundColor: {
light: stylingDefaults.cardBackgroundColor?.light ?? COLOR_DEFAULTS.cardBackgroundColor,
},
cardBorderColor: { light: stylingDefaults.cardBorderColor?.light ?? COLOR_DEFAULTS.cardBorderColor },
cardShadowColor: { light: stylingDefaults.cardShadowColor?.light ?? COLOR_DEFAULTS.cardShadowColor },
highlightBorderColor: stylingDefaults.highlightBorderColor?.light
? {
light: stylingDefaults.highlightBorderColor.light,
}
: undefined,
isDarkModeEnabled: stylingDefaults.isDarkModeEnabled ?? false,
roundness: stylingDefaults.roundness ?? 8,
cardArrangement: stylingDefaults.cardArrangement ?? {
linkSurveys: "simple",
appSurveys: "simple",
},
background: stylingDefaults.background,
hideProgressBar: stylingDefaults.hideProgressBar ?? false,
isLogoHidden: stylingDefaults.isLogoHidden ?? false,
};
}, [localSurvey.styling, product.styling]);
const form = useForm<TSurveyStyling>({
defaultValues: {
...localSurvey.styling,
...stylingDefaults,
},
});
const overwriteThemeStyling = form.watch("overwriteThemeStyling");
const setOverwriteThemeStyling = (value: boolean) => form.setValue("overwriteThemeStyling", value);
const [formStylingOpen, setFormStylingOpen] = useState(false);
const [cardStylingOpen, setCardStylingOpen] = useState(false);
@@ -56,8 +112,13 @@ export const StylingView = ({
...baseStyling,
overwriteThemeStyling: true,
});
setConfirmResetStylingModalOpen(false);
form.reset({
...baseStyling,
overwriteThemeStyling: true,
});
setConfirmResetStylingModalOpen(false);
toast.success("Styling set to theme styles");
};
@@ -69,14 +130,20 @@ export const StylingView = ({
}
}, [overwriteThemeStyling]);
const watchedValues = useWatch({
control: form.control,
});
useEffect(() => {
if (styling) {
setLocalSurvey((prev) => ({
...prev,
styling,
}));
}
}, [setLocalSurvey, styling]);
// @ts-expect-error
setLocalSurvey((prev) => ({
...prev,
styling: {
...prev.styling,
...watchedValues,
},
}));
}, [watchedValues, setLocalSurvey]);
const defaultProductStyling = useMemo(() => {
const { styling: productStyling } = product;
@@ -129,79 +196,96 @@ export const StylingView = ({
};
return (
<div className="mt-12 space-y-3 p-5">
<div className="flex items-center gap-4 py-4">
<Switch checked={overwriteThemeStyling} onCheckedChange={handleOverwriteToggle} />
<div className="flex flex-col">
<h3 className="text-base font-semibold text-slate-900">Add custom styles</h3>
<p className="text-sm text-slate-800">Override the theme with individual styles for this survey.</p>
</div>
</div>
<FormProvider {...form}>
<form onSubmit={(e) => e.preventDefault()}>
<div className="mt-12 space-y-3 p-5">
<div className="flex items-center gap-4 py-4">
<FormField
control={form.control}
name="overwriteThemeStyling"
render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl>
<Switch checked={!!field.value} onCheckedChange={handleOverwriteToggle} />
</FormControl>
<FormStylingSettings
open={formStylingOpen}
setOpen={setFormStylingOpen}
styling={styling}
setStyling={setStyling}
disabled={!overwriteThemeStyling}
/>
<div>
<FormLabel className="text-base font-semibold text-slate-900">
Add custom styles
</FormLabel>
<FormDescription className="text-sm text-slate-800">
Override the theme with individual styles for this survey.
</FormDescription>
</div>
</FormItem>
)}
/>
</div>
<CardStylingSettings
open={cardStylingOpen}
setOpen={setCardStylingOpen}
styling={styling}
setStyling={setStyling}
surveyType={localSurvey.type}
disabled={!overwriteThemeStyling}
localProduct={product}
/>
<FormStylingSettings
open={formStylingOpen}
setOpen={setFormStylingOpen}
disabled={!overwriteThemeStyling}
form={form as UseFormReturn<TProductStyling | TSurveyStyling>}
/>
{localSurvey.type === "link" && (
<BackgroundStylingCard
open={stylingOpen}
setOpen={setStylingOpen}
styling={styling}
setStyling={setStyling}
environmentId={environment.id}
colors={colors}
disabled={!overwriteThemeStyling}
isUnsplashConfigured={isUnsplashConfigured}
/>
)}
<CardStylingSettings
open={cardStylingOpen}
setOpen={setCardStylingOpen}
surveyType={localSurvey.type}
disabled={!overwriteThemeStyling}
product={product}
form={form as UseFormReturn<TProductStyling | TSurveyStyling>}
/>
<div className="mt-4 flex h-8 items-center justify-between">
<div>
{overwriteThemeStyling && (
<Button
variant="minimal"
className="flex items-center gap-2"
onClick={() => setConfirmResetStylingModalOpen(true)}>
Reset to theme styles
<RotateCcwIcon className="h-4 w-4" />
</Button>
{localSurvey.type === "link" && (
<BackgroundStylingCard
open={stylingOpen}
setOpen={setStylingOpen}
environmentId={environment.id}
colors={colors}
disabled={!overwriteThemeStyling}
isUnsplashConfigured={isUnsplashConfigured}
form={form as UseFormReturn<TProductStyling | TSurveyStyling>}
/>
)}
</div>
<p className="text-sm text-slate-500">
Adjust the theme in the{" "}
<Link
href={`/environments/${environment.id}/product/look`}
target="_blank"
className="font-semibold underline">
Look & Feel
</Link>{" "}
settings
</p>
</div>
<AlertDialog
open={confirmResetStylingModalOpen}
setOpen={setConfirmResetStylingModalOpen}
headerText="Reset to theme styles"
mainText="Are you sure you want to reset the styling to the theme styles? This will remove all custom styling."
confirmBtnLabel="Confirm"
onDecline={() => setConfirmResetStylingModalOpen(false)}
onConfirm={onResetThemeStyling}
/>
</div>
<div className="mt-4 flex h-8 items-center justify-between">
<div>
{overwriteThemeStyling && (
<Button
type="button"
variant="minimal"
className="flex items-center gap-2"
onClick={() => setConfirmResetStylingModalOpen(true)}>
Reset to theme styles
<RotateCcwIcon className="h-4 w-4" />
</Button>
)}
</div>
<p className="text-sm text-slate-500">
Adjust the theme in the{" "}
<Link
href={`/environments/${environment.id}/product/look`}
target="_blank"
className="font-semibold underline">
Look & Feel
</Link>{" "}
settings
</p>
</div>
<AlertDialog
open={confirmResetStylingModalOpen}
setOpen={setConfirmResetStylingModalOpen}
headerText="Reset to theme styles"
mainText="Are you sure you want to reset the styling to the theme styles? This will remove all custom styling."
confirmBtnLabel="Confirm"
onDecline={() => setConfirmResetStylingModalOpen(false)}
onConfirm={onResetThemeStyling}
/>
</div>
</form>
</FormProvider>
);
};

View File

@@ -1,7 +1,5 @@
import { useEffect, useState } from "react";
import { TProductStyling } from "@formbricks/types/product";
import { TSurveyStyling } from "@formbricks/types/surveys";
import { TabBar } from "@formbricks/ui/TabBar";
import { AnimatedSurveyBg } from "./AnimatedSurveyBg";
@@ -14,8 +12,8 @@ interface SurveyBgSelectorTabProps {
colors: string[];
bgType: string | null | undefined;
environmentId: string;
styling: TSurveyStyling | TProductStyling | null;
isUnsplashConfigured: boolean;
bg: string;
}
const tabs = [
@@ -26,29 +24,28 @@ const tabs = [
];
export const SurveyBgSelectorTab = ({
styling,
handleBgChange,
colors,
bgType,
bg,
environmentId,
isUnsplashConfigured,
}: SurveyBgSelectorTabProps) => {
const [activeTab, setActiveTab] = useState(bgType || "color");
const bgUrl = styling?.background?.bg || "";
const [colorBackground, setColorBackground] = useState(bgUrl);
const [animationBackground, setAnimationBackground] = useState(bgUrl);
const [uploadBackground, setUploadBackground] = useState(bgUrl);
const [colorBackground, setColorBackground] = useState(bg);
const [animationBackground, setAnimationBackground] = useState(bg);
const [uploadBackground, setUploadBackground] = useState(bg);
useEffect(() => {
if (bgType === "color") {
setColorBackground(bgUrl);
setColorBackground(bg);
setAnimationBackground("");
setUploadBackground("");
}
if (bgType === "animation") {
setAnimationBackground(bgUrl);
setAnimationBackground(bg);
setColorBackground("");
setUploadBackground("");
}
@@ -60,11 +57,11 @@ export const SurveyBgSelectorTab = ({
}
if (bgType === "upload") {
setUploadBackground(bgUrl);
setUploadBackground(bg);
setColorBackground("");
setAnimationBackground("");
}
}, [bgUrl, bgType, isUnsplashConfigured]);
}, [bg, bgType, isUnsplashConfigured]);
const renderContent = () => {
switch (activeTab) {
@@ -90,7 +87,7 @@ export const SurveyBgSelectorTab = ({
};
return (
<div className="mt-4 flex flex-col items-center justify-center rounded-lg ">
<div className="mt-4 flex flex-col items-center justify-center rounded-lg">
<TabBar
tabs={tabs.filter((tab) => tab.id !== "image" || isUnsplashConfigured)}
activeId={activeTab}

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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);
}
}

View File

@@ -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";

View File

@@ -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)]);

View File

@@ -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 = {

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";
@@ -164,7 +164,7 @@ export const MainNavigation = ({
href: `/environments/${environment.id}/product/general`,
icon: Cog,
isActive: pathname?.includes("/product"),
isHidden: false,
isHidden: isViewer,
},
],
[environment.id, pathname, isViewer]

View File

@@ -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>&quot;{ques.name}&quot;</i> of type{" "}
<b>{questionTypes.find((qt) => qt.id === ques.type)?.label}</b> can&apos;t be mapped to the
column <i>&quot;{col.name}&quot;</i> of type <b>{col.type}</b>
- <i>&quot;{ques.name}&quot;</i> of type <b>{question.label}</b> can&apos;t be mapped to the
column <i>&quot;{col.name}&quot;</i> of type <b>{col.type}</b>. Instead use column of type{" "}
{""}
<b>{TYPE_MAPPING[question.id].join(" ,")}.</b>
</>
);
default:

View File

@@ -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 = [

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -7,7 +7,7 @@ import { z } from "zod";
import { TProduct, ZProduct } from "@formbricks/types/product";
import { Button } from "@formbricks/ui/Button";
import { FormControl, FormField, FormItem, FormLabel, FormMessage, FormProvider } from "@formbricks/ui/Form";
import { FormControl, FormError, FormField, FormItem, FormLabel, FormProvider } from "@formbricks/ui/Form";
import { Input } from "@formbricks/ui/Input";
import { updateProductAction } from "../actions";
@@ -85,7 +85,7 @@ export const EditProductNameForm: React.FC<EditProductNameProps> = ({
isInvalid={!!nameError}
/>
</FormControl>
<FormMessage />
<FormError />
</FormItem>
)}
/>

View File

@@ -7,7 +7,7 @@ import { z } from "zod";
import { TProduct, ZProduct } from "@formbricks/types/product";
import { Button } from "@formbricks/ui/Button";
import { FormControl, FormField, FormItem, FormLabel, FormMessage, FormProvider } from "@formbricks/ui/Form";
import { FormControl, FormError, FormField, FormItem, FormLabel, FormProvider } from "@formbricks/ui/Form";
import { Input } from "@formbricks/ui/Input";
import { updateProductAction } from "../actions";
@@ -70,7 +70,7 @@ export const EditWaitingTimeForm: React.FC<EditWaitingTimeProps> = ({ product, e
}}
/>
</FormControl>
<FormMessage />
<FormError />
</FormItem>
)}
/>

View File

@@ -4,22 +4,30 @@ import { BackgroundStylingCard } from "@/app/(app)/(survey-editor)/environments/
import { CardStylingSettings } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CardStylingSettings";
import { FormStylingSettings } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/FormStylingSettings";
import { ThemeStylingPreviewSurvey } from "@/app/(app)/environments/[environmentId]/product/look/components/ThemeStylingPreviewSurvey";
import { zodResolver } from "@hookform/resolvers/zod";
import { RotateCcwIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { SubmitHandler, UseFormReturn, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { COLOR_DEFAULTS, PREVIEW_SURVEY } from "@formbricks/lib/styling/constants";
import { TProduct } from "@formbricks/types/product";
import { TSurvey, TSurveyType } from "@formbricks/types/surveys";
import { TProduct, TProductStyling, ZProductStyling } from "@formbricks/types/product";
import { TSurvey, TSurveyStyling, TSurveyType } from "@formbricks/types/surveys";
import { AlertDialog } from "@formbricks/ui/AlertDialog";
import { Button } from "@formbricks/ui/Button";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@formbricks/ui/Form";
import { Switch } from "@formbricks/ui/Switch";
import { updateProductAction } from "../actions";
let setQuestionId = (_: string) => {};
type ThemeStylingProps = {
product: TProduct;
environmentId: string;
@@ -29,84 +37,49 @@ type ThemeStylingProps = {
export const ThemeStyling = ({ product, environmentId, colors, isUnsplashConfigured }: ThemeStylingProps) => {
const router = useRouter();
const [localProduct, setLocalProduct] = useState(product);
const form = useForm<TProductStyling>({
defaultValues: {
...product.styling,
// specify the default values for the colors
allowStyleOverwrite: product.styling.allowStyleOverwrite ?? true,
brandColor: { light: product.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor },
questionColor: { light: product.styling.questionColor?.light ?? COLOR_DEFAULTS.questionColor },
inputColor: { light: product.styling.inputColor?.light ?? COLOR_DEFAULTS.inputColor },
inputBorderColor: { light: product.styling.inputBorderColor?.light ?? COLOR_DEFAULTS.inputBorderColor },
cardBackgroundColor: {
light: product.styling.cardBackgroundColor?.light ?? COLOR_DEFAULTS.cardBackgroundColor,
},
cardBorderColor: { light: product.styling.cardBorderColor?.light ?? COLOR_DEFAULTS.cardBorderColor },
cardShadowColor: { light: product.styling.cardShadowColor?.light ?? COLOR_DEFAULTS.cardShadowColor },
highlightBorderColor: product.styling.highlightBorderColor?.light
? {
light: product.styling.highlightBorderColor.light,
}
: undefined,
isDarkModeEnabled: product.styling.isDarkModeEnabled ?? false,
roundness: product.styling.roundness ?? 8,
cardArrangement: product.styling.cardArrangement ?? {
linkSurveys: "simple",
appSurveys: "simple",
},
background: product.styling.background,
hideProgressBar: product.styling.hideProgressBar ?? false,
isLogoHidden: product.styling.isLogoHidden ?? false,
},
resolver: zodResolver(ZProductStyling),
});
const [previewSurveyType, setPreviewSurveyType] = useState<TSurveyType>("link");
const [confirmResetStylingModalOpen, setConfirmResetStylingModalOpen] = useState(false);
const [styling, setStyling] = useState(product.styling);
const [formStylingOpen, setFormStylingOpen] = useState(false);
const [cardStylingOpen, setCardStylingOpen] = useState(false);
const [backgroundStylingOpen, setBackgroundStylingOpen] = useState(false);
const allowStyleOverwrite = localProduct.styling.allowStyleOverwrite ?? false;
const setAllowStyleOverwrite = (value: boolean) => {
setLocalProduct((prev) => ({
...prev,
styling: {
...prev.styling,
allowStyleOverwrite: value,
},
}));
};
const [styledPreviewSurvey, setStyledPreviewSurvey] = useState<TSurvey>(PREVIEW_SURVEY);
useEffect(() => {
setQuestionId(PREVIEW_SURVEY.questions[0].id);
}, []);
useEffect(() => {
// sync the local product with the product prop
// TODO: this is not ideal, we should find a better way to do this.
setLocalProduct(product);
}, [product]);
const onSave = useCallback(async () => {
await updateProductAction(product.id, {
styling: localProduct.styling,
});
toast.success("Styling updated successfully.");
router.refresh();
}, [localProduct, product.id, router]);
const onReset = useCallback(async () => {
await updateProductAction(product.id, {
styling: {
allowStyleOverwrite: true,
brandColor: {
light: COLOR_DEFAULTS.brandColor,
},
questionColor: {
light: COLOR_DEFAULTS.questionColor,
},
inputColor: {
light: COLOR_DEFAULTS.inputColor,
},
inputBorderColor: {
light: COLOR_DEFAULTS.inputBorderColor,
},
cardBackgroundColor: {
light: COLOR_DEFAULTS.cardBackgroundColor,
},
cardBorderColor: {
light: COLOR_DEFAULTS.cardBorderColor,
},
isLogoHidden: undefined,
highlightBorderColor: undefined,
isDarkModeEnabled: false,
roundness: 8,
cardArrangement: {
linkSurveys: "simple",
appSurveys: "simple",
},
},
});
setAllowStyleOverwrite(true);
setStyling({
const defaultStyling: TProductStyling = {
allowStyleOverwrite: true,
brandColor: {
light: COLOR_DEFAULTS.brandColor,
@@ -126,140 +99,156 @@ export const ThemeStyling = ({ product, environmentId, colors, isUnsplashConfigu
cardBorderColor: {
light: COLOR_DEFAULTS.cardBorderColor,
},
isLogoHidden: undefined,
isLogoHidden: false,
highlightBorderColor: undefined,
isDarkModeEnabled: false,
background: {
bg: "#fff",
bgType: "color",
},
roundness: 8,
cardArrangement: {
linkSurveys: "simple",
appSurveys: "simple",
},
};
await updateProductAction(product.id, {
styling: { ...defaultStyling },
});
// Update the background of the PREVIEW SURVEY
setStyledPreviewSurvey((currentSurvey) => ({
...currentSurvey,
styling: {
...currentSurvey.styling,
background: {
...(currentSurvey.styling?.background ?? {}),
bg: "#ffffff",
bgType: "color",
},
},
}));
form.reset({ ...defaultStyling });
toast.success("Styling updated successfully.");
router.refresh();
}, [product.id, router]);
}, [form, product.id, router]);
useEffect(() => {
setLocalProduct((prev) => ({
...prev,
styling: {
...styling,
allowStyleOverwrite,
},
}));
}, [allowStyleOverwrite, styling]);
const onSubmit: SubmitHandler<TProductStyling> = async (data) => {
try {
const updatedProduct = await updateProductAction(product.id, {
styling: data,
});
form.reset({ ...updatedProduct.styling });
toast.success("Styling updated successfully.");
} catch (err) {
toast.error("Error updating styling.");
}
};
return (
<div className="flex">
{/* Styling settings */}
<div className="relative flex w-1/2 flex-col pr-6">
<div className="flex flex-1 flex-col gap-4">
<div className="flex flex-col gap-4 rounded-lg bg-slate-50 p-4">
<div className="flex items-center gap-6">
<Switch
checked={allowStyleOverwrite}
onCheckedChange={(value) => {
setAllowStyleOverwrite(value);
}}
/>
<div className="flex flex-col">
<h3 className="text-sm font-semibold text-slate-700">Enable custom styling</h3>
<p className="text-xs text-slate-500">
Allow users to override this theme in the survey editor.
</p>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex">
{/* Styling settings */}
<div className="relative flex w-1/2 flex-col pr-6">
<div className="flex flex-1 flex-col gap-4">
<div className="flex flex-col gap-4 rounded-lg bg-slate-50 p-4">
<div className="flex items-center gap-6">
<FormField
control={form.control}
name="allowStyleOverwrite"
render={({ field }) => (
<FormItem className="flex w-full items-center gap-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
}}
/>
</FormControl>
<div>
<FormLabel>Enable custom styling</FormLabel>
<FormDescription>
Allow users to override this theme in the survey editor.
</FormDescription>
</div>
</FormItem>
)}
/>
</div>
</div>
<div className="flex flex-col gap-3 rounded-lg bg-slate-50 p-4">
<FormStylingSettings
open={formStylingOpen}
setOpen={setFormStylingOpen}
isSettingsPage
form={form as UseFormReturn<TProductStyling | TSurveyStyling>}
/>
<CardStylingSettings
open={cardStylingOpen}
setOpen={setCardStylingOpen}
isSettingsPage
product={product}
surveyType={previewSurveyType}
form={form as UseFormReturn<TProductStyling | TSurveyStyling>}
/>
<BackgroundStylingCard
open={backgroundStylingOpen}
setOpen={setBackgroundStylingOpen}
environmentId={environmentId}
colors={colors}
key={form.watch("background.bg")}
isSettingsPage
isUnsplashConfigured={isUnsplashConfigured}
form={form as UseFormReturn<TProductStyling | TSurveyStyling>}
/>
</div>
</div>
<div className="mt-4 flex items-center gap-2">
<Button variant="darkCTA" size="sm" type="submit">
Save
</Button>
<Button
type="button"
size="sm"
variant="minimal"
className="flex items-center gap-2"
onClick={() => setConfirmResetStylingModalOpen(true)}>
Reset to default
<RotateCcwIcon className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex flex-col gap-3 rounded-lg bg-slate-50 p-4">
<FormStylingSettings
open={formStylingOpen}
setOpen={setFormStylingOpen}
styling={styling}
setStyling={setStyling}
isSettingsPage
/>
{/* Survey Preview */}
<CardStylingSettings
open={cardStylingOpen}
setOpen={setCardStylingOpen}
styling={styling}
setStyling={setStyling}
isSettingsPage
localProduct={localProduct}
surveyType={previewSurveyType}
/>
<BackgroundStylingCard
open={backgroundStylingOpen}
setOpen={setBackgroundStylingOpen}
styling={styling}
setStyling={setStyling}
environmentId={environmentId}
colors={colors}
key={styling.background?.bg}
isSettingsPage
isUnsplashConfigured={isUnsplashConfigured}
/>
<div className="relative w-1/2 rounded-lg bg-slate-100 pt-4">
<div className="sticky top-4 mb-4 h-[600px]">
<ThemeStylingPreviewSurvey
setQuestionId={(_id: string) => {}}
survey={PREVIEW_SURVEY as TSurvey}
product={{
...product,
styling: form.getValues(),
}}
previewType={previewSurveyType}
setPreviewType={setPreviewSurveyType}
/>
</div>
</div>
</div>
<div className="mt-4 flex items-center gap-2">
<Button variant="darkCTA" size="sm" onClick={onSave}>
Save
</Button>
<Button
size="sm"
variant="minimal"
className="flex items-center gap-2"
onClick={() => setConfirmResetStylingModalOpen(true)}>
Reset to default
<RotateCcwIcon className="h-4 w-4" />
</Button>
</div>
</div>
{/* Survey Preview */}
<div className="relative w-1/2 rounded-lg bg-slate-100 pt-4">
<div className="sticky top-4 mb-4 h-[600px]">
<ThemeStylingPreviewSurvey
setQuestionId={setQuestionId}
survey={styledPreviewSurvey as TSurvey}
product={localProduct}
previewType={previewSurveyType}
setPreviewType={setPreviewSurveyType}
{/* Confirm reset styling modal */}
<AlertDialog
open={confirmResetStylingModalOpen}
setOpen={setConfirmResetStylingModalOpen}
headerText="Reset styling"
mainText="Are you sure you want to reset the styling to default?"
confirmBtnLabel="Confirm"
onConfirm={() => {
onReset();
setConfirmResetStylingModalOpen(false);
}}
onDecline={() => setConfirmResetStylingModalOpen(false)}
/>
</div>
</div>
{/* Confirm reset styling modal */}
<AlertDialog
open={confirmResetStylingModalOpen}
setOpen={setConfirmResetStylingModalOpen}
headerText="Reset styling"
mainText="Are you sure you want to reset the styling to default?"
confirmBtnLabel="Confirm"
onConfirm={() => {
onReset();
setConfirmResetStylingModalOpen(false);
}}
onDecline={() => setConfirmResetStylingModalOpen(false)}
/>
</div>
</form>
</FormProvider>
);
};

View File

@@ -137,7 +137,7 @@ export const ThemeStylingPreviewSurvey = ({
: "expanded_with_fixed_positioning"
: "shrink"
}
className="relative flex h-[95] max-h-[95%] w-5/6 items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
className="relative flex h-[95%] max-h-[95%] w-5/6 items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
<div className="flex h-full w-5/6 flex-1 flex-col">
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
<div className="ml-6 flex space-x-2">
@@ -184,7 +184,7 @@ export const ThemeStylingPreviewSurvey = ({
</div>
)}
<div
className={`${product.logo?.url && !product.styling.isLogoHidden && !isFullScreenPreview ? "mt-12" : ""} z-0 w-full max-w-md rounded-lg p-4`}>
className={`${product.logo?.url && !product.styling.isLogoHidden && !isFullScreenPreview ? "mt-12" : ""} z-0 w-full max-w-md rounded-lg p-4`}>
<SurveyInline
survey={{ ...survey, type: "link" }}
isBrandingEnabled={product.linkSurveyBranding}

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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} />
))}

View File

@@ -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.">

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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} />
))}

View File

@@ -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}

View File

@@ -2,7 +2,8 @@ import Link from "next/link";
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
import { timeSince } from "@formbricks/lib/time";
import { TSurveyQuestionSummaryAddress } from "@formbricks/types/surveys";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyQuestionSummaryAddress } from "@formbricks/types/surveys";
import { AddressResponse } from "@formbricks/ui/AddressResponse";
import { PersonAvatar } from "@formbricks/ui/Avatars";
@@ -11,12 +12,23 @@ import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface AddressSummaryProps {
questionSummary: TSurveyQuestionSummaryAddress;
environmentId: string;
survey: TSurvey;
attributeClasses: TAttributeClass[];
}
export const AddressSummary = ({ questionSummary, environmentId }: AddressSummaryProps) => {
export const AddressSummary = ({
questionSummary,
environmentId,
survey,
attributeClasses,
}: AddressSummaryProps) => {
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} />
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
attributeClasses={attributeClasses}
/>
<div className="">
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
<div className="pl-4 md:pl-6">User</div>

View File

@@ -1,6 +1,7 @@
import { InboxIcon } from "lucide-react";
import { TSurveyQuestionSummaryCta } from "@formbricks/types/surveys";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyQuestionSummaryCta } from "@formbricks/types/surveys";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
import { convertFloatToNDecimal } from "../lib/utils";
@@ -8,14 +9,18 @@ import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface CTASummaryProps {
questionSummary: TSurveyQuestionSummaryCta;
survey: TSurvey;
attributeClasses: TAttributeClass[];
}
export const CTASummary = ({ questionSummary }: CTASummaryProps) => {
export const CTASummary = ({ questionSummary, survey, attributeClasses }: CTASummaryProps) => {
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader
survey={survey}
questionSummary={questionSummary}
showResponses={false}
attributeClasses={attributeClasses}
insights={
<>
<div className="flex items-center rounded-lg bg-slate-100 p-2">

View File

@@ -1,6 +1,7 @@
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { TSurveyQuestionSummaryCal } from "@formbricks/types/surveys";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyQuestionSummaryCal } from "@formbricks/types/surveys";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
@@ -8,12 +9,18 @@ import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface CalSummaryProps {
questionSummary: TSurveyQuestionSummaryCal;
environmentId: string;
survey: TSurvey;
attributeClasses: TAttributeClass[];
}
export const CalSummary = ({ questionSummary }: CalSummaryProps) => {
export const CalSummary = ({ questionSummary, survey, attributeClasses }: CalSummaryProps) => {
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} />
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
attributeClasses={attributeClasses}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div>
<div className="text flex justify-between px-2 pb-2">

View File

@@ -1,4 +1,5 @@
import { TSurveyQuestionSummaryConsent } from "@formbricks/types/surveys";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyQuestionSummaryConsent } from "@formbricks/types/surveys";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
import { convertFloatToNDecimal } from "../lib/utils";
@@ -6,12 +7,18 @@ import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface ConsentSummaryProps {
questionSummary: TSurveyQuestionSummaryConsent;
survey: TSurvey;
attributeClasses: TAttributeClass[];
}
export const ConsentSummary = ({ questionSummary }: ConsentSummaryProps) => {
export const ConsentSummary = ({ questionSummary, survey, attributeClasses }: ConsentSummaryProps) => {
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} />
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
attributeClasses={attributeClasses}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div>
<div className="text flex justify-between px-2 pb-2">

View File

@@ -4,7 +4,8 @@ import { useState } from "react";
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
import { timeSince } from "@formbricks/lib/time";
import { formatDateWithOrdinal } from "@formbricks/lib/utils/datetime";
import { TSurveyQuestionSummaryDate } from "@formbricks/types/surveys";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyQuestionSummaryDate } from "@formbricks/types/surveys";
import { PersonAvatar } from "@formbricks/ui/Avatars";
import { Button } from "@formbricks/ui/Button";
@@ -13,9 +14,16 @@ import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface DateQuestionSummary {
questionSummary: TSurveyQuestionSummaryDate;
environmentId: string;
survey: TSurvey;
attributeClasses: TAttributeClass[];
}
export const DateQuestionSummary = ({ questionSummary, environmentId }: DateQuestionSummary) => {
export const DateQuestionSummary = ({
questionSummary,
environmentId,
survey,
attributeClasses,
}: DateQuestionSummary) => {
const [visibleResponses, setVisibleResponses] = useState(10);
const handleLoadMore = () => {
@@ -27,7 +35,11 @@ export const DateQuestionSummary = ({ questionSummary, environmentId }: DateQues
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} />
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
attributeClasses={attributeClasses}
/>
<div className="">
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
<div className="pl-4 md:pl-6">User</div>

View File

@@ -5,7 +5,8 @@ import { useState } from "react";
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils";
import { timeSince } from "@formbricks/lib/time";
import { TSurveyQuestionSummaryFileUpload } from "@formbricks/types/surveys";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyQuestionSummaryFileUpload } from "@formbricks/types/surveys";
import { PersonAvatar } from "@formbricks/ui/Avatars";
import { Button } from "@formbricks/ui/Button";
@@ -14,9 +15,16 @@ import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface FileUploadSummaryProps {
questionSummary: TSurveyQuestionSummaryFileUpload;
environmentId: string;
survey: TSurvey;
attributeClasses: TAttributeClass[];
}
export const FileUploadSummary = ({ questionSummary, environmentId }: FileUploadSummaryProps) => {
export const FileUploadSummary = ({
questionSummary,
environmentId,
survey,
attributeClasses,
}: FileUploadSummaryProps) => {
const [visibleResponses, setVisibleResponses] = useState(10);
const handleLoadMore = () => {
@@ -28,7 +36,11 @@ export const FileUploadSummary = ({ questionSummary, environmentId }: FileUpload
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} />
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
attributeClasses={attributeClasses}
/>
<div className="">
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
<div className="pl-4 md:pl-6">User</div>

View File

@@ -1,13 +1,20 @@
import { TSurveyQuestionSummaryMatrix } from "@formbricks/types/surveys";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyQuestionSummaryMatrix } from "@formbricks/types/surveys";
import { TooltipRenderer } from "@formbricks/ui/Tooltip";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface MatrixQuestionSummaryProps {
questionSummary: TSurveyQuestionSummaryMatrix;
survey: TSurvey;
attributeClasses: TAttributeClass[];
}
export const MatrixQuestionSummary = ({ questionSummary }: MatrixQuestionSummaryProps) => {
export const MatrixQuestionSummary = ({
questionSummary,
survey,
attributeClasses,
}: MatrixQuestionSummaryProps) => {
const getOpacityLevel = (percentage: number): string => {
const parsedPercentage = percentage;
const opacity = parsedPercentage * 0.75 + 15;
@@ -27,7 +34,11 @@ export const MatrixQuestionSummary = ({ questionSummary }: MatrixQuestionSummary
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} />
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
attributeClasses={attributeClasses}
/>
<div className="overflow-x-auto p-6">
{/* Summary Table */}
<table className="mx-auto border-collapse cursor-default text-left">

View File

@@ -2,7 +2,8 @@ import Link from "next/link";
import { useState } from "react";
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
import { TSurveyQuestionSummaryMultipleChoice, TSurveyType } from "@formbricks/types/surveys";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyQuestionSummaryMultipleChoice, TSurveyType } from "@formbricks/types/surveys";
import { PersonAvatar } from "@formbricks/ui/Avatars";
import { Button } from "@formbricks/ui/Button";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
@@ -14,12 +15,16 @@ interface MultipleChoiceSummaryProps {
questionSummary: TSurveyQuestionSummaryMultipleChoice;
environmentId: string;
surveyType: TSurveyType;
survey: TSurvey;
attributeClasses: TAttributeClass[];
}
export const MultipleChoiceSummary = ({
questionSummary,
environmentId,
surveyType,
survey,
attributeClasses,
}: MultipleChoiceSummaryProps) => {
const [visibleOtherResponses, setVisibleOtherResponses] = useState(10);
@@ -45,7 +50,11 @@ export const MultipleChoiceSummary = ({
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} />
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
attributeClasses={attributeClasses}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => (
<div key={result.value}>

View File

@@ -1,4 +1,5 @@
import { TSurveyQuestionSummaryNps } from "@formbricks/types/surveys";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyQuestionSummaryNps } from "@formbricks/types/surveys";
import { HalfCircle, ProgressBar } from "@formbricks/ui/ProgressBar";
import { convertFloatToNDecimal } from "../lib/utils";
@@ -6,12 +7,18 @@ import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface NPSSummaryProps {
questionSummary: TSurveyQuestionSummaryNps;
survey: TSurvey;
attributeClasses: TAttributeClass[];
}
export const NPSSummary = ({ questionSummary }: NPSSummaryProps) => {
export const NPSSummary = ({ questionSummary, survey, attributeClasses }: NPSSummaryProps) => {
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} />
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
attributeClasses={attributeClasses}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors"].map((group) => (
<div key={group}>

View File

@@ -3,7 +3,8 @@ import { useState } from "react";
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
import { timeSince } from "@formbricks/lib/time";
import { TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys";
import { PersonAvatar } from "@formbricks/ui/Avatars";
import { Button } from "@formbricks/ui/Button";
@@ -12,9 +13,16 @@ import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface OpenTextSummaryProps {
questionSummary: TSurveyQuestionSummaryOpenText;
environmentId: string;
survey: TSurvey;
attributeClasses: TAttributeClass[];
}
export const OpenTextSummary = ({ questionSummary, environmentId }: OpenTextSummaryProps) => {
export const OpenTextSummary = ({
questionSummary,
environmentId,
survey,
attributeClasses,
}: OpenTextSummaryProps) => {
const [visibleResponses, setVisibleResponses] = useState(10);
const handleLoadMore = () => {
@@ -26,7 +34,11 @@ export const OpenTextSummary = ({ questionSummary, environmentId }: OpenTextSumm
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} />
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
attributeClasses={attributeClasses}
/>
<div className="">
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
<div className="pl-4 md:pl-6">User</div>

View File

@@ -1,6 +1,7 @@
import Image from "next/image";
import { TSurveyQuestionSummaryPictureSelection } from "@formbricks/types/surveys";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyQuestionSummaryPictureSelection } from "@formbricks/types/surveys";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
import { convertFloatToNDecimal } from "../lib/utils";
@@ -8,14 +9,24 @@ import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface PictureChoiceSummaryProps {
questionSummary: TSurveyQuestionSummaryPictureSelection;
survey: TSurvey;
attributeClasses: TAttributeClass[];
}
export const PictureChoiceSummary = ({ questionSummary }: PictureChoiceSummaryProps) => {
export const PictureChoiceSummary = ({
questionSummary,
survey,
attributeClasses,
}: PictureChoiceSummaryProps) => {
const results = questionSummary.choices.sort((a, b) => b.count - a.count);
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} />
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
attributeClasses={attributeClasses}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result) => (
<div key={result.id}>

View File

@@ -1,23 +1,55 @@
import { questionTypes } from "@/app/lib/questions";
import { InboxIcon } from "lucide-react";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { recallToHeadline } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyQuestionSummary } from "@formbricks/types/surveys";
interface HeadProps {
questionSummary: TSurveyQuestionSummary;
showResponses?: boolean;
insights?: JSX.Element;
survey: TSurvey;
attributeClasses: TAttributeClass[];
}
export const QuestionSummaryHeader = ({ questionSummary, insights, showResponses = true }: HeadProps) => {
export const QuestionSummaryHeader = ({
questionSummary,
insights,
showResponses = true,
survey,
attributeClasses,
}: HeadProps) => {
const questionType = questionTypes.find((type) => type.id === questionSummary.question.type);
// formats the text to highlight specific parts of the text with slashes
const formatTextWithSlashes = (text: string): (string | JSX.Element)[] => {
const regex = /\/(.*?)\\/g;
const parts = text.split(regex);
return parts.map((part, index) => {
// Check if the part was inside slashes
if (index % 2 !== 0) {
return (
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-lg">
@{part}
</span>
);
} else {
return part;
}
});
};
return (
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<div className={"align-center flex justify-between gap-4 "}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{getLocalizedValue(questionSummary.question.headline, "default")}
{formatTextWithSlashes(
recallToHeadline(questionSummary.question.headline, survey, true, "default", attributeClasses)[
"default"
]
)}
</h3>
</div>
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">

View File

@@ -1,6 +1,7 @@
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { TSurveyQuestionSummaryRating } from "@formbricks/types/surveys";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyQuestionSummaryRating } from "@formbricks/types/surveys";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
import { RatingResponse } from "@formbricks/ui/RatingResponse";
@@ -8,12 +9,18 @@ import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface RatingSummaryProps {
questionSummary: TSurveyQuestionSummaryRating;
survey: TSurvey;
attributeClasses: TAttributeClass[];
}
export const RatingSummary = ({ questionSummary }: RatingSummaryProps) => {
export const RatingSummary = ({ questionSummary, survey, attributeClasses }: RatingSummaryProps) => {
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} />
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
attributeClasses={attributeClasses}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((result) => (
<div key={result.rating}>

View File

@@ -12,6 +12,7 @@ import { OpenTextSummary } from "@/app/(app)/environments/[environmentId]/survey
import { PictureChoiceSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary";
import { RatingSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurveySummary } from "@formbricks/types/surveys";
import { TSurveyQuestionType } from "@formbricks/types/surveys";
@@ -28,6 +29,7 @@ interface SummaryListProps {
survey: TSurvey;
fetchingSummary: boolean;
totalResponseCount: number;
attributeClasses: TAttributeClass[];
}
export const SummaryList = ({
@@ -37,6 +39,7 @@ export const SummaryList = ({
survey,
fetchingSummary,
totalResponseCount,
attributeClasses,
}: SummaryListProps) => {
return (
<div className="mt-10 space-y-8">
@@ -61,6 +64,8 @@ export const SummaryList = ({
key={questionSummary.question.id}
questionSummary={questionSummary}
environmentId={environment.id}
survey={survey}
attributeClasses={attributeClasses}
/>
);
}
@@ -74,24 +79,59 @@ export const SummaryList = ({
questionSummary={questionSummary}
environmentId={environment.id}
surveyType={survey.type}
survey={survey}
attributeClasses={attributeClasses}
/>
);
}
if (questionSummary.type === TSurveyQuestionType.NPS) {
return <NPSSummary key={questionSummary.question.id} questionSummary={questionSummary} />;
return (
<NPSSummary
key={questionSummary.question.id}
questionSummary={questionSummary}
survey={survey}
attributeClasses={attributeClasses}
/>
);
}
if (questionSummary.type === TSurveyQuestionType.CTA) {
return <CTASummary key={questionSummary.question.id} questionSummary={questionSummary} />;
return (
<CTASummary
key={questionSummary.question.id}
questionSummary={questionSummary}
survey={survey}
attributeClasses={attributeClasses}
/>
);
}
if (questionSummary.type === TSurveyQuestionType.Rating) {
return <RatingSummary key={questionSummary.question.id} questionSummary={questionSummary} />;
return (
<RatingSummary
key={questionSummary.question.id}
questionSummary={questionSummary}
survey={survey}
attributeClasses={attributeClasses}
/>
);
}
if (questionSummary.type === TSurveyQuestionType.Consent) {
return <ConsentSummary key={questionSummary.question.id} questionSummary={questionSummary} />;
return (
<ConsentSummary
key={questionSummary.question.id}
questionSummary={questionSummary}
survey={survey}
attributeClasses={attributeClasses}
/>
);
}
if (questionSummary.type === TSurveyQuestionType.PictureSelection) {
return (
<PictureChoiceSummary key={questionSummary.question.id} questionSummary={questionSummary} />
<PictureChoiceSummary
key={questionSummary.question.id}
questionSummary={questionSummary}
survey={survey}
attributeClasses={attributeClasses}
/>
);
}
if (questionSummary.type === TSurveyQuestionType.Date) {
@@ -100,6 +140,8 @@ export const SummaryList = ({
key={questionSummary.question.id}
questionSummary={questionSummary}
environmentId={environment.id}
survey={survey}
attributeClasses={attributeClasses}
/>
);
}
@@ -109,6 +151,8 @@ export const SummaryList = ({
key={questionSummary.question.id}
questionSummary={questionSummary}
environmentId={environment.id}
survey={survey}
attributeClasses={attributeClasses}
/>
);
}
@@ -118,12 +162,19 @@ export const SummaryList = ({
key={questionSummary.question.id}
questionSummary={questionSummary}
environmentId={environment.id}
survey={survey}
attributeClasses={attributeClasses}
/>
);
}
if (questionSummary.type === TSurveyQuestionType.Matrix) {
return (
<MatrixQuestionSummary key={questionSummary.question.id} questionSummary={questionSummary} />
<MatrixQuestionSummary
key={questionSummary.question.id}
questionSummary={questionSummary}
survey={survey}
attributeClasses={attributeClasses}
/>
);
}
if (questionSummary.type === TSurveyQuestionType.Address) {
@@ -132,6 +183,8 @@ export const SummaryList = ({
key={questionSummary.question.id}
questionSummary={questionSummary}
environmentId={environment.id}
survey={survey}
attributeClasses={attributeClasses}
/>
);
}

View File

@@ -136,6 +136,7 @@ export const SummaryPage = ({
environment={environment}
fetchingSummary={isFetchingSummary}
totalResponseCount={totalResponseCount}
attributeClasses={attributeClasses}
/>
</>
);

View File

@@ -3,11 +3,7 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import {
getResponseDownloadUrl,
getResponseMeta,
getResponsePersonAttributes,
} from "@formbricks/lib/response/service";
import { getResponseDownloadUrl, getResponseFilteringValues } from "@formbricks/lib/response/service";
import { canUserAccessSurvey, verifyUserRoleAccess } from "@formbricks/lib/survey/auth";
import { updateSurvey } from "@formbricks/lib/survey/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
@@ -36,13 +32,12 @@ export const getSurveyFilterDataAction = async (surveyId: string, environmentId:
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const [tags, attributes, meta] = await Promise.all([
const [tags, { personAttributes: attributes, meta, hiddenFields }] = await Promise.all([
getTagsByEnvironmentId(environmentId),
getResponsePersonAttributes(surveyId),
getResponseMeta(surveyId),
getResponseFilteringValues(surveyId),
]);
return { environmentTags: tags, attributes, meta };
return { environmentTags: tags, attributes, meta, hiddenFields };
};
export const updateSurveyAction = async (survey: TSurvey): Promise<TSurvey> => {

View File

@@ -23,13 +23,7 @@ type QuestionFilterComboBoxProps = {
filterComboBoxValue: string | string[] | undefined;
onChangeFilterValue: (o: string) => void;
onChangeFilterComboBoxValue: (o: string | string[]) => void;
type:
| OptionsType.OTHERS
| TSurveyQuestionType
| OptionsType.ATTRIBUTES
| OptionsType.TAGS
| OptionsType.META
| undefined;
type?: TSurveyQuestionType | Omit<OptionsType, OptionsType.QUESTIONS>;
handleRemoveMultiSelect: (value: string[]) => void;
disabled?: boolean;
};

View File

@@ -6,6 +6,7 @@ import {
CheckIcon,
ChevronDown,
ChevronUp,
EyeOff,
GlobeIcon,
GridIcon,
HashIcon,
@@ -41,6 +42,7 @@ export enum OptionsType {
ATTRIBUTES = "Attributes",
OTHERS = "Other Filters",
META = "Meta",
HIDDEN_FIELDS = "Hidden Fields",
}
export type QuestionOption = {
@@ -88,6 +90,9 @@ const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOpti
}
case OptionsType.ATTRIBUTES:
return <User width={18} height={18} className="text-white" />;
case OptionsType.HIDDEN_FIELDS:
return <EyeOff width={18} height={18} className="text-white" />;
case OptionsType.META:
switch (label) {
case "device":

View File

@@ -46,7 +46,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
// Fetch the initial data for the filter and load it into the state
const handleInitialData = async () => {
if (isOpen) {
const { attributes, meta, environmentTags } = isSharingPage
const { attributes, meta, environmentTags, hiddenFields } = isSharingPage
? await getSurveyFilterDataBySurveySharingKeyAction(sharingKey, survey.environmentId)
: await getSurveyFilterDataAction(survey.id, survey.environmentId);
@@ -54,7 +54,8 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
survey,
environmentTags,
attributes,
meta
meta,
hiddenFields
);
setSelectedOptions({ questionFilterOptions, questionOptions });
}

View File

@@ -63,7 +63,7 @@ const Page = async ({ params }) => {
<PageContentWrapper>
{surveyCount > 0 ? (
<>
<PageHeader pageTitle="Surveys" cta={CreateSurveyButton} />
<PageHeader pageTitle="Surveys" cta={isViewer ? <></> : CreateSurveyButton} />
<SurveysList
environment={environment}
otherEnvironment={otherEnvironment}

View File

@@ -6,7 +6,6 @@ import Lost from "@/images/onboarding-lost.gif";
import { ArrowRight } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import { usePostHog } from "posthog-js/react";
import { useEffect, useState } from "react";
@@ -85,7 +84,7 @@ const NotConnectedState = ({ environment, webAppUrl, jsPackageVersion, goToOrgan
/>
<div className="flex w-full items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-12 py-3 text-slate-700">
<span className="animate-pulse">Waiting for your signal...</span>
Waiting for your signal...
<Image src={Lost} alt="lost" height={75} />
</div>
<div className="w-full border-b border-slate-200 " />
@@ -100,9 +99,7 @@ const NotConnectedState = ({ environment, webAppUrl, jsPackageVersion, goToOrgan
className="mt-8 font-normal text-slate-400"
variant="minimal"
onClick={goToOrganizationInvitePage}>
{posthog.getFeatureFlag("website-survey-activation") === "test"
? "Skip"
: "I don't know how to do this"}
Skip
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>

View File

@@ -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)),
};
});

View File

@@ -4,14 +4,12 @@ import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
import { sendResponseFinishedEmail } from "@formbricks/email";
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
import { INTERNAL_SECRET } from "@formbricks/lib/constants";
import { getIntegrations } from "@formbricks/lib/integration/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { convertDatesInObject } from "@formbricks/lib/time";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { ZPipelineInput } from "@formbricks/types/pipelines";
import { TUserNotificationSettings } from "@formbricks/types/user";
@@ -39,7 +37,6 @@ export const POST = async (request: Request) => {
const { environmentId, surveyId, event, response } = inputValidation.data;
const product = await getProductByEnvironmentId(environmentId);
const attributeClasses = await getAttributeClasses(environmentId);
if (!product) return;
// get all webhooks of this environment where event in triggers
@@ -108,7 +105,7 @@ export const POST = async (request: Request) => {
getIntegrations(environmentId),
getSurvey(surveyId),
]);
const survey = surveyData ? replaceHeadlineRecall(surveyData, "default", attributeClasses) : undefined;
const survey = surveyData ?? undefined;
if (integrations.length > 0 && survey) {
handleIntegrations(integrations, inputValidation.data, survey);

View File

@@ -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,
};

View File

@@ -12,6 +12,7 @@ import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/
import {
TResponseFilterCriteria,
TResponseHiddenFieldsFilter,
TSurveyMetaFieldFilter,
TSurveyPersonAttributes,
} from "@formbricks/types/responses";
@@ -49,7 +50,8 @@ export const generateQuestionAndFilterOptions = (
survey: TSurvey,
environmentTags: TTag[] | undefined,
attributes: TSurveyPersonAttributes,
meta: TSurveyMetaFieldFilter
meta: TSurveyMetaFieldFilter,
hiddenFields: TResponseHiddenFieldsFilter
): {
questionOptions: QuestionOptions[];
questionFilterOptions: QuestionFilterOptions[];
@@ -164,6 +166,26 @@ export const generateQuestionAndFilterOptions = (
});
}
if (hiddenFields) {
questionOptions = [
...questionOptions,
{
header: OptionsType.HIDDEN_FIELDS,
option: Object.keys(hiddenFields).map((hiddenField) => {
return { label: hiddenField, type: OptionsType.HIDDEN_FIELDS, id: hiddenField };
}),
},
];
Object.keys(hiddenFields).forEach((hiddenField) => {
questionFilterOptions.push({
type: "Hidden Fields",
filterOptions: ["Equals", "Not equals"],
filterComboBoxOptions: hiddenFields[hiddenField],
id: hiddenField,
});
});
}
let languageQuestion: QuestionOption[] = [];
//can be extended to include more properties
@@ -189,23 +211,29 @@ export const getFormattedFilters = (
dateRange: DateRange
): TResponseFilterCriteria => {
const filters: TResponseFilterCriteria = {};
const [questions, tags, attributes, others, meta] = selectedFilter.filter.reduce(
(result: [FilterValue[], FilterValue[], FilterValue[], FilterValue[], FilterValue[]], filter) => {
if (filter.questionType?.type === "Questions") {
result[0].push(filter);
} else if (filter.questionType?.type === "Tags") {
result[1].push(filter);
} else if (filter.questionType?.type === "Attributes") {
result[2].push(filter);
} else if (filter.questionType?.type === "Other Filters") {
result[3].push(filter);
} else if (filter.questionType?.type === "Meta") {
result[4].push(filter);
}
return result;
},
[[], [], [], [], []]
);
const questions: FilterValue[] = [];
const tags: FilterValue[] = [];
const attributes: FilterValue[] = [];
const others: FilterValue[] = [];
const meta: FilterValue[] = [];
const hiddenFields: FilterValue[] = [];
selectedFilter.filter.forEach((filter) => {
if (filter.questionType?.type === "Questions") {
questions.push(filter);
} else if (filter.questionType?.type === "Tags") {
tags.push(filter);
} else if (filter.questionType?.type === "Attributes") {
attributes.push(filter);
} else if (filter.questionType?.type === "Other Filters") {
others.push(filter);
} else if (filter.questionType?.type === "Meta") {
meta.push(filter);
} else if (filter.questionType?.type === "Hidden Fields") {
hiddenFields.push(filter);
}
});
// for completed responses
if (selectedFilter.onlyComplete) {
@@ -359,10 +387,30 @@ export const getFormattedFilters = (
});
}
// for hidden fields
if (hiddenFields.length) {
hiddenFields.forEach(({ filterType, questionType }) => {
if (!filters.data) filters.data = {};
if (!filterType.filterComboBoxValue) return;
if (filterType.filterValue === "Equals") {
filters.data[questionType.label ?? ""] = {
op: "equals",
value: filterType.filterComboBoxValue as string,
};
} else if (filterType.filterValue === "Not equals") {
filters.data[questionType.label ?? ""] = {
op: "notEquals",
value: filterType.filterComboBoxValue as string,
};
}
});
}
// for attributes
if (attributes.length) {
attributes.forEach(({ filterType, questionType }) => {
if (!filters.personAttributes) filters.personAttributes = {};
if (!filterType.filterComboBoxValue) return;
if (filterType.filterValue === "Equals") {
filters.personAttributes[questionType.label ?? ""] = {
op: "equals",
@@ -381,6 +429,7 @@ export const getFormattedFilters = (
if (others.length) {
others.forEach(({ filterType, questionType }) => {
if (!filters.others) filters.others = {};
if (!filterType.filterComboBoxValue) return;
if (filterType.filterValue === "Equals") {
filters.others[questionType.label ?? ""] = {
op: "equals",
@@ -399,6 +448,7 @@ export const getFormattedFilters = (
if (meta.length) {
meta.forEach(({ filterType, questionType }) => {
if (!filters.meta) filters.meta = {};
if (!filterType.filterComboBoxValue) return;
if (filterType.filterValue === "Equals") {
filters.meta[questionType.label ?? ""] = {
op: "equals",

View File

@@ -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>

View File

@@ -2,8 +2,7 @@
import {
getResponseCountBySurveyId,
getResponseMeta,
getResponsePersonAttributes,
getResponseFilteringValues,
getResponses,
getSurveySummary,
} from "@formbricks/lib/response/service";
@@ -53,11 +52,10 @@ export const getSurveyFilterDataBySurveySharingKeyAction = async (
const surveyId = await getSurveyIdByResultShareKey(sharingKey);
if (!surveyId) throw new AuthorizationError("Not authorized");
const [tags, attributes, meta] = await Promise.all([
const [tags, { personAttributes: attributes, meta, hiddenFields }] = await Promise.all([
getTagsByEnvironmentId(environmentId),
getResponsePersonAttributes(surveyId),
getResponseMeta(surveyId),
getResponseFilteringValues(surveyId),
]);
return { environmentTags: tags, attributes, meta };
return { environmentTags: tags, attributes, meta, hiddenFields };
};

View File

@@ -14,6 +14,7 @@ test.describe("Onboarding Flow Test", async () => {
await page.getByRole("button", { name: "Link Surveys Create a new" }).click();
await page.getByRole("button", { name: "Collect Feedback Collect" }).click();
await page.waitForTimeout(2000);
await page.getByRole("button", { name: "Publish" }).click();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);

View File

@@ -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();

View File

@@ -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);
};

View File

@@ -1,7 +1,7 @@
{
"functions": {
"app/api/cron/**/*.ts": {
"maxDuration": 30,
"maxDuration": 180,
"memory": 512
},
"app/api/v1/client/**/*.ts": {

Some files were not shown because too many files have changed in this diff Show More