Compare commits

..

97 Commits

Author SHA1 Message Date
Anshuman Pandey
1e2fe7b066 fix: validation UI (#3137)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2024-09-12 11:52:58 +00:00
Johannes
426a0a3847 fix: tweak action UI (#3136) 2024-09-12 11:50:58 +00:00
Jonas Höbenreich
80ef504bef fix: Device Info Overflowing for Long URLs (#3135)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2024-09-12 11:44:41 +00:00
Anshuman Pandey
6ab83e25d3 fix: multi lang issue sync (#3132)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2024-09-12 10:59:15 +00:00
Jonas Höbenreich
688dc25990 fix: cuid validation 🚨 (#3130) 2024-09-12 10:26:33 +00:00
Johannes
c53b58c64f fix: update RN docs (#3127) 2024-09-11 12:00:04 +02:00
Piyush Gupta
61d18edb5d feat: add support for webp file format in file inputs (#3117)
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
2024-09-11 08:53:05 +00:00
Anshuman Pandey
07b5dfe28a fix: trigger (#3125) 2024-09-11 04:31:37 +00:00
Anshuman Pandey
f55cad0121 fix: multi lang sync issue (#3121)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2024-09-10 12:37:55 +00:00
Dhruwang Jariwala
494299cd89 fix: optional behaviour for matrix question (#3118) 2024-09-10 12:32:42 +00:00
RajuGangitla
5cc071e5a8 fix: Loading Skeleton for survey summary page (#3108)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
2024-09-09 15:11:56 +00:00
Anshuman Pandey
0532f2744b feat: delete uploaded files with response deletion (#3114) 2024-09-09 12:24:25 +00:00
Anshuman Pandey
43ea26a33a fix: segment update (#3115) 2024-09-09 11:47:38 +00:00
Piyush Gupta
ec54e40a8b fix: SSRF vulnerability in unsplash image fetching (#3111)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
2024-09-09 10:46:07 +00:00
Dhruwang Jariwala
4b508f02e3 fix: added people column to data table (#3110)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-09-09 04:30:35 +00:00
Johannes
eec7e1b62a fix: update docs (#3113) 2024-09-08 15:29:32 +02:00
Johannes
39e87eb8d3 fix: remove description from new questions (#3112) 2024-09-07 14:25:55 +02:00
Dhruwang Jariwala
780115ffb8 fix: infinite loading in survey animation (#3107) 2024-09-06 07:02:57 +00:00
Anshuman Pandey
c7c4ba6e49 release 2.5.1 (#3103) 2024-09-05 12:55:29 +02:00
Piyush Gupta
b9a7edf1f5 fix: create response via api can change created and updated at (#3091) 2024-09-05 10:34:42 +00:00
Dhruwang Jariwala
86bf2accc9 style: tweak environment switch (#3102) 2024-09-05 10:29:59 +00:00
Anshuman Pandey
954c435404 fix: setAttribute error in the js package (#3099) 2024-09-05 09:23:49 +00:00
Johannes
538c1bd809 feat: add new template to GitHub (#3101) 2024-09-05 11:33:00 +02:00
Johannes
e0767881f2 fix: update link to landing page (#3097) 2024-09-05 09:21:09 +02:00
Dhruwang Jariwala
7d0cbad326 feat: one response per email (#3088)
Co-authored-by: Johannes <johannes@formbricks.com>
2024-09-04 12:43:03 +00:00
Dhruwang Jariwala
0ce7703ab8 fix: data table tweaks (#3095) 2024-09-04 12:40:51 +00:00
Dhruwang Jariwala
4362fdf35a fix: verified email to exports (#3094) 2024-09-04 12:35:29 +00:00
Dhruwang Jariwala
4bcca2daf4 fix: hidden fields in data table (#3087)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2024-09-04 08:45:30 +00:00
Piyush Gupta
a1d83ac7b9 fix: final bugs in release 2.5 (#3084) 2024-09-03 12:22:28 +00:00
Piyush Gupta
492729baf3 docs: adds data migration guide for formbricks 2.5 release (#3083) 2024-09-03 10:54:25 +00:00
Dhruwang Jariwala
4003d21826 feat: Relevance option for sorting (#2968)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
2024-09-03 10:43:55 +00:00
Piyush Gupta
b10d398728 fix: Summary auto-update also queries when not in focus (#3067)
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
2024-09-03 09:22:18 +00:00
Dhruwang Jariwala
198df84b89 fix: NPS response exported as empty string (#3080)
Co-authored-by: Johannes <johannes@formbricks.com>
2024-09-03 08:58:48 +00:00
Dhruwang Jariwala
211fc22b2a fix: docs app build (#3081) 2024-09-03 08:30:48 +00:00
Dhruwang Jariwala
4a0a5c9591 feat: Data table view for Responses (#3053)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
2024-09-03 03:19:58 +00:00
Sai Suhas Sawant
8f51afe198 feat: skeleton loading #3051 (#3057)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2024-09-02 15:16:50 +00:00
Dhruwang Jariwala
40b6642ef0 fix: caching issue for resultShareKey (#3063) 2024-09-02 14:35:54 +00:00
Dhruwang Jariwala
0076cbaf54 fix: Validation for callback URL (#3061) 2024-09-02 13:15:57 +00:00
Dhruwang Jariwala
14e0d57091 chore: removed environmentId from language services (#3070) 2024-09-02 12:57:55 +00:00
Johannes
3e79ec9a61 fix: change question type CTA wording (#3076) 2024-08-31 11:22:16 +02:00
Johannes
42cd7d3b77 feat: reword RN docs and tweak for SEO (#3073) 2024-08-30 18:47:56 +02:00
Dhruwang Jariwala
6f4c65c178 fix: tweaked preview (#3072)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-08-30 15:31:38 +00:00
Naitik Kapadia
863424ffd7 feat: Add Ranking Question (#2996)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2024-08-30 11:21:36 +00:00
Dhruwang Jariwala
c35cfbd170 fix: reset survey button (#3069) 2024-08-30 11:00:51 +00:00
Dhruwang Jariwala
22425726d1 fix: other filter not working (#3041)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2024-08-29 12:53:15 +00:00
Anshuman Pandey
94e8c1da68 fix: survey cache invalidation in survey-status cron (#3066) 2024-08-29 12:27:35 +00:00
Dhruwang Jariwala
55c305c569 fix: rounded-corner-gap=issue (#3064)
Co-authored-by: Johannes <johannes@formbricks.com>
2024-08-29 12:26:25 +00:00
Piyush Gupta
ad028947e0 fix: new survey button visible to viewers (#3065) 2024-08-29 11:31:39 +00:00
mdm317
212e0753c8 fix: closing survey on mobile falsely positions it (#3004)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2024-08-27 12:24:47 +00:00
Matti Nannt
711dc83f5c fix: build errors on docs page (#3056) 2024-08-27 10:35:01 +02:00
Matti Nannt
6314eeda0a chore: release js 2.2.0 (#3055) 2024-08-27 10:01:21 +02:00
Anshuman Pandey
cf783ea480 fix: s3 file upload (#3050)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-08-26 14:43:25 +00:00
Filip Gornitzka Abelson
899fbef948 feat: replace emojis in survey editor and survey fetching (#3047)
Co-authored-by: Johannes <johannes@formbricks.com>
2024-08-26 12:39:41 +00:00
Anshuman Pandey
4a6d7952a7 fix: segment evaluation fix (#3043) 2024-08-26 05:04:37 +00:00
Matti Nannt
5443226e27 chore: increase react-native version number to 1.0.0 (#3046) 2024-08-23 21:31:57 +02:00
ty kerr
89ffe99dcc feat: react native sdk (#2565)
Co-authored-by: tykerr <tykerr@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-08-23 12:43:49 +02:00
Dhruwang Jariwala
ede306b88e fix: matrix question validation (#3030)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-08-23 10:05:28 +00:00
Dhruwang Jariwala
0acc49c57d fix: open outbound links in new tab (#3042) 2024-08-23 10:11:22 +02:00
Matti Nannt
2bbeb040c2 fix: use createdAt instead of updatedAt in responseCard (#3040) 2024-08-22 11:14:08 +00:00
Piyush Gupta
5689c36b12 feat: adds management endpoint to generate multiple suId (#3022)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-08-22 09:55:13 +00:00
Anshuman Pandey
f4a367d2de feat: survey variables (#3013)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-08-22 09:43:39 +00:00
Dhruwang Jariwala
afe042ecfc fix: member invite issues (#3028)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-08-22 08:55:44 +00:00
Anshuman Pandey
c108cd4780 fix: js command queue support for late initialization (#2974) 2024-08-21 11:12:30 +00:00
Dhruwang Jariwala
d84146fd88 fix: display creation (#3034) 2024-08-21 07:03:34 +00:00
use-tusk[bot]
30e6316e16 feat: Add support for managing path style in S3 compatible service (#3031)
Co-authored-by: use-tusk[bot] <144006087+use-tusk[bot]@users.noreply.github.com>
2024-08-20 10:16:48 +00:00
Dhruwang Jariwala
6835e585b0 fix: missing question type in Add question below menu (#3029) 2024-08-20 08:59:17 +02:00
Matti Nannt
49d4f43652 chore: add address collection to stripe checkout (#3025) 2024-08-19 14:37:22 +00:00
Matti Nannt
70dd9c7724 fix: dismissed inconsistency data migration fails with many entries (#3021) 2024-08-19 12:20:39 +02:00
Piyush Gupta
f386e47efa fix: Let CTA and consent question store nothing if dismissed (#2977)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-08-19 08:50:19 +00:00
Piyush Gupta
ec16159497 chore: Move all server action to new authorization approach (#2999) 2024-08-19 07:07:37 +00:00
Vishal Tyagi
5ba2959eb4 feat: Added ColorPicker component to Storybook (#3019) 2024-08-19 06:59:10 +00:00
Saurabh Chaddha
e9c5b00628 fix: recall info on thank you card (#3016) 2024-08-16 22:11:15 +02:00
Johannes
8e1f43eb8b fix: update upgrade plan notice (#3018) 2024-08-16 22:10:36 +02:00
Matti Nannt
3788293bc0 chore: npm dependency and version upgrades before release (#3014) 2024-08-15 21:18:05 +02:00
Matti Nannt
89a2e26f25 fix: remove failing prisma migration (#3015) 2024-08-15 16:42:43 +02:00
Piyush Gupta
2c453bd491 fix: Filters do not apply to summary impressions (#2986)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-08-15 14:02:34 +00:00
Matti Nannt
0aebf234f9 fix: styles get overwritten by loading indicator changes (#3012) 2024-08-15 14:09:29 +02:00
Dhruwang Jariwala
3e25ef4b5a fix: wait for background to load in survey loading indicator (#3011) 2024-08-15 06:53:47 +00:00
Dhruwang Jariwala
3dc3edb83e fix: progress bar on welcome card (#3006) 2024-08-15 06:51:41 +00:00
Piyush Gupta
4ebe144191 feat: Add auto-refresh to the analysis view (#3007)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-08-15 06:51:26 +00:00
Dhruwang Jariwala
49cd06a9b4 feat: Loading indicator for link surveys (#2982)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2024-08-14 16:34:02 +00:00
Anshuman Pandey
e0208da0ac fix: sync call in e2e tests (#3010) 2024-08-14 13:06:07 +00:00
Anshuman Pandey
1f41770060 fix: catching errors in integrations (#2989) 2024-08-13 10:26:35 +00:00
Dhruwang Jariwala
3eff06281c fix: surveyEditor issues (#2997) 2024-08-13 10:23:47 +00:00
Matti Nannt
5848dfb4f3 fix: next-config env variables are not unique (#3003) 2024-08-12 17:42:35 +02:00
Piyush Gupta
89f27adce5 fix: adds default weekly summary notification value in signin (#2993)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-08-12 14:59:53 +00:00
Dhruwang Jariwala
bbab7fa672 fix: linting warnings in email package (#2998) 2024-08-12 14:45:50 +00:00
Yuhang
fb149796fa docs: add deploy to zeabur button in readme (#3000) 2024-08-12 16:13:10 +02:00
Piyush Gupta
3939013415 fix: setup shell script, removes input case sensitivity (#3002) 2024-08-12 16:09:37 +02:00
Anantesh G
7fe18e99c3 fix: Open external links in a new tab #2994 (#2995) 2024-08-10 11:08:27 +02:00
Anshuman Pandey
6ddfd29be8 fix: embed survey height (#2990) 2024-08-09 11:26:58 +02:00
Piyush Gupta
46efad94db fix: rating question email embed alignment (#2988) 2024-08-09 10:30:47 +02:00
Johannes
b7ea073204 fix: sort features, update wording (#2985) 2024-08-09 08:26:14 +02:00
Matti Nannt
0d9c90ceeb fix: docker image permission issue (#2987)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2024-08-08 17:55:25 +02:00
Johannes
3ba23e1787 fix: fix typo in self-hosting license page (#2980) 2024-08-08 09:46:02 +02:00
Matthias Nannt
e365718556 Merge branch 'main' of github.com:formbricks/formbricks 2024-08-07 23:44:27 +02:00
Matthias Nannt
d65a49a7e7 fix: data migration type error 2024-08-07 23:40:11 +02:00
459 changed files with 16941 additions and 5512 deletions

View File

@@ -11,33 +11,26 @@ WEBAPP_URL=http://localhost:3000
# Required for next-auth. Should be the same as WEBAPP_URL
NEXTAUTH_URL=http://localhost:3000
# Set this if you want to have a shorter link for surveys
SHORT_URL_BASE=
# Encryption keys
# Please set both for now, we will change this in the future
# You can use: `openssl rand -hex 32` to generate one
ENCRYPTION_KEY=
# @see: https://next-auth.js.org/configuration/options#nextauth_secret
# You can use: `openssl rand -hex 32` to generate a secure one
NEXTAUTH_SECRET=
# API Secret for running cron jobs. (mandatory)
# You can use: `openssl rand -hex 32` to generate a secure one
CRON_SECRET=
##############
# DATABASE #
##############
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
###############
# NEXT AUTH #
###############
# @see: https://next-auth.js.org/configuration/options#nextauth_secret
# You can use: `openssl rand -hex 32` to generate a secure one
NEXTAUTH_SECRET=RANDOM_STRING
# API Secret for running cron jobs. (mandatory)
# You can use: `openssl rand -hex 32` to generate a secure one
CRON_SECRET=RANDOM_STRING
################
# MAIL SETUP #
################
@@ -77,6 +70,8 @@ S3_BUCKET_NAME=
# Configure a third party S3 compatible storage service endpoint like StorJ leave empty if you use Amazon S3
# e.g., https://gateway.storjshare.io
S3_ENDPOINT_URL=
# Force path style for S3 compatible storage (0 for disabled, 1 for enabled)
S3_FORCE_PATH_STYLE=0
#####################
# Disable Features #

View File

@@ -0,0 +1,33 @@
name: oss.gg hack submission 🕹️
description: "Submit your contribution for the for the oss.gg hackathon"
title: "[oss.gg hackathon]"
labels: 🕹️ oss.gg, player submission
assignees: []
body:
- type: textarea
id: contribution-name
attributes:
label: What side quest or challenge are you solving?
description: Add the name of the side quest or challenge.
validations:
required: true
- type: textarea
id: points
attributes:
label: Points
description: How many points are assigned to this contribution?
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: What's the task your performed?
validations:
- type: textarea
id: proof
attributes:
label: Provide proof that you've completed the task
description: Screenshots, loom recordings, links to the content you shared or interacted with.
validations:
required: true

View File

@@ -53,10 +53,12 @@ runs:
run: cp .env.example .env
shell: bash
- name: Fill ENCRYPTION_KE, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
shell: bash

View File

@@ -2,6 +2,8 @@ name: E2E Tests
on:
workflow_call:
workflow_dispatch:
env:
TELEMETRY_DISABLED: 1
jobs:
build:
name: Run E2E Tests
@@ -25,29 +27,35 @@ jobs:
- uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- name: Build & Cache Web Binaries
uses: ./.github/actions/cache-build-web
- name: Setup Node.js 20.x
uses: actions/setup-node@v3
with:
e2e_testing_mode: "1"
- name: Check if pnpm is installed
id: pnpm-check
run: |
if pnpm --version; then
echo "pnpm is installed."
echo "PNPM_INSTALLED=true" >> $GITHUB_ENV
else
echo "pnpm is not installed."
echo "PNPM_INSTALLED=false" >> $GITHUB_ENV
fi
node-version: 20.x
- name: Install pnpm
if: env.PNPM_INSTALLED == 'false'
uses: pnpm/action-setup@v4
- name: Install dependencies
if: env.PNPM_INSTALLED == 'false'
run: pnpm install
run: pnpm install --config.platform=linux --config.architecture=x64
shell: bash
- name: create .env
run: cp .env.example .env
shell: bash
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env
echo "E2E_TESTING=1" >> .env
shell: bash
- name: Build App
run: |
pnpm build --filter=@formbricks/web...
- name: Apply Prisma Migrations
run: |
@@ -70,15 +78,7 @@ jobs:
sleep 10
done
- name: Cache Playwright
uses: actions/cache@v3
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install --with-deps
- name: Run E2E Tests

View File

@@ -25,10 +25,12 @@ jobs:
- name: create .env
run: cp .env.example .env
- name: Generate Random ENCRYPTION_KEY and fill in .env
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
run: |
ENCRYPTION_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${ENCRYPTION_KEY}/" .env
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
- name: Lint
run: pnpm lint

View File

@@ -25,10 +25,12 @@ jobs:
- name: create .env
run: cp .env.example .env
- name: Generate Random ENCRYPTION_KEY and fill in .env
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
run: |
ENCRYPTION_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${ENCRYPTION_KEY}/" .env
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
- name: Test
run: pnpm test

View File

@@ -144,6 +144,12 @@ Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the
[![Deploy on RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploy.png)](https://repocloud.io/details/?app_id=254)
##### Zeabur
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
[![Deploy to Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/G4TUJL)
<a id="development"></a>
## 👨‍💻 Development

View File

@@ -0,0 +1,2 @@
EXPO_PUBLIC_API_HOST=http://192.168.178.20:3000
EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=clzr04nkd000bcdl110j0ijyq

View File

@@ -0,0 +1,7 @@
module.exports = {
extends: ["@formbricks/eslint-config/react.js"],
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: __dirname,
},
};

35
apps/demo-react-native/.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
# Native
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo

View File

View File

@@ -0,0 +1,34 @@
{
"expo": {
"name": "react-native-demo",
"slug": "react-native-demo",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"jsEngine": "hermes",
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true,
"infoPlist": {
"NSCameraUsageDescription": "Take pictures for certain activities.",
"NSPhotoLibraryUsageDescription": "Select pictures for certain activities.",
"NSMicrophoneUsageDescription": "Need microphone access for recording videos."
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
},
"web": {
"favicon": "./assets/favicon.png"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,6 @@
module.exports = function babel(api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
};
};

View File

@@ -0,0 +1,7 @@
import { registerRootComponent } from "expo";
import { LogBox } from "react-native";
import App from "./src/app";
registerRootComponent(App);
LogBox.ignoreAllLogs();

View File

@@ -0,0 +1,21 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const path = require("node:path");
const { getDefaultConfig } = require("expo/metro-config");
// Find the workspace root, this can be replaced with `find-yarn-workspace-root`
const workspaceRoot = path.resolve(__dirname, "../..");
const projectRoot = __dirname;
const config = getDefaultConfig(projectRoot);
// 1. Watch all files within the monorepo
config.watchFolders = [workspaceRoot];
// 2. Let Metro know where to resolve packages, and in what order
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
];
// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths`
config.resolver.disableHierarchicalLookup = true;
module.exports = config;

View File

@@ -0,0 +1,28 @@
{
"name": "@formbricks/demo-react-native",
"version": "1.0.0",
"main": "./index.js",
"scripts": {
"dev": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"eject": "expo eject",
"clean": "rimraf .turbo node_modules .expo"
},
"dependencies": {
"@formbricks/js": "workspace:*",
"@formbricks/react-native": "workspace:*",
"expo": "^51.0.26",
"expo-status-bar": "~1.12.1",
"react": "^18.2.0",
"react-native": "^0.74.4",
"react-native-webview": "13.8.6"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/react": "~18.2.79",
"typescript": "^5.3.3"
},
"private": true
}

View File

@@ -0,0 +1,52 @@
import { StatusBar } from "expo-status-bar";
import { Button, LogBox, StyleSheet, Text, View } from "react-native";
import Formbricks, { track } from "@formbricks/react-native";
LogBox.ignoreAllLogs();
export default function App(): JSX.Element {
if (!process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID) {
throw new Error("EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID is required");
}
if (!process.env.EXPO_PUBLIC_API_HOST) {
throw new Error("EXPO_PUBLIC_API_HOST is required");
}
const config = {
environmentId: process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
apiHost: process.env.EXPO_PUBLIC_API_HOST,
userId: "random-user-id",
attributes: {
language: "en",
testAttr: "attr-test",
},
};
return (
<View style={styles.container}>
<Text>Formbricks React Native SDK Demo</Text>
<Button
title="Trigger Code Action"
onPress={() => {
track("code").catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error tracking event:", error);
});
}}
/>
<StatusBar style="auto" />
<Formbricks initConfig={config} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#fff",
alignItems: "center",
justifyContent: "center",
},
});

View File

@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}

View File

@@ -128,6 +128,7 @@ const AppPage = ({}) => {
}}>
Reset
</button>
<p className="text-xs text-slate-700 dark:text-slate-300">
If you made a change in Formbricks app and it does not seem to work, hit &apos;Reset&apos; and
try again.

View File

@@ -8,9 +8,9 @@ import RideHailing from "./ride-hailing.webp";
import UpsellMiro from "./upsell-miro.webp";
export const metadata = {
title: "Advanced Targeting in Surveys | Formbricks",
title: "Advanced Targeting for In-app Surveys | Formbricks",
description:
"Advanced Targeting allows you to show surveys to just the right group of people. You can target surveys based on user attributes, user events, metadata , literally anything! This helps you get more relevant feedback and make data-driven decisions. All of this without writing a single line of code.",
"Advanced Targeting allows you to show surveys to just the right group of people. You can target surveys based on user attributes, user events, and metadata. This helps you get more relevant feedback and make data-driven decisions.",
};
#### App Surveys
@@ -32,8 +32,7 @@ Advanced Targeting allows you to show surveys to the right group of people. You
## How to setup Advanced Targeting
<Note>
Advanced Targeting is available on the Pro plan! Don't worry, you just need to enter your credit card
details to start the freemium plan.
Advanced Targeting is available on the Pro plan!
</Note>
1. On the Formbricks dashboard, click on **People** tab from the top navigation bar.
@@ -72,25 +71,7 @@ Advanced Targeting allows you to show surveys to the right group of people. You
className="max-w-full rounded-lg sm:max-w-3xl"
/>
3. Target High Value users who have $100k+ in their bank account, own 20+ stocks, and have are an active user.
<MdxImage
src={Hni}
alt="Target Active High Net Worth Individuals"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
4. Target Germans on mobile phones who have regenerated chatGPT answers frequently in the last quarter and did so today.
<MdxImage
src={GermansGpt}
alt="Target Germans on Mobile Phones who have regenerated chatGPT answers frequently in the last quarter and did so today"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
5. Sneak Peak: How we at Formbricks automate inviting power users to chat with us
3. Sneak Peak: How we at Formbricks automate inviting power users to chat with us
<MdxImage
src={PowerUsers}

View File

@@ -31,12 +31,18 @@ const libraries = [
description: "Simply add us to your router change and sit back!",
logo: logoVueJs,
},
{
href: "#react-native",
name: "React Native",
description: "Easily integrate our SDK with your React Native app for seamless survey support!",
logo: logoReactJs,
},
];
export const Libraries = () => {
return (
<div className="my-16 xl:max-w-none">
<div className="not-prose mt-4 grid grid-cols-1 gap-x-6 gap-y-10 border-slate-900/5 sm:grid-cols-2 xl:max-w-none xl:grid-cols-3 dark:border-white/5">
<div className="not-prose mt-4 grid grid-cols-1 gap-x-6 gap-y-10 border-slate-900/5 xl:max-w-none xl:grid-cols-2 2xl:grid-cols-3 dark:border-white/5">
{libraries.map((library) => (
<a
key={library.name}

View File

@@ -346,6 +346,66 @@ router.afterEach((to, from) => {
Refer to our [Example VueJs project](https://github.com/formbricks/examples/tree/main/vuejs) for more help! Now visit the [Validate your Setup](#validate-your-setup) section to verify your setup!
## React Native
Install the Formbricks React Native SDK using one of the package managers, i.e., npm, pnpm, or yarn.
<Col>
<CodeGroup title="Install Formbricks JS library">
```shell {{ title: 'npm' }}
npm install @formbricks/react-native
```
```shell {{ title: 'pnpm' }}
pnpm add @formbricks/react-native
```
```shell {{ title: 'yarn' }}
yarn add @formbricks/react-native
```
</CodeGroup>
</Col>
Now, update your App.js/App.tsx file to initialize Formbricks:
<Col>
<CodeGroup title="src/App.js">
```js
// other imports
import Formbricks from "@formbricks/react-native";
const config = {
environmentId: "<environment-id>",
apiHost: "<api-host>",
userId: "<user-id>",
};
export default function App() {
return (
<>
{/* Your app content */}
<Formbricks initConfig={config} />
</>
);
}
```
</CodeGroup>
</Col>
### Required customizations to be made
<Properties>
<Property name="environment-id" type="string">
Formbricks Environment ID.
</Property>
<Property name="api-host" type="string">
URL of the hosted Formbricks instance.
</Property>
<Property name="userId" type="string">
User ID of the user who has active session.
</Property>
</Properties>
---
## Validate your setup
Once you have completed the steps above, you can validate your setup by checking the **Setup Checklist** in the Settings. Your widget status indicator should go from this:

View File

@@ -19,10 +19,12 @@ export const metadata = {
# Quickstart
App surveys have 6-10x better conversion rates than emailed out surveys. This tutorial explains how to run an app survey in your web app in 10 to 15 minutes. Lets go!
App surveys have 6-10x better conversion rates than emailed surveys. This tutorial explains how to run a survey in both your web app and mobile app (React Native) in just 10 to 15 minutes. Lets go!
<Note>
App Surveys are ideal for websites that **have a user authentication** system. If you are looking to run surveys on your public facing website, head over to the [Website Surveys Quickstart Guide](/website-surveys/quickstart).
App Surveys are ideal for websites that **have a user authentication** system. If you are looking to run
surveys on your public facing website, head over to the [Website Surveys Quickstart
Guide](/website-surveys/quickstart).
</Note>
1. **Create a free Formbricks Cloud account**: While you can [self-host](/self-hosting/deployment) Formbricks, but the quickest and easiest way to get started is with the free Cloud plan. Just [sign up here](https://app.formbricks.com/auth/signup) and you'll be guided to our onboarding like below:
@@ -35,7 +37,7 @@ App surveys have 6-10x better conversion rates than emailed out surveys. This tu
src={I1}
alt="Choose website survey from survey type"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl "
className="max-w-full rounded-lg sm:max-w-3xl"
/>
2. **Connect your App/Website**: Once you get through a couple of onboarding steps, youll be asked to connect your app or website. This is where youll find the code snippet for both HTML as well as the npm package which you need to embed in your app:

View File

@@ -7,12 +7,12 @@ import I3 from "./images/3-survey-logs-in-app-survey-popup.webp";
export const metadata = {
title: "Formbricks App Survey SDK",
description:
"An overview of all available methods & how to integrate Formbricks App Surveys for frontend developers in web applications. Learn the key methods, configuration settings, and best practices.",
"Integrate Formbricks App Surveys into your web apps with the Formbricks JS SDK for App Surveys. Learn how to initialize Formbricks, set attributes, track actions, and troubleshoot common issues.",
};
#### Developer Docs
# SDK: App Survey
# SDK: Run Surveys Inside Your Web Apps
### Overview

View File

@@ -94,6 +94,7 @@ cp .env.example .env
4. Generate & set some secret values mandatory for the `ENCRYPTION_KEY`, `NEXTAUTH_SECRET` and `CRON_SECRET` in the .env file. You can use the following command to generate the random string of required length:
- For Linux
<Col>
<CodeGroup title="For Linux">
@@ -106,7 +107,8 @@ sed -i '/^CRON_SECRET=/c\CRON_SECRET='$(openssl rand -hex 32) .env
</CodeGroup>
</Col>
- For Mac
- For Mac
<Col>
<CodeGroup title="For Mac">

View File

@@ -10,11 +10,15 @@ export const metadata = {
Welcome to the Developer Docs section, your comprehensive resource for integrating and utilizing Formbricks SDKs &APIs, as well as contributing to our open source codebase. Here's what you can expect to find in this section:
### [SDK: App Survey](/developer-docs/app-survey-sdk)
### [SDK: React Native Apps](/developer-docs/react-native-in-app-surveys)
The Formbricks React Native SDK for App Surveys is designed for React Native applications, enabling seamless integration of surveys within your mobile apps. Dive into the documentation to learn how to leverage the SDK for app surveys and engage with your users effectively.
### [SDK: Web Apps](/developer-docs/app-survey-sdk)
The Formbricks JS SDK tailored for App Surveys is designed for applications where users are logged in, allowing for targeted and identified interactions within Formbricks. This SDK is particularly useful for advanced user tracking, enabling deeper insights into user behavior. Learn how to seamlessly integrate Formbricks into your applications and harness valuable insights from your logged-in users.
### [SDK: Website Survey](/developer-docs/website-survey-sdk)
### [SDK: Public Websites](/developer-docs/website-survey-sdk)
The Formbricks JS SDK for Website Surveys is ideal for public-facing websites without user authentication. It's recommended for pages with high traffic and no authentication walls, facilitating quick and efficient survey collection. Dive into the documentation to discover how to deploy surveys on your website and effectively engage with your audience.

View File

@@ -0,0 +1,127 @@
import { MdxImage } from "@/components/MdxImage";
export const metadata = {
title: "React Native: Formbricks App SDK",
description:
"Integrate Formbricks App Surveys into your React Native apps with the Formbricks React Native SDK.",
};
#### Developer Docs
# React Native: In App Surveys
### Overview
The Formbricks React Native SDK can be used for seamlessly integrating App Surveys into your React Native Apps. Here, w'll explore how to leverage the SDK for in app surveys. The SDK is [available on npm.](https://www.npmjs.com/package/@formbricks/react-native)
### Install
<Col>
<CodeGroup title="npm">
```js {{ title: 'npm' }}
npm install @formbricks/react-native
```
```js {{ title: 'yarn' }}
yarn add @formbricks/react-native
```
```js {{ title: 'pnpm' }}
pnpm add @formbricks/react-native
```
</CodeGroup>
</Col>
## Methods
### Initialize Formbricks
In your React Native app, initialize the Formbricks React Native Client for app surveys where you pass the userId (creates a user if not existing in Formbricks) to attribute & target the user based on their actions.
<Col>
<CodeGroup title="Initialize Formbricks">
```javascript
// other imports
import Formbricks from "@formbricks/react-native";
const config = {
environmentId: "<environment-id>",
apiHost: "<api-host>",
userId: "<user-id>",
};
export default function App() {
return (
<>
{/* Your app content */}
<Formbricks initConfig={config} />
</>
);
}
```
</CodeGroup>
</Col>
The moment you initialise Formbricks, your user will start seeing surveys that get triggered on simpler actions such as on New Session.
### Set Attribute
You can set custom attributes for the identified user. This can be helpful for segmenting users based on specific characteristics or properties. To learn how to set custom user attributes, please check out our [User Attributes Guide](/app-surveys/user-identification).
<Col>
<CodeGroup>
```js
formbricks.setAttribute("Plan", "Paid");
```
</CodeGroup>
</Col>
### Track Action
Track user actions to trigger surveys based on user interactions, such as button clicks or scrolling:
<Col>
<CodeGroup>
```js
formbricks.track("Clicked on Claim");
```
</CodeGroup>
</Col>
### Logout
To log out and deinitialize Formbricks, use the formbricks.logout() function. This action clears the current initialization configuration and erases stored frontend information, such as the surveys a user has viewed or completed. It's an important step when a user logs out of your application or when you want to reset Formbricks.
<Col>
<CodeGroup>
```js
formbricks.logout();
```
</CodeGroup>
</Col>
After calling formbricks.logout(), you'll need to reinitialize Formbricks before using any of its features again. Ensure that you properly reinitialize Formbricks to avoid unexpected errors or behavior in your application.
### Reset
Reset the current instance and fetch the latest surveys and state again:
<Col>
<CodeGroup>
```js
formbricks.reset();
```
</CodeGroup>
</Col>

View File

@@ -39,7 +39,7 @@ We currently have the following Management API methods exposed and below is thei
- [Me API](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#79e08365-641d-4b2d-aea2-9a855e0438ec) - Retrieve Account Information
- [People API](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#cffc27a6-dafb-428f-8ea7-5165bedb911e) - List and Delete People
- [Response API](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#e544ec0d-8b30-4e33-8d35-2441cb40d676) - List, List by Survey, Update, and Delete Responses
- [Survey API](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#953189b2-37b5-4429-a7bd-f4d01ceae242) - List, Create, Update, and Delete Surveys
- [Survey API](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#953189b2-37b5-4429-a7bd-f4d01ceae242) - List, Create, Update, generate multiple suId and Delete Surveys
- [Webhook API](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#62e6ec65-021b-42a4-ac93-d1434b393c6c) - List, Create, and Delete Webhooks
## How to Generate an API key

View File

@@ -4,12 +4,12 @@ import I1 from "./images/1-set-up-website-micro-survey-popup.webp";
export const metadata = {
title: "Formbricks Website Survey SDK",
description:
"An overview of all available methods & how to integrate Formbricks Website Surveys for frontend developers in public-facing web applications. Learn the key methods, configuration settings, and best practices.",
"Run targeted pop-up surveys on your public websites with the Formbricks JS SDK for Website Surveys. Learn how to integrate the SDK, track user actions, and trigger surveys based on user interactions.",
};
#### Developer Docs
# SDK: Website Survey
# SDK: Run Surveys On Public Websites
### Overview

View File

@@ -1,68 +0,0 @@
import { MdxImage } from "@/components/MdxImage";
import StepOne from "./images/StepOne.webp";
import StepThree from "./images/StepThree.webp";
import StepTwo from "./images/StepTwo.webp";
export const metadata = {
title: "Custom Start & End Conditions for Surveys",
description:
"Optimize your survey management with custom Start & End Conditions in Formbricks. This feature allows you to control exactly when your survey is available for responses and when it should close, making it ideal for time-sensitive or number-of-response-limited surveys.",
};
# Custom Start & End Conditions
Optimize your survey management with custom Start & End Conditions in Formbricks. This feature allows you to control exactly when your survey is available for responses and when it should close, making it ideal for time-sensitive or number-of-response-limited surveys.
Configure your surveys to open and close based on specific criteria. Heres how to set up these conditions:
### **Opening a Survey**
**1. Schedule a Start Date:**
- **How to Set**: Open the Survey Editor, switch to the Settings tab. Scroll down to Response Options, Toggle the “Release Survey on Date”.
<MdxImage
src={StepOne}
alt="Choose a link survey template"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl "
/>
- **Details**: Choose the date and time when the survey should become available to respondents. All times follow UTC timezone.
- **Use Case**: This is useful for launching surveys in alignment with events, product releases, or specific marketing campaigns.
### **Closing a Survey**
**1. Limit by Number of Responses:**
- **How to Set**: Open the Survey Editor, switch to the Settings tab. Scroll down to Response Options, Toggle the “Close survey on response limit”.
{" "}
<MdxImage
src={StepTwo}
alt="Choose a link survey template"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl "
/>
- **Details**: Set a specific number of responses after which the survey automatically closes.
- **Use Case**: Perfect for limited offers, exclusive surveys, or when you need a precise sample size for statistical significance.
**2. Schedule an End Date:**
- **How to Set**: Open the Survey Editor, switch to the Settings tab. Scroll down to Response Options, Toggle the “Close survey on date”.
<MdxImage
src={StepThree}
alt="Choose a link survey template"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl "
/>
- **Details**: Define a specific date and time for the survey to close. This also follows UTC timezone. - **Use
Case**: Essential for surveys linked to time-bound events or studies where data collection needs to end at a specific
point.
### **Summary**
Setting up Custom Start & End Conditions in Formbricks allows you to control the availability and duration of your surveys with precision. Whether you are conducting academic research, market analysis, or gathering event feedback, these settings help ensure that your data collection aligns perfectly with your objectives.
---

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,28 @@
import { MdxImage } from "@/components/MdxImage";
import StepOne from "./images/StepOne.webp";
import StepThree from "./images/StepThree.webp";
import StepTwo from "./images/StepTwo.webp";
export const metadata = {
title: "Set a Maximum Number of Submissions for Surveys",
description:
"Limit the number of responses your survey can receive.",
};
# Limit by Number of Submissions
Automatically close your survey after a specific number of responses with Formbricks. This feature is perfect for limited offers, exclusive surveys, or when you need a precise sample size for statistical significance.
- **How to**: Open the Survey Editor, switch to the Settings tab. Scroll down to Response Options, Toggle the “Close survey on response limit”.
{" "}
<MdxImage
src={StepTwo}
alt="Choose a link survey template"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl "
/>
- **Details**: Set a specific number of responses after which the survey automatically closes.
- **Use Case**: Perfect for limited offers, exclusive surveys, or when you need a precise sample size for statistical significance.
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,51 @@
import { MdxImage } from "@/components/MdxImage";
import StepOne from "./images/StepOne.webp";
import StepThree from "./images/StepThree.webp";
import StepTwo from "./images/StepTwo.webp";
export const metadata = {
title: "Schedule Start & End Dates for Surveys",
description:
"Automatically release and close surveys based on specific dates.",
};
# Schedule Start & End Dates
Optimize your survey management with custom Start & End Conditions in Formbricks. This feature allows you to control exactly when your survey is available for responses and when it should close, making it ideal for time-sensitive or number-of-response-limited surveys.
Configure your surveys to open and close based on specific criteria. Heres how to set up these conditions:
## **Schedule a Survey Release**
- **How to**: Open the Survey Editor, switch to the Settings tab. Scroll down to Response Options, Toggle the “Release Survey on Date”.
<MdxImage
src={StepOne}
alt="Choose a link survey template"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl "
/>
- **Details**: Choose the date and time when the survey should become available to respondents. All times follow UTC timezone.
- **Use Case**: This is useful for launching surveys in alignment with events, product releases, or specific marketing campaigns.
## **Automatically Closing a Survey**
- **How to**: Open the Survey Editor, switch to the Settings tab. Scroll down to Response Options, Toggle the “Close survey on date”.
<MdxImage
src={StepThree}
alt="Choose a link survey template"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl "
/>
- **Details**: Define a specific date and time for the survey to close. This also follows UTC timezone. - **Use
Case**: Essential for surveys linked to time-bound events or studies where data collection needs to end at a specific
point.
### **Summary**
Setting up Start & End Dates in Formbricks allows you to control the availability and duration of your surveys with precision. Whether you are conducting academic research, market analysis, or gathering event feedback, these settings help ensure that your data collection aligns perfectly with your objectives.
---

View File

@@ -61,8 +61,6 @@ These variables are present inside your machines docker-compose file. Restart
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
| STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | |
| TELEMETRY_DISABLED | Disables telemetry if set to 1. | optional | |
| INSTANCE_ID | Instance ID for Formbricks Cloud to be sent to Telemetry. | optional | |
| INTERNAL_SECRET | Internal Secret (Currently we overwrite the value with a random value). | optional | |
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
| DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | |
| DEFAULT_ORGANIZATION_ROLE | Role of the user in the default organization. | optional | admin |

View File

@@ -9,7 +9,7 @@ export const metadata = {
## Overview
The Formbricks Core source code is licensed under AGPLv3 and available on GitHub. Additionally, we offer features for bigger organisations & enterprises for self-hostesr under a separate Enterprise License.
The Formbricks Core source code is licensed under AGPLv3 and available on GitHub. Additionally, we offer features for bigger organisations & enterprises for self-hosters under a separate Enterprise License.
<Note>
Want to present a proof of concept? Request a free 30-day Enterprise Edition trial by [filling out the form

View File

@@ -8,6 +8,103 @@ export const metadata = {
# Migration Guide
## v2.5
Formbricks v2.5 allows you to visualize responses in a data table format. This release also includes a few bug fixes and performance improvements.
<Note>
This release will fix the inconsistency of CTA and consent question values in case of skipping the question.
The value will be set to empty string instead of "dismissed" in order to make it consistent with other
questions.
</Note>
### Steps to Migrate
This guide is for users who are self-hosting Formbricks using our one-click setup. If you are using a different setup, you might adjust the commands accordingly.
To run all these steps, please navigate to the `formbricks` folder where your `docker-compose.yml` file is located.
1. **Backup your Database**: This is a crucial step. Please make sure to backup your database before proceeding with the upgrade. You can use the following command to backup your database:
<Col>
<CodeGroup title="Backup Postgres">
```bash
docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbricks_pre_v2.5_$(date +%Y%m%d_%H%M%S).dump
```
</CodeGroup>
</Col>
<Note>
If you run into “No such container”, use `docker ps` to find your container name, e.g.
`formbricks_postgres_1`.
</Note>
<Note>
If you prefer storing the backup as an `*.sql` file remove the `-Fc` (custom format) option. In case of a
restore scenario you will need to use `psql` then with an empty `formbricks` database.
</Note>
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">
```bash
docker compose down
```
</CodeGroup>
</Col>
4. Restarting the containers with the latest version of Formbricks:
<Col>
<CodeGroup title="Restart the containers">
```bash
docker compose up -d
```
</CodeGroup>
</Col>
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>
<Col>
<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" \
-e UPGRADE_TO_VERSION="v2.5" \
ghcr.io/formbricks/data-migrations:latest
```
</CodeGroup>
</Col>
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.
6. That's it! Once the migration is complete, you can **now access your Formbricks instance** at the same URL as before.
## v2.4
Formbricks v2.4 allows you to create multiple endings for your surveys and decide which ending the user should see based on logic jumps. This release also includes many bug fixes and performance improvements.
@@ -91,12 +188,12 @@ docker compose up -d
<CodeGroup title="Migrate the data">
```bash
docker pull ghcr.io/formbricks/data-migrations:latest && \
docker pull ghcr.io/formbricks/data-migrations:v2.4.0 && \
docker run --rm \
--network=formbricks_default \
-e DATABASE_URL="postgresql://postgres:postgres@postgres:5432/formbricks?schema=public" \
-e UPGRADE_TO_VERSION="v2.4" \
ghcr.io/formbricks/data-migrations:latest
ghcr.io/formbricks/data-migrations:v2.4.0
```
</CodeGroup>

View File

@@ -296,7 +296,7 @@ File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N) y
🎉 Hooray! Docker is all set and ready to go. You're now ready to run your Formbricks instance!
📁 Created Formbricks Quickstart directory at ./formbricks.
🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):
docs@formbricks.com
my.hosted.url.com
🔗 Do you want us to set up an HTTPS certificate for you? [Y/n]
Y
🔗 Please make sure that the domain points to the server's IP address and that ports 80 & 443 are open in your server's firewall. Is everything set up? [Y/n]
@@ -326,7 +326,7 @@ Y
🔗 To edit more variables and deeper config, go to the formbricks/docker-compose.yml, edit the file, and restart the container!
🚨 Make sure you have set up the DNS records as well as inbound rules for the domain name and IP address of this instance.
🎉 All done! Please setup your Formbricks instance by visiting your domain at https://tls.piyush.formbricks.com. You can check the status of Formbricks & Traefik with 'cd formbricks && sudo docker compose ps.'
🎉 All done! Please setup your Formbricks instance by visiting your domain at https://my.hosted.url.com. You can check the status of Formbricks & Traefik with 'cd formbricks && sudo docker compose ps.'
```

View File

@@ -15,6 +15,7 @@ const TopLevelNavItem = ({ href, children }: { href: string; children: React.Rea
<li>
<Link
href={href}
target="_blank"
className="text-sm leading-5 text-slate-600 transition hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
{children}
</Link>

View File

@@ -171,6 +171,13 @@ const NavigationGroup = ({
const isParentOpen = (title: string) => openGroups.includes(title);
const sortedLinks = group.links.map((link) => {
if (link.children) {
link.children.sort((a, b) => a.title.localeCompare(b.title));
}
return link;
});
return (
<li className={clsx("relative mt-6", className)}>
<motion.h2 layout="position" className="font-semibold text-slate-900 dark:text-white">
@@ -185,7 +192,7 @@ const NavigationGroup = ({
{isActiveGroup && <ActivePageMarker group={group} pathname={pathname || "/docs"} />}
</AnimatePresence>
<ul role="list" className="border-l border-transparent">
{group.links.map((link) => (
{sortedLinks.map((link) => (
<motion.li key={link.title} layout="position" className="relative">
{link.href ? (
<NavLink

View File

@@ -40,7 +40,8 @@ export const navigation: Array<NavGroup> = [
{ title: "User Metadata", href: "/global/metadata" }, // global
{ title: "Custom Styling", href: "/global/overwrite-styling" }, // global
{ title: "Conditional Logic", href: "/global/conditional-logic" }, // global
{ title: "Custom Start & End Conditions", href: "/global/custom-start-end-conditions" }, // global
{ title: "Start & End Dates", href: "/global/custom-start-end-conditions" }, // global
{ title: "Limit submissions", href: "/global/limit-submissions" }, // global
{ title: "Recall Functionality", href: "/global/recall" }, // global
{ title: "Partial Submissions", href: "/global/partial-submissions" }, // global
],
@@ -63,7 +64,8 @@ export const navigation: Array<NavGroup> = [
{ title: "User Metadata", href: "/global/metadata" }, // global
{ title: "Custom Styling", href: "/global/overwrite-styling" }, // global
{ title: "Conditional Logic", href: "/global/conditional-logic" }, // global
{ title: "Custom Start & End Conditions", href: "/global/custom-start-end-conditions" }, // global
{ title: "Start & End Dates", href: "/global/custom-start-end-conditions" }, // global
{ title: "Limit submissions", href: "/global/limit-submissions" }, // global
{ title: "Recall Functionality", href: "/global/recall" }, // global
{ title: "Partial Submissions", href: "/global/partial-submissions" }, // global
],
@@ -89,7 +91,8 @@ export const navigation: Array<NavGroup> = [
{ title: "User Metadata", href: "/global/metadata" },
{ title: "Custom Styling", href: "/global/overwrite-styling" }, // global
{ title: "Conditional Logic", href: "/global/conditional-logic" },
{ title: "Custom Start & End Conditions", href: "/global/custom-start-end-conditions" },
{ title: "Start & End Dates", href: "/global/custom-start-end-conditions" },
{ title: "Limit submissions", href: "/global/limit-submissions" }, // global
{ title: "Recall Functionality", href: "/global/recall" },
{ title: "Verify Email before Survey", href: "/link-surveys/verify-email-before-survey" },
{ title: "PIN Protected Surveys", href: "/link-surveys/pin-protected-surveys" },
@@ -136,8 +139,9 @@ export const navigation: Array<NavGroup> = [
{ title: "Zapier", href: "/developer-docs/integrations/zapier" },
],
},
{ title: "SDK: App Survey", href: "/developer-docs/app-survey-sdk" },
{ title: "SDK: Website Survey", href: "/developer-docs/website-survey-sdk" },
{ title: "SDK: Web Apps", href: "/developer-docs/app-survey-sdk" },
{ title: "SDK: Public Websites", href: "/developer-docs/website-survey-sdk" },
{ title: "SDK: React Native", href: "/developer-docs/react-native-in-app-surveys" },
{ title: "SDK: Formbricks API", href: "/developer-docs/api-sdk" },
{ title: "REST API", href: "/developer-docs/rest-api" },
{ title: "Webhooks", href: "/developer-docs/webhooks" },

View File

@@ -1,5 +1,4 @@
import nextMDX from "@next/mdx";
import { recmaPlugins } from "./mdx/recma.mjs";
import { rehypePlugins } from "./mdx/rehype.mjs";
import { remarkPlugins } from "./mdx/remark.mjs";
@@ -105,17 +104,18 @@ const nextConfig = {
destination: "/app-surveys/user-identification",
permanent: true,
},
// Global Features
{
source: "/global/custom-start-end-conditions",
destination: "/global/schedule-start-end-dates",
permanent: true,
},
// Integrations
{
source: "/integrations/:path",
destination: "/developer-docs/integrations/:path",
permanent: true,
},
{
source: "/global/custom-styling",
destination: "/global/overwrite-styling",
permanent: true,
},
];
},
};

View File

@@ -19,23 +19,23 @@
"devDependencies": {
"@chromatic-com/storybook": "^1.6.1",
"@formbricks/config-typescript": "workspace:*",
"@storybook/addon-a11y": "^8.2.7",
"@storybook/addon-essentials": "^8.2.7",
"@storybook/addon-interactions": "^8.2.7",
"@storybook/addon-links": "^8.2.7",
"@storybook/addon-onboarding": "^8.2.7",
"@storybook/blocks": "^8.2.7",
"@storybook/react": "^8.2.7",
"@storybook/react-vite": "^8.2.7",
"@storybook/test": "^8.2.7",
"@storybook/addon-a11y": "^8.2.9",
"@storybook/addon-essentials": "^8.2.9",
"@storybook/addon-interactions": "^8.2.9",
"@storybook/addon-links": "^8.2.9",
"@storybook/addon-onboarding": "^8.2.9",
"@storybook/blocks": "^8.2.9",
"@storybook/react": "^8.2.9",
"@storybook/react-vite": "^8.2.9",
"@storybook/test": "^8.2.9",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitejs/plugin-react": "^4.3.1",
"esbuild": "^0.23.0",
"eslint-plugin-storybook": "^0.8.0",
"prop-types": "^15.8.1",
"storybook": "^8.2.7",
"tsup": "^8.2.3",
"vite": "^5.3.5"
"storybook": "^8.2.9",
"tsup": "^8.2.4",
"vite": "^5.4.1"
}
}

View File

@@ -87,7 +87,7 @@ RUN PRISMA_VERSION=$(cat prisma_version.txt) && npm install -g prisma@$PRISMA_VE
EXPOSE 3000
ENV HOSTNAME "0.0.0.0"
USER nextjs
# USER nextjs
# Prepare volume for uploads
RUN mkdir -p /home/nextjs/apps/web/uploads/

View File

@@ -36,7 +36,12 @@ export const InviteOrganizationMember = ({ organization, environmentId }: Invite
const handleInvite = async (data: TInviteOrganizationMemberDetails) => {
try {
await inviteOrganizationMemberAction(organization.id, data.email, "developer", data.inviteMessage);
await inviteOrganizationMemberAction({
organizationId: organization.id,
email: data.email,
role: "developer",
inviteMessage: data.inviteMessage,
});
toast.success("Invite sent successful");
await finishOnboarding();
} catch (error) {

View File

@@ -8,6 +8,7 @@ import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
import { FORMBRICKS_PRODUCT_ID_LS, FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@formbricks/lib/localStorage";
import { PREVIEW_SURVEY } from "@formbricks/lib/styling/constants";
import {
TProductConfigChannel,
@@ -59,6 +60,14 @@ export const ProductSettings = ({
const productionEnvironment = createProductResponse.data.environments.find(
(environment) => environment.type === "production"
);
if (productionEnvironment) {
if (typeof window !== "undefined") {
localStorage.setItem(FORMBRICKS_PRODUCT_ID_LS, productionEnvironment.productId);
// Rmove filters when creating a new product
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}
}
if (channel !== "link") {
router.push(`/environments/${productionEnvironment?.id}/connect`);
} else {

View File

@@ -1,68 +1,54 @@
"use server";
import { getServerSession } from "next-auth";
import { z } from "zod";
import { sendInviteMemberEmail } from "@formbricks/email";
import { hasOrganizationAuthority } from "@formbricks/lib/auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
import { INVITE_DISABLED } from "@formbricks/lib/constants";
import { inviteUser } from "@formbricks/lib/invite/service";
import { verifyUserRoleAccess } from "@formbricks/lib/organization/auth";
import { getUser } from "@formbricks/lib/user/service";
import { ZId } from "@formbricks/types/common";
import { AuthenticationError } from "@formbricks/types/errors";
import { TMembershipRole } from "@formbricks/types/memberships";
import { ZMembershipRole } from "@formbricks/types/memberships";
export const inviteOrganizationMemberAction = async (
organizationId: string,
email: string,
role: TMembershipRole,
inviteMessage: string
) => {
const session = await getServerSession(authOptions);
const ZInviteOrganizationMemberAction = z.object({
organizationId: ZId,
email: z.string(),
role: ZMembershipRole,
inviteMessage: z.string(),
});
if (!session) {
throw new AuthenticationError("Not authenticated");
}
export const inviteOrganizationMemberAction = authenticatedActionClient
.schema(ZInviteOrganizationMemberAction)
.action(async ({ ctx, parsedInput }) => {
if (INVITE_DISABLED) {
throw new AuthenticationError("Invite disabled");
}
const user = await getUser(session.user.id);
await checkAuthorization({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
rules: ["membership", "create"],
});
if (!user) {
throw new Error("User not found");
}
const invite = await inviteUser({
organizationId: parsedInput.organizationId,
invitee: {
email: parsedInput.email,
name: "",
role: parsedInput.role,
},
});
const isUserAuthorized = await hasOrganizationAuthority(session.user.id, organizationId);
if (invite) {
await sendInviteMemberEmail(
invite.id,
parsedInput.email,
ctx.user.name ?? "",
"",
true, // is onboarding invite
parsedInput.inviteMessage
);
}
if (INVITE_DISABLED) {
throw new AuthenticationError("Invite disabled");
}
if (!isUserAuthorized) {
throw new AuthenticationError("Not authorized");
}
const { hasCreateOrUpdateMembersAccess } = await verifyUserRoleAccess(organizationId, session.user.id);
if (!hasCreateOrUpdateMembersAccess) {
throw new AuthenticationError("Not authorized");
}
const invite = await inviteUser({
organizationId,
invitee: {
email,
name: "",
role,
},
return invite;
});
if (invite) {
await sendInviteMemberEmail(
invite.id,
email,
user.name ?? "",
"",
true, // is onboarding invite
inviteMessage
);
}
return invite;
};

View File

@@ -1,208 +1,206 @@
"use server";
import { getServerSession } from "next-auth";
import { z } from "zod";
import { createActionClass } from "@formbricks/lib/actionClass/service";
import { authOptions } from "@formbricks/lib/authOptions";
import { UNSPLASH_ACCESS_KEY } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { canUserAccessProduct } from "@formbricks/lib/product/auth";
import { actionClient, authenticatedActionClient } from "@formbricks/lib/actionClient";
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
import { UNSPLASH_ACCESS_KEY, UNSPLASH_ALLOWED_DOMAINS } from "@formbricks/lib/constants";
import {
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromProductId,
getOrganizationIdFromSegmentId,
getOrganizationIdFromSurveyId,
} from "@formbricks/lib/organization/utils";
import { getProduct } from "@formbricks/lib/product/service";
import {
cloneSegment,
createSegment,
deleteSegment,
getSegment,
resetSegmentInSurvey,
updateSegment,
} from "@formbricks/lib/segment/service";
import { canUserAccessSurvey, verifyUserRoleAccess } from "@formbricks/lib/survey/auth";
import { surveyCache } from "@formbricks/lib/survey/cache";
import {
deleteSurvey,
getSurvey,
loadNewSegmentInSurvey,
updateSurvey,
} from "@formbricks/lib/survey/service";
import { TActionClassInput } from "@formbricks/types/action-classes";
import { AuthorizationError } from "@formbricks/types/errors";
import { TProduct } from "@formbricks/types/product";
import { TBaseFilters, TSegmentUpdateInput, ZSegmentFilters } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { loadNewSegmentInSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId } from "@formbricks/types/common";
import { ZBaseFilters, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment";
import { ZSurvey } from "@formbricks/types/surveys/types";
export const surveyMutateAction = async (survey: TSurvey): Promise<TSurvey> => {
return await updateSurvey(survey);
};
export const updateSurveyAction = async (survey: TSurvey): Promise<TSurvey> => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessSurvey(session.user.id, survey.id);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const { hasCreateOrUpdateAccess } = await verifyUserRoleAccess(survey.environmentId, session.user.id);
if (!hasCreateOrUpdateAccess) throw new AuthorizationError("Not authorized");
return await updateSurvey(survey);
};
export const deleteSurveyAction = async (surveyId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const survey = await getSurvey(surveyId);
const { hasDeleteAccess } = await verifyUserRoleAccess(survey!.environmentId, session.user.id);
if (!hasDeleteAccess) throw new AuthorizationError("Not authorized");
await deleteSurvey(surveyId);
};
export const refetchProductAction = async (productId: string): Promise<TProduct | null> => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessProduct(session.user.id, productId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const product = await getProduct(productId);
return product;
};
export const createBasicSegmentAction = async ({
description,
environmentId,
filters,
isPrivate,
surveyId,
title,
}: {
environmentId: string;
surveyId: string;
title: string;
description?: string;
isPrivate: boolean;
filters: TBaseFilters;
}) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const environmentAccess = hasUserEnvironmentAccess(session.user.id, environmentId);
if (!environmentAccess) throw new AuthorizationError("Not authorized");
const parsedFilters = ZSegmentFilters.safeParse(filters);
if (!parsedFilters.success) {
const errMsg =
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message || "Invalid filters";
throw new Error(errMsg);
}
const segment = await createSegment({
environmentId,
surveyId,
title,
description: description || "",
isPrivate,
filters,
export const updateSurveyAction = authenticatedActionClient
.schema(ZSurvey)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.id),
rules: ["survey", "update"],
});
return await updateSurvey(parsedInput);
});
surveyCache.revalidate({ id: surveyId });
return segment;
};
const ZRefetchProductAction = z.object({
productId: ZId,
});
export const updateBasicSegmentAction = async (
environmentId: string,
segmentId: string,
data: TSegmentUpdateInput
) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
export const refetchProductAction = authenticatedActionClient
.schema(ZRefetchProductAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromProductId(parsedInput.productId),
rules: ["product", "read"],
});
const environmentAccess = hasUserEnvironmentAccess(session.user.id, environmentId);
if (!environmentAccess) throw new AuthorizationError("Not authorized");
return await getProduct(parsedInput.productId);
});
const { filters } = data;
if (filters) {
const parsedFilters = ZSegmentFilters.safeParse(filters);
const ZCreateBasicSegmentAction = z.object({
description: z.string().optional(),
environmentId: ZId,
filters: ZBaseFilters,
isPrivate: z.boolean(),
surveyId: ZId,
title: z.string(),
});
export const createBasicSegmentAction = authenticatedActionClient
.schema(ZCreateBasicSegmentAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
rules: ["segment", "create"],
});
const parsedFilters = ZSegmentFilters.safeParse(parsedInput.filters);
if (!parsedFilters.success) {
const errMsg =
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message || "Invalid filters";
throw new Error(errMsg);
}
}
return await updateSegment(segmentId, data);
};
const segment = await createSegment({
environmentId: parsedInput.environmentId,
surveyId: parsedInput.surveyId,
title: parsedInput.title,
description: parsedInput.description || "",
isPrivate: parsedInput.isPrivate,
filters: parsedInput.filters,
});
surveyCache.revalidate({ id: parsedInput.surveyId });
export const loadNewBasicSegmentAction = async (surveyId: string, segmentId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const environmentAccess = await canUserAccessSurvey(session.user.id, surveyId);
if (!environmentAccess) throw new AuthorizationError("Not authorized");
return await loadNewSegmentInSurvey(surveyId, segmentId);
};
export const cloneBasicSegmentAction = async (segmentId: string, surveyId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const environmentAccess = await canUserAccessSurvey(session.user.id, surveyId);
if (!environmentAccess) throw new AuthorizationError("Not authorized");
try {
const clonedSegment = await cloneSegment(segmentId, surveyId);
return clonedSegment;
} catch (err: any) {
throw new Error(err);
}
};
export const deleteBasicSegmentAction = async (environmentId: string, segmentId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const environmentAccess = hasUserEnvironmentAccess(session.user.id, environmentId);
if (!environmentAccess) throw new AuthorizationError("Not authorized");
const foundSegment = await getSegment(segmentId);
if (!foundSegment) {
throw new Error(`Segment with id ${segmentId} not found`);
}
return await deleteSegment(segmentId);
};
export const resetBasicSegmentFiltersAction = async (surveyId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const environmentAccess = await canUserAccessSurvey(session.user.id, surveyId);
if (!environmentAccess) throw new AuthorizationError("Not authorized");
return await resetSegmentInSurvey(surveyId);
};
export const getImagesFromUnsplashAction = async (searchQuery: string, page: number = 1) => {
if (!UNSPLASH_ACCESS_KEY) {
throw new Error("Unsplash access key is not set");
}
const baseUrl = "https://api.unsplash.com/search/photos";
const params = new URLSearchParams({
query: searchQuery,
client_id: UNSPLASH_ACCESS_KEY,
orientation: "landscape",
per_page: "9",
page: page.toString(),
return segment;
});
try {
const ZUpdateBasicSegmentAction = z.object({
segmentId: ZId,
data: ZSegmentUpdateInput,
});
export const updateBasicSegmentAction = authenticatedActionClient
.schema(ZUpdateBasicSegmentAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSegmentId(parsedInput.segmentId),
rules: ["segment", "update"],
});
const { filters } = parsedInput.data;
if (filters) {
const parsedFilters = ZSegmentFilters.safeParse(filters);
if (!parsedFilters.success) {
const errMsg =
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message || "Invalid filters";
throw new Error(errMsg);
}
}
return await updateSegment(parsedInput.segmentId, parsedInput.data);
});
const ZLoadNewBasicSegmentAction = z.object({
surveyId: ZId,
segmentId: ZId,
});
export const loadNewBasicSegmentAction = authenticatedActionClient
.schema(ZLoadNewBasicSegmentAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSegmentId(parsedInput.surveyId),
rules: ["segment", "read"],
});
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
rules: ["survey", "update"],
});
return await loadNewSegmentInSurvey(parsedInput.surveyId, parsedInput.segmentId);
});
const ZCloneBasicSegmentAction = z.object({
segmentId: ZId,
surveyId: ZId,
});
export const cloneBasicSegmentAction = authenticatedActionClient
.schema(ZCloneBasicSegmentAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSegmentId(parsedInput.segmentId),
rules: ["segment", "create"],
});
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
rules: ["survey", "read"],
});
return await cloneSegment(parsedInput.segmentId, parsedInput.surveyId);
});
const ZResetBasicSegmentFiltersAction = z.object({
surveyId: ZId,
});
export const resetBasicSegmentFiltersAction = authenticatedActionClient
.schema(ZResetBasicSegmentFiltersAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
rules: ["segment", "update"],
});
return await resetSegmentInSurvey(parsedInput.surveyId);
});
const ZGetImagesFromUnsplashAction = z.object({
searchQuery: z.string(),
page: z.number().optional(),
});
export const getImagesFromUnsplashAction = actionClient
.schema(ZGetImagesFromUnsplashAction)
.action(async ({ parsedInput }) => {
if (!UNSPLASH_ACCESS_KEY) {
throw new Error("Unsplash access key is not set");
}
const baseUrl = "https://api.unsplash.com/search/photos";
const params = new URLSearchParams({
query: parsedInput.searchQuery,
client_id: UNSPLASH_ACCESS_KEY,
orientation: "landscape",
per_page: "9",
page: (parsedInput.page || 1).toString(),
});
const response = await fetch(`${baseUrl}?${params}`, {
method: "GET",
headers: { "Content-Type": "application/json" },
@@ -227,14 +225,29 @@ export const getImagesFromUnsplashAction = async (searchQuery: string, page: num
},
};
});
} catch (error) {
throw new Error("Error getting images from Unsplash");
});
const isValidUnsplashUrl = (url: string): boolean => {
try {
const parsedUrl = new URL(url);
return parsedUrl.protocol === "https:" && UNSPLASH_ALLOWED_DOMAINS.includes(parsedUrl.hostname);
} catch {
return false;
}
};
export const triggerDownloadUnsplashImageAction = async (downloadUrl: string) => {
try {
const response = await fetch(`${downloadUrl}/?client_id=${UNSPLASH_ACCESS_KEY}`, {
const ZTriggerDownloadUnsplashImageAction = z.object({
downloadUrl: z.string().url(),
});
export const triggerDownloadUnsplashImageAction = actionClient
.schema(ZTriggerDownloadUnsplashImageAction)
.action(async ({ parsedInput }) => {
if (!isValidUnsplashUrl(parsedInput.downloadUrl)) {
throw new Error("Invalid Unsplash URL");
}
const response = await fetch(`${parsedInput.downloadUrl}/?client_id=${UNSPLASH_ACCESS_KEY}`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
@@ -245,20 +258,20 @@ export const triggerDownloadUnsplashImageAction = async (downloadUrl: string) =>
}
return;
} catch (error) {
throw new Error("Error downloading image from Unsplash");
}
};
});
export const createActionClassAction = async (environmentId: string, action: TActionClassInput) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const ZCreateActionClassAction = z.object({
action: ZActionClassInput,
});
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, action.environmentId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
export const createActionClassAction = authenticatedActionClient
.schema(ZCreateActionClassAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.action.environmentId),
rules: ["actionClass", "create"],
});
const { hasCreateOrUpdateAccess } = await verifyUserRoleAccess(environmentId, session.user.id);
if (!hasCreateOrUpdateAccess) throw new AuthorizationError("Not authorized");
return await createActionClass(action.environmentId, action);
};
return await createActionClass(parsedInput.action.environmentId, parsedInput.action);
});

View File

@@ -56,6 +56,7 @@ export const AddActionModal = ({
return (
<ModalWithTabs
label="Add action"
description="Capture a new action to trigger a survey on."
open={open}
setOpen={setOpen}
tabs={tabs}

View File

@@ -8,7 +8,7 @@ interface ColorSurveyBgProps {
}
export const ColorSurveyBg = ({ handleBgChange, colors, background }: ColorSurveyBgProps) => {
const [color, setColor] = useState(background || "#ffff");
const [color, setColor] = useState(background || "#FFFFFF");
const handleBg = (x: string) => {
setColor(x);
@@ -23,7 +23,7 @@ export const ColorSurveyBg = ({ handleBgChange, colors, background }: ColorSurve
{colors.map((x) => {
return (
<div
className={`h-16 w-16 cursor-pointer rounded-lg ${
className={`h-16 w-16 cursor-pointer rounded-lg border border-slate-300 ${
color === x ? "border-4 border-slate-500" : ""
}`}
key={x}

View File

@@ -135,10 +135,14 @@ export const CreateNewActionTab = ({
};
}
const newActionClass: TActionClass = await createActionClassAction(
environmentId,
updatedAction as TActionClassInput
);
// const newActionClass: TActionClass =
const createActionClassResposne = await createActionClassAction({
action: updatedAction as TActionClassInput,
});
if (!createActionClassResposne?.data) return;
const newActionClass = createActionClassResposne.data;
if (setActionClasses) {
setActionClasses((prevActionClasses: TActionClass[]) => [...prevActionClasses, newActionClass]);
}
@@ -167,14 +171,14 @@ export const CreateNewActionTab = ({
<div>
<FormProvider {...form}>
<form onSubmit={handleSubmit(submitHandler)}>
<div className="max-h-[400px] w-full space-y-4 overflow-y-auto">
<div className="max-h-[500px] w-full space-y-4 overflow-y-auto pr-4">
<div className="w-3/5">
<FormField
name={`type`}
control={control}
render={({ field }) => (
<div>
<Label className="font-semibold">Type</Label>
<Label className="font-semibold">Action Type</Label>
<TabToggle
id="type"
options={[

View File

@@ -8,7 +8,7 @@ import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { createId } from "@paralleldrive/cuid2";
import * as Collapsible from "@radix-ui/react-collapsible";
import { GripIcon } from "lucide-react";
import { GripIcon, Handshake, Undo2 } from "lucide-react";
import { cn } from "@formbricks/lib/cn";
import { recallToHeadline } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
@@ -129,7 +129,13 @@ export const EditEndingCard = ({
"flex w-10 flex-col items-center justify-between rounded-l-lg border-b border-l border-t py-2 group-aria-expanded:rounded-bl-none",
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
)}>
<p className="mt-3">{endingCard.type === "endScreen" ? "🙏" : "↪️"}</p>
<div className="mt-3 flex w-full justify-center">
{endingCard.type === "endScreen" ? (
<Handshake className="h-4 w-4" />
) : (
<Undo2 className="h-4 w-4 rotate-180" />
)}
</div>
<button className="opacity-0 transition-all duration-300 hover:cursor-move group-hover:opacity-100">
<GripIcon className="h-4 w-4" />
</button>

View File

@@ -1,6 +1,7 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { Hand } from "lucide-react";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { LocalizedEditor } from "@formbricks/ee/multi-language/components/localized-editor";
@@ -66,7 +67,7 @@ export const EditWelcomeCard = ({
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none",
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
)}>
<p></p>
<Hand className="h-4 w-4" />
</div>
<Collapsible.Root
open={open}
@@ -109,7 +110,7 @@ export const EditWelcomeCard = ({
<div className="mt-3 flex w-full items-center justify-center">
<FileInput
id="welcome-card-image"
allowedFileExtensions={["png", "jpeg", "jpg"]}
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
environmentId={environmentId}
onFileUpload={(url: string[]) => {
updateSurvey({ fileUrl: url[0] });

View File

@@ -213,8 +213,6 @@ export const EditorCardMenu = ({
<DropdownMenuSubContent className="ml-4 border border-slate-200">
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
if (type === card.type) return null;
return (
<DropdownMenuItem
key={type}

View File

@@ -72,7 +72,7 @@ export const EndScreenForm = ({
} else {
updateSurvey({
buttonLabel: { default: "Create your own Survey" },
buttonLink: "https://formbricks.com/signup",
buttonLink: "https://formbricks.com",
});
}
setshowEndingCardCTA(!showEndingCardCTA);
@@ -111,7 +111,7 @@ export const EndScreenForm = ({
id="buttonLink"
name="buttonLink"
className="bg-white"
placeholder="https://formbricks.com/signup"
placeholder="https://formbricks.com"
value={endingCard.buttonLink}
onChange={(e) => updateSurvey({ buttonLink: e.target.value })}
/>

View File

@@ -1,9 +1,11 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { EyeOff } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { extractRecallInfo } from "@formbricks/lib/utils/recall";
import { TSurvey, TSurveyHiddenFields } from "@formbricks/types/surveys/types";
import { validateId } from "@formbricks/types/surveys/validation";
import { Button } from "@formbricks/ui/Button";
@@ -36,9 +38,26 @@ export const HiddenFieldsCard = ({
}
};
const updateSurvey = (data: TSurveyHiddenFields) => {
const updateSurvey = (data: TSurveyHiddenFields, currentFieldId?: string) => {
const questions = [...localSurvey.questions];
// Remove recall info from question headlines
if (currentFieldId) {
questions.forEach((question) => {
for (const [languageCode, headline] of Object.entries(question.headline)) {
if (headline.includes(`recall:${currentFieldId}`)) {
const recallInfo = extractRecallInfo(headline);
if (recallInfo) {
question.headline[languageCode] = headline.replace(recallInfo, "");
}
}
}
});
}
setLocalSurvey({
...localSurvey,
questions,
hiddenFields: {
...localSurvey.hiddenFields,
...data,
@@ -53,7 +72,7 @@ export const HiddenFieldsCard = ({
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
)}>
<p>🥷</p>
<EyeOff className="h-4 w-4" />
</div>
<Collapsible.Root
open={open}
@@ -93,10 +112,13 @@ export const HiddenFieldsCard = ({
<Tag
key={fieldId}
onDelete={() => {
updateSurvey({
enabled: true,
fieldIds: localSurvey.hiddenFields?.fieldIds?.filter((q) => q !== fieldId),
});
updateSurvey(
{
enabled: true,
fieldIds: localSurvey.hiddenFields?.fieldIds?.filter((q) => q !== fieldId),
},
fieldId
);
}}
tagId={fieldId}
tagName={fieldId}

View File

@@ -16,7 +16,7 @@ export const UploadImageSurveyBg = ({
<div className="flex w-full items-center justify-center">
<FileInput
id="survey-bg-file-input"
allowedFileExtensions={["png", "jpeg", "jpg"]}
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
environmentId={environmentId}
onFileUpload={(url: string[]) => {
if (url.length > 0) {

View File

@@ -80,6 +80,7 @@ const conditions = {
cal: ["skipped", "booked"],
matrix: ["isCompletelySubmitted", "isPartiallySubmitted", "skipped"],
address: ["submitted", "skipped"],
ranking: ["submitted", "skipped"],
};
export const LogicEditor = ({
@@ -452,14 +453,13 @@ export const LogicEditor = ({
<div className="mt-2 flex items-center space-x-2">
<Button
id="logicJumps"
className="bg-slate-100 hover:bg-slate-50"
type="button"
name="logicJumps"
size="sm"
variant="secondary"
StartIcon={SplitIcon}
onClick={() => addLogic()}>
Add Logic
Add logic
</Button>
<TooltipProvider delayDuration={50}>
<Tooltip>

View File

@@ -5,8 +5,7 @@ import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"
import { createId } from "@paralleldrive/cuid2";
import { PlusIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { createI18nString, extractLanguageCodes, getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import {
TI18nString,
@@ -19,7 +18,7 @@ import { Button } from "@formbricks/ui/Button";
import { Label } from "@formbricks/ui/Label";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
import { SelectQuestionChoice } from "./SelectQuestionChoice";
import { QuestionOptionChoice } from "./QuestionOptionChoice";
interface OpenQuestionFormProps {
localSurvey: TSurvey;
@@ -245,7 +244,7 @@ export const MultipleChoiceQuestionForm = ({
<div className="flex flex-col">
{question.choices &&
question.choices.map((choice, choiceIdx) => (
<SelectQuestionChoice
<QuestionOptionChoice
key={choice.id}
choice={choice}
choiceIdx={choiceIdx}
@@ -267,7 +266,7 @@ export const MultipleChoiceQuestionForm = ({
</div>
</SortableContext>
</DndContext>
<div className="flex items-center justify-between space-x-2">
<div className="mt-2 flex items-center justify-between space-x-2">
{question.choices.filter((c) => c.id === "other").length === 0 && (
<Button size="sm" variant="minimal" type="button" onClick={() => addOther()}>
Add &quot;Other&quot;
@@ -296,7 +295,7 @@ export const MultipleChoiceQuestionForm = ({
onValueChange={(e: TShuffleOption) => {
updateQuestion(questionIdx, { shuffleOption: e });
}}>
<SelectTrigger className="w-fit space-x-2 overflow-hidden border-0 font-semibold text-slate-600">
<SelectTrigger className="w-fit space-x-2 overflow-hidden border-0 font-medium text-slate-600">
<SelectValue placeholder="Select ordering" />
</SelectTrigger>
<SelectContent>

View File

@@ -139,7 +139,7 @@ export const PictureSelectionForm = ({
<div className="mt-3 flex w-full items-center justify-center">
<FileInput
id="choices-file-input"
allowedFileExtensions={["png", "jpeg", "jpg"]}
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
environmentId={environmentId}
onFileUpload={handleFileInputChanges}
fileUrl={question?.choices?.map((choice) => choice.imageUrl)}

View File

@@ -1,5 +1,6 @@
"use client";
import { RankingQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm";
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util";
import { QUESTIONS_ICON_MAP, getTSurveyQuestionTypeEnumName } from "@/app/lib/questions";
import { useSortable } from "@dnd-kit/sortable";
@@ -367,6 +368,18 @@ export const QuestionCard = ({
isInvalid={isInvalid}
attributeClasses={attributeClasses}
/>
) : question.type === TSurveyQuestionTypeEnum.Ranking ? (
<RankingQuestionForm
localSurvey={localSurvey}
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
attributeClasses={attributeClasses}
/>
) : null}
<div className="mt-4">
<Collapsible.Root open={openAdvanced} onOpenChange={setOpenAdvanced} className="mt-5">

View File

@@ -9,15 +9,14 @@ import {
TSurvey,
TSurveyLanguage,
TSurveyMultipleChoiceQuestion,
TSurveyQuestionChoice,
TSurveyRankingQuestion,
} from "@formbricks/types/surveys/types";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
import { isLabelValidForAllLanguages } from "../lib/validation";
interface ChoiceProps {
choice: {
id: string;
label: Record<string, string>;
};
choice: TSurveyQuestionChoice;
choiceIdx: number;
questionIdx: number;
updateChoice: (choiceIdx: number, updatedAttributes: { label: TI18nString }) => void;
@@ -28,13 +27,16 @@ interface ChoiceProps {
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
surveyLanguages: TSurveyLanguage[];
question: TSurveyMultipleChoiceQuestion;
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyMultipleChoiceQuestion>) => void;
question: TSurveyMultipleChoiceQuestion | TSurveyRankingQuestion;
updateQuestion: (
questionIdx: number,
updatedAttributes: Partial<TSurveyMultipleChoiceQuestion> | Partial<TSurveyRankingQuestion>
) => void;
surveyLanguageCodes: string[];
attributeClasses: TAttributeClass[];
}
export const SelectQuestionChoice = ({
export const QuestionOptionChoice = ({
addChoice,
choice,
choiceIdx,

View File

@@ -18,7 +18,7 @@ const tabs: Tab[] = [
{
id: "styling",
label: "Styling",
icon: <PaintbrushIcon />,
icon: <PaintbrushIcon className="h-5 w-5" />,
},
{
id: "settings",
@@ -46,7 +46,7 @@ export const QuestionsAudienceTabs = ({
}, [isStylingTabVisible]);
return (
<div className="fixed z-30 flex h-14 w-full items-center justify-center border bg-white md:w-1/2">
<div className="fixed z-30 flex h-12 w-full items-center justify-center border-b bg-white md:w-1/2">
<nav className="flex h-full items-center space-x-4" aria-label="Tabs">
{tabsComputed.map((tab) => (
<button
@@ -55,9 +55,9 @@ export const QuestionsAudienceTabs = ({
onClick={() => setActiveId(tab.id)}
className={cn(
tab.id === activeId
? "border-brand-dark border-b-2 font-semibold text-slate-900"
: "text-slate-500 hover:text-slate-700",
"flex h-full items-center px-3 text-sm font-medium"
? "border-brand-dark font-semibold text-slate-900"
: "border-transparent text-slate-500 hover:text-slate-700",
"flex h-full items-center border-b-2 px-3 text-sm font-medium"
)}
aria-current={tab.id === activeId ? "page" : undefined}>
{tab.icon && <div className="mr-2 h-5 w-5">{tab.icon}</div>}

View File

@@ -14,7 +14,7 @@ import { createId } from "@paralleldrive/cuid2";
import React, { SetStateAction, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { MultiLanguageCard } from "@formbricks/ee/multi-language/components/multi-language-card";
import { addMultiLanguageLabels, extractLanguageCodes, getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { addMultiLanguageLabels, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { getDefaultEndingCard } from "@formbricks/lib/templates";
import { checkForEmptyFallBackValue, extractRecallInfo } from "@formbricks/lib/utils/recall";
@@ -209,15 +209,14 @@ export const QuestionsView = ({
const activeQuestionIdTemp = activeQuestionId ?? localSurvey.questions[0].id;
let updatedSurvey: TSurvey = { ...localSurvey };
// check if we are recalling from this question
// check if we are recalling from this question for every language
updatedSurvey.questions.forEach((question) => {
if (question.headline[selectedLanguageCode].includes(`recall:${questionId}`)) {
const recallInfo = extractRecallInfo(getLocalizedValue(question.headline, selectedLanguageCode));
if (recallInfo) {
question.headline[selectedLanguageCode] = question.headline[selectedLanguageCode].replace(
recallInfo,
""
);
for (const [languageCode, headline] of Object.entries(question.headline)) {
if (headline.includes(`recall:${questionId}`)) {
const recallInfo = extractRecallInfo(headline);
if (recallInfo) {
question.headline[languageCode] = headline.replace(recallInfo, "");
}
}
}
});
@@ -359,7 +358,7 @@ export const QuestionsView = ({
};
return (
<div className="mt-16 w-full px-5 py-4">
<div className="mt-12 w-full px-5 py-4">
<div className="mb-5 flex w-full flex-col gap-5">
<EditWelcomeCard
localSurvey={localSurvey}
@@ -434,6 +433,13 @@ export const QuestionsView = ({
activeQuestionId={activeQuestionId}
/>
{/* <SurveyVariablesCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
/> */}
<MultiLanguageCard
localSurvey={localSurvey}
product={product}

View File

@@ -0,0 +1,251 @@
"use client";
import { DndContext } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { createId } from "@paralleldrive/cuid2";
import { PlusIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import {
TI18nString,
TShuffleOption,
TSurvey,
TSurveyRankingQuestion,
} from "@formbricks/types/surveys/types";
import { Button } from "@formbricks/ui/Button";
import { Label } from "@formbricks/ui/Label";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
import { QuestionOptionChoice } from "./QuestionOptionChoice";
interface RankingQuestionFormProps {
localSurvey: TSurvey;
question: TSurveyRankingQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyRankingQuestion>) => void;
lastQuestion: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
isInvalid: boolean;
attributeClasses: TAttributeClass[];
}
export const RankingQuestionForm = ({
question,
questionIdx,
updateQuestion,
isInvalid,
localSurvey,
selectedLanguageCode,
setSelectedLanguageCode,
attributeClasses,
}: RankingQuestionFormProps): JSX.Element => {
const lastChoiceRef = useRef<HTMLInputElement>(null);
const [isInvalidValue, setIsInvalidValue] = useState<string | null>(null);
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
const surveyLanguages = localSurvey.languages ?? [];
const updateChoice = (choiceIdx: number, updatedAttributes: { label: TI18nString }) => {
if (question.choices) {
const newChoices = question.choices.map((choice, idx) => {
if (idx !== choiceIdx) return choice;
return { ...choice, ...updatedAttributes };
});
updateQuestion(questionIdx, { choices: newChoices });
}
};
const addChoice = (choiceIdx: number) => {
let newChoices = !question.choices ? [] : question.choices;
const newChoice = {
id: createId(),
label: createI18nString("", surveyLanguageCodes),
};
updateQuestion(questionIdx, {
choices: [...newChoices.slice(0, choiceIdx + 1), newChoice, ...newChoices.slice(choiceIdx + 1)],
});
};
const addOption = () => {
const choices = !question.choices ? [] : question.choices;
const newChoice = {
id: createId(),
label: createI18nString("", surveyLanguageCodes),
};
updateQuestion(questionIdx, { choices: [...choices, newChoice] });
};
const deleteChoice = (choiceIdx: number) => {
const newChoices = !question.choices ? [] : question.choices.filter((_, idx) => idx !== choiceIdx);
const choiceValue = question.choices[choiceIdx].label[selectedLanguageCode];
if (isInvalidValue === choiceValue) {
setIsInvalidValue(null);
}
updateQuestion(questionIdx, { choices: newChoices });
};
const shuffleOptionsTypes = {
none: {
id: "none",
label: "Keep current order",
show: true,
},
all: {
id: "all",
label: "Randomize all",
show: question.choices.length > 0,
},
};
useEffect(() => {
if (lastChoiceRef.current) {
lastChoiceRef.current?.focus();
}
}, [question.choices?.length]);
return (
<form>
<QuestionFormInput
id="headline"
value={question.headline}
label={"Question*"}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
<div>
{question.subheader !== undefined && (
<div className="inline-flex w-full items-center">
<div className="w-full">
<QuestionFormInput
id="subheader"
value={question.subheader}
label={"Description"}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
</div>
)}
{question.subheader === undefined && (
<Button
size="sm"
variant="minimal"
className="mt-3"
type="button"
onClick={() => {
updateQuestion(questionIdx, {
subheader: createI18nString("", surveyLanguageCodes),
});
}}>
<PlusIcon className="mr-1 h-4 w-4" />
Add Description
</Button>
)}
</div>
<div className="mt-3">
<Label htmlFor="choices">Options*</Label>
<div className="mt-2" id="choices">
<DndContext
onDragEnd={(event) => {
const { active, over } = event;
if (!active || !over) {
return;
}
const activeIndex = question.choices.findIndex((choice) => choice.id === active.id);
const overIndex = question.choices.findIndex((choice) => choice.id === over.id);
const newChoices = [...question.choices];
newChoices.splice(activeIndex, 1);
newChoices.splice(overIndex, 0, question.choices[activeIndex]);
updateQuestion(questionIdx, { choices: newChoices });
}}>
<SortableContext items={question.choices} strategy={verticalListSortingStrategy}>
<div className="flex flex-col">
{question.choices &&
question.choices.map((choice, choiceIdx) => (
<QuestionOptionChoice
key={choice.id}
choice={choice}
choiceIdx={choiceIdx}
questionIdx={questionIdx}
updateChoice={updateChoice}
deleteChoice={deleteChoice}
addChoice={addChoice}
isInvalid={isInvalid}
localSurvey={localSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
surveyLanguages={surveyLanguages}
question={question}
updateQuestion={updateQuestion}
surveyLanguageCodes={surveyLanguageCodes}
attributeClasses={attributeClasses}
/>
))}
</div>
</SortableContext>
</DndContext>
<div className="mt-2 flex flex-1 items-center justify-between gap-2">
<Button
size="sm"
variant="secondary"
EndIcon={PlusIcon}
type="button"
onClick={() => addOption()}>
Add option
</Button>
<Select
defaultValue={question.shuffleOption}
value={question.shuffleOption}
onValueChange={(option: TShuffleOption) => {
updateQuestion(questionIdx, { shuffleOption: option });
}}>
<SelectTrigger className="w-fit space-x-2 overflow-hidden border-0 font-medium text-slate-600">
<SelectValue placeholder="Select ordering" />
</SelectTrigger>
<SelectContent>
{Object.values(shuffleOptionsTypes).map(
(shuffleOptionsType) =>
shuffleOptionsType.show && (
<SelectItem
key={shuffleOptionsType.id}
value={shuffleOptionsType.id}
title={shuffleOptionsType.label}>
{shuffleOptionsType.label}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
</div>
</div>
</form>
);
};

View File

@@ -1,4 +1,3 @@
import React from "react";
import { TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
@@ -17,7 +16,7 @@ export const RedirectUrlForm = ({ endingCard, updateSurvey }: RedirectUrlFormPro
id="redirectUrl"
name="redirectUrl"
className="bg-white"
placeholder="https://formbricks.com/signup"
placeholder="https://formbricks.com"
value={endingCard.url}
onChange={(e) => updateSurvey({ url: e.target.value })}
/>

View File

@@ -30,7 +30,10 @@ export const ResponseOptionsCard = ({
const [closeOnDateToggle, setCloseOnDateToggle] = useState(false);
useState;
const [surveyClosedMessageToggle, setSurveyClosedMessageToggle] = useState(false);
const [verifyEmailToggle, setVerifyEmailToggle] = useState(false);
const [verifyEmailToggle, setVerifyEmailToggle] = useState(localSurvey.isVerifyEmailEnabled);
const [isSingleResponsePerEmailEnabledToggle, setIsSingleResponsePerEmailToggle] = useState(
localSurvey.isSingleResponsePerEmailEnabled
);
const [surveyClosedMessage, setSurveyClosedMessage] = useState({
heading: "Survey Completed",
@@ -114,6 +117,14 @@ export const ResponseOptionsCard = ({
setLocalSurvey({ ...localSurvey, isVerifyEmailEnabled: !localSurvey.isVerifyEmailEnabled });
};
const handleSingleResponsePerEmailToggle = () => {
setIsSingleResponsePerEmailToggle(!isSingleResponsePerEmailEnabledToggle);
setLocalSurvey({
...localSurvey,
isSingleResponsePerEmailEnabled: !localSurvey.isSingleResponsePerEmailEnabled,
});
};
const handleRunOnDateChange = (date: Date) => {
const equivalentDate = date?.getDate();
date?.setUTCHours(0, 0, 0, 0);
@@ -213,10 +224,6 @@ export const ResponseOptionsCard = ({
setSingleUseEncryption(localSurvey.singleUse.isEncrypted);
}
if (localSurvey.isVerifyEmailEnabled) {
setVerifyEmailToggle(true);
}
if (localSurvey.runOnDate) {
setRunOnDate(localSurvey.runOnDate);
setRunOnDateToggle(true);
@@ -450,8 +457,17 @@ export const ResponseOptionsCard = ({
onToggle={handleVerifyEmailToogle}
title="Verify email before submission"
description="Only let people with a real email respond."
childBorder={true}
/>
childBorder={true}>
<div className="m-1">
<AdvancedOptionToggle
htmlId="preventDoubleSubmission"
isChecked={isSingleResponsePerEmailEnabledToggle}
onToggle={handleSingleResponsePerEmailToggle}
title="Prevent double submission"
description={"Only allow 1 response per email address"}
/>
</div>
</AdvancedOptionToggle>
<AdvancedOptionToggle
htmlId="protectSurveyWithPin"
isChecked={isPinProtectionEnabled}

View File

@@ -68,9 +68,9 @@ export const SurveyEditor = ({
const [localStylingChanges, setLocalStylingChanges] = useState<TSurveyStyling | null>(null);
const fetchLatestProduct = useCallback(async () => {
const latestProduct = await refetchProductAction(localProduct.id);
if (latestProduct) {
setLocalProduct(latestProduct);
const refetchProductResponse = await refetchProductAction({ productId: localProduct.id });
if (refetchProductResponse?.data) {
setLocalProduct(refetchProductResponse.data);
}
}, [localProduct.id]);
@@ -95,9 +95,9 @@ export const SurveyEditor = ({
const listener = () => {
if (document.visibilityState === "visible") {
const fetchLatestProduct = async () => {
const latestProduct = await refetchProductAction(localProduct.id);
if (latestProduct) {
setLocalProduct(latestProduct);
const refetchProductResponse = await refetchProductAction({ productId: localProduct.id });
if (refetchProductResponse?.data) {
setLocalProduct(refetchProductResponse.data);
}
};
fetchLatestProduct();
@@ -206,7 +206,7 @@ export const SurveyEditor = ({
)}
</main>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-200 bg-slate-100 py-6 shadow-inner md:flex md:flex-col">
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-200 bg-slate-100 shadow-inner md:flex md:flex-col">
<PreviewSurvey
survey={localSurvey}
questionId={activeQuestionId}

View File

@@ -7,6 +7,7 @@ import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { createSegmentAction } from "@formbricks/ee/advanced-targeting/lib/actions";
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
import { getLanguageLabel } from "@formbricks/lib/i18n/utils";
import { TEnvironment } from "@formbricks/types/environment";
import { TProduct } from "@formbricks/types/product";
@@ -132,7 +133,7 @@ export const SurveyMenuBar = ({
title: localSurvey.id,
});
return newSegment;
return newSegment?.data;
}
};
@@ -178,7 +179,9 @@ export const SurveyMenuBar = ({
(invalidLanguage: string) => getLanguageLabel(invalidLanguage) ?? invalidLanguage
);
toast.error(`${currentError.message} ${invalidLanguageLabels.join(", ")}`);
const messageSplit = currentError.message.split("-fLang-")[0];
toast.error(`${messageSplit} ${invalidLanguageLabels.join(", ")}`);
} else {
toast.error(currentError.message);
}
@@ -223,13 +226,23 @@ export const SurveyMenuBar = ({
}
});
if (localSurvey.type !== "link" && !localSurvey.triggers?.length) {
toast.error("Please set a survey trigger");
setIsSurveySaving(false);
return false;
}
const segment = await handleSegmentUpdate();
const updatedSurvey = await updateSurveyAction({ ...localSurvey, segment });
const updatedSurveyResponse = await updateSurveyAction({ ...localSurvey, segment });
setIsSurveySaving(false);
setLocalSurvey(updatedSurvey);
toast.success("Changes saved.");
if (updatedSurveyResponse?.data) {
setLocalSurvey(updatedSurveyResponse.data);
toast.success("Changes saved.");
} else {
const errorMessage = getFormattedErrorMessage(updatedSurveyResponse);
toast.error(errorMessage);
}
return true;
} catch (e) {
@@ -281,10 +294,12 @@ export const SurveyMenuBar = ({
return (
<>
<div className="border-b border-slate-200 bg-white px-5 py-3 sm:flex sm:items-center sm:justify-between">
<div className="flex items-center space-x-2 whitespace-nowrap">
<div className="border-b border-slate-200 bg-white px-5 py-2.5 sm:flex sm:items-center sm:justify-between">
<div className="flex h-full items-center space-x-2 whitespace-nowrap">
<Button
size="sm"
variant="secondary"
className="h-full"
StartIcon={ArrowLeftIcon}
onClick={() => {
handleBack();
@@ -298,11 +313,11 @@ export const SurveyMenuBar = ({
const updatedSurvey = { ...localSurvey, name: e.target.value };
setLocalSurvey(updatedSurvey);
}}
className="w-72 border-white hover:border-slate-200"
className="h-8 w-72 border-white py-0 hover:border-slate-200"
/>
</div>
{responseCount > 0 && (
<div className="ju flex items-center rounded-lg border border-amber-200 bg-amber-100 p-2 text-amber-700 shadow-sm lg:mx-auto">
<div className="ju flex items-center rounded-lg border border-amber-200 bg-amber-100 p-1.5 text-amber-800 shadow-sm lg:mx-auto">
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
@@ -313,7 +328,9 @@ export const SurveyMenuBar = ({
</TooltipContent>
</Tooltip>
</TooltipProvider>
<p className="hidden pl-1 text-xs md:text-sm lg:block">{cautionText}</p>
<p className="hidden text-ellipsis whitespace-nowrap pl-1.5 text-xs md:text-sm lg:block">
{cautionText}
</p>
</div>
)}
<div className="mt-3 flex sm:ml-4 sm:mt-0">
@@ -327,6 +344,7 @@ export const SurveyMenuBar = ({
<Button
disabled={disableSave}
variant="secondary"
size="sm"
className="mr-3"
loading={isSurveySaving}
onClick={() => handleSurveySave()}
@@ -337,6 +355,7 @@ export const SurveyMenuBar = ({
<Button
disabled={disableSave}
className="mr-3"
size="sm"
loading={isSurveySaving}
onClick={() => handleSaveAndGoBack()}>
Save & Close
@@ -344,6 +363,7 @@ export const SurveyMenuBar = ({
)}
{localSurvey.status === "draft" && audiencePrompt && !isLinkSurvey && (
<Button
size="sm"
onClick={() => {
setAudiencePrompt(false);
setActiveId("settings");
@@ -355,6 +375,7 @@ export const SurveyMenuBar = ({
{/* Always display Publish button for link surveys for better CR */}
{localSurvey.status === "draft" && (!audiencePrompt || isLinkSurvey) && (
<Button
size="sm"
disabled={isSurveySaving || containsEmptyTriggers}
loading={isSurveyPublishing}
onClick={handleSurveyPublish}>

View File

@@ -0,0 +1,79 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { cn } from "@formbricks/lib/cn";
import { TSurvey } from "@formbricks/types/surveys/types";
import { SurveyVariablesCardItem } from "./SurveyVariablesCardItem";
interface SurveyVariablesCardProps {
localSurvey: TSurvey;
setLocalSurvey: (survey: TSurvey) => void;
activeQuestionId: string | null;
setActiveQuestionId: (id: string | null) => void;
}
const variablesCardId = `fb-variables-${Date.now()}`;
export const SurveyVariablesCard = ({
localSurvey,
setLocalSurvey,
activeQuestionId,
setActiveQuestionId,
}: SurveyVariablesCardProps) => {
const open = activeQuestionId === variablesCardId;
const setOpenState = (state: boolean) => {
if (state) {
setActiveQuestionId(variablesCardId);
} else {
setActiveQuestionId(null);
}
};
return (
<div className={cn(open ? "shadow-lg" : "shadow-md", "group z-10 flex flex-row rounded-lg bg-white")}>
<div
className={cn(
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
)}>
<p>🪣</p>
</div>
<Collapsible.Root
open={open}
onOpenChange={setOpenState}
className="flex-1 rounded-r-lg border border-slate-200 transition-all duration-300 ease-in-out">
<Collapsible.CollapsibleTrigger
asChild
className="flex cursor-pointer justify-between p-4 hover:bg-slate-50">
<div>
<div className="inline-flex">
<div>
<p className="text-sm font-semibold">Variables</p>
</div>
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="px-4 pb-6">
<div className="flex flex-col gap-2">
{localSurvey.variables.length > 0 ? (
localSurvey.variables.map((variable) => (
<SurveyVariablesCardItem
key={variable.id}
mode="edit"
variable={variable}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
/>
))
) : (
<p className="mt-2 text-sm italic text-slate-500">No variables yet. Add the first one below.</p>
)}
</div>
<SurveyVariablesCardItem mode="create" localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
</Collapsible.CollapsibleContent>
</Collapsible.Root>
</div>
);
};

View File

@@ -0,0 +1,224 @@
"use client";
import { createId } from "@paralleldrive/cuid2";
import { TrashIcon } from "lucide-react";
import React, { useCallback, useEffect } from "react";
import { useForm } from "react-hook-form";
import { extractRecallInfo } from "@formbricks/lib/utils/recall";
import { TSurvey, TSurveyVariable } from "@formbricks/types/surveys/types";
import { Button } from "@formbricks/ui/Button";
import { FormControl, FormField, FormItem, FormProvider } from "@formbricks/ui/Form";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
interface SurveyVariablesCardItemProps {
variable?: TSurveyVariable;
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
mode: "create" | "edit";
}
export const SurveyVariablesCardItem = ({
variable,
localSurvey,
setLocalSurvey,
mode,
}: SurveyVariablesCardItemProps) => {
const form = useForm<TSurveyVariable>({
defaultValues: variable ?? {
id: createId(),
name: "",
type: "number",
value: 0,
},
mode: "onChange",
});
const { errors } = form.formState;
const isNameError = !!errors.name?.message;
const variableType = form.watch("type");
const editSurveyVariable = useCallback(
(data: TSurveyVariable) => {
setLocalSurvey((prevSurvey) => {
const updatedVariables = prevSurvey.variables.map((v) => (v.id === data.id ? data : v));
return { ...prevSurvey, variables: updatedVariables };
});
},
[setLocalSurvey]
);
const createSurveyVariable = (data: TSurveyVariable) => {
setLocalSurvey({
...localSurvey,
variables: [...localSurvey.variables, data],
});
form.reset({
id: createId(),
name: "",
type: "number",
value: 0,
});
};
useEffect(() => {
if (mode === "create") {
return;
}
const subscription = form.watch(() => form.handleSubmit(editSurveyVariable)());
return () => subscription.unsubscribe();
}, [form, mode, editSurveyVariable]);
const onVaribleDelete = (variable: TSurveyVariable) => {
const questions = [...localSurvey.questions];
// find if this variable is used in any question's recall and remove it for every language
questions.forEach((question) => {
for (const [languageCode, headline] of Object.entries(question.headline)) {
if (headline.includes(`recall:${variable.id}`)) {
const recallInfo = extractRecallInfo(headline);
if (recallInfo) {
question.headline[languageCode] = headline.replace(recallInfo, "");
}
}
}
});
setLocalSurvey((prevSurvey) => {
const updatedVariables = prevSurvey.variables.filter((v) => v.id !== variable.id);
return { ...prevSurvey, variables: updatedVariables, questions };
});
};
if (mode === "edit" && !variable) {
return null;
}
return (
<div>
<FormProvider {...form}>
<form
className="mt-5"
onSubmit={form.handleSubmit((data) => {
if (mode === "create") {
createSurveyVariable(data);
} else {
editSurveyVariable(data);
}
})}>
{mode === "create" && <Label htmlFor="headline">Add variable</Label>}
<div className="mt-2 flex w-full items-center gap-2">
<FormField
control={form.control}
name="name"
rules={{
pattern: {
value: /^[a-z0-9_]+$/,
message: "Only lower case letters, numbers, and underscores are allowed.",
},
validate: (value) => {
// if the variable name is already taken
if (
mode === "create" &&
localSurvey.variables.find((variable) => variable.name === value)
) {
return "Variable name is already taken, please choose another.";
}
if (mode === "edit" && variable && variable.name !== value) {
if (localSurvey.variables.find((variable) => variable.name === value)) {
return "Variable name is already taken, please choose another.";
}
}
// if it does not start with a letter
if (!/^[a-z]/.test(value)) {
return "Variable name must start with a letter.";
}
},
}}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input
{...field}
isInvalid={isNameError}
type="text"
placeholder="Field name e.g, score, price"
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<Select
{...field}
onValueChange={(value) => {
form.setValue("value", value === "number" ? 0 : "");
field.onChange(value);
}}>
<SelectTrigger className="w-24">
<SelectValue placeholder="Select type" className="text-sm" />
</SelectTrigger>
<SelectContent>
<SelectItem value={"number"}>Number</SelectItem>
<SelectItem value={"text"}>Text</SelectItem>
</SelectContent>
</Select>
)}
/>
<p className="text-slate-600">=</p>
<FormField
control={form.control}
name="value"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input
{...field}
onChange={(e) => {
field.onChange(variableType === "number" ? Number(e.target.value) : e.target.value);
}}
placeholder="Initial value"
type={variableType === "number" ? "number" : "text"}
/>
</FormControl>
</FormItem>
)}
/>
{mode === "create" && (
<Button variant="secondary" type="submit" className="h-10 whitespace-nowrap">
Add variable
</Button>
)}
{mode === "edit" && variable && (
<Button
variant="minimal"
type="button"
size="sm"
className="whitespace-nowrap"
onClick={() => onVaribleDelete(variable)}>
<TrashIcon className="h-4 w-4" />
</Button>
)}
</div>
{isNameError && <p className="mt-1 text-sm text-red-500">{errors.name?.message}</p>}
</form>
</FormProvider>
</div>
);
};

View File

@@ -6,6 +6,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
import { cn } from "@formbricks/lib/cn";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { isAdvancedSegment } from "@formbricks/lib/segment/utils";
@@ -81,34 +82,38 @@ export const TargetingCard = ({
const handleCloneSegment = async () => {
if (!segment) return;
try {
const clonedSegment = await cloneBasicSegmentAction(segment.id, localSurvey.id);
const cloneBasicSegmentResponse = await cloneBasicSegmentAction({
segmentId: segment.id,
surveyId: localSurvey.id,
});
setSegment(clonedSegment);
} catch (err) {
toast.error(err.message);
if (cloneBasicSegmentResponse?.data) {
setSegment(cloneBasicSegmentResponse.data);
} else {
const errorMessage = getFormattedErrorMessage(cloneBasicSegmentResponse);
toast.error(errorMessage);
}
};
const handleLoadNewSegment = async (surveyId: string, segmentId: string) => {
const updatedSurvey = await loadNewBasicSegmentAction(surveyId, segmentId);
return updatedSurvey;
const loadNewBasicSegmentResponse = await loadNewBasicSegmentAction({ surveyId, segmentId });
return loadNewBasicSegmentResponse?.data as TSurvey;
};
const handleSegmentUpdate = async (environmentId: string, segmentId: string, data: TSegmentUpdateInput) => {
const updatedSegment = await updateBasicSegmentAction(environmentId, segmentId, data);
return updatedSegment;
const handleSegmentUpdate = async (segmentId: string, data: TSegmentUpdateInput) => {
const updateBasicSegmentResponse = await updateBasicSegmentAction({ segmentId, data });
return updateBasicSegmentResponse?.data as TSegment;
};
const handleSegmentCreate = async (data: TSegmentCreateInput) => {
const createdSegment = await createBasicSegmentAction(data);
return createdSegment;
return createdSegment?.data as TSegment;
};
const handleSaveSegment = async (data: TSegmentUpdateInput) => {
try {
if (!segment) throw new Error("Invalid segment");
await updateBasicSegmentAction(environmentId, segment?.id, data);
await updateBasicSegmentAction({ segmentId: segment?.id, data });
router.refresh();
toast.success("Segment saved successfully");
@@ -122,7 +127,10 @@ export const TargetingCard = ({
const handleResetAllFilters = async () => {
try {
return await resetBasicSegmentFiltersAction(localSurvey.id);
const resetBasicSegmentFiltersResponse = await resetBasicSegmentFiltersAction({
surveyId: localSurvey.id,
});
return resetBasicSegmentFiltersResponse?.data;
} catch (err) {
toast.error("Error resetting filters");
}
@@ -361,7 +369,7 @@ export const TargetingCard = ({
{isFormbricksCloud ? (
<UpgradePlanNotice
message="For advanced targeting, please"
textForUrl="upgrade to the User Identification plan."
textForUrl="upgrade to the Scale plan."
url={`/environments/${environmentId}/settings/billing`}
/>
) : (

View File

@@ -123,7 +123,13 @@ export const ImageFromUnsplashSurveyBg = ({ handleBgChange }: ImageFromUnsplashS
const fetchData = async (searchQuery: string, currentPage: number) => {
try {
setIsLoading(true);
const imagesFromUnsplash = await getImagesFromUnsplashAction(searchQuery, currentPage);
const getImagesFromUnsplashResponse = await getImagesFromUnsplashAction({
searchQuery: searchQuery,
page: currentPage,
});
if (!getImagesFromUnsplashResponse?.data) return;
const imagesFromUnsplash = getImagesFromUnsplashResponse.data;
for (let i = 0; i < imagesFromUnsplash.length; i++) {
const authorName = new URL(imagesFromUnsplash[i].urls.regularWithAttribution).searchParams.get(
"authorName"
@@ -163,7 +169,7 @@ export const ImageFromUnsplashSurveyBg = ({ handleBgChange }: ImageFromUnsplashS
try {
handleBgChange(imageUrl, "image");
if (downloadImageUrl) {
await triggerDownloadUnsplashImageAction(downloadImageUrl);
await triggerDownloadUnsplashImageAction({ downloadUrl: downloadImageUrl });
}
} catch (error) {
toast.error(error.message);

View File

@@ -37,4 +37,6 @@ export const minimalSurvey: TSurvey = {
languages: [],
showLanguageSwitch: false,
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
variables: [],
};

View File

@@ -9,6 +9,7 @@ export const BackButton = () => {
return (
<Button
variant="secondary"
size="sm"
StartIcon={ArrowLeftIcon}
onClick={() => {
router.back();

View File

@@ -5,7 +5,7 @@ import { BackButton } from "@/app/(app)/(survey-editor)/environments/[environmen
export const MenuBar = () => {
return (
<>
<div className="border-b border-slate-200 bg-white px-5 py-3 sm:flex sm:items-center sm:justify-between">
<div className="border-b border-slate-200 bg-white px-5 py-2.5 sm:flex sm:items-center sm:justify-between">
<div className="flex items-center space-x-2 whitespace-nowrap">
<BackButton />
</div>

View File

@@ -65,16 +65,14 @@ export const TemplateContainerWithPreview = ({
</div>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 md:flex md:flex-col">
{activeTemplate && (
<div className="my-6 flex h-[90%] w-full flex-col items-center justify-center">
<PreviewSurvey
survey={{ ...minimalSurvey, ...activeTemplate.preset }}
questionId={activeQuestionId}
product={product}
environment={environment}
languageCode={"default"}
onFileUpload={async (file) => file.name}
/>
</div>
<PreviewSurvey
survey={{ ...minimalSurvey, ...activeTemplate.preset }}
questionId={activeQuestionId}
product={product}
environment={environment}
languageCode={"default"}
onFileUpload={async (file) => file.name}
/>
)}
</aside>
</div>

View File

@@ -1,6 +1,9 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@formbricks/lib/authOptions";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getUser } from "@formbricks/lib/user/service";
import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
@@ -43,6 +46,15 @@ const Page = async ({ params, searchParams }: SurveyTemplateProps) => {
if (!environment) {
throw new Error("Environment not found");
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(
session?.user.id,
product.organizationId
);
const { isViewer } = getAccessFlags(currentUserMembership?.role);
if (isViewer) {
return redirect(`/environments/${environment.id}/surveys`);
}
const prefilledFilters = [product.config.channel, product.config.industry, searchParams.role ?? null];

View File

@@ -1,23 +1,40 @@
"use server";
import { getServerSession } from "next-auth";
import { canUserAccessAttributeClass } from "@formbricks/lib/attributeClass/auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { z } from "zod";
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
import {
getOrganizationIdFromAttributeClassId,
getOrganizationIdFromEnvironmentId,
} from "@formbricks/lib/organization/utils";
import { getSegmentsByAttributeClassName } from "@formbricks/lib/segment/service";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { AuthorizationError } from "@formbricks/types/errors";
import { ZAttributeClass } from "@formbricks/types/attribute-classes";
import { ZId } from "@formbricks/types/common";
export const getSegmentsByAttributeClassAction = async (
environmentId: string,
attributeClass: TAttributeClass
): Promise<{ activeSurveys: string[]; inactiveSurveys: string[] }> => {
try {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const ZGetSegmentsByAttributeClassAction = z.object({
environmentId: ZId,
attributeClass: ZAttributeClass,
});
const isAuthorized = await canUserAccessAttributeClass(session.user.id, attributeClass.id);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const segments = await getSegmentsByAttributeClassName(environmentId, attributeClass.name);
export const getSegmentsByAttributeClassAction = authenticatedActionClient
.schema(ZGetSegmentsByAttributeClassAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromAttributeClassId(parsedInput.attributeClass.id),
rules: ["attributeClass", "read"],
});
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
rules: ["environment", "read"],
});
const segments = await getSegmentsByAttributeClassName(
parsedInput.environmentId,
parsedInput.attributeClass.name
);
// segments is an array of segments, each segment has a survey array with objects with properties: id, name and status.
// We need the name of the surveys only and we need to filter out the surveys that are both in progress and not in progress.
@@ -34,8 +51,4 @@ export const getSegmentsByAttributeClassAction = async (
.flat();
return { activeSurveys, inactiveSurveys };
} catch (err) {
console.error(`Error getting segments by attribute class: ${err}`);
throw err;
}
};
});

View File

@@ -2,6 +2,7 @@
import { TagIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
import { convertDateTimeStringShort } from "@formbricks/lib/time";
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
@@ -24,20 +25,20 @@ export const AttributeActivityTab = ({ attributeClass }: EventActivityTabProps)
setLoading(true);
const getSurveys = async () => {
try {
setLoading(true);
const segmentsWithAttributeClassName = await getSegmentsByAttributeClassAction(
attributeClass.environmentId,
attributeClass
);
setLoading(true);
const segmentsWithAttributeClassNameResponse = await getSegmentsByAttributeClassAction({
environmentId: attributeClass.environmentId,
attributeClass,
});
setActiveSurveys(segmentsWithAttributeClassName.activeSurveys);
setInactiveSurveys(segmentsWithAttributeClassName.inactiveSurveys);
} catch (err) {
setError(err);
} finally {
setLoading(false);
if (segmentsWithAttributeClassNameResponse?.data) {
setActiveSurveys(segmentsWithAttributeClassNameResponse.data.activeSurveys);
setInactiveSurveys(segmentsWithAttributeClassNameResponse.data.inactiveSurveys);
} else {
const errorMessage = getFormattedErrorMessage(segmentsWithAttributeClassNameResponse);
setError(new Error(errorMessage));
}
setLoading(false);
};
getSurveys();

View File

@@ -5,9 +5,10 @@ import { authenticatedActionClient } from "@formbricks/lib/actionClient";
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
import { getOrganizationIdFromPersonId } from "@formbricks/lib/organization/utils";
import { deletePerson } from "@formbricks/lib/person/service";
import { ZId } from "@formbricks/types/common";
const ZPersonDeleteAction = z.object({
personId: z.string(),
personId: ZId,
});
export const deletePersonAction = authenticatedActionClient

View File

@@ -36,8 +36,8 @@ export const ResponseFeed = ({
setFetchedResponses(responses);
}, [responses]);
const deleteResponse = (responseId: string) => {
setFetchedResponses(responses.filter((response) => response.id !== responseId));
const deleteResponses = (responseIds: string[]) => {
setFetchedResponses(responses.filter((response) => !responseIds.includes(response.id)));
};
const updateResponse = (responseId: string, updatedResponse: TResponse) => {
@@ -59,7 +59,7 @@ export const ResponseFeed = ({
user={user}
environmentTags={environmentTags}
environment={environment}
deleteResponse={deleteResponse}
deleteResponses={deleteResponses}
updateResponse={updateResponse}
attributeClasses={attributeClasses}
/>
@@ -75,7 +75,7 @@ const ResponseSurveyCard = ({
user,
environmentTags,
environment,
deleteResponse,
deleteResponses,
updateResponse,
attributeClasses,
}: {
@@ -84,7 +84,7 @@ const ResponseSurveyCard = ({
user: TUser;
environmentTags: TTag[];
environment: TEnvironment;
deleteResponse: (responseId: string) => void;
deleteResponses: (responseIds: string[]) => void;
updateResponse: (responseId: string, response: TResponse) => void;
attributeClasses: TAttributeClass[];
}) => {
@@ -105,7 +105,7 @@ const ResponseSurveyCard = ({
pageType="people"
environmentTags={environmentTags}
environment={environment}
deleteResponse={deleteResponse}
deleteResponses={deleteResponses}
updateResponse={updateResponse}
isViewer={isViewer}
/>

View File

@@ -1,49 +1,53 @@
"use server";
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { deleteSegment, getSegment, updateSegment } from "@formbricks/lib/segment/service";
import { AuthorizationError } from "@formbricks/types/errors";
import { TSegmentUpdateInput, ZSegmentFilters } from "@formbricks/types/segment";
import { z } from "zod";
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
import { getOrganizationIdFromSegmentId } from "@formbricks/lib/organization/utils";
import { deleteSegment, updateSegment } from "@formbricks/lib/segment/service";
import { ZId } from "@formbricks/types/common";
import { ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment";
export const deleteBasicSegmentAction = async (environmentId: string, segmentId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const ZDeleteBasicSegmentAction = z.object({
segmentId: ZId,
});
const environmentAccess = hasUserEnvironmentAccess(session.user.id, environmentId);
if (!environmentAccess) throw new AuthorizationError("Not authorized");
export const deleteBasicSegmentAction = authenticatedActionClient
.schema(ZDeleteBasicSegmentAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSegmentId(parsedInput.segmentId),
rules: ["segment", "delete"],
});
const foundSegment = await getSegment(segmentId);
return await deleteSegment(parsedInput.segmentId);
});
if (!foundSegment) {
throw new Error(`Segment with id ${segmentId} not found`);
}
const ZUpdateBasicSegmentAction = z.object({
segmentId: ZId,
data: ZSegmentUpdateInput,
});
return await deleteSegment(segmentId);
};
export const updateBasicSegmentAction = authenticatedActionClient
.schema(ZUpdateBasicSegmentAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSegmentId(parsedInput.segmentId),
rules: ["segment", "update"],
});
export const updateBasicSegmentAction = async (
environmentId: string,
segmentId: string,
data: TSegmentUpdateInput
) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const { filters } = parsedInput.data;
if (filters) {
const parsedFilters = ZSegmentFilters.safeParse(filters);
const environmentAccess = hasUserEnvironmentAccess(session.user.id, environmentId);
if (!environmentAccess) throw new AuthorizationError("Not authorized");
const { filters } = data;
if (filters) {
const parsedFilters = ZSegmentFilters.safeParse(filters);
if (!parsedFilters.success) {
const errMsg =
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message || "Invalid filters";
throw new Error(errMsg);
if (!parsedFilters.success) {
const errMsg =
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message || "Invalid filters";
throw new Error(errMsg);
}
}
}
return await updateSegment(segmentId, data);
};
return await updateSegment(parsedInput.segmentId, parsedInput.data);
});

View File

@@ -70,11 +70,14 @@ export const BasicSegmentSettings = ({
try {
setIsUpdatingSegment(true);
await updateBasicSegmentAction(segment.environmentId, segment.id, {
title: segment.title,
description: segment.description ?? "",
isPrivate: segment.isPrivate,
filters: segment.filters,
await updateBasicSegmentAction({
segmentId: segment.id,
data: {
title: segment.title,
description: segment.description ?? "",
isPrivate: segment.isPrivate,
filters: segment.filters,
},
});
setIsUpdatingSegment(false);
@@ -99,7 +102,7 @@ export const BasicSegmentSettings = ({
const handleDeleteSegment = async () => {
try {
setIsDeletingSegment(true);
await deleteBasicSegmentAction(segment.environmentId, segment.id);
await deleteBasicSegmentAction({ segmentId: segment.id });
setIsDeletingSegment(false);
toast.success("Segment deleted successfully!");

View File

@@ -1,68 +1,67 @@
"use server";
import { Organization } from "@prisma/client";
import { getServerSession } from "next-auth";
import { z } from "zod";
import { getIsMultiOrgEnabled } from "@formbricks/ee/lib/service";
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
import { authOptions } from "@formbricks/lib/authOptions";
import { createMembership } from "@formbricks/lib/membership/service";
import { createOrganization } from "@formbricks/lib/organization/service";
import { createProduct } from "@formbricks/lib/product/service";
import { getUser, updateUser } from "@formbricks/lib/user/service";
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import { updateUser } from "@formbricks/lib/user/service";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZProductUpdateInput } from "@formbricks/types/product";
import { TUserNotificationSettings } from "@formbricks/types/user";
export const createOrganizationAction = async (organizationName: string): Promise<Organization> => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled)
throw new OperationNotAllowedError(
"Creating Multiple organization is restricted on your instance of Formbricks"
);
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const ZCreateOrganizationAction = z.object({
organizationName: z.string(),
});
const user = await getUser(session.user.id);
if (!user) throw new Error("User not found");
export const createOrganizationAction = authenticatedActionClient
.schema(ZCreateOrganizationAction)
.action(async ({ ctx, parsedInput }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled)
throw new OperationNotAllowedError(
"Creating Multiple organization is restricted on your instance of Formbricks"
);
const newOrganization = await createOrganization({
name: organizationName,
const newOrganization = await createOrganization({
name: parsedInput.organizationName,
});
await createMembership(newOrganization.id, ctx.user.id, {
role: "owner",
accepted: true,
});
const product = await createProduct(newOrganization.id, {
name: "My Product",
});
const updatedNotificationSettings: TUserNotificationSettings = {
...ctx.user.notificationSettings,
alert: {
...ctx.user.notificationSettings?.alert,
},
weeklySummary: {
...ctx.user.notificationSettings?.weeklySummary,
[product.id]: true,
},
unsubscribedOrganizationIds: Array.from(
new Set([...(ctx.user.notificationSettings?.unsubscribedOrganizationIds || []), newOrganization.id])
),
};
await updateUser(ctx.user.id, {
notificationSettings: updatedNotificationSettings,
});
return newOrganization;
});
await createMembership(newOrganization.id, session.user.id, {
role: "owner",
accepted: true,
});
const product = await createProduct(newOrganization.id, {
name: "My Product",
});
const updatedNotificationSettings: TUserNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[product.id]: true,
},
unsubscribedOrganizationIds: Array.from(
new Set([...(user.notificationSettings?.unsubscribedOrganizationIds || []), newOrganization.id])
),
};
await updateUser(session.user.id, {
notificationSettings: updatedNotificationSettings,
});
return newOrganization;
};
const ZCreateProductAction = z.object({
organizationId: z.string(),
organizationId: ZId,
data: ZProductUpdateInput,
});

View File

@@ -1,76 +1,74 @@
"use server";
import { getServerSession } from "next-auth";
import { canUserUpdateActionClass, verifyUserRoleAccess } from "@formbricks/lib/actionClass/auth";
import { deleteActionClass, updateActionClass } from "@formbricks/lib/actionClass/service";
import { authOptions } from "@formbricks/lib/authOptions";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { z } from "zod";
import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service";
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
import { getOrganizationIdFromActionClassId } from "@formbricks/lib/organization/utils";
import { getSurveysByActionClassId } from "@formbricks/lib/survey/service";
import { TActionClassInput } from "@formbricks/types/action-classes";
import { AuthorizationError } from "@formbricks/types/errors";
import { ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
export const deleteActionClassAction = async (environmentId, actionClassId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const ZDeleteActionClassAction = z.object({
actionClassId: ZId,
});
const organization = await getOrganizationByEnvironmentId(environmentId);
export const deleteActionClassAction = authenticatedActionClient
.schema(ZDeleteActionClassAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId),
rules: ["actionClass", "delete"],
});
if (!organization) {
throw new Error("Organization not found");
}
await deleteActionClass(parsedInput.actionClassId);
});
const isAuthorized = await canUserUpdateActionClass(session.user.id, actionClassId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const ZUpdateActionClassAction = z.object({
actionClassId: ZId,
updatedAction: ZActionClassInput,
});
const { hasDeleteAccess } = await verifyUserRoleAccess(environmentId, session.user.id);
if (!hasDeleteAccess) throw new AuthorizationError("Not authorized");
export const updateActionClassAction = authenticatedActionClient
.schema(ZUpdateActionClassAction)
.action(async ({ ctx, parsedInput }) => {
const actionClass = await getActionClass(parsedInput.actionClassId);
if (actionClass === null) {
throw new ResourceNotFoundError("ActionClass", parsedInput.actionClassId);
}
await deleteActionClass(environmentId, actionClassId);
};
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId),
rules: ["actionClass", "update"],
});
export const updateActionClassAction = async (
environmentId: string,
actionClassId: string,
updatedAction: Partial<TActionClassInput>
) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
return await updateActionClass(
actionClass.environmentId,
parsedInput.actionClassId,
parsedInput.updatedAction
);
});
const organization = await getOrganizationByEnvironmentId(environmentId);
const ZGetActiveInactiveSurveysAction = z.object({
actionClassId: ZId,
});
if (!organization) {
throw new Error("Organization not found");
}
export const getActiveInactiveSurveysAction = authenticatedActionClient
.schema(ZGetActiveInactiveSurveysAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId),
rules: ["survey", "read"],
});
const isAuthorized = await canUserUpdateActionClass(session.user.id, actionClassId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const { hasCreateOrUpdateAccess } = await verifyUserRoleAccess(environmentId, session.user.id);
if (!hasCreateOrUpdateAccess) throw new AuthorizationError("Not authorized");
return await updateActionClass(environmentId, actionClassId, updatedAction);
};
export const getActiveInactiveSurveysAction = async (
actionClassId: string,
environmentId: string
): Promise<{ activeSurveys: string[]; inactiveSurveys: string[] }> => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new Error("Organization not found");
}
const isAuthorized = await canUserUpdateActionClass(session.user.id, actionClassId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const surveys = await getSurveysByActionClassId(actionClassId);
const response = {
activeSurveys: surveys.filter((s) => s.status === "inProgress").map((survey) => survey.name),
inactiveSurveys: surveys.filter((s) => s.status !== "inProgress").map((survey) => survey.name),
};
return response;
};
const surveys = await getSurveysByActionClassId(parsedInput.actionClassId);
const response = {
activeSurveys: surveys.filter((s) => s.status === "inProgress").map((survey) => survey.name),
inactiveSurveys: surveys.filter((s) => s.status !== "inProgress").map((survey) => survey.name),
};
return response;
});

View File

@@ -2,6 +2,7 @@
import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
import { convertDateTimeStringShort } from "@formbricks/lib/time";
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
import { TActionClass } from "@formbricks/types/action-classes";
@@ -25,16 +26,18 @@ export const ActionActivityTab = ({ actionClass, environmentId }: ActivityTabPro
setLoading(true);
const updateState = async () => {
try {
setLoading(true);
const activeInactiveSurveys = await getActiveInactiveSurveysAction(actionClass.id, environmentId);
setActiveSurveys(activeInactiveSurveys.activeSurveys);
setInactiveSurveys(activeInactiveSurveys.inactiveSurveys);
} catch (err) {
setError(err);
} finally {
setLoading(false);
setLoading(true);
const getActiveInactiveSurveysResponse = await getActiveInactiveSurveysAction({
actionClassId: actionClass.id,
});
if (getActiveInactiveSurveysResponse?.data) {
setActiveSurveys(getActiveInactiveSurveysResponse.data.activeSurveys);
setInactiveSurveys(getActiveInactiveSurveysResponse.data.inactiveSurveys);
} else {
const errorMessage = getFormattedErrorMessage(getActiveInactiveSurveysResponse);
setError(new Error(errorMessage));
}
setLoading(false);
};
updateState();

View File

@@ -31,7 +31,6 @@ export const ActionDetailModal = ({
title: "Settings",
children: (
<ActionSettingsTab
environmentId={environmentId}
actionClass={actionClass}
actionClasses={actionClasses}
setOpen={setOpen}

View File

@@ -23,7 +23,6 @@ import { CodeActionForm } from "@formbricks/ui/organisms/CodeActionForm";
import { NoCodeActionForm } from "@formbricks/ui/organisms/NoCodeActionForm";
interface ActionSettingsTabProps {
environmentId: string;
actionClass: TActionClass;
actionClasses: TActionClass[];
setOpen: (v: boolean) => void;
@@ -31,7 +30,6 @@ interface ActionSettingsTabProps {
}
export const ActionSettingsTab = ({
environmentId,
actionClass,
actionClasses,
setOpen,
@@ -104,7 +102,10 @@ export const ActionSettingsTab = ({
},
}),
};
await updateActionClassAction(environmentId, actionClass.id, updatedData);
await updateActionClassAction({
actionClassId: actionClass.id,
updatedAction: updatedData,
});
setOpen(false);
router.refresh();
toast.success("Action updated successfully");
@@ -118,7 +119,7 @@ export const ActionSettingsTab = ({
const handleDeleteAction = async () => {
try {
setIsDeletingAction(true);
await deleteActionClassAction(environmentId, actionClass.id);
await deleteActionClassAction({ actionClassId: actionClass.id });
router.refresh();
toast.success("Action deleted successfully");
setOpen(false);

View File

@@ -102,6 +102,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
environment={environment}
environments={environments}
currentProductChannel={currentProductChannel}
membershipRole={currentUserMembership?.role}
/>
<div className="mt-14">{children}</div>
</div>

View File

@@ -40,7 +40,7 @@ export const EnvironmentSwitch = ({ environment, environments }: EnvironmentSwit
<Label
htmlFor="development-mode"
className={cn("hover:cursor-pointer", isEnvSwitchChecked && "text-orange-800")}>
Test mode
Dev Env
</Label>
<Switch
className="focus:ring-orange-800 data-[state=checked]:bg-orange-800"

View File

@@ -30,6 +30,7 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { FORMBRICKS_PRODUCT_ID_LS, FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@formbricks/lib/localStorage";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
import { TEnvironment } from "@formbricks/types/environment";
@@ -114,6 +115,16 @@ export const MainNavigation = ({
}
}, [organization]);
useEffect(() => {
if (typeof window === "undefined") return;
const productId = localStorage.getItem(FORMBRICKS_PRODUCT_ID_LS);
const targetProduct = products.find((product) => product.id === productId);
if (targetProduct && productId && product && product.id !== targetProduct.id) {
router.push(`/products/${targetProduct.id}/`);
}
}, []);
const sortedOrganizations = useMemo(() => {
return [...organizations].sort((a, b) => a.name.localeCompare(b.name));
}, [organizations]);
@@ -140,6 +151,12 @@ export const MainNavigation = ({
}, [products]);
const handleEnvironmentChangeByProduct = (productId: string) => {
if (typeof window !== "undefined") {
localStorage.setItem(FORMBRICKS_PRODUCT_ID_LS, productId);
// Remove filters when switching products
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}
router.push(`/products/${productId}/`);
};

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