Compare commits

..

127 Commits

Author SHA1 Message Date
pandeymangg
ed383e7862 test perf 2024-09-26 20:08:14 +05:30
pandeymangg
3db7c7946b fix: testing perf 2024-09-26 15:00:40 +05:30
pandeymangg
2f63659739 fix: fixes debouncing in e2e tests 2024-09-25 18:50:35 +05:30
Piyush Gupta
b3757ae7c1 fix: typo 2024-09-25 15:53:10 +05:30
Piyush Gupta
942bf818a5 chore: add docs 2024-09-25 15:50:45 +05:30
Piyush Gupta
74b03a54e1 fix: reviews 2024-09-25 12:22:34 +05:30
Piyush Gupta
5282637772 Merge branch 'feat-advanced-logic-editor' of https://github.com/formbricks/formbricks into feat-advanced-logic-editor 2024-09-25 10:30:09 +05:30
pandeymangg
9165daffe9 fix: perf 2024-09-25 10:26:41 +05:30
Piyush Gupta
de05d2abdf Merge branch 'feat-advanced-logic-editor' of https://github.com/formbricks/formbricks into feat-advanced-logic-editor 2024-09-24 22:59:25 +05:30
Piyush Gupta
a907179c7d chore: variable name 2024-09-24 20:10:27 +05:30
Piyush Gupta
bc7d3e5da4 chore: code rabbit review changes 2024-09-24 20:08:32 +05:30
Johannes
7042d73e99 UI tweaks 2024-09-24 15:11:01 +02:00
Piyush Gupta
fe7ca5a923 fix: functionality and code bugs 2024-09-24 17:01:48 +05:30
Piyush Gupta
960edfd3f0 removes error throwing from data-migration 2024-09-24 11:15:42 +05:30
Piyush Gupta
2b0df8280d fix: build error 2024-09-24 09:51:26 +05:30
Piyush Gupta
13ce552a39 Merge branch 'main' of https://github.com/formbricks/formbricks into feat-advanced-logic-editor 2024-09-23 23:23:50 +05:30
Piyush Gupta
4d6665ab3e Merge branch 'feat-advanced-logic-editor' of https://github.com/formbricks/formbricks into feat-advanced-logic-editor 2024-09-23 23:22:02 +05:30
Piyush Gupta
483bdc0eff fix: code review changes 2024-09-23 23:21:46 +05:30
pandeymangg
8238b502fe fix: error 2024-09-23 18:18:22 +05:30
Dhruwang Jariwala
01ceaa13ec fix: load more button applies filter (#3167) 2024-09-23 12:37:07 +00:00
pandeymangg
35ff935a27 fix: lag 2024-09-23 17:00:30 +05:30
pandeymangg
901cd42f56 Merge branch 'feat-advanced-logic-editor' of https://github.com/formbricks/formbricks into feat-advanced-logic-editor 2024-09-23 16:33:12 +05:30
pandeymangg
63e1ac11cf fix: lag 2024-09-23 16:32:55 +05:30
Piyush Gupta
744f3410ae Merge branch 'main' of https://github.com/formbricks/formbricks into feat-advanced-logic-editor 2024-09-23 15:45:23 +05:30
pandeymangg
0e73d81999 fix 2024-09-23 12:19:11 +05:30
pandeymangg
ba46782da4 remove comment 2024-09-23 11:42:22 +05:30
Piyush Gupta
25774f6f08 chore: removes stale action docs (#3166) 2024-09-23 04:43:13 +00:00
Dhruwang Jariwala
9a98772210 fix: notion integration (#3162) 2024-09-20 14:17:28 +00:00
Piyush Gupta
59a29dd3d6 feat: Introduce Formbricks CX (#3152)
Co-authored-by: RajuGangitla <gangitlaraju8520@gmail.com>
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-20 09:17:18 +00:00
Dhruwang Jariwala
e4fceb2e5e fix: improved person fetching (#3161) 2024-09-20 05:09:22 +00:00
pandeymangg
350c895d8c fixes 2024-09-20 09:51:35 +05:30
Dhruwang Jariwala
0b553447e0 fix: person loading skeleton (#3160) 2024-09-19 16:44:16 +00:00
Dhruwang Jariwala
6b64367d99 feat: Data table for persons (#3154) 2024-09-19 13:34:25 +00:00
Matti Nannt
fe9746ba67 fix: update displays data migration run out of memory on many displays (#3157)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2024-09-19 11:00:39 +00:00
Dhruwang Jariwala
e4009d5951 chore: Refactor display response relationship (#3100)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2024-09-18 10:55:46 +00:00
Sai Suhas Sawant
b1ed61c247 fix: Added DialogTitle and DialogDescrtiption components to the dialog. (#3146)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2024-09-18 09:53:50 +00:00
Anshuman Pandey
10255aa102 fix: api errors (#3150)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2024-09-18 09:52:05 +00:00
mdm317
774c6f19a5 fix: notes not appearing (#3131)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-09-18 09:37:36 +00:00
Piyush Gupta
ebf35ea582 feat: adds auto animate to survey questions (#3147)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-09-17 09:24:12 +00:00
Anshuman Pandey
f13efc954e fix: removes rewrites from next config (#3149) 2024-09-17 09:19:21 +00:00
Matti Nannt
9ee052a229 feat: Make app surveys scalable (#3024)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2024-09-17 08:15:19 +00:00
RajuGangitla
152fbede90 feat: info about new formbricks version (#3126)
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>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2024-09-17 07:35:19 +00:00
Dhruwang Jariwala
c9b8ffa9ef fix: billing tab (#3138)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-09-16 10:31:54 +00:00
Piyush Gupta
4ed1747ee2 fix: variable and hidden field comparison 2024-09-15 21:29:30 +05:30
Piyush Gupta
88c492afd8 adds complex logic evaluate check 2024-09-13 12:25:51 +05:30
Piyush Gupta
c7e0b02595 feat: adds unit tests 2024-09-13 11:52:40 +05:30
Piyush Gupta
15aa9b2731 Merge branch 'main' of https://github.com/formbricks/formbricks into feat-advanced-logic-editor 2024-09-12 22:05:32 +05:30
Piyush Gupta
6f7359abf6 feat: adds e2e tests 2024-09-12 22:05:25 +05:30
Anshuman Pandey
04e16d44a1 fix: release 2.5.3 (#3139) 2024-09-12 16:07:39 +02:00
Jonas Höbenreich
29131f93c2 fix: increase timeout (#3120)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2024-09-12 15:35:12 +02:00
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
Piyush Gupta
0bf5e0fd4c fix: comparison fixes 2024-09-12 15:57:11 +05:30
Jonas Höbenreich
688dc25990 fix: cuid validation 🚨 (#3130) 2024-09-12 10:26:33 +00:00
Piyush Gupta
887c5c0eef feat: adds image support in inputCombobox 2024-09-11 16:48:23 +05:30
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
Piyush Gupta
af4ae38564 fix: input clear on operand change 2024-09-11 14:10:26 +05:30
Anshuman Pandey
07b5dfe28a fix: trigger (#3125) 2024-09-11 04:31:37 +00:00
Piyush Gupta
6b829744a1 fix: dhru review changes 2024-09-10 19:47:12 +05:30
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
Piyush Gupta
7b0d4926e8 fix: updated storybook component 2024-09-10 12:28:55 +05:30
Piyush Gupta
334166b4b1 fix: refine logic completed 2024-09-10 12:17:04 +05:30
Piyush Gupta
1685e77a35 fix: evaluate logic 2024-09-10 10:14:11 +05:30
Piyush Gupta
927f97e9ad Merge branch 'main' of https://github.com/formbricks/formbricks into feat-advanced-logic-editor 2024-09-10 09:28:33 +05:30
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
Piyush Gupta
76c437b16a Merge branch 'main' of https://github.com/formbricks/formbricks into feat-advanced-logic-editor 2024-09-09 20:39:43 +05:30
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
Johannes
0c2d425a45 tweak 2024-09-09 12:27:20 +02:00
Piyush Gupta
7aa827d1e1 Merge branch 'main' of https://github.com/formbricks/formbricks into feat-advanced-logic-editor 2024-09-09 14:21:56 +05:30
Piyush Gupta
d4961f1840 fix: dropoff and comparison funciton 2024-09-09 14:21:20 +05:30
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
Piyush Gupta
6772ea6be4 fix: build error 2024-09-06 19:28:57 +05:30
Piyush Gupta
9bacc88063 fix: advanced logic data migration 2024-09-06 19:23:59 +05:30
Piyush Gupta
005c777c9c Merge branch 'main' of https://github.com/formbricks/formbricks into feat-advanced-logic-editor 2024-09-06 15:34:54 +05:30
Piyush Gupta
560dce3bbf feat: add includeVariables option to integration settings 2024-09-06 15:18:25 +05:30
Piyush Gupta
ffd45a6f20 feat: adds variables in response table, response finished email, response card, download responses sheet 2024-09-06 14:27:16 +05:30
Piyush Gupta
1ddd82c084 fix: data migration for surveys with end destination 2024-09-06 14:25:16 +05:30
Dhruwang Jariwala
780115ffb8 fix: infinite loading in survey animation (#3107) 2024-09-06 07:02:57 +00:00
Piyush Gupta
647539c617 fix: undefined conditions or actions 2024-09-06 11:52:29 +05:30
Piyush Gupta
a5a3161f7c Merge branch 'main' of https://github.com/formbricks/formbricks into feat-advanced-logic-editor 2024-09-06 11:20:48 +05:30
Piyush Gupta
0535581f6d WIP: adds survey refine for advanced logic 2024-09-05 22:05:42 +05:30
Piyush Gupta
97b04f9e43 changed left operand id -> value 2024-09-05 17:00:39 +05:30
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
Piyush Gupta
6ec0861c49 fix: inputCombobox styling 2024-09-05 15:19:48 +05:30
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
Piyush Gupta
8542320a8e fix build error 2024-09-05 12:54:32 +05:30
Johannes
e0767881f2 fix: update link to landing page (#3097) 2024-09-05 09:21:09 +02:00
Piyush Gupta
7d3c8d35e1 Merge branch 'main' of https://github.com/formbricks/formbricks into feat-advanced-logic-editor 2024-09-05 11:31:27 +05:30
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
6abcf91a07 Merge branch 'main' of https://github.com/formbricks/formbricks into feat-advanced-logic-editor 2024-09-04 11:39:24 +05:30
Piyush Gupta
9a7887d9fd Merge branch 'main' of https://github.com/formbricks/formbricks into feat-advanced-logic-editor 2024-09-03 16:01:37 +05:30
Piyush Gupta
7dd8bb95bd fix: delete logicItem in case of no conditions 2024-09-03 10:38:13 +05:30
Piyush Gupta
df40c0ef11 adds ranking question logic 2024-09-02 16:19:42 +05:30
Piyush Gupta
7e14b86a63 fix: UI suggestions 2024-09-02 15:45:08 +05:30
Piyush Gupta
a1ba3af439 fix: data migration for incomplete logic 2024-09-01 20:57:35 +05:30
Piyush Gupta
0a8c5e384d fix: adds data migration script 2024-08-30 16:41:15 +05:30
Piyush Gupta
b20ce46a7b Merge branch 'main' of https://github.com/formbricks/formbricks into feat-advanced-logic-editor 2024-08-29 20:29:45 +05:30
Piyush Gupta
58636a9d51 Merge branch 'main' of https://github.com/formbricks/formbricks into feat-advanced-logic-editor 2024-08-29 09:16:23 +05:30
Piyush Gupta
328e3d0b9a adds conditional options in matchValue 2024-08-28 22:34:12 +05:30
Piyush Gupta
f1a2ecaa3a feat: adds data migration script WIP 2024-08-28 17:55:34 +05:30
Piyush Gupta
d56f05fb19 fix: question, hiddenfield, variable delete error if used in logic 2024-08-28 16:35:05 +05:30
Piyush Gupta
c32ced20f1 fix: adds variable in response schema and updated config to store variables on each response 2024-08-27 19:57:40 +05:30
Piyush Gupta
387590986a fix: removes userAttributes based logic chaining 2024-08-27 16:41:27 +05:30
Piyush Gupta
4a5c0b1409 feat: updates survey template 2024-08-27 15:26:08 +05:30
Piyush Gupta
275731e381 replaces logic with advancedLogic 2024-08-27 06:30:09 +05:30
Piyush Gupta
71c3ac0e4e component render fix based on new schema 2024-08-25 23:16:19 +05:30
Piyush Gupta
d876c495be new schema 2024-08-24 22:04:04 +05:30
Piyush Gupta
1ba885e5dc adds local schema updation handlers 2024-08-23 15:43:34 +05:30
Piyush Gupta
245972234e Merge branch 'main' of https://github.com/formbricks/formbricks into feat-advanced-logic-editor 2024-08-22 15:37:07 +05:30
Piyush Gupta
f743709908 adds inputCombobox in actions 2024-08-22 15:14:14 +05:30
Piyush Gupta
79603293a0 adds inputCombobox storybook 2024-08-21 21:54:50 +05:30
Piyush Gupta
29e0cf96d4 adds condition handling, added logicInput component 2024-08-21 21:39:35 +05:30
Piyush Gupta
4eea6a11c8 feat: added types for advanced logic editor 2024-08-19 13:16:40 +05:30
311 changed files with 15934 additions and 4413 deletions

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

@@ -51,6 +51,7 @@ jobs:
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
echo "NEXT_PUBLIC_E2E_TESTING=1" >> .env
shell: bash
- name: Build App

View File

@@ -16,7 +16,7 @@ export default function App(): JSX.Element {
const config = {
environmentId: process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
apiHost: process.env.EXPO_PUBLIC_API_HOST,
userId: "random user id",
userId: "random-user-id",
attributes: {
language: "en",
testAttr: "attr-test",

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

@@ -79,28 +79,6 @@ Promise<{ id: string }, NetworkError | Error>
</CodeGroup>
</Col>
- Update Display
<Col>
<CodeGroup title="Update Display">
```javascript {{ title: 'Update Display Method Call'}}
await api.client.display.update(
displayId: "<your-display-id>",
{
userId: "<your-user-id>", // optional
responseId: "<your-response-id>", // optional
},
);
```
```javascript {{ title: 'Update Display Method Return Type' }}
Promise<{ }, NetworkError | Error]>
```
</CodeGroup>
</Col>
## Responses
- Create Response
@@ -173,29 +151,6 @@ Promise<{ }, NetworkError | Error]>
</CodeGroup>
</Col>
## Action
- Create Action:
<Note> An environment cannot have 2 actions with the same name. </Note>
<Col>
<CodeGroup title="Create Action">
```javascript {{ title: 'Create Action Method Call'}}
await api.client.action.create({
name: "<your-action-name>", // required
userId: "<your-user-id>", // required
});
```
```javascript {{ title: 'Create Action Method Return Type' }}
Promise<{ }, NetworkError | Error]>
```
</CodeGroup>
</Col>
## Attribute
- Update Attribute

View File

@@ -14,10 +14,6 @@ export const metadata = {
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)
<Note>
Our React Native SDK is now in public beta. We're happy to **offer you a free 3-month trial for our Scale plan** if you're providing feedback on your developer experience. Reach out on Discord and shoot Johannes a message to get the extended trial started.
</Note>
### Install
<Col>

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,171 @@
import { MdxImage } from "@/components/MdxImage";
import ActionCalculateOperators from "./images/action-calculate-operators.webp";
import ActionCalculateValue from "./images/action-calculate-value.webp";
import ActionCalculateVariables from "./images/action-calculate-variables.webp";
import ActionCalculate from "./images/action-calculate.webp";
import ActionJump from "./images/action-jump.webp";
import ActionOptions from "./images/action-options.webp";
import ActionRequire from "./images/action-require.webp";
import AddLogic from "./images/add-logic.webp";
import ConditionChaining from "./images/condition-chaining.webp";
import ConditionOperators from "./images/condition-operators.webp";
import ConditionOptions from "./images/condition-options.webp";
import ConditionValue from "./images/condition-value.webp";
import Conditions from "./images/conditions.webp";
import Editor from "./images/editor.webp";
import QuestionLogic from "./images/question-logic.webp";
export const metadata = {
title: "Logic Editor",
description:
"Create complex survey logic with the Logic Editor. Use conditions, actions, and variables to create a personalized survey experience.",
};
# Logic Editor
Create complex survey logic with the Logic Editor. Use conditions, actions, and variables to create a personalized survey experience.
<MdxImage src={Editor} alt="Logic Editor" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
## Terminology
- **Condition**: A rule that determines when an action should be executed.
- **Action**: A task that is executed when a condition is met.
## **Creating Logic**
1. **Add a Logic Block**: Click the `Add logic +` button to add a new logic block.
<MdxImage src={AddLogic} alt="Add Logic" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
<Note>
You can add multiple logic blocks to a survey. Logic blocks are executed in the order they are added. You
can rearrange the order of logic blocks.
</Note>
2. **Add Conditions**: Add conditions to the logic block. Conditions are rules that determine when an action should be executed.
<MdxImage
src={Conditions}
alt="Add Conditions"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
Conditons can be based on:
- **Question**: The answer to a question.
- **Variable**: A variable value.
- **Hidden Field**: The value of a hidden field.
2.a **Condition Options**: Choose from a list of available conditions.
<MdxImage
src={ConditionOptions}
alt="Condition Options"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
2.b **Condition Operators**: Choose an operator to compare the condition value.
<MdxImage
src={ConditionOperators}
alt="Condition Operators"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
2.c **Condition Value**: Enter a value to compare the condition against.
Comparisons can be made against a fixed value or a dynamic value.
Dynamic values can be based on a question, variable, or hidden field.
<MdxImage
src={ConditionValue}
alt="Condition Value"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
<Note>
- Conditions can be grouped. - Conditions can be combined using AND or OR operators. You can add multiple
conditions to a logic block. Conditions are evaluated in the order they are added.
</Note>
<MdxImage
src={ConditionChaining}
alt="Condition Chaining"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
3. **Add Actions**: Add actions to the logic block. Actions are tasks that are executed when a condition is met.
<Note>You can add multiple actions to a logic block. Actions are executed in the order they are added.</Note>
- 3.a **Action Options**: Choose from a list of available actions.
<MdxImage
src={ActionOptions}
alt="Add Actions"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
Action is of the following types:
- **Calculate**: Perform a calculation. These variables are then available for use in other questions.
- Calculations can be performed on variables.
- Calculations can be based on fixed values or dynamic values.
<MdxImage
src={ActionCalculateVariables}
alt="Action Calculate Variables"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
<MdxImage
src={ActionCalculateOperators}
alt="Action Calculate Variables"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
<MdxImage
src={ActionCalculateValue}
alt="Action Calculate Variables"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
<MdxImage
src={ActionCalculate}
alt="Action Calculate"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
- **Require Answer**: Make a question required. Only the optional questions can be marked as required while filling the survey.
<MdxImage
src={ActionRequire}
alt="Action Require"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
- **Jump to Question**: Skip to a specific question. The user will be redirected to the specified question based on the condition.
<MdxImage
src={ActionJump}
alt="Action Jump"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
4. **Save Logic**: Click the `Save` button to save the logic block.
# Question Logic
This logic is executed when the user answers the question. Logic can be as simple as showing a follow-up question based on the answer or as complex as calculating a score based on multiple answers.
<MdxImage
src={QuestionLogic}
alt="Question Logic"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -1,15 +1,15 @@
import { MdxImage } from "@/components/MdxImage";
import EntraIDAppReg01 from "./images/entra_app_reg_01.png";
import EntraIDAppReg02 from "./images/entra_app_reg_02.png";
import EntraIDAppReg03 from "./images/entra_app_reg_03.png";
import EntraIDAppReg04 from "./images/entra_app_reg_04.png";
import EntraIDAppReg05 from "./images/entra_app_reg_05.png";
import EntraIDAppReg06 from "./images/entra_app_reg_06.png";
import EntraIDAppReg07 from "./images/entra_app_reg_07.png";
import EntraIDAppReg08 from "./images/entra_app_reg_08.png";
import EntraIDAppReg09 from "./images/entra_app_reg_09.png";
import EntraIDAppReg10 from "./images/entra_app_reg_10.png";
import EntraIDAppReg01 from "./images/entra_app_reg_01.webp";
import EntraIDAppReg02 from "./images/entra_app_reg_02.webp";
import EntraIDAppReg03 from "./images/entra_app_reg_03.webp";
import EntraIDAppReg04 from "./images/entra_app_reg_04.webp";
import EntraIDAppReg05 from "./images/entra_app_reg_05.webp";
import EntraIDAppReg06 from "./images/entra_app_reg_06.webp";
import EntraIDAppReg07 from "./images/entra_app_reg_07.webp";
import EntraIDAppReg08 from "./images/entra_app_reg_08.webp";
import EntraIDAppReg09 from "./images/entra_app_reg_09.webp";
import EntraIDAppReg10 from "./images/entra_app_reg_10.webp";
export const metadata = {
title: "Configure Formbricks with External auth providers",

View File

@@ -106,6 +106,7 @@ export const navigation: Array<NavGroup> = [
links: [
{ title: "Access Roles", href: "/global/access-roles" },
{ title: "Styling Theme", href: "/global/styling-theme" },
{ title: "Logic Editor", href: "/global/logic-editor" },
],
},
{
@@ -139,9 +140,9 @@ export const navigation: Array<NavGroup> = [
{ title: "Zapier", href: "/developer-docs/integrations/zapier" },
],
},
{ title: "SDK: React Native", href: "/developer-docs/react-native-in-app-surveys" },
{ 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,7 +1,6 @@
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { getCustomHeadline } from "@/app/(app)/(onboarding)/lib/utils";
import { XIcon } from "lucide-react";
import { notFound } from "next/navigation";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
@@ -26,12 +25,9 @@ const Page = async ({ params }: ConnectPageProps) => {
throw new Error("Product not found");
}
const channel = product.config.channel;
const industry = product.config.industry;
const channel = product.config.channel || null;
const industry = product.config.industry || null;
if (!channel || !industry) {
return notFound();
}
const customHeadline = getCustomHeadline(channel, industry);
return (

View File

@@ -0,0 +1,100 @@
"use client";
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils";
import { XMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { ActivityIcon, ShoppingCartIcon, SmileIcon, StarIcon, ThumbsUpIcon, UsersIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
import { TProduct } from "@formbricks/types/product";
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { TXMTemplate } from "@formbricks/types/templates";
import { TUser } from "@formbricks/types/user";
import { createSurveyAction } from "@formbricks/ui/TemplateList/actions";
interface XMTemplateListProps {
product: TProduct;
user: TUser;
environmentId: string;
}
export const XMTemplateList = ({ product, user, environmentId }: XMTemplateListProps) => {
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
const router = useRouter();
const createSurvey = async (activeTemplate: TXMTemplate) => {
const augmentedTemplate: TSurveyCreateInput = {
...activeTemplate,
type: "link",
createdBy: user.id,
};
const createSurveyResponse = await createSurveyAction({
environmentId: environmentId,
surveyBody: augmentedTemplate,
});
if (createSurveyResponse?.data) {
router.push(`/environments/${environmentId}/surveys/${createSurveyResponse.data.id}/edit?mode=cx`);
} else {
const errorMessage = getFormattedErrorMessage(createSurveyResponse);
toast.error(errorMessage);
}
};
const handleTemplateClick = (templateIdx) => {
setActiveTemplateId(templateIdx);
const template = XMTemplates[templateIdx];
const newTemplate = replacePresetPlaceholders(template, product);
createSurvey(newTemplate);
};
const XMTemplateOptions = [
{
title: "NPS",
description: "Implement proven best practices to understand WHY people buy.",
icon: ShoppingCartIcon,
onClick: () => handleTemplateClick(0),
isLoading: activeTemplateId === 0,
},
{
title: "5-Star Rating",
description: "Universal feedback solution to gauge overall satisfaction.",
icon: StarIcon,
onClick: () => handleTemplateClick(1),
isLoading: activeTemplateId === 1,
},
{
title: "CSAT",
description: "Implement best practices to measure customer satisfaction.",
icon: ThumbsUpIcon,
onClick: () => handleTemplateClick(2),
isLoading: activeTemplateId === 2,
},
{
title: "CES",
description: "Leverage every touchpoint to understand ease of customer interaction.",
icon: ActivityIcon,
onClick: () => handleTemplateClick(3),
isLoading: activeTemplateId === 3,
},
{
title: "Smileys",
description: "Use visual indicators to capture feedback across customer touchpoints.",
icon: SmileIcon,
onClick: () => handleTemplateClick(4),
isLoading: activeTemplateId === 4,
},
{
title: "eNPS",
description: "Universal feedback to understand employee engagement and satisfaction.",
icon: UsersIcon,
onClick: () => handleTemplateClick(5),
isLoading: activeTemplateId === 5,
},
];
return <OnboardingOptionsContainer options={XMTemplateOptions} />;
};

View File

@@ -0,0 +1,13 @@
import { replaceQuestionPresetPlaceholders } from "@formbricks/lib/utils/templates";
import { TProduct } from "@formbricks/types/product";
import { TXMTemplate } from "@formbricks/types/templates";
// replace all occurences of productName with the actual product name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, product: TProduct) => {
const survey = structuredClone(template);
survey.name = survey.name.replace("{{productName}}", product.name);
survey.questions = survey.questions.map((question) => {
return replaceQuestionPresetPlaceholders(question, product);
});
return { ...template, ...survey };
};

View File

@@ -0,0 +1,406 @@
import { createId } from "@paralleldrive/cuid2";
import { getDefaultEndingCard } from "@formbricks/lib/templates";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TXMTemplate } from "@formbricks/types/templates";
export const XMSurveyDefault: TXMTemplate = {
name: "",
endings: [getDefaultEndingCard([])],
questions: [],
styling: {
overwriteThemeStyling: true,
},
};
const NPSSurvey = (): TXMTemplate => {
return {
...XMSurveyDefault,
name: "NPS Survey",
questions: [
{
id: createId(),
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "How likely are you to recommend {{productName}} to a friend or colleague?" },
required: true,
lowerLabel: { default: "Not at all likely" },
upperLabel: { default: "Extremely likely" },
isColorCodingEnabled: true,
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "To help us improve, can you describe the reason(s) for your rating?" },
required: false,
inputType: "text",
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Any other comments, feedback, or concerns?" },
required: false,
inputType: "text",
},
],
};
};
const StarRatingSurvey = (): TXMTemplate => {
const reusableQuestionIds = [createId(), createId(), createId()];
return {
...XMSurveyDefault,
name: "{{productName}}'s Rating Survey",
questions: [
{
id: reusableQuestionIds[0],
type: TSurveyQuestionTypeEnum.Rating,
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[0],
type: "question",
},
operator: "isLessThanOrEqual",
rightOperand: {
type: "static",
value: 3,
},
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: reusableQuestionIds[2],
},
],
},
],
range: 5,
scale: "number",
headline: { default: "How do you like {{productName}}?" },
required: true,
lowerLabel: { default: "Extremely dissatisfied" },
upperLabel: { default: "Extremely satisfied" },
isColorCodingEnabled: false,
},
{
id: reusableQuestionIds[1],
html: { default: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>' },
type: TSurveyQuestionTypeEnum.CTA,
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[1],
type: "question",
},
operator: "isClicked",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: XMSurveyDefault.endings[0].id,
},
],
},
],
headline: { default: "Happy to hear 🙏 Please write a review for us!" },
required: true,
buttonUrl: "https://formbricks.com/github",
buttonLabel: { default: "Write review" },
buttonExternal: true,
},
{
id: reusableQuestionIds[2],
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Sorry to hear! What is ONE thing we can do better?" },
required: true,
subheader: { default: "Help us improve your experience." },
buttonLabel: { default: "Send" },
placeholder: { default: "Type your answer here..." },
inputType: "text",
},
],
};
};
const CSATSurvey = (): TXMTemplate => {
const reusableQuestionIds = [createId(), createId(), createId()];
return {
...XMSurveyDefault,
name: "{{productName}} CSAT",
questions: [
{
id: reusableQuestionIds[0],
type: TSurveyQuestionTypeEnum.Rating,
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[0],
type: "question",
},
operator: "isLessThanOrEqual",
rightOperand: {
type: "static",
value: 3,
},
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: reusableQuestionIds[2],
},
],
},
],
range: 5,
scale: "smiley",
headline: { default: "How satisfied are you with your {{productName}} experience?" },
required: true,
lowerLabel: { default: "Extremely dissatisfied" },
upperLabel: { default: "Extremely satisfied" },
isColorCodingEnabled: false,
},
{
id: reusableQuestionIds[1],
type: TSurveyQuestionTypeEnum.OpenText,
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[1],
type: "question",
},
operator: "isSubmitted",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: XMSurveyDefault.endings[0].id,
},
],
},
],
headline: { default: "Lovely! Is there anything we can do to improve your experience?" },
required: false,
placeholder: { default: "Type your answer here..." },
inputType: "text",
},
{
id: reusableQuestionIds[2],
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Ugh, sorry! Is there anything we can do to improve your experience?" },
required: false,
placeholder: { default: "Type your answer here..." },
inputType: "text",
},
],
};
};
const CESSurvey = (): TXMTemplate => {
return {
...XMSurveyDefault,
name: "CES Survey",
questions: [
{
id: createId(),
type: TSurveyQuestionTypeEnum.Rating,
range: 5,
scale: "number",
headline: { default: "{{productName}} makes it easy for me to [ADD GOAL]" },
required: true,
lowerLabel: { default: "Disagree strongly" },
upperLabel: { default: "Agree strongly" },
isColorCodingEnabled: false,
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Thanks! How could we make it easier for you to [ADD GOAL]?" },
required: true,
placeholder: { default: "Type your answer here..." },
inputType: "text",
},
],
};
};
const SmileysRatingSurvey = (): TXMTemplate => {
const reusableQuestionIds = [createId(), createId(), createId()];
return {
...XMSurveyDefault,
name: "Smileys Survey",
questions: [
{
id: reusableQuestionIds[0],
type: TSurveyQuestionTypeEnum.Rating,
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[0],
type: "question",
},
operator: "isLessThanOrEqual",
rightOperand: {
type: "static",
value: 3,
},
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: reusableQuestionIds[2],
},
],
},
],
range: 5,
scale: "smiley",
headline: { default: "How do you like {{productName}}?" },
required: true,
lowerLabel: { default: "Not good" },
upperLabel: { default: "Very satisfied" },
isColorCodingEnabled: false,
},
{
id: reusableQuestionIds[1],
html: { default: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>' },
type: TSurveyQuestionTypeEnum.CTA,
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[1],
type: "question",
},
operator: "isClicked",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: XMSurveyDefault.endings[0].id,
},
],
},
],
headline: { default: "Happy to hear 🙏 Please write a review for us!" },
required: true,
buttonUrl: "https://formbricks.com/github",
buttonLabel: { default: "Write review" },
buttonExternal: true,
},
{
id: reusableQuestionIds[2],
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Sorry to hear! What is ONE thing we can do better?" },
required: true,
subheader: { default: "Help us improve your experience." },
buttonLabel: { default: "Send" },
placeholder: { default: "Type your answer here..." },
inputType: "text",
},
],
};
};
const eNPSSurvey = (): TXMTemplate => {
return {
...XMSurveyDefault,
name: "eNPS Survey",
questions: [
{
id: createId(),
type: TSurveyQuestionTypeEnum.NPS,
headline: {
default: "How likely are you to recommend working at this company to a friend or colleague?",
},
required: false,
lowerLabel: { default: "Not at all likely" },
upperLabel: { default: "Extremely likely" },
isColorCodingEnabled: true,
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "To help us improve, can you describe the reason(s) for your rating?" },
required: false,
inputType: "text",
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Any other comments, feedback, or concerns?" },
required: false,
inputType: "text",
},
],
};
};
export const XMTemplates: TXMTemplate[] = [
NPSSurvey(),
StarRatingSurvey(),
CSATSurvey(),
CESSurvey(),
SmileysRatingSurvey(),
eNPSSurvey(),
];

View File

@@ -0,0 +1,60 @@
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
import { getProductByEnvironmentId, getProducts } from "@formbricks/lib/product/service";
import { getUser } from "@formbricks/lib/user/service";
import { Button } from "@formbricks/ui/Button";
import { Header } from "@formbricks/ui/Header";
interface XMTemplatePageProps {
params: {
environmentId: string;
};
}
const Page = async ({ params }: XMTemplatePageProps) => {
const session = await getServerSession(authOptions);
const environment = await getEnvironment(params.environmentId);
if (!session) {
throw new Error("Session not found");
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error("User not found");
}
if (!environment) {
throw new Error("Environment not found");
}
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const product = await getProductByEnvironmentId(environment.id);
if (!product) {
throw new Error("Product not found");
}
const products = await getProducts(organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title="What kind of feedback would you like to get?" />
<XMTemplateList product={product} user={user} environmentId={environment.id} />
{products.length >= 2 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="minimal"
href={`/environments/${environment.id}/surveys`}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Button>
)}
</div>
);
};
export default Page;

View File

@@ -1,6 +1,6 @@
import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
export const getCustomHeadline = (channel: TProductConfigChannel, industry: TProductConfigIndustry) => {
export const getCustomHeadline = (channel?: TProductConfigChannel, industry?: TProductConfigIndustry) => {
const combinations = {
"website+eCommerce": "web shop",
"website+saas": "landing page",

View File

@@ -17,14 +17,14 @@ const Page = async ({ params }: ChannelPageProps) => {
description: "Run well-timed pop-up surveys.",
icon: GlobeIcon,
iconText: "Built for scale",
href: `/organizations/${params.organizationId}/products/new/industry?channel=website`,
href: `/organizations/${params.organizationId}/products/new/settings?channel=website`,
},
{
title: "App with sign up",
description: "Run highly-targeted micro-surveys.",
icon: GlobeLockIcon,
iconText: "Enrich user profiles",
href: `/organizations/${params.organizationId}/products/new/industry?channel=app`,
href: `/organizations/${params.organizationId}/products/new/settings?channel=app`,
},
{
channel: "link",
@@ -32,7 +32,7 @@ const Page = async ({ params }: ChannelPageProps) => {
description: "Reach people anywhere online.",
icon: LinkIcon,
iconText: "Anywhere online",
href: `/organizations/${params.organizationId}/products/new/industry?channel=link`,
href: `/organizations/${params.organizationId}/products/new/settings?channel=link`,
},
];

View File

@@ -0,0 +1,47 @@
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
import { getProducts } from "@formbricks/lib/product/service";
import { Button } from "@formbricks/ui/Button";
import { Header } from "@formbricks/ui/Header";
interface ModePageProps {
params: {
organizationId: string;
};
}
const Page = async ({ params }: ModePageProps) => {
const channelOptions = [
{
title: "Formbricks Surveys",
description: "Multi-purpose survey platform for web, app and email surveys.",
icon: ListTodoIcon,
href: `/organizations/${params.organizationId}/products/new/channel`,
},
{
title: "Formbricks CX",
description: "Surveys and reports to understand what your customers need.",
icon: HeartIcon,
href: `/organizations/${params.organizationId}/products/new/settings?mode=cx`,
},
];
const products = await getProducts(params.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title="What are you here for?" />
<OnboardingOptionsContainer options={channelOptions} />
{products.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="minimal"
href={"/"}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Button>
)}
</div>
);
};
export default Page;

View File

@@ -13,6 +13,7 @@ import { PREVIEW_SURVEY } from "@formbricks/lib/styling/constants";
import {
TProductConfigChannel,
TProductConfigIndustry,
TProductMode,
TProductUpdateInput,
ZProductUpdateInput,
} from "@formbricks/types/product";
@@ -32,6 +33,7 @@ import { SurveyInline } from "@formbricks/ui/Survey";
interface ProductSettingsProps {
organizationId: string;
productMode: TProductMode;
channel: TProductConfigChannel;
industry: TProductConfigIndustry;
defaultBrandColor: string;
@@ -39,6 +41,7 @@ interface ProductSettingsProps {
export const ProductSettings = ({
organizationId,
productMode,
channel,
industry,
defaultBrandColor,
@@ -68,10 +71,12 @@ export const ProductSettings = ({
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}
}
if (channel !== "link") {
if (channel === "app" || channel === "website") {
router.push(`/environments/${productionEnvironment?.id}/connect`);
} else {
} else if (channel === "link") {
router.push(`/environments/${productionEnvironment?.id}/surveys`);
} else if (productMode === "cx") {
router.push(`/environments/${productionEnvironment?.id}/xm-templates`);
}
} else {
const errorMessage = getFormattedErrorMessage(createProductResponse);

View File

@@ -1,11 +1,10 @@
import { getCustomHeadline } from "@/app/(app)/(onboarding)/lib/utils";
import { ProductSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings";
import { XIcon } from "lucide-react";
import { notFound } from "next/navigation";
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
import { getProducts } from "@formbricks/lib/product/service";
import { startsWithVowel } from "@formbricks/lib/utils/strings";
import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
import { TProductConfigChannel, TProductConfigIndustry, TProductMode } from "@formbricks/types/product";
import { Button } from "@formbricks/ui/Button";
import { Header } from "@formbricks/ui/Header";
@@ -16,19 +15,21 @@ interface ProductSettingsPageProps {
searchParams: {
channel?: TProductConfigChannel;
industry?: TProductConfigIndustry;
mode?: TProductMode;
};
}
const Page = async ({ params, searchParams }: ProductSettingsPageProps) => {
const channel = searchParams.channel;
const industry = searchParams.industry;
if (!channel || !industry) return notFound();
const channel = searchParams.channel || null;
const industry = searchParams.industry || null;
const mode = searchParams.mode || "surveys";
const customHeadline = getCustomHeadline(channel, industry);
const products = await getProducts(params.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
{channel === "link" ? (
{channel === "link" || mode === "cx" ? (
<Header
title="Match your brand, get 2x more responses."
subtitle="When people recognize your brand, they are much more likely to start and complete responses."
@@ -41,6 +42,7 @@ const Page = async ({ params, searchParams }: ProductSettingsPageProps) => {
)}
<ProductSettings
organizationId={params.organizationId}
productMode={mode}
channel={channel}
industry={industry}
defaultBrandColor={DEFAULT_BRAND_COLOR}

View File

@@ -1,6 +1,7 @@
import { LucideProps } from "lucide-react";
import Link from "next/link";
import { ForwardRefExoticComponent, RefAttributes } from "react";
import { cn } from "@formbricks/lib/cn";
import { OptionCard } from "@formbricks/ui/OptionCard";
interface OnboardingOptionsContainerProps {
@@ -8,34 +9,51 @@ interface OnboardingOptionsContainerProps {
title: string;
description: string;
icon: ForwardRefExoticComponent<Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>>;
iconText: string;
href: string;
iconText?: string;
href?: string;
onClick?: () => void;
isLoading?: boolean;
}[];
}
export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => {
const getOptionCard = (option) => {
const Icon = option.icon;
return (
<OptionCard
size="md"
key={option.title}
title={option.title}
onSelect={option.onClick}
description={option.description}
loading={option.isLoading || false}>
<div className="flex flex-col items-center">
<Icon className="h-16 w-16 text-slate-600" strokeWidth={0.5} />
{option.iconText && (
<p className="mt-4 w-fit rounded-xl bg-slate-200 px-4 text-sm text-slate-700">
{option.iconText}
</p>
)}
</div>
</OptionCard>
);
};
return (
<div className="grid w-5/6 grid-cols-3 gap-8 text-center lg:w-2/3">
{options.map((option, index) => {
const Icon = option.icon;
return (
<Link href={option.href}>
<OptionCard
size="md"
key={index}
title={option.title}
description={option.description}
loading={false}>
<div className="flex flex-col items-center">
<Icon className="h-16 w-16 text-slate-600" strokeWidth={0.5} />
<p className="mt-4 w-fit rounded-xl bg-slate-200 px-4 text-sm text-slate-700">
{option.iconText}
</p>
</div>
</OptionCard>
<div
className={cn({
"grid w-5/6 grid-cols-3 gap-8 text-center lg:w-2/3": options.length >= 3,
"flex justify-center gap-8": options.length < 3,
})}>
{options.map((option) =>
option.href ? (
<Link key={option.title} href={option.href}>
{getOptionCard(option)}
</Link>
);
})}
) : (
getOptionCard(option)
)
)}
</div>
);
};

View File

@@ -4,7 +4,7 @@ import { z } from "zod";
import { createActionClass } from "@formbricks/lib/actionClass/service";
import { actionClient, authenticatedActionClient } from "@formbricks/lib/actionClient";
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
import { UNSPLASH_ACCESS_KEY } from "@formbricks/lib/constants";
import { UNSPLASH_ACCESS_KEY, UNSPLASH_ALLOWED_DOMAINS } from "@formbricks/lib/constants";
import {
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromProductId,
@@ -227,13 +227,26 @@ export const getImagesFromUnsplashAction = actionClient
});
});
const isValidUnsplashUrl = (url: string): boolean => {
try {
const parsedUrl = new URL(url);
return parsedUrl.protocol === "https:" && UNSPLASH_ALLOWED_DOMAINS.includes(parsedUrl.hostname);
} catch {
return false;
}
};
const ZTriggerDownloadUnsplashImageAction = z.object({
downloadUrl: z.string(),
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" },

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

@@ -1,21 +1,29 @@
"use client";
import { getQuestionDefaults, questionTypes, universalQuestionPresets } from "@/app/lib/questions";
import { createId } from "@paralleldrive/cuid2";
import * as Collapsible from "@radix-ui/react-collapsible";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import {
CXQuestionTypes,
getQuestionDefaults,
questionTypes,
universalQuestionPresets,
} from "@formbricks/lib/utils/questions";
import { TProduct } from "@formbricks/types/product";
interface AddQuestionButtonProps {
addQuestion: (question: any) => void;
product: TProduct;
isCxMode: boolean;
}
export const AddQuestionButton = ({ addQuestion, product }: AddQuestionButtonProps) => {
export const AddQuestionButton = ({ addQuestion, product, isCxMode }: AddQuestionButtonProps) => {
const [open, setOpen] = useState(false);
const availableQuestionTypes = isCxMode ? CXQuestionTypes : questionTypes;
return (
<Collapsible.Root
open={open}
@@ -37,7 +45,7 @@ export const AddQuestionButton = ({ addQuestion, product }: AddQuestionButtonPro
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="justify-left flex flex-col">
{/* <hr className="py-1 text-slate-600" /> */}
{questionTypes.map((questionType) => (
{availableQuestionTypes.map((questionType) => (
<button
type="button"
key={questionType.id}

View File

@@ -1,6 +1,6 @@
import { ConditionalLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConditionalLogic";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { LogicEditor } from "./LogicEditor";
import { UpdateQuestionId } from "./UpdateQuestionId";
interface AdvancedSettingsProps {
@@ -19,16 +19,14 @@ export const AdvancedSettings = ({
attributeClasses,
}: AdvancedSettingsProps) => {
return (
<div>
<div className="mb-4">
<LogicEditor
question={question}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
questionIdx={questionIdx}
attributeClasses={attributeClasses}
/>
</div>
<div className="flex flex-col gap-4">
<ConditionalLogic
question={question}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
questionIdx={questionIdx}
attributeClasses={attributeClasses}
/>
<UpdateQuestionId
question={question}

View File

@@ -0,0 +1,215 @@
import { LogicEditor } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor";
import {
getDefaultOperatorForQuestion,
replaceEndingCardHeadlineRecall,
} from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
import { createId } from "@paralleldrive/cuid2";
import { debounce } from "lodash";
import {
ArrowDownIcon,
ArrowUpIcon,
CopyIcon,
EllipsisVerticalIcon,
PlusIcon,
SplitIcon,
TrashIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { duplicateLogicItem } from "@formbricks/lib/surveyLogic/utils";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { Button } from "@formbricks/ui/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@formbricks/ui/DropdownMenu";
import { Label } from "@formbricks/ui/Label";
interface ConditionalLogicProps {
localSurvey: TSurvey;
questionIdx: number;
question: TSurveyQuestion;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
attributeClasses: TAttributeClass[];
}
export function ConditionalLogic({
attributeClasses,
localSurvey,
question,
questionIdx,
updateQuestion,
}: ConditionalLogicProps) {
const [questionLogic, setQuestionLogic] = useState(question.logic);
const debouncedUpdateQuestion = useMemo(() => debounce(updateQuestion, 500), [updateQuestion]);
useEffect(() => {
debouncedUpdateQuestion(questionIdx, {
logic: questionLogic,
});
}, [questionLogic]);
const transformedSurvey = useMemo(() => {
let modifiedSurvey = replaceHeadlineRecall(localSurvey, "default", attributeClasses);
modifiedSurvey = replaceEndingCardHeadlineRecall(modifiedSurvey, "default", attributeClasses);
return modifiedSurvey;
}, [localSurvey, attributeClasses]);
const updateQuestionLogic = (_questionIdx: number, updatedAttributes: any) => {
setQuestionLogic(updatedAttributes.logic);
};
const addLogic = () => {
const operator = getDefaultOperatorForQuestion(question);
const initialCondition: TSurveyLogic = {
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: question.id,
type: "question",
},
operator,
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: "",
},
],
};
updateQuestionLogic(questionIdx, {
logic: [...(questionLogic ?? []), initialCondition],
});
};
const handleRemoveLogic = (logicItemIdx: number) => {
const logicCopy = structuredClone(questionLogic ?? []);
logicCopy.splice(logicItemIdx, 1);
updateQuestionLogic(questionIdx, {
logic: logicCopy,
});
};
const moveLogic = (from: number, to: number) => {
const logicCopy = structuredClone(questionLogic ?? []);
const [movedItem] = logicCopy.splice(from, 1);
logicCopy.splice(to, 0, movedItem);
updateQuestionLogic(questionIdx, {
logic: logicCopy,
});
};
const duplicateLogic = (logicItemIdx: number) => {
const logicCopy = structuredClone(questionLogic ?? []);
const logicItem = logicCopy[logicItemIdx];
const newLogicItem = duplicateLogicItem(logicItem);
logicCopy.splice(logicItemIdx + 1, 0, newLogicItem);
updateQuestionLogic(questionIdx, {
logic: logicCopy,
});
};
return (
<div className="mt-2">
<Label className="flex gap-2">
Conditional Logic
<SplitIcon className="h-4 w-4 rotate-90" />
</Label>
{questionLogic && questionLogic.length > 0 && (
<div className="mt-2 flex flex-col gap-4">
{questionLogic.map((logicItem, logicItemIdx) => (
<div
key={logicItem.id}
className="flex w-full grow items-start gap-2 rounded-lg border border-slate-200 bg-slate-50 p-4">
<LogicEditor
localSurvey={transformedSurvey}
logicItem={logicItem}
updateQuestion={updateQuestionLogic}
question={question}
questionLogic={questionLogic}
questionIdx={questionIdx}
logicIdx={logicItemIdx}
isLast={logicItemIdx === (questionLogic ?? []).length - 1}
/>
<DropdownMenu>
<DropdownMenuTrigger>
<EllipsisVerticalIcon className="h-4 w-4 text-slate-700 hover:text-slate-950" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className="flex items-center gap-2"
onClick={() => {
duplicateLogic(logicItemIdx);
}}>
<CopyIcon className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2"
disabled={logicItemIdx === 0}
onClick={() => {
moveLogic(logicItemIdx, logicItemIdx - 1);
}}>
<ArrowUpIcon className="h-4 w-4" />
Move up
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2"
disabled={logicItemIdx === (questionLogic ?? []).length - 1}
onClick={() => {
moveLogic(logicItemIdx, logicItemIdx + 1);
}}>
<ArrowDownIcon className="h-4 w-4" />
Move down
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2"
onClick={() => {
handleRemoveLogic(logicItemIdx);
}}>
<TrashIcon className="h-4 w-4" />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
)}
<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"
EndIcon={PlusIcon}
onClick={addLogic}>
Add logic
</Button>
</div>
</div>
);
}

View File

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

@@ -3,7 +3,7 @@
import { EditorCardMenu } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditorCardMenu";
import { EndScreenForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EndScreenForm";
import { RedirectUrlForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RedirectUrlForm";
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util";
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { createId } from "@paralleldrive/cuid2";
@@ -32,11 +32,6 @@ interface EditEndingCardProps {
isFormbricksCloud: boolean;
}
const endingCardTypes = [
{ value: "endScreen", label: "Ending card" },
{ value: "redirectToUrl", label: "Redirect to Url" },
];
export const EditEndingCard = ({
localSurvey,
endingCardIndex,
@@ -52,9 +47,16 @@ export const EditEndingCard = ({
isFormbricksCloud,
}: EditEndingCardProps) => {
const endingCard = localSurvey.endings[endingCardIndex];
const isRedirectToUrlDisabled = isFormbricksCloud
? plan === "free" && endingCard.type !== "redirectToUrl"
: false;
const endingCardTypes = [
{ value: "endScreen", label: "Ending card" },
{ value: "redirectToUrl", label: "Redirect to Url", disabled: isRedirectToUrlDisabled },
];
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: endingCard.id,
});
@@ -204,14 +206,16 @@ export const EditEndingCard = ({
<OptionsSwitch
options={endingCardTypes}
currentOption={endingCard.type}
handleOptionChange={() => {
if (endingCard.type === "endScreen") {
updateSurvey({ type: "redirectToUrl" });
} else {
updateSurvey({ type: "endScreen" });
handleOptionChange={(newType) => {
const selectedOption = endingCardTypes.find((option) => option.value === newType);
if (!selectedOption?.disabled) {
if (newType === "redirectToUrl") {
updateSurvey({ type: "redirectToUrl" });
} else {
updateSurvey({ type: "endScreen" });
}
}
}}
disabled={isRedirectToUrlDisabled}
/>
</TooltipRenderer>
{endingCard.type === "endScreen" && (

View File

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

@@ -1,10 +1,15 @@
"use client";
import { QUESTIONS_ICON_MAP, QUESTIONS_NAME_MAP, getQuestionDefaults } from "@/app/lib/questions";
import { createId } from "@paralleldrive/cuid2";
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import {
CX_QUESTIONS_NAME_MAP,
QUESTIONS_ICON_MAP,
QUESTIONS_NAME_MAP,
getQuestionDefaults,
} from "@formbricks/lib/utils/questions";
import { TProduct } from "@formbricks/types/product";
import {
TSurvey,
@@ -12,7 +17,6 @@ import {
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveyRedirectUrlCard,
ZSurveyQuestion,
} from "@formbricks/types/surveys/types";
import { ConfirmationModal } from "@formbricks/ui/ConfirmationModal";
import {
@@ -37,6 +41,7 @@ interface EditorCardMenuProps {
addCard: (question: any, index?: number) => void;
cardType: "question" | "ending";
product?: TProduct;
isCxMode?: boolean;
}
export const EditorCardMenu = ({
@@ -51,76 +56,75 @@ export const EditorCardMenu = ({
updateCard,
addCard,
cardType,
isCxMode = false,
}: EditorCardMenuProps) => {
const [logicWarningModal, setLogicWarningModal] = useState(false);
const [changeToType, setChangeToType] = useState(
card.type !== "endScreen" && card.type !== "redirectToUrl" ? card.type : undefined
);
const [changeToType, setChangeToType] = useState(() => {
if (card.type !== "endScreen" && card.type !== "redirectToUrl") {
return card.type;
}
return undefined;
});
const isDeleteDisabled =
cardType === "question"
? survey.questions.length === 1
: survey.type === "link" && survey.endings.length === 1;
const availableQuestionTypes = isCxMode ? CX_QUESTIONS_NAME_MAP : QUESTIONS_NAME_MAP;
const changeQuestionType = (type?: TSurveyQuestionTypeEnum) => {
const parseResult = ZSurveyQuestion.safeParse(card);
if (parseResult.success && type) {
const question = parseResult.data;
const { headline, required, subheader, imageUrl, videoUrl, buttonLabel, backButtonLabel } = question;
if (!type) return;
const questionDefaults = getQuestionDefaults(type, product);
const { headline, required, subheader, imageUrl, videoUrl, buttonLabel, backButtonLabel } =
card as TSurveyQuestion;
// if going from single select to multi select or vice versa, we need to keep the choices as well
if (
(type === TSurveyQuestionTypeEnum.MultipleChoiceSingle &&
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) ||
(type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle)
) {
updateCard(cardIdx, {
choices: question.choices,
type,
logic: undefined,
});
return;
}
const questionDefaults = getQuestionDefaults(type, product);
if (
(type === TSurveyQuestionTypeEnum.MultipleChoiceSingle &&
card.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) ||
(type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
card.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle)
) {
updateCard(cardIdx, {
...questionDefaults,
choices: card.choices,
type,
headline,
subheader,
required,
imageUrl,
videoUrl,
buttonLabel,
backButtonLabel,
logic: undefined,
});
return;
}
updateCard(cardIdx, {
...questionDefaults,
type,
headline,
subheader,
required,
imageUrl,
videoUrl,
buttonLabel,
backButtonLabel,
logic: undefined,
});
};
const addQuestionCardBelow = (type: TSurveyQuestionTypeEnum) => {
const parseResult = ZSurveyQuestion.safeParse(card);
if (parseResult.success) {
const question = parseResult.data;
const questionDefaults = getQuestionDefaults(type, product);
const questionDefaults = getQuestionDefaults(type, product);
addCard(
{
...questionDefaults,
type,
id: createId(),
required: true,
},
cardIdx + 1
);
addCard(
{
...questionDefaults,
type,
id: createId(),
required: true,
},
cardIdx + 1
);
// scroll to the new question
const section = document.getElementById(`${question.id}`);
section?.scrollIntoView({ behavior: "smooth", block: "end", inline: "end" });
}
const section = document.getElementById(`${card.id}`);
section?.scrollIntoView({ behavior: "smooth", block: "end", inline: "end" });
};
const addEndingCardBelow = () => {
@@ -167,29 +171,25 @@ export const EditorCardMenu = ({
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="ml-2 border border-slate-200 text-slate-600 hover:text-slate-700">
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
const parsedResult = ZSurveyQuestion.safeParse(card);
if (parsedResult.success) {
const question = parsedResult.data;
if (type === question.type) return null;
return (
<DropdownMenuItem
key={type}
className="min-h-8 cursor-pointer"
onClick={() => {
setChangeToType(type as TSurveyQuestionTypeEnum);
if (question.logic) {
setLogicWarningModal(true);
return;
}
{Object.entries(availableQuestionTypes).map(([type, name]) => {
if (type === card.type) return null;
return (
<DropdownMenuItem
key={type}
className="min-h-8 cursor-pointer"
onClick={() => {
setChangeToType(type as TSurveyQuestionTypeEnum);
if ((card as TSurveyQuestion).logic) {
setLogicWarningModal(true);
return;
}
changeQuestionType(type as TSurveyQuestionTypeEnum);
}}>
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
<span className="ml-2">{name}</span>
</DropdownMenuItem>
);
}
changeQuestionType(type as TSurveyQuestionTypeEnum);
}}>
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
<span className="ml-2">{name}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
@@ -212,7 +212,7 @@ export const EditorCardMenu = ({
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="ml-4 border border-slate-200">
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
{Object.entries(availableQuestionTypes).map(([type, name]) => {
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,5 +1,6 @@
"use client";
import { findHiddenFieldUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
import * as Collapsible from "@radix-ui/react-collapsible";
import { EyeOff } from "lucide-react";
import { useState } from "react";
@@ -65,6 +66,25 @@ export const HiddenFieldsCard = ({
});
};
const handleDeleteHiddenField = (fieldId: string) => {
const quesIdx = findHiddenFieldUsedInLogic(localSurvey, fieldId);
if (quesIdx !== -1) {
toast.error(
`${fieldId} is used in logic of question ${quesIdx + 1}. Please remove it from logic first.`
);
return;
}
updateSurvey(
{
enabled: true,
fieldIds: localSurvey.hiddenFields?.fieldIds?.filter((q) => q !== fieldId),
},
fieldId
);
};
return (
<div className={cn(open ? "shadow-lg" : "shadow-md", "group z-10 flex flex-row rounded-lg bg-white")}>
<div
@@ -111,15 +131,7 @@ export const HiddenFieldsCard = ({
return (
<Tag
key={fieldId}
onDelete={() => {
updateSurvey(
{
enabled: true,
fieldIds: localSurvey.hiddenFields?.fieldIds?.filter((q) => q !== fieldId),
},
fieldId
);
}}
onDelete={(fieldId) => handleDeleteHiddenField(fieldId)}
tagId={fieldId}
tagName={fieldId}
/>

View File

@@ -231,7 +231,7 @@ export const HowToSendCard = ({
You can also use Formbricks to run {promotedFeaturesString} surveys.{" "}
<Link
target="_blank"
href={`/organizations/${organizationId}/products/new/channel`}
href={`/organizations/${organizationId}/products/new/mode`}
className="font-medium underline decoration-slate-400 underline-offset-2">
Create a new product
</Link>{" "}

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

@@ -1,477 +1,53 @@
import {
ArrowDownIcon,
ChevronDown,
CornerDownRightIcon,
HelpCircle,
SplitIcon,
TrashIcon,
} from "lucide-react";
import Image from "next/image";
import { useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import {
TSurvey,
TSurveyLogic,
TSurveyLogicCondition,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { Button } from "@formbricks/ui/Button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@formbricks/ui/DropdownMenu";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
import { LogicEditorActions } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditorActions";
import { LogicEditorConditions } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditorConditions";
import { ArrowRightIcon } from "lucide-react";
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
interface LogicEditorProps {
localSurvey: TSurvey;
questionIdx: number;
question: TSurveyQuestion;
logicItem: TSurveyLogic;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
attributeClasses: TAttributeClass[];
question: TSurveyQuestion;
questionLogic: TSurveyLogic[];
questionIdx: number;
logicIdx: number;
isLast: boolean;
}
type LogicConditions = {
[K in TSurveyLogicCondition]: {
label: string;
values: string[] | null;
unique?: boolean;
multiSelect?: boolean;
};
};
const conditions = {
openText: ["submitted", "skipped"],
multipleChoiceSingle: ["submitted", "skipped", "equals", "notEquals", "includesOne"],
multipleChoiceMulti: ["submitted", "skipped", "includesAll", "includesOne", "equals"],
nps: [
"equals",
"notEquals",
"lessThan",
"lessEqual",
"greaterThan",
"greaterEqual",
"submitted",
"skipped",
],
rating: [
"equals",
"notEquals",
"lessThan",
"lessEqual",
"greaterThan",
"greaterEqual",
"submitted",
"skipped",
],
cta: ["clicked", "skipped"],
consent: ["skipped", "accepted"],
pictureSelection: ["submitted", "skipped", "includesAll", "includesOne", "equals"],
fileUpload: ["uploaded", "notUploaded"],
cal: ["skipped", "booked"],
matrix: ["isCompletelySubmitted", "isPartiallySubmitted", "skipped"],
address: ["submitted", "skipped"],
ranking: ["submitted", "skipped"],
};
export const LogicEditor = ({
export function LogicEditor({
localSurvey,
question,
questionIdx,
logicItem,
updateQuestion,
attributeClasses,
}: LogicEditorProps) => {
const [searchValue, setSearchValue] = useState<string>("");
const showDropdownSearch = question.type !== "pictureSelection";
const transformedSurvey = useMemo(() => {
return replaceHeadlineRecall(localSurvey, "default", attributeClasses);
}, [localSurvey, attributeClasses]);
const questionValues: string[] = useMemo(() => {
if ("choices" in question) {
if (question.type === "pictureSelection") {
return question.choices.map((choice) => choice.id);
} else {
return question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
}
} else if ("range" in question) {
return Array.from({ length: question.range ? question.range : 0 }, (_, i) => (i + 1).toString());
} else if (question.type === TSurveyQuestionTypeEnum.NPS) {
return Array.from({ length: 11 }, (_, i) => (i + 0).toString());
}
return [];
}, [question]);
const logicConditions: LogicConditions = {
submitted: {
label: "is submitted",
values: null,
unique: true,
},
skipped: {
label: "is skipped",
values: null,
unique: true,
},
accepted: {
label: "is accepted",
values: null,
unique: true,
},
clicked: {
label: "is clicked",
values: null,
unique: true,
},
equals: {
label: "equals",
values: questionValues,
},
notEquals: {
label: "does not equal",
values: questionValues,
},
lessThan: {
label: "is less than",
values: questionValues,
},
lessEqual: {
label: "is less or equal to",
values: questionValues,
},
greaterThan: {
label: "is greater than",
values: questionValues,
},
greaterEqual: {
label: "is greater or equal to",
values: questionValues,
},
includesAll: {
label: "includes all of",
values: questionValues,
multiSelect: true,
},
includesOne: {
label: "includes one of",
values: questionValues,
multiSelect: true,
},
uploaded: {
label: "has uploaded file",
values: null,
unique: true,
},
notUploaded: {
label: "has not uploaded file",
values: null,
unique: true,
},
booked: {
label: "has a call booked",
values: null,
unique: true,
},
isCompletelySubmitted: {
label: "is completely submitted",
values: null,
unique: true,
},
isPartiallySubmitted: {
label: "is partially submitted",
values: null,
unique: true,
},
};
const addLogic = () => {
if (question.logic && question.logic?.length >= 0) {
const hasUndefinedLogic = question.logic.some(
(logic) =>
logic.condition === undefined && logic.value === undefined && logic.destination === undefined
);
if (hasUndefinedLogic) {
toast("Please fill current logic jumps first.", {
icon: "🤓",
});
return;
}
}
const newLogic: TSurveyLogic[] = !question.logic ? [] : question.logic;
newLogic.push({
condition: undefined,
value: undefined,
destination: undefined,
});
updateQuestion(questionIdx, { logic: newLogic });
};
const updateLogic = (logicIdx: number, updatedAttributes: any) => {
const currentLogic: any = question.logic ? question.logic[logicIdx] : undefined;
if (!currentLogic) return;
// clean logic value if not needed or if condition changed between multiSelect and singleSelect conditions
const updatedCondition = updatedAttributes?.condition;
const currentCondition = currentLogic?.condition;
const updatedLogicCondition = logicConditions[updatedCondition];
const currentLogicCondition = logicConditions[currentCondition];
if (updatedCondition) {
if (updatedLogicCondition?.multiSelect && !currentLogicCondition?.multiSelect) {
updatedAttributes.value = [];
} else if (
(!updatedLogicCondition?.multiSelect && currentLogicCondition?.multiSelect) ||
updatedLogicCondition?.values === null
) {
updatedAttributes.value = undefined;
}
}
const newLogic = !question.logic
? []
: question.logic.map((logic, idx) => {
if (idx === logicIdx) {
return { ...logic, ...updatedAttributes };
}
return logic;
});
updateQuestion(questionIdx, { logic: newLogic });
};
const updateMultiSelectLogic = (logicIdx: number, checked: boolean, value: string) => {
const newLogic = !question.logic
? []
: question.logic.map((logic, idx) => {
if (idx === logicIdx) {
const newValues = !logic.value ? [] : logic.value;
if (checked) {
newValues.push(value);
} else {
newValues.splice(newValues.indexOf(value), 1);
}
return { ...logic, value: Array.from(new Set(newValues)) };
}
return logic;
});
updateQuestion(questionIdx, { logic: newLogic });
};
const deleteLogic = (logicIdx: number) => {
const updatedLogic = !question.logic ? [] : structuredClone(question.logic);
updatedLogic.splice(logicIdx, 1);
updateQuestion(questionIdx, { logic: updatedLogic });
};
if (!(question.type in conditions)) {
return <></>;
}
const getLogicDisplayValue = (value: string | string[]): string => {
if (question.type === "pictureSelection") {
if (Array.isArray(value)) {
return value
.map((val) => {
const choiceIndex = question.choices.findIndex((choice) => choice.id === val);
return `Picture ${choiceIndex + 1}`;
})
.join(", ");
} else {
const choiceIndex = question.choices.findIndex((choice) => choice.id === value);
return `Picture ${choiceIndex + 1}`;
}
} else if (Array.isArray(value)) {
return value.join(", ");
}
return value;
};
const getOptionPreview = (value: string) => {
if (question.type === "pictureSelection") {
const choice = question.choices.find((choice) => choice.id === value);
if (choice) {
return <Image src={choice.imageUrl} alt={"Picture"} width={20} height={12} className="rounded-xs" />;
}
}
};
question,
questionLogic,
questionIdx,
logicIdx,
isLast,
}: LogicEditorProps) {
return (
<div className="mt-3">
<Label>Logic Jumps</Label>
{question?.logic && question?.logic?.length !== 0 && (
<div className="mt-2 space-y-3">
{question?.logic?.map((logic, logicIdx) => (
<div key={logicIdx} className="flex items-center space-x-2 space-y-1 text-xs xl:text-sm">
<div>
<CornerDownRightIcon className="h-4 w-4" />
</div>
<p className="text-slate-800">If this answer</p>
<Select value={logic.condition} onValueChange={(e) => updateLogic(logicIdx, { condition: e })}>
<SelectTrigger className="min-w-fit flex-1">
<SelectValue placeholder="Select condition" className="text-xs lg:text-sm" />
</SelectTrigger>
<SelectContent>
{conditions[question.type].map(
(condition) =>
!(question.required && (condition === "skipped" || condition === "notUploaded")) && (
<SelectItem
key={condition}
value={condition}
title={logicConditions[condition].label}
className="text-xs lg:text-sm">
{logicConditions[condition].label}
</SelectItem>
)
)}
</SelectContent>
</Select>
{logic.condition && logicConditions[logic.condition].values != null && (
<DropdownMenu>
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
<div className="flex h-10 w-full items-center justify-between overflow-hidden rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50">
{!logic.value || logic.value?.length === 0 ? (
<p className="line-clamp-1 text-slate-400" title="Select match type">
Select match type
</p>
) : (
<p className="line-clamp-1" title={getLogicDisplayValue(logic.value)}>
{getLogicDisplayValue(logic.value)}
</p>
)}
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-40 bg-slate-50 text-slate-700"
align="start"
side="bottom">
{showDropdownSearch && (
<Input
autoFocus
placeholder="Search options"
className="mb-1 w-full bg-white"
onChange={(e) => setSearchValue(e.target.value)}
value={searchValue}
onKeyDown={(e) => e.stopPropagation()}
/>
)}
<div className="max-h-72 overflow-y-auto overflow-x-hidden">
{logicConditions[logic.condition].values
?.filter((value) => value.includes(searchValue))
?.map((value) => (
<DropdownMenuCheckboxItem
key={value}
title={value}
checked={
!logicConditions[logic.condition].multiSelect
? logic.value === value
: logic.value?.includes(value)
}
onSelect={(e) => e.preventDefault()}
onCheckedChange={(e) =>
!logicConditions[logic.condition].multiSelect
? updateLogic(logicIdx, { value })
: updateMultiSelectLogic(logicIdx, e, value)
}>
<div className="flex space-x-2">
{question.type === "pictureSelection" && getOptionPreview(value)}
<p>{getLogicDisplayValue(value)}</p>
</div>
</DropdownMenuCheckboxItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
<p className="text-slate-800">jump to</p>
<Select
value={logic.destination}
onValueChange={(e) => updateLogic(logicIdx, { destination: e })}>
<SelectTrigger className="w-fit overflow-hidden">
<SelectValue placeholder="Select question" />
</SelectTrigger>
<SelectContent>
{transformedSurvey.questions.map(
(question, idx) =>
idx !== questionIdx && (
<SelectItem
key={question.id}
value={question.id}
title={getLocalizedValue(question.headline, "default")}>
<div className="w-96">
<p className="truncate text-left">
{idx + 1}
{". "}
{getLocalizedValue(question.headline, "default")}
</p>
</div>
</SelectItem>
)
)}
{localSurvey.endings.map((ending) => {
return (
<SelectItem value={ending.id}>
{ending.type === "endScreen"
? getLocalizedValue(ending.headline, "default")
: ending.label}
</SelectItem>
);
})}
</SelectContent>
</Select>
<div>
<TrashIcon
className="h-4 w-4 cursor-pointer text-slate-400"
onClick={() => deleteLogic(logicIdx)}
/>
</div>
</div>
))}
<div className="flex flex-wrap items-center space-x-2 py-1 text-sm">
<ArrowDownIcon className="h-4 w-4" />
<p className="text-slate-700">All other answers will continue to the next question</p>
</div>
<div className="flex w-full grow flex-col gap-4 overflow-x-auto pb-2 text-sm">
<LogicEditorConditions
conditions={logicItem.conditions}
updateQuestion={updateQuestion}
question={question}
questionIdx={questionIdx}
localSurvey={localSurvey}
logicIdx={logicIdx}
/>
<LogicEditorActions
logicItem={logicItem}
logicIdx={logicIdx}
questionLogic={questionLogic}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
questionIdx={questionIdx}
/>
{isLast ? (
<div className="flex flex-wrap items-center space-x-2">
<ArrowRightIcon className="h-4 w-4" />
<p className="text-slate-700">All other answers will continue to the next question</p>
</div>
)}
<div className="mt-2 flex items-center space-x-2">
<Button
id="logicJumps"
type="button"
name="logicJumps"
size="sm"
variant="secondary"
StartIcon={SplitIcon}
onClick={() => addLogic()}>
Add logic
</Button>
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="ml-2 inline h-4 w-4 cursor-default text-slate-500" />
</TooltipTrigger>
<TooltipContent className="max-w-[300px]" side="top">
With logic jumps you can skip questions based on the responses users give.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : null}
</div>
);
};
}

View File

@@ -0,0 +1,390 @@
import {
actionObjectiveOptions,
getActionOperatorOptions,
} from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
import {
CopyIcon,
EllipsisVerticalIcon,
EyeOffIcon,
FileDigitIcon,
FileType2Icon,
PlusIcon,
TrashIcon,
} from "lucide-react";
import React, { useCallback, useMemo } from "react";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { questionIconMapping } from "@formbricks/lib/utils/questions";
import {
TActionNumberVariableCalculateOperator,
TActionObjective,
TActionTextVariableCalculateOperator,
TActionVariableValueType,
TSurvey,
TSurveyLogicAction,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@formbricks/ui/DropdownMenu";
import { InputCombobox, TComboboxGroupedOption, TComboboxOption } from "@formbricks/ui/InputCombobox";
interface LogicEditorActionProps {
action: TSurveyLogicAction;
actionIdx: number;
handleObjectiveChange: (actionIdx: number, val: TActionObjective) => void;
handleValuesChange: (actionIdx: number, values: any) => void;
handleActionsChange: (operation: "remove" | "addBelow" | "duplicate", actionIdx: number) => void;
isRemoveDisabled: boolean;
questions: TSurveyQuestion[];
endings: TSurvey["endings"];
variables: TSurvey["variables"];
questionIdx: number;
hiddenFields: {
enabled: boolean;
fieldIds?: string[] | undefined;
};
}
const _LogicEditorAction = ({
action,
actionIdx,
handleActionsChange,
handleObjectiveChange,
handleValuesChange,
isRemoveDisabled,
questions,
endings,
variables,
questionIdx,
hiddenFields,
}: LogicEditorActionProps) => {
const actionTargetOptions = useMemo((): TComboboxOption[] => {
let filteredQuestions = questions.filter((_, idx) => idx !== questionIdx);
if (action.objective === "requireAnswer") {
filteredQuestions = filteredQuestions.filter((question) => !question.required);
}
const questionOptions = filteredQuestions.map((question) => {
return {
icon: questionIconMapping[question.type],
label: getLocalizedValue(question.headline, "default"),
value: question.id,
};
});
if (action.objective === "requireAnswer") return questionOptions;
const endingCardOptions = endings.map((ending) => {
return {
label:
ending.type === "endScreen"
? getLocalizedValue(ending.headline, "default") || "End Screen"
: ending.label || "Redirect Thank you card",
value: ending.id,
};
});
return [...questionOptions, ...endingCardOptions];
}, [action.objective, endings, questionIdx, questions]);
const actionVariableOptions = useMemo((): TComboboxOption[] => {
return variables.map((variable) => {
return {
icon: variable.type === "number" ? FileDigitIcon : FileType2Icon,
label: variable.name,
value: variable.id,
meta: {
variableType: variable.type,
},
};
});
}, [variables]);
const getActionValueOptions = useCallback(
(variableId: string): TComboboxGroupedOption[] => {
const hiddenFieldIds = hiddenFields?.fieldIds ?? [];
const hiddenFieldsOptions = hiddenFieldIds.map((field) => {
return {
icon: EyeOffIcon,
label: field,
value: field,
meta: {
type: "hiddenField",
},
};
});
const selectedVariable = variables.find((variable) => variable.id === variableId);
const filteredVariables = variables.filter((variable) => variable.id !== variableId);
if (!selectedVariable) return [];
if (selectedVariable.type === "text") {
const allowedQuestions = questions.filter((question) =>
[
TSurveyQuestionTypeEnum.OpenText,
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
TSurveyQuestionTypeEnum.Rating,
TSurveyQuestionTypeEnum.NPS,
TSurveyQuestionTypeEnum.Date,
].includes(question.type)
);
const questionOptions = allowedQuestions.map((question) => {
return {
icon: questionIconMapping[question.type],
label: getLocalizedValue(question.headline, "default"),
value: question.id,
meta: {
type: "question",
},
};
});
const stringVariables = filteredVariables.filter((variable) => variable.type === "text");
const variableOptions = stringVariables.map((variable) => {
return {
icon: FileType2Icon,
label: variable.name,
value: variable.id,
meta: {
type: "variable",
},
};
});
const groupedOptions: TComboboxGroupedOption[] = [];
if (questionOptions.length > 0) {
groupedOptions.push({
label: "Questions",
value: "questions",
options: questionOptions,
});
}
if (variableOptions.length > 0) {
groupedOptions.push({
label: "Variables",
value: "variables",
options: variableOptions,
});
}
if (hiddenFieldsOptions.length > 0) {
groupedOptions.push({
label: "Hidden Fields",
value: "hiddenFields",
options: hiddenFieldsOptions,
});
}
return groupedOptions;
} else if (selectedVariable.type === "number") {
const allowedQuestions = questions.filter((question) =>
[TSurveyQuestionTypeEnum.Rating, TSurveyQuestionTypeEnum.NPS].includes(question.type)
);
const questionOptions = allowedQuestions.map((question) => {
return {
icon: questionIconMapping[question.type],
label: getLocalizedValue(question.headline, "default"),
value: question.id,
meta: {
type: "question",
},
};
});
const numberVariables = filteredVariables.filter((variable) => variable.type === "number");
const variableOptions = numberVariables.map((variable) => {
return {
icon: FileDigitIcon,
label: variable.name,
value: variable.id,
meta: {
type: "variable",
},
};
});
const groupedOptions: TComboboxGroupedOption[] = [];
if (questionOptions.length > 0) {
groupedOptions.push({
label: "Questions",
value: "questions",
options: questionOptions,
});
}
if (variableOptions.length > 0) {
groupedOptions.push({
label: "Variables",
value: "variables",
options: variableOptions,
});
}
if (hiddenFieldsOptions.length > 0) {
groupedOptions.push({
label: "Hidden Fields",
value: "hiddenFields",
options: hiddenFieldsOptions,
});
}
return groupedOptions;
}
return [];
},
[hiddenFields?.fieldIds, questions, variables]
);
return (
<div key={action.id} className="flex grow items-center justify-between gap-x-2">
<div className="block w-9 shrink-0">{actionIdx === 0 ? "Then" : "and"}</div>
<div className="flex grow items-center gap-x-2">
<InputCombobox
id={`action-${actionIdx}-objective`}
key={`objective-${action.id}`}
showSearch={false}
options={actionObjectiveOptions}
value={action.objective}
onChangeValue={(val: TActionObjective) => {
handleObjectiveChange(actionIdx, val);
}}
comboboxClasses="grow"
/>
{action.objective !== "calculate" && (
<InputCombobox
id={`action-${actionIdx}-target`}
key={`target-${action.id}`}
showSearch={false}
options={actionTargetOptions}
value={action.target}
onChangeValue={(val: string) => {
handleValuesChange(actionIdx, {
target: val,
});
}}
comboboxClasses="grow"
/>
)}
{action.objective === "calculate" && (
<>
<InputCombobox
id={`action-${actionIdx}-variableId`}
key={`variableId-${action.id}`}
showSearch={false}
options={actionVariableOptions}
value={action.variableId}
onChangeValue={(val: string) => {
handleValuesChange(actionIdx, {
variableId: val,
value: {
type: "static",
value: "",
},
});
}}
comboboxClasses="grow"
emptyDropdownText="Add a variable to calculate"
/>
<InputCombobox
id={`action-${actionIdx}-operator`}
key={`operator-${action.id}`}
showSearch={false}
options={getActionOperatorOptions(variables.find((v) => v.id === action.variableId)?.type)}
value={action.operator}
onChangeValue={(
val: TActionTextVariableCalculateOperator | TActionNumberVariableCalculateOperator
) => {
handleValuesChange(actionIdx, {
operator: val,
});
}}
comboboxClasses="grow"
/>
<InputCombobox
id={`action-${actionIdx}-value`}
key={`value-${action.id}`}
withInput={true}
clearable={true}
value={action.value?.value ?? ""}
inputProps={{
placeholder: "Value",
type: variables.find((v) => v.id === action.variableId)?.type || "text",
}}
groupedOptions={getActionValueOptions(action.variableId)}
onChangeValue={(val, option, fromInput) => {
const fieldType = option?.meta?.type as TActionVariableValueType;
if (!fromInput && fieldType !== "static") {
handleValuesChange(actionIdx, {
value: {
type: fieldType,
value: val as string,
},
});
} else if (fromInput) {
handleValuesChange(actionIdx, {
value: {
type: "static",
value: val as string,
},
});
}
}}
comboboxClasses="grow shrink-0"
/>
</>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger id={`actions-${actionIdx}-dropdown`}>
<EllipsisVerticalIcon className="h-4 w-4 text-slate-700 hover:text-slate-950" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className="flex items-center gap-2"
onClick={() => {
handleActionsChange("addBelow", actionIdx);
}}>
<PlusIcon className="h-4 w-4" />
Add action below
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2"
disabled={isRemoveDisabled}
onClick={() => {
handleActionsChange("remove", actionIdx);
}}>
<TrashIcon className="h-4 w-4" />
Remove
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2"
onClick={() => {
handleActionsChange("duplicate", actionIdx);
}}>
<CopyIcon className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
export const LogicEditorAction = React.memo(_LogicEditorAction);

View File

@@ -0,0 +1,110 @@
import { LogicEditorAction } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditorAction";
import { createId } from "@paralleldrive/cuid2";
import { CornerDownRightIcon } from "lucide-react";
import React, { useCallback, useMemo } from "react";
import { getUpdatedActionBody } from "@formbricks/lib/surveyLogic/utils";
import { TActionObjective, TSurvey, TSurveyLogic, TSurveyLogicAction } from "@formbricks/types/surveys/types";
interface LogicEditorActions {
localSurvey: TSurvey;
logicItem: TSurveyLogic;
logicIdx: number;
questionLogic: TSurveyLogic[];
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
questionIdx: number;
}
export const LogicEditorActions = ({
localSurvey,
logicItem,
logicIdx,
questionLogic,
updateQuestion,
questionIdx,
}: LogicEditorActions) => {
const actions = logicItem.actions;
const handleActionsChange = useCallback(
(
operation: "remove" | "addBelow" | "duplicate" | "update",
actionIdx: number,
action?: TSurveyLogicAction
) => {
const currentLogicCopy = structuredClone(logicItem);
const actionsClone = currentLogicCopy.actions;
switch (operation) {
case "remove":
actionsClone.splice(actionIdx, 1);
break;
case "addBelow":
actionsClone.splice(actionIdx + 1, 0, {
id: createId(),
objective: "jumpToQuestion",
target: "",
});
break;
case "duplicate":
actionsClone.splice(actionIdx + 1, 0, { ...actionsClone[actionIdx], id: createId() });
break;
case "update":
if (!action) return;
actionsClone[actionIdx] = action;
break;
}
const updatedLogic = questionLogic.map((item, idx) => (idx === logicIdx ? currentLogicCopy : item));
updateQuestion(questionIdx, {
logic: updatedLogic,
});
},
[logicIdx, logicItem, questionIdx, questionLogic]
);
const handleObjectiveChange = useCallback(
(actionIdx: number, objective: TActionObjective) => {
const action = actions[actionIdx];
const actionBody = getUpdatedActionBody(action, objective);
handleActionsChange("update", actionIdx, actionBody);
},
[actions]
);
const handleValuesChange = useCallback(
(actionIdx: number, values: Partial<TSurveyLogicAction>) => {
const action = actions[actionIdx];
const actionBody = { ...action, ...values } as TSurveyLogicAction;
handleActionsChange("update", actionIdx, actionBody);
},
[actions]
);
const questions = useMemo(() => localSurvey.questions, [localSurvey.questions]);
const endings = useMemo(() => localSurvey.endings, [localSurvey.endings]);
const variables = useMemo(() => localSurvey.variables, [localSurvey.variables]);
const hiddenFields = useMemo(() => localSurvey.hiddenFields, [localSurvey.hiddenFields]);
return (
<div className="flex grow gap-2">
<CornerDownRightIcon className="mt-3 h-4 w-4 shrink-0" />
<div className="flex grow flex-col gap-y-2">
{actions?.map((action, idx) => (
<LogicEditorAction
action={action}
actionIdx={idx}
handleActionsChange={handleActionsChange}
handleObjectiveChange={handleObjectiveChange}
handleValuesChange={handleValuesChange}
endings={endings}
isRemoveDisabled={actions.length === 1}
questions={questions}
variables={variables}
questionIdx={questionIdx}
hiddenFields={hiddenFields}
/>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,347 @@
import {
getConditionOperatorOptions,
getConditionValueOptions,
getDefaultOperatorForQuestion,
getMatchValueProps,
} from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
import { createId } from "@paralleldrive/cuid2";
import { CopyIcon, EllipsisVerticalIcon, PlusIcon, TrashIcon, WorkflowIcon } from "lucide-react";
import { cn } from "@formbricks/lib/cn";
import {
addConditionBelow,
createGroupFromResource,
duplicateCondition,
isConditionGroup,
removeCondition,
toggleGroupConnector,
updateCondition,
} from "@formbricks/lib/surveyLogic/utils";
import {
TConditionGroup,
TDynamicLogicField,
TRightOperand,
TSingleCondition,
TSurvey,
TSurveyLogicConditionsOperator,
TSurveyQuestion,
} from "@formbricks/types/surveys/types";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@formbricks/ui/DropdownMenu";
import { InputCombobox, TComboboxOption } from "@formbricks/ui/InputCombobox";
interface LogicEditorConditionsProps {
conditions: TConditionGroup;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
question: TSurveyQuestion;
localSurvey: TSurvey;
questionIdx: number;
logicIdx: number;
depth?: number;
}
export function LogicEditorConditions({
conditions,
logicIdx,
question,
localSurvey,
questionIdx,
updateQuestion,
depth = 0,
}: LogicEditorConditionsProps) {
const handleAddConditionBelow = (resourceId: string) => {
const operator = getDefaultOperatorForQuestion(question);
const condition: TSingleCondition = {
id: createId(),
leftOperand: {
value: question.id,
type: "question",
},
operator,
};
const logicCopy = structuredClone(question.logic) ?? [];
const logicItem = logicCopy[logicIdx];
addConditionBelow(logicItem.conditions, resourceId, condition);
updateQuestion(questionIdx, {
logic: logicCopy,
});
};
const handleConnectorChange = (groupId: string) => {
const logicCopy = structuredClone(question.logic) ?? [];
const logicItem = logicCopy[logicIdx];
toggleGroupConnector(logicItem.conditions, groupId);
updateQuestion(questionIdx, {
logic: logicCopy,
});
};
const handleRemoveCondition = (resourceId: string) => {
const logicCopy = structuredClone(question.logic) ?? [];
const logicItem = logicCopy[logicIdx];
removeCondition(logicItem.conditions, resourceId);
// Remove the logic item if there are no conditions left
if (logicItem.conditions.conditions.length === 0) {
logicCopy.splice(logicIdx, 1);
}
updateQuestion(questionIdx, {
logic: logicCopy,
});
};
const handleDuplicateCondition = (resourceId: string) => {
const logicCopy = structuredClone(question.logic) ?? [];
const logicItem = logicCopy[logicIdx];
duplicateCondition(logicItem.conditions, resourceId);
updateQuestion(questionIdx, {
logic: logicCopy,
});
};
const handleCreateGroup = (resourceId: string) => {
const logicCopy = structuredClone(question.logic) ?? [];
const logicItem = logicCopy[logicIdx];
createGroupFromResource(logicItem.conditions, resourceId);
updateQuestion(questionIdx, {
logic: logicCopy,
});
};
const handleUpdateCondition = (resourceId: string, updateConditionBody: Partial<TSingleCondition>) => {
const logicCopy = structuredClone(question.logic) ?? [];
const logicItem = logicCopy[logicIdx];
updateCondition(logicItem.conditions, resourceId, updateConditionBody);
updateQuestion(questionIdx, {
logic: logicCopy,
});
};
const handleQuestionChange = (condition: TSingleCondition, value: string, option?: TComboboxOption) => {
handleUpdateCondition(condition.id, {
leftOperand: {
value,
type: option?.meta?.type as TDynamicLogicField,
},
operator: "isSkipped",
rightOperand: undefined,
});
};
const handleOperatorChange = (condition: TSingleCondition, value: TSurveyLogicConditionsOperator) => {
if (value !== condition.operator) {
handleUpdateCondition(condition.id, {
operator: value,
rightOperand: undefined,
});
}
};
const handleRightOperandChange = (
condition: TSingleCondition,
value: string | number | string[],
option?: TComboboxOption
) => {
const type = (option?.meta?.type as TRightOperand["type"]) || "static";
switch (type) {
case "question":
case "hiddenField":
case "variable":
handleUpdateCondition(condition.id, {
rightOperand: {
value: value as string,
type,
},
});
break;
case "static":
handleUpdateCondition(condition.id, {
rightOperand: {
value,
type,
},
});
break;
}
};
const renderCondition = (
condition: TSingleCondition | TConditionGroup,
index: number,
parentConditionGroup: TConditionGroup
) => {
const connector = parentConditionGroup.connector;
if (isConditionGroup(condition)) {
return (
<div key={condition.id} className="flex items-start justify-between gap-4">
{index === 0 ? (
<div>When</div>
) : (
<div
className={cn("w-14", index === 1 && "cursor-pointer underline")}
onClick={() => {
if (index !== 1) return;
handleConnectorChange(parentConditionGroup.id);
}}>
{connector}
</div>
)}
<div className="rounded-lg border border-slate-400 p-3">
<LogicEditorConditions
conditions={condition}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
question={question}
questionIdx={questionIdx}
logicIdx={logicIdx}
depth={depth + 1}
/>
</div>
<div className="mt-2">
<DropdownMenu>
<DropdownMenuTrigger>
<EllipsisVerticalIcon className="h-4 w-4 text-slate-700 hover:text-slate-950" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className="flex items-center gap-2"
onClick={() => {
handleAddConditionBelow(condition.id);
}}>
<PlusIcon className="h-4 w-4" />
Add condition below
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2"
disabled={depth === 0 && conditions.conditions.length === 1}
onClick={() => handleRemoveCondition(condition.id)}>
<TrashIcon className="h-4 w-4" />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
const conditionValueOptions = getConditionValueOptions(localSurvey, questionIdx);
const conditionOperatorOptions = getConditionOperatorOptions(condition, localSurvey);
const { show, options, showInput = false, inputType } = getMatchValueProps(condition, localSurvey);
const allowMultiSelect = ["equalsOneOf", "includesAllOf", "includesOneOf"].includes(condition.operator);
return (
<div key={condition.id} className="flex items-center gap-x-2">
<div className="w-10 shrink-0">
{index === 0 ? (
"When"
) : (
<div
className={cn("w-14", index === 1 && "cursor-pointer underline")}
onClick={() => {
if (index !== 1) return;
handleConnectorChange(parentConditionGroup.id);
}}>
{connector}
</div>
)}
</div>
<InputCombobox
id={`condition-${depth}-${index}-conditionValue`}
key="conditionValue"
showSearch={false}
groupedOptions={conditionValueOptions}
value={condition.leftOperand.value}
onChangeValue={(val: string, option) => {
handleQuestionChange(condition, val, option);
}}
comboboxClasses="grow"
/>
<InputCombobox
id={`condition-${depth}-${index}-conditionOperator`}
key="conditionOperator"
showSearch={false}
options={conditionOperatorOptions}
value={condition.operator}
onChangeValue={(val: TSurveyLogicConditionsOperator) => {
handleOperatorChange(condition, val);
}}
comboboxClasses="grow min-w-[150px]"
/>
{show && (
<InputCombobox
id={`condition-${depth}-${index}-conditionMatchValue`}
withInput={showInput}
inputProps={{
type: inputType,
placeholder: "Value",
}}
key="conditionMatchValue"
showSearch={false}
groupedOptions={options}
allowMultiSelect={allowMultiSelect}
showCheckIcon={allowMultiSelect}
comboboxClasses="grow min-w-[180px] max-w-[300px]"
value={condition.rightOperand?.value}
clearable={true}
onChangeValue={(val, option) => {
handleRightOperandChange(condition, val, option);
}}
/>
)}
<DropdownMenu>
<DropdownMenuTrigger id={`condition-${depth}-${index}-dropdown`}>
<EllipsisVerticalIcon className="h-4 w-4 text-slate-700 hover:text-slate-950" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className="flex items-center gap-2"
onClick={() => {
handleAddConditionBelow(condition.id);
}}>
<PlusIcon className="h-4 w-4" />
Add condition below
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2"
disabled={depth === 0 && conditions.conditions.length === 1}
onClick={() => handleRemoveCondition(condition.id)}>
<TrashIcon className="h-4 w-4" />
Remove
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2"
onClick={() => handleDuplicateCondition(condition.id)}>
<CopyIcon className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2"
onClick={() => handleCreateGroup(condition.id)}>
<WorkflowIcon className="h-4 w-4" />
Create group
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
return (
<div className="flex flex-col gap-y-2">
{conditions?.conditions.map((condition, index) => renderCondition(condition, index, conditions))}
</div>
);
}

View File

@@ -1,11 +1,13 @@
"use client";
import { findOptionUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
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, getLocalizedValue } from "@formbricks/lib/i18n/utils";
import toast from "react-hot-toast";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import {
TI18nString,
@@ -68,8 +70,6 @@ export const MultipleChoiceQuestionForm = ({
};
const updateChoice = (choiceIdx: number, updatedAttributes: { label: TI18nString }) => {
const newLabel = updatedAttributes.label.en;
const oldLabel = question.choices[choiceIdx].label;
let newChoices: any[] = [];
if (question.choices) {
newChoices = question.choices.map((choice, idx) => {
@@ -78,19 +78,9 @@ export const MultipleChoiceQuestionForm = ({
});
}
let newLogic: any[] = [];
question.logic?.forEach((logic) => {
let newL: string | string[] | undefined = logic.value;
if (Array.isArray(logic.value)) {
newL = logic.value.map((value) =>
value === getLocalizedValue(oldLabel, selectedLanguageCode) ? newLabel : value
);
} else {
newL = logic.value === getLocalizedValue(oldLabel, selectedLanguageCode) ? newLabel : logic.value;
}
newLogic.push({ ...logic, value: newL });
updateQuestion(questionIdx, {
choices: newChoices,
});
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
};
const addChoice = (choiceIdx?: number) => {
@@ -132,23 +122,27 @@ export const MultipleChoiceQuestionForm = ({
};
const deleteChoice = (choiceIdx: number) => {
const choiceToDelete = question.choices[choiceIdx].id;
if (choiceToDelete !== "other") {
const questionIdx = findOptionUsedInLogic(localSurvey, question.id, choiceToDelete);
if (questionIdx !== -1) {
toast.error(
`This option is used in logic for question ${questionIdx + 1}. Please fix the logic first before deleting.`
);
return;
}
}
const newChoices = !question.choices ? [] : question.choices.filter((_, idx) => idx !== choiceIdx);
const choiceValue = question.choices[choiceIdx].label[selectedLanguageCode];
if (isInvalidValue === choiceValue) {
setisInvalidValue(null);
}
let newLogic: any[] = [];
question.logic?.forEach((logic) => {
let newL: string | string[] | undefined = logic.value;
if (Array.isArray(logic.value)) {
newL = logic.value.filter((value) => value !== choiceValue);
} else {
newL = logic.value !== choiceValue ? logic.value : undefined;
}
newLogic.push({ ...logic, value: newL });
});
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
updateQuestion(questionIdx, {
choices: newChoices,
});
};
useEffect(() => {
@@ -219,6 +213,7 @@ export const MultipleChoiceQuestionForm = ({
<Label htmlFor="choices">Options*</Label>
<div className="mt-2" id="choices">
<DndContext
id="multi-choice-choices"
onDragEnd={(event) => {
const { active, over } = event;

View File

@@ -39,22 +39,10 @@ export const PictureSelectionForm = ({
// Filter out the deleted choice from the choices array
const newChoices = question.choices?.filter((choice) => choice.id !== choiceValue) || [];
// Update the logic, removing the deleted choice value
const newLogic =
question.logic?.map((logic) => {
let updatedValue = logic.value;
if (Array.isArray(logic.value)) {
updatedValue = logic.value.filter((value) => value !== choiceValue);
} else if (logic.value === choiceValue) {
updatedValue = undefined;
}
return { ...logic, value: updatedValue };
}) || [];
// Update the question with new choices and logic
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
updateQuestion(questionIdx, {
choices: newChoices,
});
};
const handleFileInputChanges = (urls: string[]) => {
@@ -139,7 +127,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,14 +1,14 @@
"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 { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import * as Collapsible from "@radix-ui/react-collapsible";
import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { QUESTIONS_ICON_MAP, getTSurveyQuestionTypeEnumName } from "@formbricks/lib/utils/questions";
import { recallToHeadline } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TProduct } from "@formbricks/types/product";
@@ -54,6 +54,7 @@ interface QuestionCardProps {
attributeClasses: TAttributeClass[];
addQuestion: (question: any, index?: number) => void;
isFormbricksCloud: boolean;
isCxMode: boolean;
}
export const QuestionCard = ({
@@ -74,6 +75,7 @@ export const QuestionCard = ({
attributeClasses,
addQuestion,
isFormbricksCloud,
isCxMode,
}: QuestionCardProps) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: question.id,
@@ -206,6 +208,7 @@ export const QuestionCard = ({
updateCard={updateQuestion}
addCard={addQuestion}
cardType="question"
isCxMode={isCxMode}
/>
</div>
</div>

View File

@@ -20,6 +20,7 @@ interface QuestionsDraggableProps {
attributeClasses: TAttributeClass[];
addQuestion: (question: any, index?: number) => void;
isFormbricksCloud: boolean;
isCxMode: boolean;
}
export const QuestionsDroppable = ({
@@ -38,6 +39,7 @@ export const QuestionsDroppable = ({
attributeClasses,
addQuestion,
isFormbricksCloud,
isCxMode,
}: QuestionsDraggableProps) => {
return (
<div className="group mb-5 flex w-full flex-col gap-5">
@@ -62,6 +64,7 @@ export const QuestionsDroppable = ({
attributeClasses={attributeClasses}
addQuestion={addQuestion}
isFormbricksCloud={isFormbricksCloud}
isCxMode={isCxMode}
/>
))}
</SortableContext>

View File

@@ -31,12 +31,14 @@ interface QuestionsAudienceTabsProps {
activeId: TSurveyEditorTabs;
setActiveId: React.Dispatch<React.SetStateAction<TSurveyEditorTabs>>;
isStylingTabVisible?: boolean;
isCxMode: boolean;
}
export const QuestionsAudienceTabs = ({
activeId,
setActiveId,
isStylingTabVisible,
isCxMode,
}: QuestionsAudienceTabsProps) => {
const tabsComputed = useMemo(() => {
if (isStylingTabVisible) {
@@ -45,10 +47,13 @@ export const QuestionsAudienceTabs = ({
return tabs.filter((tab) => tab.id !== "styling");
}, [isStylingTabVisible]);
// Hide settings tab in CX mode
let tabsToDisplay = isCxMode ? tabsComputed.filter((tab) => tab.id !== "settings") : tabsComputed;
return (
<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) => (
{tabsToDisplay.map((tab) => (
<button
type="button"
key={tab.id}

View File

@@ -1,6 +1,8 @@
"use client";
import { AddEndingCardButton } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddEndingCardButton";
import { SurveyVariablesCard } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCard";
import { findQuestionUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
import {
DndContext,
DragEndEvent,
@@ -16,11 +18,18 @@ import toast from "react-hot-toast";
import { MultiLanguageCard } from "@formbricks/ee/multi-language/components/multi-language-card";
import { addMultiLanguageLabels, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { isConditionGroup } from "@formbricks/lib/surveyLogic/utils";
import { getDefaultEndingCard } from "@formbricks/lib/templates";
import { checkForEmptyFallBackValue, extractRecallInfo } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
import { TProduct } from "@formbricks/types/product";
import {
TConditionGroup,
TSingleCondition,
TSurveyLogic,
TSurveyLogicAction,
} from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { findQuestionsWithCyclicLogic } from "@formbricks/types/surveys/validation";
import {
@@ -49,6 +58,7 @@ interface QuestionsViewProps {
isFormbricksCloud: boolean;
attributeClasses: TAttributeClass[];
plan: TOrganizationBillingPlan;
isCxMode: boolean;
}
export const QuestionsView = ({
@@ -65,6 +75,7 @@ export const QuestionsView = ({
isFormbricksCloud,
attributeClasses,
plan,
isCxMode,
}: QuestionsViewProps) => {
const internalQuestionIdMap = useMemo(() => {
return localSurvey.questions.reduce((acc, question) => {
@@ -75,22 +86,75 @@ export const QuestionsView = ({
const surveyLanguages = localSurvey.languages;
const [backButtonLabel, setbackButtonLabel] = useState(null);
const handleQuestionLogicChange = (survey: TSurvey, compareId: string, updatedId: string): TSurvey => {
survey.questions.forEach((question) => {
if (question.headline[selectedLanguageCode].includes(`recall:${compareId}`)) {
question.headline[selectedLanguageCode] = question.headline[selectedLanguageCode].replaceAll(
`recall:${compareId}`,
`recall:${updatedId}`
);
const updateConditions = (conditions: TConditionGroup): TConditionGroup => {
return {
...conditions,
conditions: conditions?.conditions.map((condition) => {
if (isConditionGroup(condition)) {
return updateConditions(condition);
} else {
return updateSingleCondition(condition);
}
}),
};
};
const updateSingleCondition = (condition: TSingleCondition): TSingleCondition => {
let updatedCondition = { ...condition };
if (condition.leftOperand.value === compareId) {
updatedCondition.leftOperand = { ...condition.leftOperand, value: updatedId };
}
if (!question.logic) return;
question.logic.forEach((rule) => {
if (rule.destination === compareId) {
rule.destination = updatedId;
if (condition.rightOperand?.type === "question" && condition.rightOperand?.value === compareId) {
updatedCondition.rightOperand = { ...condition.rightOperand, value: updatedId };
}
return updatedCondition;
};
const updateActions = (actions: TSurveyLogicAction[]): TSurveyLogicAction[] => {
return actions.map((action) => {
let updatedAction = { ...action };
if (updatedAction.objective === "jumpToQuestion" && updatedAction.target === compareId) {
updatedAction.target = updatedId;
}
if (updatedAction.objective === "requireAnswer" && updatedAction.target === compareId) {
updatedAction.target = updatedId;
}
return updatedAction;
});
});
return survey;
};
return {
...survey,
questions: survey.questions.map((question) => {
let updatedQuestion = { ...question };
if (question.headline[selectedLanguageCode].includes(`recall:${compareId}`)) {
question.headline[selectedLanguageCode] = question.headline[selectedLanguageCode].replaceAll(
`recall:${compareId}`,
`recall:${updatedId}`
);
}
// Update advanced logic
if (question.logic) {
updatedQuestion.logic = question.logic.map((logicRule: TSurveyLogic) => ({
...logicRule,
conditions: updateConditions(logicRule.conditions),
actions: updateActions(logicRule.actions),
}));
}
return updatedQuestion;
}),
};
};
useEffect(() => {
@@ -200,6 +264,7 @@ export const QuestionsView = ({
}
}
});
setLocalSurvey(updatedSurvey);
validateSurveyQuestion(updatedSurvey.questions[questionIdx]);
};
@@ -209,6 +274,14 @@ export const QuestionsView = ({
const activeQuestionIdTemp = activeQuestionId ?? localSurvey.questions[0].id;
let updatedSurvey: TSurvey = { ...localSurvey };
// checking if this question is used in logic of any other question
const quesIdx = findQuestionUsedInLogic(localSurvey, questionId);
if (quesIdx !== -1) {
toast.error(`This question is used in logic of question ${quesIdx + 1}.`);
return;
}
// check if we are recalling from this question for every language
updatedSurvey.questions.forEach((question) => {
for (const [languageCode, headline] of Object.entries(question.headline)) {
@@ -221,7 +294,7 @@ export const QuestionsView = ({
}
});
updatedSurvey.questions.splice(questionIdx, 1);
updatedSurvey = handleQuestionLogicChange(updatedSurvey, questionId, "");
const firstEndingCard = localSurvey.endings[0];
setLocalSurvey(updatedSurvey);
delete internalQuestionIdMap[questionId];
@@ -359,20 +432,26 @@ export const QuestionsView = ({
return (
<div className="mt-12 w-full px-5 py-4">
<div className="mb-5 flex w-full flex-col gap-5">
<EditWelcomeCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setActiveQuestionId={setActiveQuestionId}
activeQuestionId={activeQuestionId}
isInvalid={invalidQuestions ? invalidQuestions.includes("start") : false}
setSelectedLanguageCode={setSelectedLanguageCode}
selectedLanguageCode={selectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
{!isCxMode && (
<div className="mb-5 flex w-full flex-col gap-5">
<EditWelcomeCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setActiveQuestionId={setActiveQuestionId}
activeQuestionId={activeQuestionId}
isInvalid={invalidQuestions ? invalidQuestions.includes("start") : false}
setSelectedLanguageCode={setSelectedLanguageCode}
selectedLanguageCode={selectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
)}
<DndContext sensors={sensors} onDragEnd={onQuestionCardDragEnd} collisionDetection={closestCorners}>
<DndContext
id="questions"
sensors={sensors}
onDragEnd={onQuestionCardDragEnd}
collisionDetection={closestCorners}>
<QuestionsDroppable
localSurvey={localSurvey}
product={product}
@@ -389,13 +468,18 @@ export const QuestionsView = ({
attributeClasses={attributeClasses}
addQuestion={addQuestion}
isFormbricksCloud={isFormbricksCloud}
isCxMode={isCxMode}
/>
</DndContext>
<AddQuestionButton addQuestion={addQuestion} product={product} />
<AddQuestionButton addQuestion={addQuestion} product={product} isCxMode={isCxMode} />
<div className="mt-5 flex flex-col gap-5">
<hr className="border-t border-dashed" />
<DndContext sensors={sensors} onDragEnd={onEndingCardDragEnd} collisionDetection={closestCorners}>
<DndContext
id="endings"
sensors={sensors}
onDragEnd={onEndingCardDragEnd}
collisionDetection={closestCorners}>
<SortableContext items={localSurvey.endings} strategy={verticalListSortingStrategy}>
{localSurvey.endings.map((ending, index) => {
return (
@@ -419,37 +503,41 @@ export const QuestionsView = ({
</SortableContext>
</DndContext>
<AddEndingCardButton
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
addEndingCard={addEndingCard}
/>
<hr />
{!isCxMode && (
<>
<AddEndingCardButton
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
addEndingCard={addEndingCard}
/>
<hr />
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setActiveQuestionId={setActiveQuestionId}
activeQuestionId={activeQuestionId}
/>
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setActiveQuestionId={setActiveQuestionId}
activeQuestionId={activeQuestionId}
/>
{/* <SurveyVariablesCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
/> */}
<SurveyVariablesCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
/>
<MultiLanguageCard
localSurvey={localSurvey}
product={product}
setLocalSurvey={setLocalSurvey}
setActiveQuestionId={setActiveQuestionId}
activeQuestionId={activeQuestionId}
isMultiLanguageAllowed={isMultiLanguageAllowed}
isFormbricksCloud={isFormbricksCloud}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
<MultiLanguageCard
localSurvey={localSurvey}
product={product}
setLocalSurvey={setLocalSurvey}
setActiveQuestionId={setActiveQuestionId}
activeQuestionId={activeQuestionId}
isMultiLanguageAllowed={isMultiLanguageAllowed}
isFormbricksCloud={isFormbricksCloud}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</>
)}
</div>
</div>
);

View File

@@ -167,6 +167,7 @@ export const RankingQuestionForm = ({
<Label htmlFor="choices">Options*</Label>
<div className="mt-2" id="choices">
<DndContext
id="ranking-choices"
onDragEnd={(event) => {
const { active, over } = event;

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

@@ -34,6 +34,7 @@ type StylingViewProps = {
localStylingChanges: TSurveyStyling | null;
setLocalStylingChanges: React.Dispatch<React.SetStateAction<TSurveyStyling | null>>;
isUnsplashConfigured: boolean;
isCxMode: boolean;
};
export const StylingView = ({
@@ -47,6 +48,7 @@ export const StylingView = ({
localStylingChanges,
setLocalStylingChanges,
isUnsplashConfigured,
isCxMode,
}: StylingViewProps) => {
const stylingDefaults: TBaseStyling = useMemo(() => {
let stylingDefaults: TBaseStyling;
@@ -197,28 +199,30 @@ export const StylingView = ({
<FormProvider {...form}>
<form onSubmit={(e) => e.preventDefault()}>
<div className="mt-12 space-y-3 p-5">
<div className="flex items-center gap-4 py-4">
<FormField
control={form.control}
name="overwriteThemeStyling"
render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl>
<Switch checked={!!field.value} onCheckedChange={handleOverwriteToggle} />
</FormControl>
{!isCxMode && (
<div className="flex items-center gap-4 py-4">
<FormField
control={form.control}
name="overwriteThemeStyling"
render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl>
<Switch checked={!!field.value} onCheckedChange={handleOverwriteToggle} />
</FormControl>
<div>
<FormLabel className="text-base font-semibold text-slate-900">
Add custom styles
</FormLabel>
<FormDescription className="text-sm text-slate-800">
Override the theme with individual styles for this survey.
</FormDescription>
</div>
</FormItem>
)}
/>
</div>
<div>
<FormLabel className="text-base font-semibold text-slate-900">
Add custom styles
</FormLabel>
<FormDescription className="text-sm text-slate-800">
Override the theme with individual styles for this survey.
</FormDescription>
</div>
</FormItem>
)}
/>
</div>
)}
<FormStylingSettings
open={formStylingOpen}
@@ -248,31 +252,32 @@ export const StylingView = ({
/>
)}
<div className="mt-4 flex h-8 items-center justify-between">
<div>
{overwriteThemeStyling && (
<Button
type="button"
variant="minimal"
className="flex items-center gap-2"
onClick={() => setConfirmResetStylingModalOpen(true)}>
Reset to theme styles
<RotateCcwIcon className="h-4 w-4" />
</Button>
)}
{!isCxMode && (
<div className="mt-4 flex h-8 items-center justify-between">
<div>
{overwriteThemeStyling && (
<Button
type="button"
variant="minimal"
className="flex items-center gap-2"
onClick={() => setConfirmResetStylingModalOpen(true)}>
Reset to theme styles
<RotateCcwIcon className="h-4 w-4" />
</Button>
)}
</div>
<p className="text-sm text-slate-500">
Adjust the theme in the{" "}
<Link
href={`/environments/${environment.id}/product/look`}
target="_blank"
className="font-semibold underline">
Look & Feel
</Link>{" "}
settings
</p>
</div>
<p className="text-sm text-slate-500">
Adjust the theme in the{" "}
<Link
href={`/environments/${environment.id}/product/look`}
target="_blank"
className="font-semibold underline">
Look & Feel
</Link>{" "}
settings
</p>
</div>
)}
<AlertDialog
open={confirmResetStylingModalOpen}
setOpen={setConfirmResetStylingModalOpen}

View File

@@ -37,6 +37,7 @@ interface SurveyEditorProps {
isFormbricksCloud: boolean;
isUnsplashConfigured: boolean;
plan: TOrganizationBillingPlan;
isCxMode: boolean;
}
export const SurveyEditor = ({
@@ -55,6 +56,7 @@ export const SurveyEditor = ({
isFormbricksCloud,
isUnsplashConfigured,
plan,
isCxMode = false,
}: SurveyEditorProps) => {
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("questions");
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
@@ -144,6 +146,7 @@ export const SurveyEditor = ({
responseCount={responseCount}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isCxMode={isCxMode}
/>
<div className="relative z-0 flex flex-1 overflow-hidden">
<main
@@ -152,6 +155,7 @@ export const SurveyEditor = ({
<QuestionsAudienceTabs
activeId={activeView}
setActiveId={setActiveView}
isCxMode={isCxMode}
isStylingTabVisible={!!product.styling.allowStyleOverwrite}
/>
@@ -170,6 +174,7 @@ export const SurveyEditor = ({
isFormbricksCloud={isFormbricksCloud}
attributeClasses={attributeClasses}
plan={plan}
isCxMode={isCxMode}
/>
)}
@@ -185,6 +190,7 @@ export const SurveyEditor = ({
localStylingChanges={localStylingChanges}
setLocalStylingChanges={setLocalStylingChanges}
isUnsplashConfigured={isUnsplashConfigured}
isCxMode={isCxMode}
/>
)}

View File

@@ -39,6 +39,7 @@ interface SurveyMenuBarProps {
responseCount: number;
selectedLanguageCode: string;
setSelectedLanguageCode: (selectedLanguage: string) => void;
isCxMode: boolean;
}
export const SurveyMenuBar = ({
@@ -52,6 +53,7 @@ export const SurveyMenuBar = ({
product,
responseCount,
selectedLanguageCode,
isCxMode,
}: SurveyMenuBarProps) => {
const router = useRouter();
const [audiencePrompt, setAudiencePrompt] = useState(true);
@@ -86,6 +88,13 @@ export const SurveyMenuBar = ({
};
}, [localSurvey, survey]);
const clearSurveyLocalStorage = () => {
if (typeof localStorage !== "undefined") {
localStorage.removeItem(`${localSurvey.id}-columnOrder`);
localStorage.removeItem(`${localSurvey.id}-columnVisibility`);
}
};
const containsEmptyTriggers = useMemo(() => {
if (localSurvey.type === "link") return false;
@@ -179,9 +188,13 @@ 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);
toast.error(currentError.message, {
className: "w-fit !max-w-md",
});
}
return false;
@@ -224,7 +237,14 @@ 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();
clearSurveyLocalStorage();
const updatedSurveyResponse = await updateSurveyAction({ ...localSurvey, segment });
setIsSurveySaving(false);
@@ -270,6 +290,7 @@ export const SurveyMenuBar = ({
}
const status = localSurvey.runOnDate ? "scheduled" : "inProgress";
const segment = await handleSegmentUpdate();
clearSurveyLocalStorage();
await updateSurveyAction({
...localSurvey,
@@ -288,16 +309,18 @@ export const SurveyMenuBar = ({
<>
<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();
}}>
Back
</Button>
{!isCxMode && (
<Button
size="sm"
variant="secondary"
className="h-full"
StartIcon={ArrowLeftIcon}
onClick={() => {
handleBack();
}}>
Back
</Button>
)}
<p className="hidden pl-4 font-semibold md:block">{product.name} / </p>
<Input
defaultValue={localSurvey.name}
@@ -333,16 +356,19 @@ export const SurveyMenuBar = ({
updateLocalSurveyStatus={updateLocalSurveyStatus}
/>
</div>
<Button
disabled={disableSave}
variant="secondary"
size="sm"
className="mr-3"
loading={isSurveySaving}
onClick={() => handleSurveySave()}
type="submit">
Save
</Button>
{!isCxMode && (
<Button
disabled={disableSave}
variant="secondary"
size="sm"
className="mr-3"
loading={isSurveySaving}
onClick={() => handleSurveySave()}
type="submit">
Save
</Button>
)}
{localSurvey.status !== "draft" && (
<Button
disabled={disableSave}
@@ -371,7 +397,7 @@ export const SurveyMenuBar = ({
disabled={isSurveySaving || containsEmptyTriggers}
loading={isSurveyPublishing}
onClick={handleSurveyPublish}>
Publish
{isCxMode ? "Save & Close" : "Publish"}
</Button>
)}
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { FileDigitIcon } from "lucide-react";
import { cn } from "@formbricks/lib/cn";
import { TSurvey } from "@formbricks/types/surveys/types";
import { SurveyVariablesCardItem } from "./SurveyVariablesCardItem";
@@ -37,7 +38,9 @@ export const SurveyVariablesCard = ({
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 className="flex w-full justify-center">
<FileDigitIcon className="h-4 w-4" />
</div>
</div>
<Collapsible.Root
open={open}

View File

@@ -1,9 +1,11 @@
"use client";
import { findVariableUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
import { createId } from "@paralleldrive/cuid2";
import { TrashIcon } from "lucide-react";
import React, { useCallback, useEffect } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { extractRecallInfo } from "@formbricks/lib/utils/recall";
import { TSurvey, TSurveyVariable } from "@formbricks/types/surveys/types";
import { Button } from "@formbricks/ui/Button";
@@ -72,11 +74,19 @@ export const SurveyVariablesCardItem = ({
return () => subscription.unsubscribe();
}, [form, mode, editSurveyVariable]);
const onVaribleDelete = (variable: TSurveyVariable) => {
const onVariableDelete = (variable: TSurveyVariable) => {
const questions = [...localSurvey.questions];
// find if this variable is used in any question's recall and remove it for every language
const quesIdx = findVariableUsedInLogic(localSurvey, variable.id);
if (quesIdx !== -1) {
toast.error(
`${variable.name} is used in logic of question ${quesIdx + 1}. Please remove it from logic first.`
);
return;
}
// 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}`)) {
@@ -210,7 +220,7 @@ export const SurveyVariablesCardItem = ({
type="button"
size="sm"
className="whitespace-nowrap"
onClick={() => onVaribleDelete(variable)}>
onClick={() => onVariableDelete(variable)}>
<TrashIcon className="h-4 w-4" />
</Button>
)}

View File

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

@@ -0,0 +1,456 @@
import { TSurveyQuestionTypeEnum, ZSurveyLogicConditionsOperator } from "@formbricks/types/surveys/types";
export const logicRules = {
question: {
[`${TSurveyQuestionTypeEnum.OpenText}.text`]: {
options: [
{
label: "equals",
value: ZSurveyLogicConditionsOperator.Enum.equals,
},
{
label: "does not equal",
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
},
{
label: "contains",
value: ZSurveyLogicConditionsOperator.Enum.contains,
},
{
label: "does not contain",
value: ZSurveyLogicConditionsOperator.Enum.doesNotContain,
},
{
label: "starts with",
value: ZSurveyLogicConditionsOperator.Enum.startsWith,
},
{
label: "does not start with",
value: ZSurveyLogicConditionsOperator.Enum.doesNotStartWith,
},
{
label: "ends with",
value: ZSurveyLogicConditionsOperator.Enum.endsWith,
},
{
label: "does not end with",
value: ZSurveyLogicConditionsOperator.Enum.doesNotEndWith,
},
{
label: "is submitted",
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
},
{
label: "is skipped",
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
},
],
},
[`${TSurveyQuestionTypeEnum.OpenText}.number`]: {
options: [
{
label: "=",
value: ZSurveyLogicConditionsOperator.Enum.equals,
},
{
label: "!=",
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
},
{
label: ">",
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThan,
},
{
label: "<",
value: ZSurveyLogicConditionsOperator.Enum.isLessThan,
},
{
label: ">=",
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThanOrEqual,
},
{
label: "<=",
value: ZSurveyLogicConditionsOperator.Enum.isLessThanOrEqual,
},
{
label: "is submitted",
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
},
{
label: "is skipped",
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
},
],
},
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: {
options: [
{
label: "equals",
value: ZSurveyLogicConditionsOperator.Enum.equals,
},
{
label: "does not equal",
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
},
{
label: "equals one of",
value: ZSurveyLogicConditionsOperator.Enum.equalsOneOf,
},
{
label: "is submitted",
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
},
{
label: "is skipped",
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
},
],
},
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: {
options: [
{
label: "equals",
value: ZSurveyLogicConditionsOperator.Enum.equals,
},
{
label: "does not equal",
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
},
{
label: "includes all of",
value: ZSurveyLogicConditionsOperator.Enum.includesAllOf,
},
{
label: "includes one of",
value: ZSurveyLogicConditionsOperator.Enum.includesOneOf,
},
{
label: "is submitted",
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
},
{
label: "is skipped",
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
},
],
},
[TSurveyQuestionTypeEnum.PictureSelection]: {
options: [
{
label: "equals",
value: ZSurveyLogicConditionsOperator.Enum.equals,
},
{
label: "does not equal",
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
},
{
label: "includes all of",
value: ZSurveyLogicConditionsOperator.Enum.includesAllOf,
},
{
label: "includes one of",
value: ZSurveyLogicConditionsOperator.Enum.includesOneOf,
},
{
label: "is submitted",
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
},
{
label: "is skipped",
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
},
],
},
[TSurveyQuestionTypeEnum.Rating]: {
options: [
{
label: "=",
value: ZSurveyLogicConditionsOperator.Enum.equals,
},
{
label: "!=",
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
},
{
label: ">",
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThan,
},
{
label: "<",
value: ZSurveyLogicConditionsOperator.Enum.isLessThan,
},
{
label: ">=",
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThanOrEqual,
},
{
label: "<=",
value: ZSurveyLogicConditionsOperator.Enum.isLessThanOrEqual,
},
{
label: "is submitted",
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
},
{
label: "is skipped",
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
},
],
},
[TSurveyQuestionTypeEnum.NPS]: {
options: [
{
label: "=",
value: ZSurveyLogicConditionsOperator.Enum.equals,
},
{
label: "!=",
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
},
{
label: ">",
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThan,
},
{
label: "<",
value: ZSurveyLogicConditionsOperator.Enum.isLessThan,
},
{
label: ">=",
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThanOrEqual,
},
{
label: "<=",
value: ZSurveyLogicConditionsOperator.Enum.isLessThanOrEqual,
},
{
label: "is submitted",
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
},
{
label: "is skipped",
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
},
],
},
[TSurveyQuestionTypeEnum.CTA]: {
options: [
{
label: "is clicked",
value: ZSurveyLogicConditionsOperator.Enum.isClicked,
},
{
label: "is skipped",
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
},
],
},
[TSurveyQuestionTypeEnum.Consent]: {
options: [
{
label: "is accepted",
value: ZSurveyLogicConditionsOperator.Enum.isAccepted,
},
{
label: "is skipped",
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
},
],
},
[TSurveyQuestionTypeEnum.Date]: {
options: [
{
label: "equals",
value: ZSurveyLogicConditionsOperator.Enum.equals,
},
{
label: "does not equal",
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
},
{
label: "is before",
value: ZSurveyLogicConditionsOperator.Enum.isBefore,
},
{
label: "is after",
value: ZSurveyLogicConditionsOperator.Enum.isAfter,
},
{
label: "is submitted",
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
},
{
label: "is skipped",
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
},
],
},
[TSurveyQuestionTypeEnum.FileUpload]: {
options: [
{
label: "is submitted",
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
},
{
label: "is skipped",
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
},
],
},
[TSurveyQuestionTypeEnum.Ranking]: {
options: [
{
label: "is submitted",
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
},
{
label: "is skipped",
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
},
],
},
[TSurveyQuestionTypeEnum.Cal]: {
options: [
{
label: "is booked",
value: ZSurveyLogicConditionsOperator.Enum.isBooked,
},
{
label: "is skipped",
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
},
],
},
[TSurveyQuestionTypeEnum.Matrix]: {
options: [
{
label: "is partially submitted",
value: ZSurveyLogicConditionsOperator.Enum.isPartiallySubmitted,
},
{
label: "is completely submitted",
value: ZSurveyLogicConditionsOperator.Enum.isCompletelySubmitted,
},
{
label: "is skipped",
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
},
],
},
[TSurveyQuestionTypeEnum.Address]: {
options: [
{
label: "is submitted",
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
},
{
label: "is skipped",
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
},
],
},
},
["variable.text"]: {
options: [
{
label: "equals",
value: ZSurveyLogicConditionsOperator.Enum.equals,
},
{
label: "does not equal",
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
},
{
label: "contains",
value: ZSurveyLogicConditionsOperator.Enum.contains,
},
{
label: "does not contain",
value: ZSurveyLogicConditionsOperator.Enum.doesNotContain,
},
{
label: "starts with",
value: ZSurveyLogicConditionsOperator.Enum.startsWith,
},
{
label: "does not start with",
value: ZSurveyLogicConditionsOperator.Enum.doesNotStartWith,
},
{
label: "ends with",
value: ZSurveyLogicConditionsOperator.Enum.endsWith,
},
{
label: "does not end with",
value: ZSurveyLogicConditionsOperator.Enum.doesNotEndWith,
},
],
},
["variable.number"]: {
options: [
{
label: "=",
value: ZSurveyLogicConditionsOperator.Enum.equals,
},
{
label: "!=",
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
},
{
label: ">",
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThan,
},
{
label: "<",
value: ZSurveyLogicConditionsOperator.Enum.isLessThan,
},
{
label: ">=",
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThanOrEqual,
},
{
label: "<=",
value: ZSurveyLogicConditionsOperator.Enum.isLessThanOrEqual,
},
],
},
hiddenField: {
options: [
{
label: "equals",
value: ZSurveyLogicConditionsOperator.Enum.equals,
},
{
label: "does not equal",
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
},
{
label: "contains",
value: ZSurveyLogicConditionsOperator.Enum.contains,
},
{
label: "does not contain",
value: ZSurveyLogicConditionsOperator.Enum.doesNotContain,
},
{
label: "starts with",
value: ZSurveyLogicConditionsOperator.Enum.startsWith,
},
{
label: "does not start with",
value: ZSurveyLogicConditionsOperator.Enum.doesNotStartWith,
},
{
label: "ends with",
value: ZSurveyLogicConditionsOperator.Enum.endsWith,
},
{
label: "does not end with",
value: ZSurveyLogicConditionsOperator.Enum.doesNotEndWith,
},
],
},
};
export type TLogicRuleOption = (typeof logicRules.question)[keyof typeof logicRules.question]["options"];

View File

@@ -1,18 +0,0 @@
// formats the text to highlight specific parts of the text with slashes
export const formatTextWithSlashes = (text: string) => {
const regex = /\/(.*?)\\/g;
const parts = text.split(regex);
return parts.map((part, index) => {
// Check if the part was inside slashes
if (index % 2 !== 0) {
return (
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-xs">
{part}
</span>
);
} else {
return part;
}
});
};

View File

@@ -0,0 +1,930 @@
import { EyeOffIcon, FileDigitIcon, FileType2Icon } from "lucide-react";
import { HTMLInputTypeAttribute, useMemo } from "react";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { isConditionGroup } from "@formbricks/lib/surveyLogic/utils";
import { questionIconMapping, questionTypes } from "@formbricks/lib/utils/questions";
import { recallToHeadline } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import {
TConditionGroup,
TLeftOperand,
TRightOperand,
TSingleCondition,
TSurvey,
TSurveyLogic,
TSurveyLogicAction,
TSurveyLogicConditionsOperator,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveyVariable,
} from "@formbricks/types/surveys/types";
import { TComboboxGroupedOption, TComboboxOption } from "@formbricks/ui/InputCombobox";
import { TLogicRuleOption, logicRules } from "./logicRuleEngine";
// formats the text to highlight specific parts of the text with slashes
export const formatTextWithSlashes = (text: string) => {
const regex = /\/(.*?)\\/g;
const parts = text.split(regex);
return parts.map((part, index) => {
// Check if the part was inside slashes
if (index % 2 !== 0) {
return (
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-xs">
{part}
</span>
);
} else {
return part;
}
});
};
export const getConditionValueOptions = (
localSurvey: TSurvey,
currQuestionIdx: number
): TComboboxGroupedOption[] => {
const hiddenFields = localSurvey.hiddenFields?.fieldIds ?? [];
const variables = localSurvey.variables ?? [];
const questions = localSurvey.questions;
const groupedOptions: TComboboxGroupedOption[] = [];
const questionOptions = questions
.filter((_, idx) => idx <= currQuestionIdx)
.map((question) => {
return {
icon: questionIconMapping[question.type],
label: getLocalizedValue(question.headline, "default"),
value: question.id,
meta: {
type: "question",
},
};
});
const variableOptions = variables.map((variable) => {
return {
icon: variable.type === "number" ? FileDigitIcon : FileType2Icon,
label: variable.name,
value: variable.id,
meta: {
type: "variable",
},
};
});
const hiddenFieldsOptions = hiddenFields.map((field) => {
return {
icon: EyeOffIcon,
label: field,
value: field,
meta: {
type: "hiddenField",
},
};
});
if (questionOptions.length > 0) {
groupedOptions.push({
label: "Questions",
value: "questions",
options: questionOptions,
});
}
if (variableOptions.length > 0) {
groupedOptions.push({
label: "Variables",
value: "variables",
options: variableOptions,
});
}
if (hiddenFieldsOptions.length > 0) {
groupedOptions.push({
label: "Hidden Fields",
value: "hiddenFields",
options: hiddenFieldsOptions,
});
}
return groupedOptions;
};
export const replaceEndingCardHeadlineRecall = (
survey: TSurvey,
language: string,
attributeClasses: TAttributeClass[]
) => {
const modifiedSurvey = structuredClone(survey);
modifiedSurvey.endings.forEach((ending) => {
if (ending.type === "endScreen") {
ending.headline = recallToHeadline(
ending.headline || {},
modifiedSurvey,
false,
language,
attributeClasses
);
}
});
return modifiedSurvey;
};
export const actionObjectiveOptions: TComboboxOption[] = [
{ label: "Calculate", value: "calculate" },
{ label: "Require Answer", value: "requireAnswer" },
{ label: "Jump to question", value: "jumpToQuestion" },
];
const getQuestionOperatorOptions = (question: TSurveyQuestion): TComboboxOption[] => {
let options: TLogicRuleOption;
if (question.type === "openText") {
const inputType = question.inputType === "number" ? "number" : "text";
options = logicRules.question[`openText.${inputType}`].options;
} else {
options = logicRules.question[question.type].options;
}
if (question.required) {
options = options.filter((option) => option.value !== "isSkipped") as TLogicRuleOption;
}
return options;
};
export const getDefaultOperatorForQuestion = (question: TSurveyQuestion): TSurveyLogicConditionsOperator => {
const options = getQuestionOperatorOptions(question);
return options[0].value.toString() as TSurveyLogicConditionsOperator;
};
export const getConditionOperatorOptions = (
condition: TSingleCondition,
localSurvey: TSurvey
): TComboboxOption[] => {
if (condition.leftOperand.type === "variable") {
const variables = localSurvey.variables ?? [];
const variableType =
variables.find((variable) => variable.id === condition.leftOperand.value)?.type || "text";
return logicRules[`variable.${variableType}`].options;
} else if (condition.leftOperand.type === "hiddenField") {
return logicRules.hiddenField.options;
} else if (condition.leftOperand.type === "question") {
const questions = localSurvey.questions ?? [];
const question = questions.find((question) => question.id === condition.leftOperand.value);
if (!question) return [];
return getQuestionOperatorOptions(question);
}
return [];
};
export const getMatchValueProps = (
condition: TSingleCondition,
localSurvey: TSurvey
): {
show?: boolean;
showInput?: boolean;
inputType?: HTMLInputTypeAttribute;
options: TComboboxGroupedOption[];
} => {
if (
[
"isAccepted",
"isBooked",
"isClicked",
"isCompletelySubmitted",
"isPartiallySubmitted",
"isSkipped",
"isSubmitted",
].includes(condition.operator)
) {
return { show: false, options: [] };
}
let questions = localSurvey.questions ?? [];
let variables = localSurvey.variables ?? [];
let hiddenFields = localSurvey.hiddenFields?.fieldIds ?? [];
const selectedQuestion = questions.find((question) => question.id === condition.leftOperand.value);
const selectedVariable = variables.find((variable) => variable.id === condition.leftOperand.value);
if (condition.leftOperand.type === "question") {
questions = questions.filter((question) => question.id !== condition.leftOperand.value);
} else if (condition.leftOperand.type === "variable") {
variables = variables.filter((variable) => variable.id !== condition.leftOperand.value);
} else if (condition.leftOperand.type === "hiddenField") {
hiddenFields = hiddenFields.filter((field) => field !== condition.leftOperand.value);
}
if (condition.leftOperand.type === "question") {
if (selectedQuestion?.type === TSurveyQuestionTypeEnum.OpenText) {
const allowedQuestionTypes = [TSurveyQuestionTypeEnum.OpenText];
if (selectedQuestion.inputType === "number") {
allowedQuestionTypes.push(TSurveyQuestionTypeEnum.Rating, TSurveyQuestionTypeEnum.NPS);
}
if (["equals", "doesNotEqual"].includes(condition.operator)) {
if (selectedQuestion.inputType !== "number") {
allowedQuestionTypes.push(
TSurveyQuestionTypeEnum.Date,
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
TSurveyQuestionTypeEnum.MultipleChoiceMulti
);
}
}
const allowedQuestions = questions.filter((question) => allowedQuestionTypes.includes(question.type));
const questionOptions = allowedQuestions.map((question) => {
return {
icon: questionIconMapping[question.type],
label: getLocalizedValue(question.headline, "default"),
value: question.id,
meta: {
type: "question",
},
};
});
const variableOptions = variables
.filter((variable) =>
selectedQuestion.inputType !== "number" ? variable.type === "text" : variable.type === "number"
)
.map((variable) => {
return {
icon: variable.type === "number" ? FileDigitIcon : FileType2Icon,
label: variable.name,
value: variable.id,
meta: {
type: "variable",
},
};
});
const hiddenFieldsOptions = hiddenFields.map((field) => {
return {
icon: EyeOffIcon,
label: field,
value: field,
meta: {
type: "hiddenField",
},
};
});
const groupedOptions: TComboboxGroupedOption[] = [];
if (questionOptions.length > 0) {
groupedOptions.push({
label: "Questions",
value: "questions",
options: questionOptions,
});
}
if (variableOptions.length > 0) {
groupedOptions.push({
label: "Variables",
value: "variables",
options: variableOptions,
});
}
if (hiddenFieldsOptions.length > 0) {
groupedOptions.push({
label: "Hidden Fields",
value: "hiddenFields",
options: hiddenFieldsOptions,
});
}
return {
show: true,
showInput: true,
inputType: selectedQuestion.inputType === "number" ? "number" : "text",
options: groupedOptions,
};
} else if (
selectedQuestion?.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
selectedQuestion?.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
) {
const choices = selectedQuestion.choices.map((choice) => {
return {
label: getLocalizedValue(choice.label, "default"),
value: choice.id,
meta: {
type: "static",
},
};
});
return {
show: true,
showInput: false,
options: [{ label: "Choices", value: "choices", options: choices }],
};
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.PictureSelection) {
const choices = selectedQuestion.choices.map((choice, idx) => {
return {
imgSrc: choice.imageUrl,
label: `Picture ${idx + 1}`,
value: choice.id,
meta: {
type: "static",
},
};
});
return {
show: true,
showInput: false,
options: [{ label: "Choices", value: "choices", options: choices }],
};
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.Rating) {
const choices = Array.from({ length: selectedQuestion.range }, (_, idx) => {
return {
label: `${idx + 1}`,
value: idx + 1,
meta: {
type: "static",
},
};
});
const numberVariables = variables.filter((variable) => variable.type === "number");
const variableOptions = numberVariables.map((variable) => {
return {
icon: FileDigitIcon,
label: variable.name,
value: variable.id,
meta: {
type: "variable",
},
};
});
const groupedOptions: TComboboxGroupedOption[] = [];
if (choices.length > 0) {
groupedOptions.push({
label: "Choices",
value: "choices",
options: choices,
});
}
if (variableOptions.length > 0) {
groupedOptions.push({
label: "Variables",
value: "variables",
options: variableOptions,
});
}
return {
show: true,
showInput: false,
options: groupedOptions,
};
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.NPS) {
const choices = Array.from({ length: 11 }, (_, idx) => {
return {
label: `${idx}`,
value: idx,
meta: {
type: "static",
},
};
});
const numberVariables = variables.filter((variable) => variable.type === "number");
const variableOptions = numberVariables.map((variable) => {
return {
icon: FileDigitIcon,
label: variable.name,
value: variable.id,
meta: {
type: "variable",
},
};
});
const groupedOptions: TComboboxGroupedOption[] = [];
if (choices.length > 0) {
groupedOptions.push({
label: "Choices",
value: "choices",
options: choices,
});
}
if (variableOptions.length > 0) {
groupedOptions.push({
label: "Variables",
value: "variables",
options: variableOptions,
});
}
return {
show: true,
showInput: false,
options: groupedOptions,
};
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.Date) {
const openTextQuestions = questions.filter((question) =>
[TSurveyQuestionTypeEnum.OpenText, TSurveyQuestionTypeEnum.Date].includes(question.type)
);
const questionOptions = openTextQuestions.map((question) => {
return {
icon: questionIconMapping[question.type],
label: getLocalizedValue(question.headline, "default"),
value: question.id,
meta: {
type: "question",
},
};
});
const stringVariables = variables.filter((variable) => variable.type === "text");
const variableOptions = stringVariables.map((variable) => {
return {
icon: FileType2Icon,
label: variable.name,
value: variable.id,
meta: {
type: "variable",
},
};
});
const hiddenFieldsOptions = hiddenFields.map((field) => {
return {
icon: EyeOffIcon,
label: field,
value: field,
meta: {
type: "hiddenField",
},
};
});
const groupedOptions: TComboboxGroupedOption[] = [];
if (questionOptions.length > 0) {
groupedOptions.push({
label: "Questions",
value: "questions",
options: questionOptions,
});
}
if (variableOptions.length > 0) {
groupedOptions.push({
label: "Variables",
value: "variables",
options: variableOptions,
});
}
if (hiddenFieldsOptions.length > 0) {
groupedOptions.push({
label: "Hidden Fields",
value: "hiddenFields",
options: hiddenFieldsOptions,
});
}
return {
show: true,
showInput: true,
inputType: "date",
options: groupedOptions,
};
}
} else if (condition.leftOperand.type === "variable") {
if (selectedVariable?.type === "text") {
const allowedQuestionTypes = [
TSurveyQuestionTypeEnum.OpenText,
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
];
if (["equals", "doesNotEqual"].includes(condition.operator)) {
allowedQuestionTypes.push(TSurveyQuestionTypeEnum.MultipleChoiceMulti, TSurveyQuestionTypeEnum.Date);
}
const allowedQuestions = questions.filter((question) => allowedQuestionTypes.includes(question.type));
const questionOptions = allowedQuestions.map((question) => {
return {
icon: questionIconMapping[question.type],
label: getLocalizedValue(question.headline, "default"),
value: question.id,
meta: {
type: "question",
},
};
});
const stringVariables = variables.filter((variable) => variable.type === "text");
const variableOptions = stringVariables.map((variable) => {
return {
icon: FileType2Icon,
label: variable.name,
value: variable.id,
meta: {
type: "variable",
},
};
});
const hiddenFieldsOptions = hiddenFields.map((field) => {
return {
icon: EyeOffIcon,
label: field,
value: field,
meta: {
type: "hiddenField",
},
};
});
const groupedOptions: TComboboxGroupedOption[] = [];
if (questionOptions.length > 0) {
groupedOptions.push({
label: "Questions",
value: "questions",
options: questionOptions,
});
}
if (variableOptions.length > 0) {
groupedOptions.push({
label: "Variables",
value: "variables",
options: variableOptions,
});
}
if (hiddenFieldsOptions.length > 0) {
groupedOptions.push({
label: "Hidden Fields",
value: "hiddenFields",
options: hiddenFieldsOptions,
});
}
return {
show: true,
showInput: true,
inputType: "text",
options: groupedOptions,
};
} else if (selectedVariable?.type === "number") {
const allowedQuestions = questions.filter((question) =>
[TSurveyQuestionTypeEnum.Rating, TSurveyQuestionTypeEnum.NPS].includes(question.type)
);
const questionOptions = allowedQuestions.map((question) => {
return {
icon: questionIconMapping[question.type],
label: getLocalizedValue(question.headline, "default"),
value: question.id,
meta: {
type: "question",
},
};
});
const numberVariables = variables.filter((variable) => variable.type === "number");
const variableOptions = numberVariables.map((variable) => {
return {
icon: FileDigitIcon,
label: variable.name,
value: variable.id,
meta: {
type: "variable",
},
};
});
const hiddenFieldsOptions = hiddenFields.map((field) => {
return {
icon: EyeOffIcon,
label: field,
value: field,
meta: {
type: "hiddenField",
},
};
});
const groupedOptions: TComboboxGroupedOption[] = [];
if (questionOptions.length > 0) {
groupedOptions.push({
label: "Questions",
value: "questions",
options: questionOptions,
});
}
if (variableOptions.length > 0) {
groupedOptions.push({
label: "Variables",
value: "variables",
options: variableOptions,
});
}
if (hiddenFieldsOptions.length > 0) {
groupedOptions.push({
label: "Hidden Fields",
value: "hiddenFields",
options: hiddenFieldsOptions,
});
}
return {
show: true,
showInput: true,
inputType: "number",
options: groupedOptions,
};
}
} else if (condition.leftOperand.type === "hiddenField") {
const allowedQuestionTypes = [
TSurveyQuestionTypeEnum.OpenText,
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
];
if (["equals", "doesNotEqual"].includes(condition.operator)) {
allowedQuestionTypes.push(TSurveyQuestionTypeEnum.MultipleChoiceMulti, TSurveyQuestionTypeEnum.Date);
}
const allowedQuestions = questions.filter((question) => allowedQuestionTypes.includes(question.type));
const questionOptions = allowedQuestions.map((question) => {
return {
icon: questionIconMapping[question.type],
label: getLocalizedValue(question.headline, "default"),
value: question.id,
meta: {
type: "question",
},
};
});
const variableOptions = variables
.filter((variable) => variable.type === "text")
.map((variable) => {
return {
icon: FileType2Icon,
label: variable.name,
value: variable.id,
meta: {
type: "variable",
},
};
});
const hiddenFieldsOptions = hiddenFields.map((field) => {
return {
icon: EyeOffIcon,
label: field,
value: field,
meta: {
type: "hiddenField",
},
};
});
const groupedOptions: TComboboxGroupedOption[] = [];
if (questionOptions.length > 0) {
groupedOptions.push({
label: "Questions",
value: "questions",
options: questionOptions,
});
}
if (variableOptions.length > 0) {
groupedOptions.push({
label: "Variables",
value: "variables",
options: variableOptions,
});
}
if (hiddenFieldsOptions.length > 0) {
groupedOptions.push({
label: "Hidden Fields",
value: "hiddenFields",
options: hiddenFieldsOptions,
});
}
return {
show: true,
showInput: true,
inputType: "text",
options: groupedOptions,
};
}
return { show: false, options: [] };
};
export const getActionOperatorOptions = (variableType?: TSurveyVariable["type"]): TComboboxOption[] => {
if (variableType === "number") {
return [
{
label: "Add +",
value: "add",
},
{
label: "Subtract -",
value: "subtract",
},
{
label: "Multiply *",
value: "multiply",
},
{
label: "Divide /",
value: "divide",
},
{
label: "Assign =",
value: "assign",
},
];
} else if (variableType === "text") {
return [
{
label: "Assign =",
value: "assign",
},
{
label: "Concat +",
value: "concat",
},
];
}
return [];
};
const isUsedInLeftOperand = (
leftOperand: TLeftOperand,
type: "question" | "hiddenField" | "variable",
id: string
): boolean => {
switch (type) {
case "question":
return leftOperand.type === "question" && leftOperand.value === id;
case "hiddenField":
return leftOperand.type === "hiddenField" && leftOperand.value === id;
case "variable":
return leftOperand.type === "variable" && leftOperand.value === id;
default:
return false;
}
};
const isUsedInRightOperand = (
rightOperand: TRightOperand,
type: "question" | "hiddenField" | "variable",
id: string
): boolean => {
switch (type) {
case "question":
return rightOperand.type === "question" && rightOperand.value === id;
case "hiddenField":
return rightOperand.type === "hiddenField" && rightOperand.value === id;
case "variable":
return rightOperand.type === "variable" && rightOperand.value === id;
default:
return false;
}
};
export const findQuestionUsedInLogic = (survey: TSurvey, questionId: string): number => {
const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => {
if (isConditionGroup(condition)) {
// It's a TConditionGroup
return condition.conditions.some(isUsedInCondition);
} else {
// It's a TSingleCondition
return (
(condition.rightOperand && isUsedInRightOperand(condition.rightOperand, "question", questionId)) ||
isUsedInLeftOperand(condition.leftOperand, "question", questionId)
);
}
};
const isUsedInAction = (action: TSurveyLogicAction): boolean => {
return (
(action.objective === "jumpToQuestion" && action.target === questionId) ||
(action.objective === "requireAnswer" && action.target === questionId)
);
};
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
return isUsedInCondition(logicRule.conditions) || logicRule.actions.some(isUsedInAction);
};
return survey.questions.findIndex(
(question) => question.logic && question.id !== questionId && question.logic.some(isUsedInLogicRule)
);
};
export const findOptionUsedInLogic = (survey: TSurvey, questionId: string, optionId: string): number => {
const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => {
if (isConditionGroup(condition)) {
// It's a TConditionGroup
return condition.conditions.some(isUsedInCondition);
} else {
// It's a TSingleCondition
return isUsedInOperand(condition);
}
};
const isUsedInOperand = (condition: TSingleCondition): boolean => {
if (condition.leftOperand.type === "question" && condition.leftOperand.value === questionId) {
if (condition.rightOperand && condition.rightOperand.type === "static") {
if (Array.isArray(condition.rightOperand.value)) {
return condition.rightOperand.value.includes(optionId);
} else {
return condition.rightOperand.value === optionId;
}
}
}
return false;
};
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
return isUsedInCondition(logicRule.conditions);
};
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
};
export const findVariableUsedInLogic = (survey: TSurvey, variableId: string): number => {
const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => {
if (isConditionGroup(condition)) {
// It's a TConditionGroup
return condition.conditions.some(isUsedInCondition);
} else {
// It's a TSingleCondition
return (
(condition.rightOperand && isUsedInRightOperand(condition.rightOperand, "variable", variableId)) ||
isUsedInLeftOperand(condition.leftOperand, "variable", variableId)
);
}
};
const isUsedInAction = (action: TSurveyLogicAction): boolean => {
return action.objective === "calculate" && action.variableId === variableId;
};
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
return isUsedInCondition(logicRule.conditions) || logicRule.actions.some(isUsedInAction);
};
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
};
export const findHiddenFieldUsedInLogic = (survey: TSurvey, hiddenFieldId: string): number => {
const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => {
if (isConditionGroup(condition)) {
// It's a TConditionGroup
return condition.conditions.some(isUsedInCondition);
} else {
// It's a TSingleCondition
return (
(condition.rightOperand &&
isUsedInRightOperand(condition.rightOperand, "hiddenField", hiddenFieldId)) ||
isUsedInLeftOperand(condition.leftOperand, "hiddenField", hiddenFieldId)
);
}
};
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
return isUsedInCondition(logicRule.conditions);
};
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
};

View File

@@ -22,7 +22,7 @@ export const generateMetadata = async ({ params }) => {
};
};
const Page = async ({ params }) => {
const Page = async ({ params, searchParams }) => {
const [
survey,
product,
@@ -70,6 +70,8 @@ const Page = async ({ params }) => {
return <ErrorComponent />;
}
const isCxMode = searchParams.mode === "cx";
return (
<SurveyEditor
survey={survey}
@@ -87,6 +89,7 @@ const Page = async ({ params }) => {
plan={organization.billing.plan}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isUnsplashConfigured={UNSPLASH_ACCESS_KEY ? true : false}
isCxMode={isCxMode}
/>
);
};

View File

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

View File

@@ -1,4 +1,4 @@
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
import { TagIcon } from "lucide-react";
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
import { PageHeader } from "@formbricks/ui/PageHeader";
@@ -8,7 +8,7 @@ const Loading = () => {
<>
<PageContentWrapper>
<PageHeader pageTitle="People">
<PeopleSecondaryNavigation activeId="attributes" loading />
<PersonSecondaryNavigation activeId="attributes" loading />
</PageHeader>
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-5 content-center border-b text-left text-sm font-semibold text-slate-900">

View File

@@ -1,4 +1,4 @@
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
import { CircleHelpIcon } from "lucide-react";
import { Metadata } from "next";
import { notFound } from "next/navigation";
@@ -42,7 +42,7 @@ const Page = async ({ params }) => {
return (
<PageContentWrapper>
<PageHeader pageTitle="People" cta={HowToAddAttributesButton}>
<PeopleSecondaryNavigation activeId="attributes" environmentId={params.environmentId} />
<PersonSecondaryNavigation activeId="attributes" environmentId={params.environmentId} />
</PageHeader>
<AttributeClassesTable attributeClasses={attributeClasses} />
</PageContentWrapper>

View File

@@ -1,11 +1,11 @@
"use client";
import { deletePersonAction } from "@/app/(app)/environments/[environmentId]/(people)/people/[personId]/actions";
import { TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
import { deletePersonAction } from "@formbricks/ui/DataTable/actions";
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
interface DeletePersonButtonProps {

View File

@@ -0,0 +1,43 @@
"use server";
import { z } from "zod";
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
import { getAttributes } from "@formbricks/lib/attribute/service";
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
import { getPeople } from "@formbricks/lib/person/service";
import { ZId } from "@formbricks/types/common";
const ZGetPersonsAction = z.object({
environmentId: ZId,
page: z.number(),
});
export const getPersonsAction = authenticatedActionClient
.schema(ZGetPersonsAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
rules: ["environment", "read"],
});
return getPeople(parsedInput.environmentId, parsedInput.page);
});
const ZGetPersonAttributesAction = z.object({
environmentId: ZId,
personId: ZId,
});
export const getPersonAttributesAction = authenticatedActionClient
.schema(ZGetPersonAttributesAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
rules: ["environment", "read"],
});
return getAttributes(parsedInput.personId);
});

View File

@@ -1,38 +0,0 @@
import Link from "next/link";
import React from "react";
import { getAttributes } from "@formbricks/lib/attribute/service";
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
import { TPerson } from "@formbricks/types/people";
import { PersonAvatar } from "@formbricks/ui/Avatars";
export const PersonCard = async ({ person }: { person: TPerson }) => {
const attributes = await getAttributes(person.id);
return (
<Link
href={`/environments/${person.environmentId}/people/${person.id}`}
key={person.id}
className="w-full">
<div className="m-2 grid h-16 grid-cols-7 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="flex items-center">
<div className="ph-no-capture h-10 w-10 flex-shrink-0">
<PersonAvatar personId={person.id} />
</div>
<div className="ml-4">
<div className="ph-no-capture font-medium text-slate-900">
<span>{getPersonIdentifier({ id: person.id, userId: person.userId }, attributes)}</span>
</div>
</div>
</div>
</div>
<div className="col-span-2 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{person.userId}</div>
</div>
<div className="col-span-2 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{attributes.email}</div>
</div>
</div>
</Link>
);
};

View File

@@ -0,0 +1,87 @@
"use client";
import { getPersonsAction } from "@/app/(app)/environments/[environmentId]/(people)/people/actions";
import { PersonTable } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonTable";
import { useEffect, useState } from "react";
import React from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TPersonWithAttributes } from "@formbricks/types/people";
interface PersonDataViewProps {
environment: TEnvironment;
personCount: number;
itemsPerPage: number;
}
export const PersonDataView = ({ environment, personCount, itemsPerPage }: PersonDataViewProps) => {
const [persons, setPersons] = useState<TPersonWithAttributes[]>([]);
const [pageNumber, setPageNumber] = useState<number>(1);
const [totalPersons, setTotalPersons] = useState<number>(0);
const [isDataLoaded, setIsDataLoaded] = useState<boolean>(false);
const [hasMore, setHasMore] = useState<boolean>(false);
const [loadingNextPage, setLoadingNextPage] = useState<boolean>(false);
useEffect(() => {
setTotalPersons(personCount);
setHasMore(pageNumber < Math.ceil(personCount / itemsPerPage));
const fetchData = async () => {
try {
const getPersonActionData = await getPersonsAction({
environmentId: environment.id,
page: pageNumber,
});
if (getPersonActionData?.data) {
setPersons(getPersonActionData.data);
}
} catch (error) {
console.error("Error fetching people data:", error);
} finally {
setIsDataLoaded(true);
}
};
fetchData();
}, [pageNumber, personCount, itemsPerPage, environment.id]);
const fetchNextPage = async () => {
if (hasMore && !loadingNextPage) {
setLoadingNextPage(true);
const getPersonsActionData = await getPersonsAction({
environmentId: environment.id,
page: pageNumber,
});
if (getPersonsActionData?.data) {
const newData = getPersonsActionData.data;
setPersons((prevPersonsData) => [...prevPersonsData, ...newData]);
}
setPageNumber((prevPage) => prevPage + 1);
setHasMore(pageNumber + 1 < Math.ceil(totalPersons / itemsPerPage));
setLoadingNextPage(false);
}
};
const deletePersons = (personIds: string[]) => {
setPersons((prevPersons) => prevPersons.filter((p) => !personIds.includes(p.id)));
};
const personTableData = persons.map((person) => ({
id: person.id,
userId: person.userId,
email: person.attributes.email,
createdAt: person.createdAt,
attributes: person.attributes,
personId: person.id,
}));
return (
<PersonTable
data={personTableData}
fetchNextPage={fetchNextPage}
hasMore={hasMore}
isDataLoaded={isDataLoaded}
deletePersons={deletePersons}
environmentId={environment.id}
/>
);
};

View File

@@ -2,17 +2,17 @@ import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { TProductConfigChannel } from "@formbricks/types/product";
import { SecondaryNavigation } from "@formbricks/ui/SecondaryNavigation";
interface PeopleSegmentsTabsProps {
interface PersonSecondaryNavigationProps {
activeId: string;
environmentId?: string;
loading?: boolean;
}
export const PeopleSecondaryNavigation = async ({
export const PersonSecondaryNavigation = async ({
activeId,
environmentId,
loading,
}: PeopleSegmentsTabsProps) => {
}: PersonSecondaryNavigationProps) => {
let currentProductChannel: TProductConfigChannel = null;
if (!loading && environmentId) {

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