Compare commits

...

22 Commits

Author SHA1 Message Date
pandeymangg
474ae4478f formssssS 2024-05-24 23:04:05 +05:30
pandeymangg
a01107521e fix: FormProvider 2024-05-24 15:05:48 +05:30
pandeymangg
2e17e70c00 fix: isDirty changes with react hook form 2024-05-24 13:49:00 +05:30
pandeymangg
641b597ddc isDirty 2024-05-24 12:11:30 +05:30
pandeymangg
b6986e5470 Merge branch 'main' into fix/product-settings-form 2024-05-23 11:30:45 +05:30
pandeymangg
82a5eed6e5 fix: Form element 2024-05-23 11:28:16 +05:30
pandeymangg
0f20a4460f fix: placement form 2024-05-23 10:41:33 +05:30
pandeymangg
95ae35a3b5 fix: adds form component and refactors the current approach 2024-05-23 10:19:05 +05:30
Matti Nannt
27b37a5f27 fix: convert number attributes to strings automatically (#2679) 2024-05-22 17:48:02 +02:00
Dhruwang Jariwala
c37d3ae0f9 fix: ui issues (#2665) 2024-05-22 14:03:17 +00:00
Matti Nannt
e69bb3501d docs: update advanced docker update steps (#2678) 2024-05-22 15:22:30 +02:00
pandeymangg
ddd91607b1 Merge branch 'main' into fix/product-settings-form 2024-05-22 18:46:32 +05:30
pandeymangg
325eeb10ef fix: look and feel settings react hook form 2024-05-22 18:34:44 +05:30
Matti Nannt
eda9c00548 fix: migration instructions to 2.0 (#2676) 2024-05-22 14:35:11 +02:00
Jonas Höbenreich
6a7e0d3ecb fix: set "desktop" as default device type (#2675) 2024-05-22 12:11:25 +00:00
Anshuman Pandey
48859facf4 fix: formbricks init error (#2633)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-05-22 12:07:17 +00:00
Matti Nannt
732b8b599f fix: docker file shouldn't check npm registry on runtime (#2663) 2024-05-21 13:39:51 +02:00
Johannes
00ad4c3895 fix: fix links to integrations on docs (#2664) 2024-05-21 11:57:24 +02:00
Dhruwang Jariwala
4858bdd838 fix: rating question and updated attribute labels on person page (#2657)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-05-20 15:02:28 +00:00
Shubham Palriwala
eee78a79d9 fix: navbar on mobile docs for grouped features (#2658)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-05-20 14:57:18 +00:00
Johannes
aa890affc9 fix: update links for integrations (#2659) 2024-05-20 16:45:41 +02:00
Anshuman Pandey
10aed2d9d8 fix: UI fixes (#2653)
Co-authored-by: Johannes <johannes@formbricks.com>
2024-05-20 09:11:18 +00:00
67 changed files with 986 additions and 941 deletions

View File

@@ -1,26 +0,0 @@
name: Build formbricks-com
on:
workflow_call:
jobs:
build:
name: Build Formbricks-com
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Setup Node.js 20.x
uses: actions/setup-node@v3
with:
node-version: 20.x
- name: Install pnpm
uses: pnpm/action-setup@v2
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Build Formbricks-com
run: pnpm build --filter=formbricks-com...

View File

@@ -1,131 +0,0 @@
name: Kamal Deploy
concurrency:
group: deploy-to-kamal
cancel-in-progress: false
on:
workflow_dispatch:
push:
branches:
- main
jobs:
Deploy:
runs-on: ubuntu-latest
environment: production
env:
DOCKER_BUILDKIT: 1
IS_FORMBRICKS_CLOUD: ${{ vars.IS_FORMBRICKS_CLOUD }}
WEBAPP_URL: ${{ vars.WEBAPP_URL }}
MIGRATE_DATABASE_URL: ${{ secrets.MIGRATE_DATABASE_URL }}
NEXTAUTH_URL: ${{ vars.NEXTAUTH_URL }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
SHORT_URL_BASE: ${{ vars.SHORT_URL_BASE }}
MAIL_FROM: ${{ secrets.MAIL_FROM }}
SMTP_HOST: ${{ secrets.SMTP_HOST }}
SMTP_PORT: ${{ secrets.SMTP_PORT }}
SMTP_USER: ${{ secrets.SMTP_USER }}
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
PRIVACY_URL: ${{ vars.PRIVACY_URL }}
TERMS_URL: ${{ vars.TERMS_URL }}
IMPRINT_URL: ${{ vars.IMPRINT_URL }}
GITHUB_ID: ${{ secrets.FB_GITHUB_ID }}
GITHUB_SECRET: ${{ secrets.FB_GITHUB_SECRET }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
AZUREAD_CLIENT_ID: ${{ secrets.AZUREAD_CLIENT_ID }}
AZUREAD_CLIENT_SECRET: ${{ secrets.AZUREAD_CLIENT_SECRET }}
AZUREAD_TENANT_ID: ${{ secrets.AZUREAD_TENANT_ID }}
OIDC_CLIENT_ID: ${{ secrets.OIDC_CLIENT_ID }}
OIDC_CLIENT_SECRET: ${{ secrets.OIDC_CLIENT_SECRET }}
OIDC_ISSUER: ${{ secrets.OIDC_ISSUER }}
OIDC_DISPLAY_NAME: ${{ secrets.OIDC_DISPLAY_NAME }}
OIDC_SIGNING_ALGORITHM: ${{ secrets.OIDC_SIGNING_ALGORITHM }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
ASSET_PREFIX_URL: ${{ vars.ASSET_PREFIX_URL }}
NOTION_OAUTH_CLIENT_ID: ${{ secrets.NOTION_OAUTH_CLIENT_ID }}
NOTION_OAUTH_CLIENT_SECRET: ${{ secrets.NOTION_OAUTH_CLIENT_SECRET }}
SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }}
SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
GOOGLE_SHEETS_CLIENT_ID: ${{ secrets.GOOGLE_SHEETS_CLIENT_ID }}
GOOGLE_SHEETS_CLIENT_SECRET: ${{ secrets.GOOGLE_SHEETS_CLIENT_SECRET }}
GOOGLE_SHEETS_REDIRECT_URL: ${{ secrets.GOOGLE_SHEETS_REDIRECT_URL }}
AIRTABLE_CLIENT_ID: ${{ secrets.AIRTABLE_CLIENT_ID }}
ENTERPRISE_LICENSE_KEY: ${{ secrets.ENTERPRISE_LICENSE_KEY }}
DEFAULT_TEAM_ID: ${{ vars.DEFAULT_TEAM_ID }}
ONBOARDING_DISABLED: ${{ vars.ONBOARDING_DISABLED }}
CUSTOMER_IO_API_KEY: ${{ secrets.CUSTOMER_IO_API_KEY }}
CUSTOMER_IO_SITE_ID: ${{ secrets.CUSTOMER_IO_SITE_ID }}
NEXT_PUBLIC_POSTHOG_API_KEY: ${{ vars.NEXT_PUBLIC_POSTHOG_API_KEY }}
NEXT_PUBLIC_POSTHOG_API_HOST: ${{ vars.NEXT_PUBLIC_POSTHOG_API_HOST }}
NEXT_PUBLIC_FORMBRICKS_API_HOST: ${{ vars.NEXT_PUBLIC_FORMBRICKS_API_HOST }}
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: ${{ vars.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID }}
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID: ${{ vars.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID }}
NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
NODE_ENV: production
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
CLOUDFLARE_DNS_API_TOKEN: ${{ secrets.CLOUDFLARE_DNS_API_TOKEN }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
S3_REGION: ${{ vars.S3_REGION }}
S3_BUCKET_NAME: ${{ vars.S3_BUCKET_NAME }}
OPENTELEMETRY_LISTENER_URL: ${{ vars.OPENTELEMETRY_LISTENER_URL }}
RATE_LIMITING_DISABLED: ${{ vars.RATE_LIMITING_DISABLED }}
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
DB_HOST: ${{ secrets.DB_HOST }}
DB_USER: ${{ secrets.DB_USER }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
DB_NAME: ${{ secrets.DB_NAME }}
REDIS_URL: ${{ secrets.REDIS_URL }}
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.3.0
bundler-cache: true
- name: Install dependencies
run: |
gem install kamal
- uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
- name: Create builder
run: docker buildx create --use --name formbricks-gh-actions-builder
if: steps.buildx.outputs.should_create_builder == 'true'
- name: Push env variables to Kamal
run: |
kamal() { command kamal "$@" -c kamal/deploy.yml; }
kamal env push
- name: Run deploy command
run: |
kamal() { command kamal "$@" -c kamal/deploy.yml; }
set +e
DEPLOY_OUTPUT=$(kamal deploy 2>&1)
DEPLOY_EXIT_CODE=$?
echo "$DEPLOY_OUTPUT"
if [[ "$DEPLOY_OUTPUT" == *"container not unhealthy (healthy)"* ]]; then
echo "Deployment reported healthy container. Considering as success."
kamal lock release
exit 0
else
exit $DEPLOY_EXIT_CODE
fi
shell: bash

View File

@@ -1,128 +0,0 @@
name: Kamal Setup
concurrency:
group: setup-kamal
cancel-in-progress: false
on:
workflow_dispatch: # Only to be triggered when accessories are updated
jobs:
Setup:
runs-on: ubuntu-latest
environment: production
env:
DOCKER_BUILDKIT: 1
IS_FORMBRICKS_CLOUD: ${{ vars.IS_FORMBRICKS_CLOUD }}
WEBAPP_URL: ${{ vars.WEBAPP_URL }}
NEXTAUTH_URL: ${{ vars.NEXTAUTH_URL }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
MIGRATE_DATABASE_URL: ${{ secrets.MIGRATE_DATABASE_URL }}
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
SHORT_URL_BASE: ${{ vars.SHORT_URL_BASE }}
MAIL_FROM: ${{ secrets.MAIL_FROM }}
SMTP_HOST: ${{ secrets.SMTP_HOST }}
SMTP_PORT: ${{ secrets.SMTP_PORT }}
SMTP_USER: ${{ secrets.SMTP_USER }}
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
PRIVACY_URL: ${{ vars.PRIVACY_URL }}
TERMS_URL: ${{ vars.TERMS_URL }}
IMPRINT_URL: ${{ vars.IMPRINT_URL }}
GITHUB_ID: ${{ secrets.FB_GITHUB_ID }}
GITHUB_SECRET: ${{ secrets.FB_GITHUB_SECRET }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
AZUREAD_CLIENT_ID: ${{ secrets.AZUREAD_CLIENT_ID }}
AZUREAD_CLIENT_SECRET: ${{ secrets.AZUREAD_CLIENT_SECRET }}
AZUREAD_TENANT_ID: ${{ secrets.AZUREAD_TENANT_ID }}
OIDC_CLIENT_ID: ${{ secrets.OIDC_CLIENT_ID }}
OIDC_CLIENT_SECRET: ${{ secrets.OIDC_CLIENT_SECRET }}
OIDC_ISSUER: ${{ secrets.OIDC_ISSUER }}
OIDC_DISPLAY_NAME: ${{ secrets.OIDC_DISPLAY_NAME }}
OIDC_SIGNING_ALGORITHM: ${{ secrets.OIDC_SIGNING_ALGORITHM }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
ASSET_PREFIX_URL: ${{ vars.ASSET_PREFIX_URL }}
NOTION_OAUTH_CLIENT_ID: ${{ secrets.NOTION_OAUTH_CLIENT_ID }}
NOTION_OAUTH_CLIENT_SECRET: ${{ secrets.NOTION_OAUTH_CLIENT_SECRET }}
SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }}
SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
GOOGLE_SHEETS_CLIENT_ID: ${{ secrets.GOOGLE_SHEETS_CLIENT_ID }}
GOOGLE_SHEETS_CLIENT_SECRET: ${{ secrets.GOOGLE_SHEETS_CLIENT_SECRET }}
GOOGLE_SHEETS_REDIRECT_URL: ${{ secrets.GOOGLE_SHEETS_REDIRECT_URL }}
AIRTABLE_CLIENT_ID: ${{ secrets.AIRTABLE_CLIENT_ID }}
ENTERPRISE_LICENSE_KEY: ${{ secrets.ENTERPRISE_LICENSE_KEY }}
DEFAULT_TEAM_ID: ${{ vars.DEFAULT_TEAM_ID }}
ONBOARDING_DISABLED: ${{ vars.ONBOARDING_DISABLED }}
CUSTOMER_IO_API_KEY: ${{ secrets.CUSTOMER_IO_API_KEY }}
CUSTOMER_IO_SITE_ID: ${{ secrets.CUSTOMER_IO_SITE_ID }}
NEXT_PUBLIC_POSTHOG_API_KEY: ${{ vars.NEXT_PUBLIC_POSTHOG_API_KEY }}
NEXT_PUBLIC_POSTHOG_API_HOST: ${{ vars.NEXT_PUBLIC_POSTHOG_API_HOST }}
NEXT_PUBLIC_FORMBRICKS_API_HOST: ${{ vars.NEXT_PUBLIC_FORMBRICKS_API_HOST }}
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: ${{ vars.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID }}
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID: ${{ vars.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID }}
NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
NODE_ENV: production
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
CLOUDFLARE_DNS_API_TOKEN: ${{ secrets.CLOUDFLARE_DNS_API_TOKEN }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
S3_REGION: ${{ vars.S3_REGION }}
S3_BUCKET_NAME: ${{ vars.S3_BUCKET_NAME }}
OPENTELEMETRY_LISTENER_URL: ${{ vars.OPENTELEMETRY_LISTENER_URL }}
RATE_LIMITING_DISABLED: ${{ vars.RATE_LIMITING_DISABLED }}
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
DB_HOST: ${{ secrets.DB_HOST }}
DB_USER: ${{ secrets.DB_USER }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
DB_NAME: ${{ secrets.DB_NAME }}
REDIS_URL: ${{ secrets.REDIS_URL }}
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.3.0
bundler-cache: true
- name: Install dependencies
run: |
gem install kamal
- uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
- name: Create builder
run: docker buildx create --use --name formbricks-gh-actions-builder
if: steps.buildx.outputs.should_create_builder == 'true'
- name: Push env variables to Kamal
run: |
kamal() { command kamal "$@" -c kamal/deploy.yml; }
kamal env push
- name: Run setup command
run: |
kamal() { command kamal "$@" -c kamal/deploy.yml; }
set +e
DEPLOY_OUTPUT=$(kamal setup 2>&1)
DEPLOY_EXIT_CODE=$?
echo "$DEPLOY_OUTPUT"
if [[ "$DEPLOY_OUTPUT" == *"container not unhealthy (healthy)"* ]]; then
echo "Deployment reported healthy container. Considering as success."
kamal lock release
exit 0
else
exit $DEPLOY_EXIT_CODE
fi
shell: bash

View File

@@ -1,9 +1,4 @@
name: Docker
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
name: Docker Release to GitHub
on:
workflow_dispatch:
@@ -53,6 +48,17 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set tags based on event type
id: set-tags
run: |
if [[ "${{ github.event_name }}" == "push" ]]; then
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "::set-output name=tags::latest,${{ github.ref }}"
fi
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "::set-output name=tags::experimental"
fi
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
@@ -60,6 +66,7 @@ jobs:
uses: docker/metadata-action@v5 # v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: ${{ steps.set-tags.outputs.tags }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action

View File

@@ -1,44 +0,0 @@
name: Release on Dockerhub
on:
push:
tags:
- "v*"
jobs:
release-image-on-dockerhub:
name: Release on Dockerhub
runs-on: ubuntu-latest
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
steps:
- name: Checkout Repo
uses: actions/checkout@v2
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Get Release Tag
id: extract_release_tag
run: |
TAG=${{ github.ref }}
TAG=${TAG#refs/tags/v}
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
file: ./apps/web/Dockerfile
push: true
tags: |
${{ secrets.DOCKER_USERNAME }}/formbricks:${{ env.RELEASE_TAG }}
${{ secrets.DOCKER_USERNAME }}/formbricks:latest

View File

@@ -24,8 +24,7 @@ export const metadata = {
The Airtable integration allows you to automatically send responses to an Airtable of your choice.
<Note>
This feature is enabled by default in Formbricks Cloud but needs to be self-configured when running a
self-hosted version of Formbricks.
If you are on a self-hosted instance, you will need to configure this integration separately. Please follow the guides [here](/self-hosting/integrations) to configure integrations on your self-hosted instance.
</Note>
## Formbricks Cloud

View File

@@ -21,9 +21,7 @@ export const metadata = {
The Google Sheets integration allows you to automatically send responses to a Google Sheet of your choice.
<Note>
This feature is enabled by default in Formbricks Cloud but needs to be self-configured when running a
self-hosted version of Formbricks. For self-configuration, see additional setup
[below](#setup-in-self-hosted-formbricks).
If you are on a self-hosted instance, you will need to configure this integration separately. Please follow the guides [here](/self-hosting/integrations) to configure integrations on your self-hosted instance.
</Note>
## Connect Google Sheets

View File

@@ -27,7 +27,7 @@ export const metadata = {
Make is a powerful tool to send information between Formbricks and thousands of apps. Here's how to set it up.
<Note>
### Nail down your survey first ? Any changes in the survey cause additional work in the _Scenario_. It
Nailed down your survey?? Any changes in the survey cause additional work in the _Scenario_. It
makes sense to first settle on the survey you want to run and then get to setting up Make.
</Note>

View File

@@ -29,7 +29,7 @@ export const metadata = {
n8n allows you to build flexible workflows focused on deep data integration. And with sharable templates and a user-friendly UI, the less technical people on your team can collaborate on them too. Unlike other tools, complexity is not a limitation. So you can build whatever you want — without stressing over budget. Hook up Formbricks with n8n and you can send your data to 350+ other apps. Here is how to do it.
<Note>
### Nail down your survey first Any changes in the survey cause additional work in the n8n node. It makes
Nail down your survey? Any changes in the survey cause additional work in the n8n node. It makes
sense to first settle on the survey you want to run and then get to setting up n8n.
</Note>

View File

@@ -21,8 +21,7 @@ export const metadata = {
The notion integration allows you to automatically send responses to a Notion database of your choice.
<Note>
This feature is enabled by default in Formbricks Cloud but needs to be self-configured when running a
self-hosted version of Formbricks.
If you are on a self-hosted instance, you will need to configure this integration separately. Please follow the guides [here](/self-hosting/integrations) to configure integrations on your self-hosted instance.
</Note>
## Formbricks Cloud

View File

@@ -10,7 +10,7 @@ export const metadata = {
At Formbricks, we understand the importance of integrating with third-party applications. We have step-by-step guides to configure our third-party integrations with a your Formbricks instance. We currently support the below integrations, click on them to see their individual guides:
<Note>
If you are on a self-hosted instance, you will need to configure these integrations manually. Please follow the guides [here](/self-hosting/integrations) to configure them.
If you are on a self-hosted instance, you will need to configure these integrations manually. Please follow the guides [here](/self-hosting/integrations) to configure integrations on your self-hosted instance.
</Note>
- [Airtable](/developer-docs/integrations/airtable): Automatically send responses to an Airtable of your choice.

View File

@@ -22,8 +22,7 @@ export const metadata = {
The slack integration allows you to automatically send responses to a Slack channel of your choice.
<Note>
This feature is enabled by default in Formbricks Cloud but needs to be self-configured when running a
self-hosted version of Formbricks.
If you are on a self-hosted instance, you will need to configure this integration separately. Please follow the guides [here](/self-hosting/integrations) to configure integrations on your self-hosted instance.
</Note>
## Formbricks Cloud

View File

@@ -8,7 +8,7 @@ export const metadata = {
# Advanced Setup
Quickly set up and start using Formbricks with our [official Docker image](https://github.com/formbricks/formbricks/pkgs/container/formbricks) that we've already built for you.
Quickly set up and start using Formbricks with our [official Docker image](https://github.com/formbricks/formbricks/pkgs/container/formbricks) that we've already built for you.
The pre-built image is ready-to-run, and it only requires minimal configuration on your part. It's as easy as downloading the Docker image and firing up the container.
@@ -104,20 +104,12 @@ You're now ready to start the Formbricks Docker setup. The following command wil
## Update
1. Stop the Formbricks stack
<Note>
Please take a look at our [migration guide](/self-hosting/migration-guide) for version specific steps to
update Formbricks.
</Note>
<Col>
<CodeGroup title="Stop the docker instance">
```bash
docker compose down
```
</CodeGroup>
</Col>
2. Pull the latest changes
1. Pull the latest Formbricks image
<Col>
<CodeGroup title="Pull the changes into docker">
@@ -130,8 +122,20 @@ You're now ready to start the Formbricks Docker setup. The following command wil
</Col>
3. Update env vars as necessary in the docker-compose file.
4. Re-start the Formbricks stack
2. Stop the Formbricks stack
<Col>
<CodeGroup title="Stop the docker instance">
```bash
docker compose down
```
</CodeGroup>
</Col>
3. Re-start the Formbricks stack with the updated image
<Col>
<CodeGroup title="Relaunch the Docker Instance">

View File

@@ -39,10 +39,10 @@ We have step-by-step guides to configure our third-party integrations with a sel
- [Airtable](#airtable)
- [Google Sheets](#google-sheets)
- [Notion](#notion)
- Make: We do not support [Make.com](http://Make.com) for Self-hosted instances yet! Please follow our Cloud guide [here](/integrations#make)
- Make: We do not support for self-hosted instances yet.
- [n8n](#n8n)
- [Slack](#slack)
- [Wordpress]: Wordpress setup is similar to steps mentioned in Cloud [here](/integrations#wordpress), just change the API Host to your self-hosted URL.
- Wordpress: Wordpress setup is similar to the [Cloud setup](/developer-docs/integrations/wordpress), just change the API Host to your self-hosted URL.
- [Zapier](#zapier)
<Note>

View File

@@ -20,7 +20,9 @@ Formbricks v2.0 comes with huge features such as Multi-Language Surveys and Adva
and
<Note>
If you've used the Formbricks Enterprise Edition with a free beta license key, your instance will be downgraded to the Community Edition 2.0. You find all license details on the [license page.](/self-hosting/license/)
If you've used the Formbricks Enterprise Edition with a free beta license key, your instance will be
downgraded to the Community Edition 2.0. You find all license details on the [license
page.](/self-hosting/license/)
</Note>
### Steps to Migrate
@@ -35,7 +37,7 @@ To run all these steps, please navigate to the `formbricks` folder where your `d
<CodeGroup title="Backup Postgres">
```bash
docker exec formbricks-quickstart-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbricks_pre_v2.0_$(date +%Y%m%d_%H%M%S).dump
docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbricks_pre_v2.0_$(date +%Y%m%d_%H%M%S).dump
```
</CodeGroup>
@@ -51,7 +53,19 @@ docker exec formbricks-quickstart-postgres-1 pg_dump -Fc -U postgres -d formbric
restore scenario you will need to use `psql` then with an empty `formbricks` database.
</Note>
2. Stop the running Formbricks instance & remove the related containers:
2. Pull the latest version of Formbricks:
<Col>
<CodeGroup title="Stop the containers">
```bash
docker-compose pull
```
</CodeGroup>
</Col>
3. Stop the running Formbricks instance & remove the related containers:
<Col>
<CodeGroup title="Stop the containers">
@@ -63,7 +77,7 @@ docker-compose down
</CodeGroup>
</Col>
3. Restarting the containers will automatically pull the latest version of Formbricks:
4. Restarting the containers with the latest version of Formbricks:
<Col>
<CodeGroup title="Restart the containers">
@@ -75,7 +89,7 @@ docker-compose up -d
</CodeGroup>
</Col>
4. Now let's migrate the data to the latest schema:
5. Now let's migrate the data to the latest schema:
<Note>To find your Docker Network name for your Postgres Database, find it using `docker network ls`</Note>
@@ -83,6 +97,7 @@ docker-compose up -d
<CodeGroup title="Migrate the data">
```bash
docker pull ghcr.io/formbricks/data-migrations:latest && \
docker run --rm \
--network=formbricks_default \
-e DATABASE_URL="postgresql://postgres:postgres@postgres:5432/formbricks?schema=public" \
@@ -95,7 +110,7 @@ docker run --rm \
The above command will migrate your data to the latest schema. This is a crucial step to migrate your existing data to the new structure. Only if the script runs successful, changes are made to the database. The script can safely run multiple times.
5. That's it! Once the migration is complete, you can **now access your Formbricks instance** at the same URL as before.
6. That's it! Once the migration is complete, you can **now access your Formbricks instance** at the same URL as before.
### App Surveys with @formbricks/js

View File

@@ -32,7 +32,7 @@ export const Layout = ({
</Link>
</div>
<Header />
<Navigation className="hidden lg:mt-10 lg:block" />
<Navigation className="hidden lg:mt-10 lg:block" isMobile={false} />
</div>
</motion.header>
<div className="relative flex h-full flex-col px-4 pt-14 sm:px-6 lg:px-8">

View File

@@ -90,7 +90,7 @@ const MobileNavigationDialog = ({ isOpen, close }: { isOpen: boolean; close: ()
<motion.div
layoutScroll
className="ring-zinc-900/7.5 fixed bottom-0 left-0 top-14 w-full overflow-y-auto bg-white px-4 pb-4 pt-6 shadow-lg shadow-zinc-900/10 ring-1 min-[416px]:max-w-sm sm:px-6 sm:pb-10 dark:bg-zinc-900 dark:ring-zinc-800">
<Navigation />
<Navigation isMobile={true} />
</motion.div>
</Transition.Child>
</Dialog.Panel>

View File

@@ -46,25 +46,41 @@ const NavLink = ({
active = false,
isAnchorLink = false,
}: {
href: string;
href?: string;
children: React.ReactNode;
active: boolean;
isAnchorLink?: boolean;
}) => {
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={clsx(
"flex justify-between gap-2 py-1 pr-3 text-sm transition",
isAnchorLink ? "pl-7" : "pl-4",
active
? "rounded-r-md bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-white"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
)}>
<span className="flex w-full truncate">{children}</span>
</Link>
const commonClasses = clsx(
"flex justify-between gap-2 py-1 pr-3 text-sm transition",
isAnchorLink ? "pl-7" : "pl-4",
active
? "rounded-r-md bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-white"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
);
if (href) {
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={clsx(
"flex justify-between gap-2 py-1 pr-3 text-sm transition",
isAnchorLink ? "pl-7" : "pl-4",
active
? "rounded-r-md bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-white"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
)}>
<span className="flex w-full truncate">{children}</span>
</Link>
);
} else {
return (
<div aria-current={active ? "page" : undefined} className={commonClasses}>
<span className="flex w-full truncate">{children}</span>
</div>
);
}
};
const VisibleSectionHighlight = ({ group, pathname }: { group: NavGroup; pathname: string }) => {
@@ -131,6 +147,7 @@ const NavigationGroup = ({
setActiveGroup,
openGroups,
setOpenGroups,
isMobile,
}: {
group: NavGroup;
className?: string;
@@ -138,6 +155,7 @@ const NavigationGroup = ({
setActiveGroup: (group: NavGroup | null) => void;
openGroups: string[];
setOpenGroups: (groups: string[]) => void;
isMobile: boolean;
}) => {
const isInsideMobileNavigation = useIsInsideMobileNavigation();
const pathname = usePathname();
@@ -171,13 +189,15 @@ const NavigationGroup = ({
{group.links.map((link) => (
<motion.li key={link.title} layout="position" className="relative">
{link.href ? (
<NavLink href={link.href} active={!!pathname?.startsWith(link.href)}>
<NavLink
href={isMobile && link.children ? "" : link.href}
active={!!pathname?.startsWith(link.href)}>
{link.title}
</NavLink>
) : (
<div onClick={() => toggleParentTitle(link.title)}>
<NavLink
href={link.children?.[0]?.href || ""}
href={!isMobile ? link.children?.[0]?.href || "" : undefined}
active={
!!(
isParentOpen(link.title) &&
@@ -197,7 +217,7 @@ const NavigationGroup = ({
</div>
)}
<AnimatePresence mode="popLayout" initial={false}>
{link.children && isParentOpen(link.title) && (
{isActiveGroup && link.children && isParentOpen(link.title) && (
<motion.ul
role="list"
initial={{ opacity: 0 }}
@@ -221,7 +241,11 @@ const NavigationGroup = ({
);
};
export const Navigation = (props: React.ComponentPropsWithoutRef<"nav">) => {
interface NavigationProps extends React.ComponentPropsWithoutRef<"nav"> {
isMobile: boolean;
}
export const Navigation = ({ isMobile, ...props }: NavigationProps) => {
const [activeGroup, setActiveGroup] = useState<NavGroup | null>(navigation[0]);
const [openGroups, setOpenGroups] = useState<string[]>([]);
@@ -237,6 +261,7 @@ export const Navigation = (props: React.ComponentPropsWithoutRef<"nav">) => {
setActiveGroup={setActiveGroup}
openGroups={openGroups}
setOpenGroups={setOpenGroups}
isMobile={isMobile}
/>
))}
<li className="sticky bottom-0 z-10 mt-6 min-[416px]:hidden">

View File

@@ -18,12 +18,11 @@ RUN turbo prune @formbricks/web --docker
FROM base AS installer
# Enable corepack and prepare pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable
# Install necessary build tools and compilers
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
# Set hardcoded environment variables
ENV DATABASE_URL="postgresql://placeholder:for@build:5432/gets_overwritten_at_runtime?schema=public"
ENV NEXTAUTH_SECRET="placeholder_for_next_auth_of_64_chars_get_overwritten_at_runtime"
@@ -59,7 +58,7 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
## step 3: setup production runner
#
FROM base AS runner
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable
RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \
@@ -92,5 +91,5 @@ RUN mkdir -p /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/uploads/
CMD supercronic -quiet /app/docker/cronjobs & \
(cd packages/database && pnpm db:migrate:deploy) && \
(cd packages/database && npm run db:migrate:deploy) && \
exec node apps/web/server.js

View File

@@ -2,7 +2,7 @@
import { formbricksEnabled } from "@/app/lib/formbricks";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useCallback, useEffect } from "react";
import formbricks from "@formbricks/js/app";
import { env } from "@formbricks/lib/env";
@@ -15,22 +15,23 @@ export const FormbricksClient = ({ session }) => {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (formbricksEnabled && session?.user && formbricks) {
formbricks.init({
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
userId: session.user.id,
});
formbricks.setEmail(session.user.email);
}
}, [session]);
const initializeFormbricksAndSetupRouteChanges = useCallback(async () => {
formbricks.init({
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
userId: session.user.id,
});
formbricks.setEmail(session.user.email);
formbricks.registerRouteChange();
}, [session.user.email, session.user.id]);
useEffect(() => {
if (formbricksEnabled && formbricks) {
formbricks?.registerRouteChange();
if (formbricksEnabled && session?.user && formbricks) {
initializeFormbricksAndSetupRouteChanges();
}
}, [pathname, searchParams]);
}, [session, pathname, searchParams, initializeFormbricksAndSetupRouteChanges]);
return null;
};

View File

@@ -200,7 +200,7 @@ export const ActionSettingsTab = ({
This is a code action. Please make changes in your code base.
</p>
) : actionClass.type === "noCode" ? (
<>
<div className="max-h-60 overflow-auto">
<div>
<Label>Select By</Label>
</div>
@@ -225,7 +225,7 @@ export const ActionSettingsTab = ({
setIsInnerHtml={setIsInnerHtml}
register={register}
/>
</>
</div>
) : actionClass.type === "automatic" ? (
<p className="text-sm text-slate-600">
This action was created automatically. You cannot make changes to it.

View File

@@ -205,7 +205,7 @@ export const MainNavigation = ({
{product && (
<aside
className={cn(
"z-20 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded",
environment.type === "development" ? `h-[calc(100vh-1.25rem)]` : "h-screen"
)}>

View File

@@ -11,7 +11,7 @@ interface SideBarProps {
export const TopControlBar = ({ environment, environments }: SideBarProps) => {
return (
<div className="fixed inset-0 top-0 z-10 flex h-14 w-full items-center justify-end bg-slate-50 px-6">
<div className="fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6">
<div className="shadow-xs z-10">
<div className="flex w-fit space-x-2 py-2">
<WidgetStatusIndicator environment={environment} type="mini" />

View File

@@ -3,6 +3,7 @@
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
import FormbricksLogo from "@/images/logo.svg";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { Button } from "@formbricks/ui/Button";
@@ -41,6 +42,12 @@ export const AirtableConnect = ({ environmentId, enabled, webAppUrl }: AirtableC
{!enabled && (
<p className="mb-8 rounded border-slate-200 bg-slate-100 p-3 text-sm">
Airtable Integration is not configured in your instance of Formbricks.
<br />
Please follow the{" "}
<Link href="https://formbricks.com/docs/self-hosting/integrations#airtable" className="underline">
docs
</Link>{" "}
to configure it.
</p>
)}
<Button variant="darkCTA" loading={isConnecting} onClick={handleGoogleLogin} disabled={!enabled}>

View File

@@ -44,7 +44,9 @@ export const Connect = ({ enabled, environmentId, webAppUrl }: ConnectProps) =>
Google Sheets Integration is not configured in your instance of Formbricks.
<br />
Please follow the{" "}
<Link href="https://formbricks.com/docs/integrations/google-sheets" className="underline">
<Link
href="https://formbricks.com/docs/self-hosting/integrations#google-sheets"
className="underline">
docs
</Link>{" "}
to configure it.

View File

@@ -54,7 +54,7 @@ export const Connect = ({ enabled, environmentId, webAppUrl }: ConnectProps) =>
Notion Integration is not configured in your instance of Formbricks.
<br />
Please follow the{" "}
<Link href="https://formbricks.com/docs/integrations/notion" className="underline">
<Link href="https://formbricks.com/docs/self-hosting/integrations#notion" className="underline">
docs
</Link>{" "}
to configure it.

View File

@@ -56,7 +56,7 @@ export const Connect = ({ isEnabled, environmentId, webAppUrl }: ConnectProps) =
Slack Integration is not configured in your instance of Formbricks.
<br />
Please follow the{" "}
<Link href="https://formbricks.com/docs/integrations/slack" className="underline">
<Link href="https://formbricks.com/docs/self-hosting/integrations#slack" className="underline">
docs
</Link>{" "}
to configure it.

View File

@@ -9,9 +9,30 @@ import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service"
import { deleteProduct, getProducts, updateProduct } from "@formbricks/lib/product/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { TEnvironment } from "@formbricks/types/environment";
import { Result, ok } from "@formbricks/types/errorHandlers";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TProduct, TProductUpdateInput } from "@formbricks/types/product";
interface State {
params: { environmentId: string; productId: string };
response?: Result<TProduct>;
}
export const updateProductFormAction = async (state: State, data: FormData): Promise<State> => {
console.log({ state });
const formData = Object.fromEntries(data);
console.log({ formData });
const updatedProduct = await updateProductAction(state.params.environmentId, state.params.productId, {
name: formData.name as string,
});
return {
...state,
response: ok(updatedProduct),
};
};
export const updateProductAction = async (
environmentId: string,
productId: string,

View File

@@ -1,91 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TProduct } from "@formbricks/types/product";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { updateProductAction } from "../actions";
type TEditProductName = {
name: string;
};
type EditProductNameProps = {
product: TProduct;
environmentId: string;
isProductNameEditDisabled: boolean;
};
export const EditProductName: React.FC<EditProductNameProps> = ({
product,
environmentId,
isProductNameEditDisabled,
}) => {
const router = useRouter();
const {
register,
handleSubmit,
formState: { isSubmitting },
watch,
} = useForm<TEditProductName>({
defaultValues: {
name: product.name,
},
});
const productNameValue = watch("name", product.name || "");
const isNotEmptySpaces = (value: string) => value.trim() !== "";
const updateProduct: SubmitHandler<TEditProductName> = async (data) => {
data.name = data.name.trim();
try {
if (!isNotEmptySpaces(data.name)) {
toast.error("Please enter at least one character");
return;
}
if (data.name === product.name) {
toast.success("This is already your product name");
return;
}
const updatedProduct = await updateProductAction(environmentId, product.id, { name: data.name });
if (isProductNameEditDisabled) {
toast.error("Only Owners, Admins and Editors can perform this action.");
throw new Error();
}
if (!!updatedProduct?.id) {
toast.success("Product name updated successfully.");
router.refresh();
}
} catch (err) {
console.error(err);
toast.error(`Error: Unable to save product information`);
}
};
return !isProductNameEditDisabled ? (
<form className="w-full max-w-sm items-center space-y-2" onSubmit={handleSubmit(updateProduct)}>
<Label htmlFor="fullname">What&apos;s your product called?</Label>
<Input
type="text"
id="fullname"
defaultValue={product.name}
{...register("name", { required: true })}
/>
<Button
type="submit"
variant="darkCTA"
size="sm"
loading={isSubmitting}
disabled={!isNotEmptySpaces(productNameValue) || isSubmitting}>
Update
</Button>
</form>
) : (
<p className="text-sm text-red-700">Only Owners, Admins and Editors can perform this action.</p>
);
};

View File

@@ -0,0 +1,116 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRef } from "react";
import { useFormState } from "react-dom";
import { SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
import { TProduct, ZProduct } from "@formbricks/types/product";
import { FormControl, FormField, FormItem, FormLabel, FormMessage, FormProvider } from "@formbricks/ui/Form";
import { Input } from "@formbricks/ui/Input";
import { updateProductAction, updateProductFormAction } from "../actions";
import { SubmitButton } from "./SubmitBtn";
type EditProductNameProps = {
product: TProduct;
environmentId: string;
isProductNameEditDisabled: boolean;
};
const ZProductNameInput = ZProduct.pick({ name: true });
type TEditProductName = z.infer<typeof ZProductNameInput>;
export const EditProductNameForm: React.FC<EditProductNameProps> = ({
product,
environmentId,
isProductNameEditDisabled,
}) => {
const form = useForm<TEditProductName>({
defaultValues: {
name: product.name,
},
resolver: zodResolver(ZProductNameInput),
mode: "onChange",
});
const formRef = useRef<HTMLFormElement>(null);
const [serverState, formAction] = useFormState(updateProductFormAction, {
params: { environmentId, productId: product.id },
});
const { errors, isDirty } = form.formState;
const nameError = errors.name?.message;
// const isSubmitting = form.formState.isSubmitting;
// const updateProduct: SubmitHandler<TEditProductName> = async (data) => {
// const name = data.name.trim();
// try {
// if (nameError) {
// toast.error(nameError);
// return;
// }
// const updatedProduct = await updateProductAction(environmentId, product.id, { name });
// if (isProductNameEditDisabled) {
// toast.error("Only Owners, Admins and Editors can perform this action.");
// return;
// }
// if (!!updatedProduct?.id) {
// toast.success("Product name updated successfully.");
// form.resetField("name", { defaultValue: updatedProduct.name });
// }
// } catch (err) {
// console.error(err);
// toast.error(`Error: Unable to save product information`);
// }
// };
return !isProductNameEditDisabled ? (
<FormProvider {...form}>
<form
ref={formRef}
className="w-full max-w-sm items-center space-y-2"
action={formAction}
onSubmit={(e) =>
form.handleSubmit(() => {
e.preventDefault();
formRef.current?.submit();
})
}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="name">What&apos;s your product called?</FormLabel>
<FormControl>
<Input
type="text"
id="name"
{...field}
placeholder="Product Name"
autoComplete="off"
required
isInvalid={!!nameError}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<SubmitButton />
</form>
</FormProvider>
) : (
<p className="text-sm text-red-700">Only Owners, Admins and Editors can perform this action.</p>
);
};

View File

@@ -1,76 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TProduct } from "@formbricks/types/product";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { updateProductAction } from "../actions";
type EditWaitingTimeFormValues = {
recontactDays: number;
};
type EditWaitingTimeProps = {
environmentId: string;
product: TProduct;
};
export const EditWaitingTime: React.FC<EditWaitingTimeProps> = ({ product, environmentId }) => {
const router = useRouter();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<EditWaitingTimeFormValues>({
defaultValues: {
recontactDays: product.recontactDays,
},
});
const updateWaitingTime: SubmitHandler<EditWaitingTimeFormValues> = async (data) => {
try {
const updatedProduct = await updateProductAction(environmentId, product.id, data);
if (!!updatedProduct?.id) {
toast.success("Waiting period updated successfully.");
router.refresh();
}
} catch (err) {
toast.error(`Error: ${err.message}`);
}
};
return (
<form className="w-full max-w-sm items-center space-y-2" onSubmit={handleSubmit(updateWaitingTime)}>
<Label htmlFor="recontactDays">Wait X days before showing next survey:</Label>
<Input
type="number"
id="recontactDays"
defaultValue={product.recontactDays}
{...register("recontactDays", {
min: { value: 0, message: "Must be a positive number" },
max: { value: 365, message: "Must be less than 365" },
valueAsNumber: true,
required: {
value: true,
message: "Required",
},
})}
/>
{errors?.recontactDays ? (
<div className="my-2">
<p className="text-xs text-red-500">{errors?.recontactDays?.message}</p>
</div>
) : null}
<Button type="submit" variant="darkCTA" size="sm">
Update
</Button>
</form>
);
};

View File

@@ -0,0 +1,90 @@
"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 { TProduct, ZProduct } from "@formbricks/types/product";
import { Button } from "@formbricks/ui/Button";
import { FormControl, FormField, FormItem, FormLabel, FormMessage, FormProvider } from "@formbricks/ui/Form";
import { Input } from "@formbricks/ui/Input";
import { updateProductAction } from "../actions";
type EditWaitingTimeProps = {
environmentId: string;
product: TProduct;
};
const ZProductRecontactDaysInput = ZProduct.pick({ recontactDays: true });
type EditWaitingTimeFormValues = z.infer<typeof ZProductRecontactDaysInput>;
export const EditWaitingTimeForm: React.FC<EditWaitingTimeProps> = ({ product, environmentId }) => {
const form = useForm<EditWaitingTimeFormValues>({
defaultValues: {
recontactDays: product.recontactDays,
},
resolver: zodResolver(ZProductRecontactDaysInput),
mode: "onChange",
});
const { isDirty, isSubmitting } = form.formState;
const updateWaitingTime: SubmitHandler<EditWaitingTimeFormValues> = async (data) => {
try {
const updatedProduct = await updateProductAction(environmentId, product.id, data);
if (!!updatedProduct?.id) {
toast.success("Waiting period updated successfully.");
form.resetField("recontactDays", { defaultValue: updatedProduct.recontactDays });
}
} catch (err) {
toast.error(`Error: ${err.message}`);
}
};
return (
<FormProvider {...form}>
<form
className="flex w-full max-w-sm flex-col space-y-4"
onSubmit={form.handleSubmit(updateWaitingTime)}>
<FormField
control={form.control}
name="recontactDays"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="recontactDays">Wait X days before showing next survey:</FormLabel>
<FormControl>
<Input
type="number"
id="recontactDays"
{...field}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange("");
}
field.onChange(parseInt(value, 10));
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
variant="darkCTA"
size="sm"
className="w-fit"
loading={isSubmitting}
disabled={isSubmitting || !isDirty}>
Update
</Button>
</form>
</FormProvider>
);
};

View File

@@ -0,0 +1,15 @@
"use client";
import { useFormStatus } from "react-dom";
import { Button } from "@formbricks/ui/Button";
export const SubmitButton = () => {
const formStatus = useFormStatus();
return (
<Button type="submit" variant="darkCTA" size="sm" loading={formStatus.pending}>
Update
</Button>
);
};

View File

@@ -15,8 +15,8 @@ import { SettingsId } from "@formbricks/ui/SettingsId";
import { SettingsCard } from "../../settings/components/SettingsCard";
import { DeleteProduct } from "./components/DeleteProduct";
import { EditProductName } from "./components/EditProductName";
import { EditWaitingTime } from "./components/EditWaitingTime";
import { EditProductNameForm } from "./components/EditProductNameForm";
import { EditWaitingTimeForm } from "./components/EditWaitingTimeForm";
const Page = async ({ params }: { params: { environmentId: string } }) => {
const [, product, session, team] = await Promise.all([
@@ -57,7 +57,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
</PageHeader>
<SettingsCard title="Product Name" description="Change your products name.">
<EditProductName
<EditProductNameForm
environmentId={params.environmentId}
product={product}
isProductNameEditDisabled={isProductNameEditDisabled}
@@ -66,7 +66,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
<SettingsCard
title="Recontact Waiting Time"
description="Control how frequently users can be surveyed across all surveys.">
<EditWaitingTime environmentId={params.environmentId} product={product} />
<EditWaitingTimeForm environmentId={params.environmentId} product={product} />
</SettingsCard>
<SettingsCard
title="Delete Product"

View File

@@ -1,134 +0,0 @@
"use client";
import { useState } from "react";
import toast from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { TPlacement } from "@formbricks/types/common";
import { TProduct, TProductUpdateInput } from "@formbricks/types/product";
import { Button } from "@formbricks/ui/Button";
import { Label } from "@formbricks/ui/Label";
import { getPlacementStyle } from "@formbricks/ui/PreviewSurvey/lib/utils";
import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
import { updateProductAction } from "../actions";
const placements = [
{ name: "Bottom Right", value: "bottomRight", disabled: false },
{ name: "Top Right", value: "topRight", disabled: false },
{ name: "Top Left", value: "topLeft", disabled: false },
{ name: "Bottom Left", value: "bottomLeft", disabled: false },
{ name: "Centered Modal", value: "center", disabled: false },
];
interface EditPlacementProps {
product: TProduct;
environmentId: string;
}
export const EditPlacement = ({ product }: EditPlacementProps) => {
const [currentPlacement, setCurrentPlacement] = useState<TPlacement>(product.placement);
const [overlay, setOverlay] = useState(product.darkOverlay ? "darkOverlay" : "lightOverlay");
const [clickOutside, setClickOutside] = useState(product.clickOutsideClose ? "allow" : "disallow");
const [updatingPlacement, setUpdatingPlacement] = useState(false);
const overlayStyle =
currentPlacement === "center" && overlay === "darkOverlay" ? "bg-gray-700/80" : "bg-slate-200";
const handleUpdatePlacement = async () => {
try {
setUpdatingPlacement(true);
let inputProduct: Partial<TProductUpdateInput> = {
placement: currentPlacement,
darkOverlay: overlay === "darkOverlay",
clickOutsideClose: clickOutside === "allow",
};
await updateProductAction(product.id, inputProduct);
toast.success("Placement updated successfully.");
} catch (error) {
toast.error(`Error: ${error.message}`);
} finally {
setUpdatingPlacement(false);
}
};
return (
<div className="w-full items-center">
<div className="flex">
<RadioGroup onValueChange={(e) => setCurrentPlacement(e as TPlacement)} value={currentPlacement}>
{placements.map((placement) => (
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id={placement.value} value={placement.value} disabled={placement.disabled} />
<Label htmlFor={placement.value} className="text-slate-900">
{placement.name}
</Label>
</div>
))}
</RadioGroup>
<div
className={cn(
clickOutside === "disallow" ? "cursor-not-allowed" : "",
"relative ml-8 h-40 w-full rounded",
overlayStyle
)}>
<div
className={cn(
"absolute h-16 w-16 cursor-default rounded bg-slate-700",
getPlacementStyle(currentPlacement)
)}></div>
</div>
</div>
{currentPlacement === "center" && (
<>
<div className="mt-6 space-y-2">
<Label className="font-semibold">Centered modal overlay color</Label>
<RadioGroup onValueChange={(e) => setOverlay(e)} value={overlay} className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="lightOverlay" value="lightOverlay" />
<Label htmlFor="lightOverlay" className="text-slate-900">
Light Overlay
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="darkOverlay" value="darkOverlay" />
<Label htmlFor="darkOverlay" className="text-slate-900">
Dark Overlay
</Label>
</div>
</RadioGroup>
</div>
<div className="mt-6 space-y-2">
<Label className="font-semibold">Allow users to exit by clicking outside the study</Label>
<RadioGroup
onValueChange={(e) => setClickOutside(e)}
value={clickOutside}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="disallow" value="disallow" />
<Label htmlFor="disallow" className="text-slate-900">
Don&apos;t Allow
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="allow" value="allow" />
<Label htmlFor="allow" className="text-slate-900">
Allow
</Label>
</div>
</RadioGroup>
</div>
</>
)}
<Button
variant="darkCTA"
className="mt-4"
size="sm"
loading={updatingPlacement}
onClick={handleUpdatePlacement}>
Save
</Button>
</div>
);
};

View File

@@ -0,0 +1,193 @@
"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 { cn } from "@formbricks/lib/cn";
import { TProduct } from "@formbricks/types/product";
import { Button } from "@formbricks/ui/Button";
import { FormControl, FormField, FormItem, FormLabel, FormProvider } from "@formbricks/ui/Form";
import { Label } from "@formbricks/ui/Label";
import { getPlacementStyle } from "@formbricks/ui/PreviewSurvey/lib/utils";
import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
import { updateProductAction } from "../actions";
const placements = [
{ name: "Bottom Right", value: "bottomRight", disabled: false },
{ name: "Top Right", value: "topRight", disabled: false },
{ name: "Top Left", value: "topLeft", disabled: false },
{ name: "Bottom Left", value: "bottomLeft", disabled: false },
{ name: "Centered Modal", value: "center", disabled: false },
];
interface EditPlacementProps {
product: TProduct;
environmentId: string;
}
const ZProductPlacementInput = z.object({
placement: z.enum(["bottomRight", "topRight", "topLeft", "bottomLeft", "center"]),
darkOverlay: z.boolean(),
clickOutsideClose: z.boolean(),
});
type EditPlacementFormValues = z.infer<typeof ZProductPlacementInput>;
export const EditPlacementForm = ({ product }: EditPlacementProps) => {
const form = useForm<EditPlacementFormValues>({
defaultValues: {
placement: product.placement,
darkOverlay: product.darkOverlay ?? false,
clickOutsideClose: product.clickOutsideClose ?? false,
},
resolver: zodResolver(ZProductPlacementInput),
});
const currentPlacement = form.watch("placement");
const darkOverlay = form.watch("darkOverlay");
const clickOutsideClose = form.watch("clickOutsideClose");
const isSubmitting = form.formState.isSubmitting;
const overlayStyle = currentPlacement === "center" && darkOverlay ? "bg-gray-700/80" : "bg-slate-200";
const onSubmit: SubmitHandler<EditPlacementFormValues> = async (data) => {
try {
await updateProductAction(product.id, {
placement: data.placement,
darkOverlay: data.darkOverlay,
clickOutsideClose: data.clickOutsideClose,
});
toast.success("Placement updated successfully.");
} catch (error) {
toast.error(`Error: ${error.message}`);
}
};
return (
<FormProvider {...form}>
<form className="w-full items-center" onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex">
<FormField
control={form.control}
name="placement"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
onValueChange={(value) => {
field.onChange(value);
}}
className="h-full">
{placements.map((placement) => (
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem
id={placement.value}
value={placement.value}
disabled={placement.disabled}
checked={field.value === placement.value}
/>
<Label htmlFor={placement.value} className="text-slate-900">
{placement.name}
</Label>
</div>
))}
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
<div
className={cn(
clickOutsideClose ? "" : "cursor-not-allowed",
"relative ml-8 h-40 w-full rounded",
overlayStyle
)}>
<div
className={cn(
"absolute h-16 w-16 cursor-default rounded bg-slate-700",
getPlacementStyle(currentPlacement)
)}></div>
</div>
</div>
{currentPlacement === "center" && (
<>
<div className="mt-6 space-y-2">
<FormField
control={form.control}
name="darkOverlay"
render={({ field }) => (
<FormItem>
<FormLabel className="font-semibold">Centered modal overlay color</FormLabel>
<FormControl>
<RadioGroup
onValueChange={(value) => {
field.onChange(value === "darkOverlay");
}}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="lightOverlay" value="lightOverlay" checked={!field.value} />
<Label htmlFor="lightOverlay" className="text-slate-900">
Light Overlay
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="darkOverlay" value="darkOverlay" checked={field.value} />
<Label htmlFor="darkOverlay" className="text-slate-900">
Dark Overlay
</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="mt-6 space-y-2">
<FormField
control={form.control}
name="clickOutsideClose"
render={({ field }) => (
<FormItem>
<FormLabel className="font-semibold">
Allow users to exit by clicking outside the study
</FormLabel>
<FormControl>
<RadioGroup
onValueChange={(value) => {
field.onChange(value === "allow");
}}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="disallow" value="disallow" checked={!field.value} />
<Label htmlFor="disallow" className="text-slate-900">
Don&apos;t Allow
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="allow" value="allow" checked={field.value} />
<Label htmlFor="allow" className="text-slate-900">
Allow
</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</div>
</>
)}
<Button variant="darkCTA" className="mt-4 w-fit" size="sm" loading={isSubmitting}>
Save
</Button>
</form>
</FormProvider>
);
};

View File

@@ -19,7 +19,7 @@ import { PageHeader } from "@formbricks/ui/PageHeader";
import { SettingsCard } from "../../settings/components/SettingsCard";
import { EditFormbricksBranding } from "./components/EditBranding";
import { EditPlacement } from "./components/EditPlacement";
import { EditPlacementForm } from "./components/EditPlacementForm";
import { ThemeStyling } from "./components/ThemeStyling";
const Page = async ({ params }: { params: { environmentId: string } }) => {
@@ -77,7 +77,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
<SettingsCard
title="In-app Survey Placement"
description="Change where surveys will be shown in your web app.">
<EditPlacement product={product} environmentId={params.environmentId} />
<EditPlacementForm product={product} environmentId={params.environmentId} />
</SettingsCard>
<SettingsCard
title="Formbricks Branding"

View File

@@ -71,7 +71,7 @@ export const BulkInviteTab = ({ setOpen, onSubmit, canDoRoleManagement }: BulkIn
return (
<div className="space-y-4">
<div
className="relative flex h-52 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-gray-300"
className="relative flex h-52 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-slate-300 bg-slate-50 transition-colors hover:bg-slate-100"
onClick={() => fileInputRef.current?.click()}>
{csvFile ? (
<XIcon
@@ -97,16 +97,18 @@ export const BulkInviteTab = ({ setOpen, onSubmit, canDoRoleManagement }: BulkIn
</Alert>
)}
</div>
<div className="flex justify-end pt-6">
<div className="flex justify-end">
<div className="flex space-x-2">
<Link
download
href="/sample-csv/formbricks-team-members-template.csv"
target="_blank"
rel="noopener noreferrer">
<Button variant="minimal">Download CSV template</Button>
<Button variant="minimal" size="sm">
Download CSV template
</Button>
</Link>
<Button onClick={onImport} variant="darkCTA" disabled={!csvFile}>
<Button onClick={onImport} size="sm" variant="darkCTA" disabled={!csvFile}>
Import
</Button>
</div>

View File

@@ -77,7 +77,7 @@ export const IndividualInviteTab = ({
</div>
</div>
</div>
<div className="flex justify-end p-6">
<div className="flex justify-end pt-4">
<div className="flex space-x-2">
<Button
size="sm"

View File

@@ -24,6 +24,13 @@ export const POST = async (req: Request, context: Context): Promise<Response> =>
const { userId, environmentId } = context.params;
const jsonInput = await req.json();
// transform all attributes to string if attributes are present
if (jsonInput.attributes) {
for (const key in jsonInput.attributes) {
jsonInput.attributes[key] = String(jsonInput.attributes[key]);
}
}
// validate using zod
const inputValidation = z.object({ attributes: ZAttributes }).safeParse(jsonInput);

View File

@@ -59,7 +59,7 @@ export const POST = async (request: Request): Promise<Response> => {
url: responseInput?.meta?.url,
userAgent: {
browser: agent?.browser.name,
device: agent?.device.type,
device: agent?.device.type || "desktop",
os: agent?.os.name,
},
country: country,

View File

@@ -82,7 +82,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
url: responseInput?.meta?.url,
userAgent: {
browser: agent?.browser.name,
device: agent?.device.type,
device: agent?.device.type || "desktop",
os: agent?.os.name,
},
country: country,

View File

@@ -114,7 +114,7 @@ export const GET = async (
const noCodeActionClasses = actionClasses.filter((actionClass) => actionClass.type === "noCode");
// Define 'transformedSurveys' which can be an array of either TLegacySurvey or TSurvey.
let transformedSurveys: TLegacySurvey[] | TSurvey[] = surveys;
let transformedSurveys: TLegacySurvey[] | TSurvey[] = filteredSurveys;
let state: TJsWebsiteStateSync | TJsWebsiteLegacyStateSync = {
surveys: !isInAppSurveyLimitReached ? transformedSurveys : [],
actionClasses,

View File

@@ -25,6 +25,7 @@
"@formbricks/tailwind-config": "workspace:*",
"@formbricks/types": "workspace:*",
"@formbricks/ui": "workspace:*",
"@hookform/resolvers": "^3.4.2",
"@json2csv/node": "^7.0.6",
"@opentelemetry/auto-instrumentations-node": "^0.46.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.51.1",

View File

@@ -1,5 +1,3 @@
version: "3.3"
# This should be the same as below if you are running via docker compose up
x-webapp-url: &webapp_url http://localhost:3000

View File

@@ -4,6 +4,7 @@ x-environment: &environment
######################################################## REQUIRED ########################################################
# The url of your Formbricks instance used in the admin panel
# Set this to your public-facing URL, e.g., https://example.com
WEBAPP_URL:
# PostgreSQL DB for Formbricks to connect to
@@ -16,7 +17,7 @@ x-environment: &environment
# Set this to your public-facing URL, e.g., https://example.com
# You do not need the NEXTAUTH_URL environment variable in Vercel.
NEXTAUTH_URL: http://localhost:3000
NEXTAUTH_URL:
# Encryption Key is used for 2FA & Single use URLs for Link Surveys
# You can use: $(openssl rand -hex 32) to generate one

View File

@@ -16,11 +16,17 @@ export class AttributeAPI {
async update(
attributeUpdateInput: Omit<TAttributeUpdateInput, "environmentId">
): Promise<Result<{ changed: boolean; message: string }, NetworkError | Error>> {
// transform all attributes to string if attributes are present into a new attributes copy
const attributes: { [key: string]: string } = {};
for (const key in attributeUpdateInput.attributes) {
attributes[key] = String(attributeUpdateInput.attributes[key]);
}
return makeRequest(
this.apiHost,
`/api/v1/client/${this.environmentId}/people/${attributeUpdateInput.userId}/attributes`,
"PUT",
{ attributes: attributeUpdateInput.attributes }
{ attributes }
);
}
}

View File

@@ -8,7 +8,10 @@ import { AppConfig } from "./config";
const appConfig = AppConfig.getInstance();
const logger = Logger.getInstance();
export const updateAttribute = async (key: string, value: string): Promise<Result<void, NetworkError>> => {
export const updateAttribute = async (
key: string,
value: string | number
): Promise<Result<void, NetworkError>> => {
const { apiHost, environmentId, userId } = appConfig.get();
const api = new FormbricksAPI({
@@ -121,7 +124,7 @@ export const setAttributeInApp = async (
return okVoid();
}
const result = await updateAttribute(key, value.toString());
const result = await updateAttribute(key, value);
if (result.ok) {
// udpdate attribute in config

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/js",
"license": "MIT",
"version": "2.0.0",
"version": "2.0.1",
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
"homepage": "https://formbricks.com",
"repository": {

View File

@@ -1,7 +1,7 @@
import { TFormbricksApp } from "@formbricks/js-core/app";
import { TFormbricksWebsite } from "@formbricks/js-core/website";
import { Result, wrapThrowsAsync } from "../../types/errorHandlers";
import { loadFormbricksToProxy } from "./shared/loadFormbricks";
declare global {
interface Window {
@@ -9,97 +9,9 @@ declare global {
}
}
// load the sdk, return the result
const loadFormbricksAppSDK = async (apiHost: string): Promise<Result<void>> => {
if (!window.formbricks) {
const res = await fetch(`${apiHost}/api/packages/app`);
// failed to fetch the app package
if (!res.ok) {
return { ok: false, error: new Error("Failed to load Formbricks App SDK") };
}
const sdkScript = await res.text();
const scriptTag = document.createElement("script");
scriptTag.innerHTML = sdkScript;
document.head.appendChild(scriptTag);
const getFormbricks = async () =>
new Promise<void>((resolve, reject) => {
const checkInterval = setInterval(() => {
if (window.formbricks) {
clearInterval(checkInterval);
resolve();
}
}, 100);
setTimeout(() => {
clearInterval(checkInterval);
reject(new Error("Formbricks App SDK loading timed out"));
}, 10000);
});
try {
await getFormbricks();
return { ok: true, data: undefined };
} catch (error: any) {
// formbricks loading failed, return the error
return {
ok: false,
error: new Error(error.message ?? "Failed to load Formbricks App SDK"),
};
}
}
return { ok: true, data: undefined };
};
type FormbricksAppMethods = {
[K in keyof TFormbricksApp]: TFormbricksApp[K] extends Function ? K : never;
}[keyof TFormbricksApp];
const formbricksProxyHandler: ProxyHandler<TFormbricksApp> = {
get(_target, prop, _receiver) {
return async (...args: any[]) => {
if (!window.formbricks) {
if (prop !== "init") {
console.error(
"🧱 Formbricks - Global error: You need to call formbricks.init before calling any other method"
);
return;
}
// still need to check if the apiHost is passed
if (!args[0]) {
console.error("🧱 Formbricks - Global error: You need to pass the apiHost as the first argument");
return;
}
const { apiHost } = args[0];
const loadSDKResult = await wrapThrowsAsync(loadFormbricksAppSDK)(apiHost);
if (!loadSDKResult.ok) {
console.error(`🧱 Formbricks - Global error: ${loadSDKResult.error.message}`);
return;
}
}
// @ts-expect-error
if (window.formbricks && typeof window.formbricks[prop as FormbricksAppMethods] !== "function") {
console.error(
`🧱 Formbricks - Global error: Formbricks App SDK does not support method ${String(prop)}`
);
return;
}
try {
// @ts-expect-error
return (window.formbricks[prop as FormbricksAppMethods] as Function)(...args);
} catch (error) {
console.error(`Something went wrong: ${error}`);
return;
}
};
return (...args: any[]) => loadFormbricksToProxy(prop as string, "app", ...args);
},
};

View File

@@ -1,2 +0,0 @@
export { default as formbricksApp } from "./app";
export { default as formbricksWebsite } from "./website";

View File

@@ -0,0 +1,38 @@
// Simple queue for formbricks methods
export class MethodQueue {
private queue: (() => Promise<void>)[] = [];
private isExecuting = false;
add = (method: () => Promise<void>) => {
this.queue.push(method);
this.run();
};
private runNext = async () => {
if (this.isExecuting) return;
const method = this.queue.shift();
if (method) {
this.isExecuting = true;
try {
await method();
} finally {
this.isExecuting = false;
if (this.queue.length > 0) {
this.runNext();
}
}
}
};
run = async () => {
if (!this.isExecuting && this.queue.length > 0) {
await this.runNext();
}
};
clear = () => {
this.queue = [];
};
}

View File

@@ -0,0 +1,122 @@
import { Result, wrapThrowsAsync } from "../../../types/errorHandlers";
import { MethodQueue } from "../methodQueue";
let isInitializing = false;
let isInitialized = false;
const methodQueue = new MethodQueue();
// Load the SDK, return the result
const loadFormbricksSDK = async (apiHost: string, sdkType: "app" | "website"): Promise<Result<void>> => {
if (!window.formbricks) {
const res = await fetch(`${apiHost}/api/packages/${sdkType}`);
// Failed to fetch the app package
if (!res.ok) {
return { ok: false, error: new Error(`Failed to load Formbricks ${sdkType} SDK`) };
}
const sdkScript = await res.text();
const scriptTag = document.createElement("script");
scriptTag.innerHTML = sdkScript;
document.head.appendChild(scriptTag);
const getFormbricks = async () =>
new Promise<void>((resolve, reject) => {
const checkInterval = setInterval(() => {
if (window.formbricks) {
clearInterval(checkInterval);
resolve();
}
}, 100);
setTimeout(() => {
clearInterval(checkInterval);
reject(new Error(`Formbricks ${sdkType} SDK loading timed out`));
}, 10000);
});
try {
await getFormbricks();
return { ok: true, data: undefined };
} catch (error: any) {
return {
ok: false,
error: new Error(error.message ?? `Failed to load Formbricks ${sdkType} SDK`),
};
}
}
return { ok: true, data: undefined };
};
// TODO: @pandeymangg - Fix these types
// type FormbricksAppMethods = {
// [K in keyof TFormbricksApp]: TFormbricksApp[K] extends Function ? K : never;
// }[keyof TFormbricksApp];
// type FormbricksWebsiteMethods = {
// [K in keyof TFormbricksWebsite]: TFormbricksWebsite[K] extends Function ? K : never;
// }[keyof TFormbricksWebsite];
export const loadFormbricksToProxy = async (prop: string, sdkType: "app" | "website", ...args: any[]) => {
const executeMethod = async () => {
try {
// @ts-expect-error
return await (window.formbricks[prop] as Function)(...args);
} catch (error) {
console.error(`🧱 Formbricks - Global error: ${error}`);
throw error;
}
};
if (!isInitialized) {
if (isInitializing) {
methodQueue.add(executeMethod);
} else {
if (prop === "init") {
isInitializing = true;
const initialize = async () => {
const { apiHost } = args[0];
const loadSDKResult = await wrapThrowsAsync(loadFormbricksSDK)(apiHost, sdkType);
if (!loadSDKResult.ok) {
isInitializing = false;
console.error(`🧱 Formbricks - Global error: ${loadSDKResult.error.message}`);
return;
}
try {
const result = await (window.formbricks[prop] as Function)(...args);
isInitialized = true;
isInitializing = false;
return result;
} catch (error) {
isInitializing = false;
console.error(`🧱 Formbricks - Global error: ${error}`);
throw error;
}
};
methodQueue.add(initialize);
} else {
console.error(
"🧱 Formbricks - Global error: You need to call formbricks.init before calling any other method"
);
return;
}
}
} else {
// @ts-expect-error
if (window.formbricks && typeof window.formbricks[prop] !== "function") {
console.error(
`🧱 Formbricks - Global error: Formbricks ${sdkType} SDK does not support method ${String(prop)}`
);
return;
}
methodQueue.add(executeMethod);
return;
}
};

View File

@@ -1,7 +1,7 @@
import { TFormbricksApp } from "@formbricks/js-core/app";
import { TFormbricksWebsite } from "@formbricks/js-core/website";
import { Result, wrapThrowsAsync } from "../../types/errorHandlers";
import { loadFormbricksToProxy } from "./shared/loadFormbricks";
declare global {
interface Window {
@@ -9,97 +9,11 @@ declare global {
}
}
// load the sdk, return the result
const loadFormbricksWebsiteSDK = async (apiHost: string): Promise<Result<void>> => {
if (!window.formbricks) {
const res = await fetch(`${apiHost}/api/packages/website`);
// failed to fetch the app package
if (!res.ok) {
return { ok: false, error: new Error("Failed to load Formbricks Website SDK") };
}
const sdkScript = await res.text();
const scriptTag = document.createElement("script");
scriptTag.innerHTML = sdkScript;
document.head.appendChild(scriptTag);
const getFormbricks = async () =>
new Promise<void>((resolve, reject) => {
const checkInterval = setInterval(() => {
if (window.formbricks) {
clearInterval(checkInterval);
resolve();
}
}, 100);
setTimeout(() => {
clearInterval(checkInterval);
reject(new Error("Formbricks Website SDK loading timed out"));
}, 10000);
});
try {
await getFormbricks();
return { ok: true, data: undefined };
} catch (error: any) {
// formbricks loading failed, return the error
return {
ok: false,
error: new Error(error.message ?? "Failed to load Formbricks Website SDK"),
};
}
}
return { ok: true, data: undefined };
};
type FormbricksWebsiteMethods = {
[K in keyof TFormbricksWebsite]: TFormbricksWebsite[K] extends Function ? K : never;
}[keyof TFormbricksWebsite];
const formbricksProxyHandler: ProxyHandler<TFormbricksWebsite> = {
get(_target, prop, _receiver) {
return async (...args: any[]) => {
if (!window.formbricks) {
if (prop !== "init") {
console.error(
"🧱 Formbricks - Global error: You need to call formbricks.init before calling any other method"
);
return;
}
// still need to check if the apiHost is passed
if (!args[0]) {
console.error("🧱 Formbricks - Global error: You need to pass the apiHost as the first argument");
return;
}
const { apiHost } = args[0];
const loadSDKResult = await wrapThrowsAsync(loadFormbricksWebsiteSDK)(apiHost);
if (!loadSDKResult.ok) {
console.error(`🧱 Formbricks - Global error: ${loadSDKResult.error.message}`);
return;
}
}
if (window.formbricks && typeof window.formbricks[prop as FormbricksWebsiteMethods] !== "function") {
console.error(
`🧱 Formbricks - Global error: Formbricks Website SDK does not support method ${String(prop)}`
);
return;
}
try {
return (window.formbricks[prop as FormbricksWebsiteMethods] as Function)(...args);
} catch (error) {
console.error(`🧱 Formbricks - Global error: Something went wrong: ${error}`);
return;
}
};
return (...args: any[]) => loadFormbricksToProxy(prop as string, "website", ...args);
},
};
const formbricksApp: TFormbricksWebsite = new Proxy({} as TFormbricksWebsite, formbricksProxyHandler);
export default formbricksApp;
const formbricksWebsite: TFormbricksWebsite = new Proxy({} as TFormbricksWebsite, formbricksProxyHandler);
export default formbricksWebsite;

View File

@@ -208,8 +208,8 @@ export const RatingQuestion = ({
))}
</div>
<div className="text-subheading mt-4 flex justify-between px-1.5 text-xs leading-6">
<p className="w-1/2 text-left">{getLocalizedValue(question.lowerLabel, "default")}</p>
<p className="w-1/2 text-right">{getLocalizedValue(question.upperLabel, "default")}</p>
<p className="w-1/2 text-left">{getLocalizedValue(question.lowerLabel, languageCode)}</p>
<p className="w-1/2 text-right">{getLocalizedValue(question.upperLabel, languageCode)}</p>
</div>
</fieldset>
</div>

View File

@@ -1,3 +1,4 @@
import { cn } from "@/lib/utils";
import { useEffect, useRef, useState } from "preact/hooks";
interface ScrollableContainerProps {
@@ -58,10 +59,8 @@ export const ScrollableContainer = ({ children }: ScrollableContainerProps) => {
scrollbarGutter: "stable both-edges",
maxHeight: isSurveyPreview ? "40dvh" : "60dvh",
}}
className={`overflow-${isOverflowHidden ? "hidden" : "auto"} px-4 pb-1`}
className={cn("overflow-auto px-4 pb-1", isOverflowHidden ? "no-scrollbar" : "bg-survey-bg")}
onMouseEnter={() => toggleOverflow(false)}
onTouchStart={() => toggleOverflow(false)}
onTouchEnd={() => toggleOverflow(true)}
onMouseLeave={() => toggleOverflow(true)}>
{children}
</div>

View File

@@ -130,6 +130,7 @@ export const StackedCardsContainer = ({
<div style={{ height: cardHeight }}></div>
{cardArrangement === "simple" ? (
<div
className="w-full"
style={{
...borderStyles,
}}>

View File

@@ -104,3 +104,18 @@ p.fb-editor-paragraph {
width: 0%;
}
}
.no-scrollbar {
-ms-overflow-style: none !important; /* Internet Explorer 10+ */
scrollbar-width: thin !important; /* Firefox */
scrollbar-color: transparent transparent !important; /* Firefox */
/* Chrome, Edge, and Safari */
&::-webkit-scrollbar {
width: 0 !important;
background: transparent !important;
}
&::-webkit-scrollbar-thumb {
background: transparent !important;
}
}

View File

@@ -3,7 +3,7 @@ import { z } from "zod";
export const ZAttributeUpdateInput = z.object({
environmentId: z.string().cuid2(),
userId: z.string(),
attributes: z.record(z.string()),
attributes: z.record(z.union([z.string(), z.number()])),
});
export type TAttributeUpdateInput = z.infer<typeof ZAttributeUpdateInput>;

View File

@@ -41,10 +41,14 @@ export const ZProduct = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
updatedAt: z.date(),
name: z.string(),
name: z.string().trim().min(1, { message: "Product name cannot be empty" }),
teamId: z.string(),
styling: ZProductStyling,
recontactDays: z.number().int(),
recontactDays: z
.number({ message: "Recontact days is required" })
.int()
.min(0, { message: "Must be a positive number" })
.max(365, { message: "Must be less than 365" }),
inAppSurveyBranding: z.boolean(),
linkSurveyBranding: z.boolean(),
placement: ZPlacement,

View File

@@ -1,7 +1,7 @@
import { Label } from "@radix-ui/react-dropdown-menu";
import clsx from "clsx";
import { Control, Controller, UseFormRegister } from "react-hook-form";
import { cn } from "@formbricks/lib/cn";
import { TActionClass } from "@formbricks/types/actionClasses";
import { AdvancedOptionToggle } from "../../AdvancedOptionToggle";
@@ -42,9 +42,9 @@ export const PageUrlSelector = ({
title="Page URL"
description="If a user visits a specific URL"
childBorder={true}>
<div className="col-span-1 space-y-3 p-4">
<div className="grid grid-cols-3 gap-x-8">
<div className="col-span-1">
<div className="col-span-1 w-full space-y-3 p-4">
<div className="flex w-full items-end gap-2">
<div>
<Label>URL</Label>
<Controller
name="noCodeConfig.pageUrl.rule"
@@ -66,7 +66,7 @@ export const PageUrlSelector = ({
)}
/>
</div>
<div className="col-span-2 flex items-end">
<div className="flex flex-1 items-end">
<Input
type="text"
className="bg-white"
@@ -81,7 +81,7 @@ export const PageUrlSelector = ({
Enter a URL to see if a user visiting it would be tracked.
</div>
<div className=" rounded bg-slate-50">
<div className="mt-1 flex">
<div className="mt-1 flex items-end">
<Input
type="text"
value={testUrl}
@@ -90,7 +90,7 @@ export const PageUrlSelector = ({
setTestUrl(e.target.value);
setIsMatch("default");
}}
className={clsx(
className={cn(
isMatch === "yes"
? "border-green-500 bg-green-50"
: isMatch === "no"

154
packages/ui/Form/index.tsx Normal file
View File

@@ -0,0 +1,154 @@
"use client";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form";
import { cn } from "@formbricks/lib/cn";
import { Label } from "../Label";
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
}
);
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return <Label ref={ref} className={cn(error && "text-error", className)} htmlFor={formItemId} {...props} />;
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
);
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p ref={ref} id={formMessageId} className={cn("text-error text-sm", className)} {...props}>
{body}
</p>
);
}
);
FormMessage.displayName = "FormMessage";
export {
useFormField,
FormProvider,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@@ -17,7 +17,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, isInv
className={cn(
"focus:border-brand-dark flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300",
className,
isInvalid && "border border-red-600 focus:border-red-600"
isInvalid && "border-error focus:border-error border"
)}
ref={ref}
{...props}

View File

@@ -50,8 +50,7 @@ const DialogContent = React.forwardRef<
`${noPadding ? "" : "px-4 pb-4 pt-5 sm:p-6"}`,
"data-[state='closed']:animate-fadeOut data-[state='open']:animate-fadeIn",
size && sizeClassName && sizeClassName[size],
className,
"max-h-screen overflow-y-auto"
className
)}
{...props}
onPointerDownOutside={(e) => {

View File

@@ -301,7 +301,7 @@ export const SingleResponseCard = ({
<div className={clsx("group relative", isOpen && "min-h-[300px]")}>
<div
className={clsx(
"relative z-30 my-6 rounded-xl border border-slate-200 bg-white shadow-sm transition-all",
"relative z-20 my-6 rounded-xl border border-slate-200 bg-white shadow-sm transition-all",
pageType === "response" &&
(isOpen
? "w-3/4"

View File

@@ -128,8 +128,8 @@ export const SurveyCard = ({
key={survey.id}
className="relative grid w-full grid-cols-8 place-items-center gap-3 rounded-xl border border-slate-200 bg-white p-4
shadow-sm transition-all ease-in-out hover:scale-[101%]">
<div className="col-span-2 flex max-w-full items-center justify-self-start truncate whitespace-nowrap text-sm font-medium text-slate-900">
{survey.name}
<div className="col-span-2 flex max-w-full items-center justify-self-start text-sm font-medium text-slate-900">
<div className="w-full truncate">{survey.name}</div>
</div>
<div
className={cn(

11
pnpm-lock.yaml generated
View File

@@ -333,6 +333,9 @@ importers:
'@formbricks/ui':
specifier: workspace:*
version: link:../../packages/ui
'@hookform/resolvers':
specifier: ^3.4.2
version: 3.4.2(react-hook-form@7.51.4)
'@json2csv/node':
specifier: ^7.0.6
version: 7.0.6
@@ -3429,6 +3432,14 @@ packages:
tailwindcss: 3.4.3
dev: false
/@hookform/resolvers@3.4.2(react-hook-form@7.51.4):
resolution: {integrity: sha512-1m9uAVIO8wVf7VCDAGsuGA0t6Z3m6jVGAN50HkV9vYLl0yixKK/Z1lr01vaRvYCkIKGoy1noVRxMzQYb4y/j1Q==}
peerDependencies:
react-hook-form: ^7.0.0
dependencies:
react-hook-form: 7.51.4(react@18.3.1)
dev: false
/@httptoolkit/websocket-stream@6.0.1:
resolution: {integrity: sha512-A0NOZI+Glp3Xgcz6Na7i7o09+/+xm2m0UCU8gdtM2nIv6/cjLmhMZMqehSpTlgbx9omtLmV8LVqOskPEyWnmZQ==}
dependencies: