Compare commits

..

96 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
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
Piyush Gupta 0bf5e0fd4c fix: comparison fixes 2024-09-12 15:57:11 +05:30
Piyush Gupta 887c5c0eef feat: adds image support in inputCombobox 2024-09-11 16:48:23 +05:30
Piyush Gupta af4ae38564 fix: input clear on operand change 2024-09-11 14:10:26 +05:30
Piyush Gupta 6b829744a1 fix: dhru review changes 2024-09-10 19:47:12 +05:30
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
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
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
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
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
Piyush Gupta 6ec0861c49 fix: inputCombobox styling 2024-09-05 15:19:48 +05:30
Piyush Gupta 8542320a8e fix build error 2024-09-05 12:54:32 +05:30
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
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
264 changed files with 15090 additions and 4137 deletions
+1
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
@@ -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
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

+171
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

@@ -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",
+1
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" },
],
},
{
@@ -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 (
@@ -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} />;
};
@@ -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 };
};
@@ -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(),
];
@@ -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;
+1 -1
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",
@@ -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`,
},
];
@@ -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;
@@ -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);
@@ -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}
@@ -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>
);
};
@@ -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}
@@ -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}
@@ -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>
);
}
@@ -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" && (
@@ -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}
@@ -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}
/>
@@ -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>{" "}
@@ -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>
);
};
}
@@ -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);
@@ -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>
);
};
@@ -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>
);
}
@@ -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;
@@ -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[]) => {
@@ -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>
@@ -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>
@@ -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}
@@ -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>
);
@@ -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;
@@ -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}
@@ -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}
/>
)}
@@ -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;
@@ -183,7 +192,9 @@ export const SurveyMenuBar = ({
toast.error(`${messageSplit} ${invalidLanguageLabels.join(", ")}`);
} else {
toast.error(currentError.message);
toast.error(currentError.message, {
className: "w-fit !max-w-md",
});
}
return false;
@@ -233,6 +244,7 @@ export const SurveyMenuBar = ({
}
const segment = await handleSegmentUpdate();
clearSurveyLocalStorage();
const updatedSurveyResponse = await updateSurveyAction({ ...localSurvey, segment });
setIsSurveySaving(false);
@@ -278,6 +290,7 @@ export const SurveyMenuBar = ({
}
const status = localSurvey.runOnDate ? "scheduled" : "inProgress";
const segment = await handleSegmentUpdate();
clearSurveyLocalStorage();
await updateSurveyAction({
...localSurvey,
@@ -296,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}
@@ -341,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}
@@ -379,7 +397,7 @@ export const SurveyMenuBar = ({
disabled={isSurveySaving || containsEmptyTriggers}
loading={isSurveyPublishing}
onClick={handleSurveyPublish}>
Publish
{isCxMode ? "Save & Close" : "Publish"}
</Button>
)}
</div>
@@ -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}
@@ -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>
)}
@@ -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"];
@@ -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;
}
});
};
@@ -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));
};
@@ -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}
/>
);
};
@@ -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">
@@ -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>
@@ -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 {
@@ -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);
});
@@ -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>
);
};
@@ -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}
/>
);
};
@@ -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) {
@@ -0,0 +1,240 @@
import { generatePersonTableColumns } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonTableColumn";
import {
DndContext,
type DragEndEvent,
KeyboardSensor,
MouseSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable";
import { VisibilityState, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { TPersonTableData } from "@formbricks/types/people";
import { Button } from "@formbricks/ui/Button";
import { DataTableHeader, DataTableSettingsModal, DataTableToolbar } from "@formbricks/ui/DataTable";
import { getCommonPinningStyles } from "@formbricks/ui/DataTable/lib/utils";
import { Skeleton } from "@formbricks/ui/Skeleton";
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@formbricks/ui/Table";
interface PersonTableProps {
data: TPersonTableData[];
fetchNextPage: () => void;
hasMore: boolean;
deletePersons: (personIds: string[]) => void;
isDataLoaded: boolean;
environmentId: string;
}
export const PersonTable = ({
data,
fetchNextPage,
hasMore,
deletePersons,
isDataLoaded,
environmentId,
}: PersonTableProps) => {
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [columnOrder, setColumnOrder] = useState<string[]>([]);
const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false);
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
const [rowSelection, setRowSelection] = useState({});
const router = useRouter();
// Generate columns
const columns = useMemo(() => generatePersonTableColumns(isExpanded ?? false), [isExpanded]);
// Load saved settings from localStorage
useEffect(() => {
const savedColumnOrder = localStorage.getItem(`${environmentId}-columnOrder`);
const savedColumnVisibility = localStorage.getItem(`${environmentId}-columnVisibility`);
const savedExpandedSettings = localStorage.getItem(`${environmentId}-rowExpand`);
if (savedColumnOrder && JSON.parse(savedColumnOrder).length > 0) {
setColumnOrder(JSON.parse(savedColumnOrder));
} else {
setColumnOrder(table.getAllLeafColumns().map((d) => d.id));
}
if (savedColumnVisibility) {
setColumnVisibility(JSON.parse(savedColumnVisibility));
}
if (savedExpandedSettings !== null) {
setIsExpanded(JSON.parse(savedExpandedSettings));
}
}, [environmentId]);
// Save settings to localStorage when they change
useEffect(() => {
if (columnOrder.length > 0) {
localStorage.setItem(`${environmentId}-columnOrder`, JSON.stringify(columnOrder));
}
if (Object.keys(columnVisibility).length > 0) {
localStorage.setItem(`${environmentId}-columnVisibility`, JSON.stringify(columnVisibility));
}
if (isExpanded !== null) {
localStorage.setItem(`${environmentId}-rowExpand`, JSON.stringify(isExpanded));
}
}, [columnOrder, columnVisibility, isExpanded, environmentId]);
// Initialize DnD sensors
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
);
// Memoize table data and columns
const tableData: TPersonTableData[] = useMemo(
() => (!isDataLoaded ? Array(10).fill({}) : data),
[data, isDataLoaded]
);
const tableColumns = useMemo(
() =>
!isDataLoaded
? columns.map((column) => ({
...column,
cell: () => (
<Skeleton className="w-full">
<div className="h-6"></div>
</Skeleton>
),
}))
: columns,
[columns, data]
);
// React Table instance
const table = useReactTable({
data: tableData,
columns: tableColumns,
getRowId: (originalRow) => originalRow.personId,
getCoreRowModel: getCoreRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onColumnOrderChange: setColumnOrder,
columnResizeMode: "onChange",
columnResizeDirection: "ltr",
manualPagination: true,
defaultColumn: { size: 300 },
state: {
columnOrder,
columnVisibility,
rowSelection,
columnPinning: {
left: ["select", "createdAt"],
},
},
});
// Handle column drag end
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (active && over && active.id !== over.id) {
setColumnOrder((prevOrder) => {
const oldIndex = prevOrder.indexOf(active.id as string);
const newIndex = prevOrder.indexOf(over.id as string);
return arrayMove(prevOrder, oldIndex, newIndex);
});
}
};
return (
<div>
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}>
<DataTableToolbar
setIsExpanded={setIsExpanded}
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
isExpanded={isExpanded ?? false}
table={table}
deleteRows={deletePersons}
type="person"
/>
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-200">
<div className="w-full overflow-x-auto">
<Table style={{ width: table.getCenterTotalSize(), tableLayout: "fixed" }}>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => (
<DataTableHeader
key={header.id}
header={header}
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
/>
))}
</SortableContext>
</tr>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={"group cursor-pointer"}>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
onClick={() => {
if (cell.column.id === "select") return;
router.push(`/environments/${environmentId}/people/${row.id}`);
}}
style={cell.column.id === "select" ? getCommonPinningStyles(cell.column) : {}}
className={cn(
"border-slate-200 bg-white shadow-none group-hover:bg-slate-100",
row.getIsSelected() && "bg-slate-100",
{
"border-r": !cell.column.getIsLastColumn(),
"border-l": !cell.column.getIsFirstColumn(),
}
)}>
<div
className={cn("flex flex-1 items-center truncate", isExpanded ? "h-full" : "h-10")}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
</TableCell>
))}
</TableRow>
))}
{table.getRowModel().rows.length === 0 && (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
{data && hasMore && data.length > 0 && (
<div className="mt-4 flex justify-center">
<Button onClick={fetchNextPage} className="bg-blue-500 text-white">
Load More
</Button>
</div>
)}
<DataTableSettingsModal
open={isTableSettingsModalOpen}
setOpen={setIsTableSettingsModalOpen}
table={table}
columnOrder={columnOrder}
handleDragEnd={handleDragEnd}
/>
</DndContext>
</div>
);
};
@@ -0,0 +1,83 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { cn } from "@formbricks/lib/cn";
import { TPersonTableData } from "@formbricks/types/people";
import { getSelectionColumn } from "@formbricks/ui/DataTable";
export const generatePersonTableColumns = (isExpanded: boolean): ColumnDef<TPersonTableData>[] => {
const dateColumn: ColumnDef<TPersonTableData> = {
accessorKey: "createdAt",
header: () => "Date",
size: 200,
cell: ({ row }) => {
const isoDateString = row.original.createdAt;
const date = new Date(isoDateString);
const formattedDate = date.toLocaleString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
const formattedTime = date.toLocaleString(undefined, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
return (
<div>
<p className="truncate text-slate-900">{formattedDate}</p>
<p className="truncate text-slate-900">{formattedTime}</p>
</div>
);
},
};
const userColumn: ColumnDef<TPersonTableData> = {
accessorKey: "user",
header: "User",
cell: ({ row }) => {
const personId = row.original.personId;
return <p className="truncate text-slate-900">{personId}</p>;
},
};
const userIdColumn: ColumnDef<TPersonTableData> = {
accessorKey: "userId",
header: "User ID",
cell: ({ row }) => {
const userId = row.original.userId;
return <p className="truncate text-slate-900">{userId}</p>;
},
};
const emailColumn: ColumnDef<TPersonTableData> = {
accessorKey: "email",
header: "Email",
};
const attributesColumn: ColumnDef<TPersonTableData> = {
accessorKey: "attributes",
header: "Attributes",
cell: ({ row }) => {
const attributes = row.original.attributes;
// Handle cases where attributes are missing or empty
if (!attributes || Object.keys(attributes).length === 0) return null;
return (
<div className={cn(!isExpanded && "flex space-x-2")}>
{Object.entries(attributes).map(([key, value]) => (
<div key={key} className="flex space-x-2">
<div className="font-semibold">{key}</div> : <div>{value}</div>
</div>
))}
</div>
);
},
};
return [getSelectionColumn(), dateColumn, userColumn, userIdColumn, emailColumn, attributesColumn];
};
@@ -1,55 +0,0 @@
export const Pagination = ({ environmentId, currentPage, totalItems, itemsPerPage }) => {
const totalPages = Math.ceil(totalItems / itemsPerPage);
const previousPageLink =
currentPage === 1 ? "#" : `/environments/${environmentId}/people?page=${currentPage - 1}`;
const nextPageLink =
currentPage === totalPages ? "#" : `/environments/${environmentId}/people?page=${currentPage + 1}`;
return (
<nav aria-label="Page navigation" className="flex justify-center">
<ul className="mt-4 inline-flex -space-x-px text-sm">
<li>
<a
href={previousPageLink}
className={`ml-0 flex h-8 items-center justify-center rounded-l-lg border border-slate-300 bg-white px-3 text-slate-500 ${
currentPage === 1
? "cursor-not-allowed opacity-50"
: "hover:bg-slate-100 hover:text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-white"
}`}>
Previous
</a>
</li>
{Array.from({ length: totalPages }).map((_, idx) => {
const pageNum = idx + 1;
const pageLink = `/environments/${environmentId}/people?page=${pageNum}`;
return (
<li key={pageNum} className="hidden sm:block">
<a
href={pageNum === currentPage ? "#" : pageLink}
className={`flex h-8 items-center justify-center px-3 ${
pageNum === currentPage ? "bg-blue-50 text-green-500" : "bg-white text-slate-500"
} border border-slate-300 hover:bg-slate-100 hover:text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-white`}>
{pageNum}
</a>
</li>
);
})}
<li>
<a
href={nextPageLink}
className={`ml-0 flex h-8 items-center justify-center rounded-r-lg border border-slate-300 bg-white px-3 text-slate-500 ${
currentPage === totalPages
? "cursor-not-allowed opacity-50"
: "hover:bg-slate-100 hover:text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-white"
}`}>
Next
</a>
</li>
</ul>
</nav>
);
};
@@ -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 { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
import { PageHeader } from "@formbricks/ui/PageHeader";
@@ -7,7 +7,7 @@ const Loading = () => {
<>
<PageContentWrapper>
<PageHeader pageTitle="People">
<PeopleSecondaryNavigation activeId="people" loading />
<PersonSecondaryNavigation activeId="people" loading />
</PageHeader>
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
@@ -1,42 +1,20 @@
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
import { PersonDataView } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonDataView";
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
import { CircleHelpIcon } from "lucide-react";
import { ITEMS_PER_PAGE } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getPeople, getPeopleCount } from "@formbricks/lib/person/service";
import { TPerson } from "@formbricks/types/people";
import { getPersonCount } from "@formbricks/lib/person/service";
import { Button } from "@formbricks/ui/Button";
import { EmptySpaceFiller } from "@formbricks/ui/EmptySpaceFiller";
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
import { PageHeader } from "@formbricks/ui/PageHeader";
import { Pagination } from "@formbricks/ui/Pagination";
import { PersonCard } from "./components/PersonCard";
const Page = async ({
params,
searchParams,
}: {
params: { environmentId: string };
searchParams: { [key: string]: string | string[] | undefined };
}) => {
const pageNumber = searchParams.page ? parseInt(searchParams.page as string) : 1;
const [environment, totalPeople] = await Promise.all([
getEnvironment(params.environmentId),
getPeopleCount(params.environmentId),
]);
const Page = async ({ params }: { params: { environmentId: string } }) => {
const environment = await getEnvironment(params.environmentId);
const personCount = await getPersonCount(params.environmentId);
if (!environment) {
throw new Error("Environment not found");
}
const maxPageNumber = Math.ceil(totalPeople / ITEMS_PER_PAGE);
let hidePagination = false;
let people: TPerson[] = [];
if (pageNumber < 1 || pageNumber > maxPageNumber) {
people = [];
hidePagination = true;
} else {
people = await getPeople(params.environmentId, pageNumber);
}
const HowToAddPeopleButton = (
<Button
@@ -52,35 +30,9 @@ const Page = async ({
return (
<PageContentWrapper>
<PageHeader pageTitle="People" cta={HowToAddPeopleButton}>
<PeopleSecondaryNavigation activeId="people" environmentId={params.environmentId} />
<PersonSecondaryNavigation activeId="people" environmentId={params.environmentId} />
</PageHeader>
{people.length === 0 ? (
<EmptySpaceFiller
type="table"
environment={environment}
emptyMessage="Your users will appear here as soon as they use your app ⏲️"
noWidgetRequired={true}
/>
) : (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-7 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
<div className="col-span-3 pl-6">User</div>
<div className="col-span-2 hidden text-center sm:block">User ID</div>
<div className="col-span-2 hidden text-center sm:block">Email</div>
</div>
{people.map((person) => (
<PersonCard person={person} key={person.id} />
))}
</div>
)}
{hidePagination ? null : (
<Pagination
baseUrl={`/environments/${params.environmentId}/people`}
currentPage={pageNumber}
totalItems={totalPeople}
itemsPerPage={ITEMS_PER_PAGE}
/>
)}
<PersonDataView environment={environment} personCount={personCount} itemsPerPage={ITEMS_PER_PAGE} />
</PageContentWrapper>
);
};
@@ -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 { UsersIcon } 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="segments" loading />
<PersonSecondaryNavigation activeId="segments" loading />
</PageHeader>
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
@@ -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 { BasicCreateSegmentModal } from "@/app/(app)/environments/[environmentId]/(people)/segments/components/BasicCreateSegmentModal";
import { SegmentTable } from "@/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTable";
import { CreateSegmentModal } from "@formbricks/ee/advanced-targeting/components/create-segment-modal";
@@ -56,7 +56,7 @@ const Page = async ({ params }) => {
return (
<PageContentWrapper>
<PageHeader pageTitle="People" cta={renderCreateSegmentButton()}>
<PeopleSecondaryNavigation activeId="segments" environmentId={params.environmentId} />
<PersonSecondaryNavigation activeId="segments" environmentId={params.environmentId} />
</PageHeader>
<SegmentTable
segments={filteredSegments}
@@ -169,7 +169,7 @@ export const MainNavigation = ({
};
const handleAddProduct = (organizationId: string) => {
router.push(`/organizations/${organizationId}/products/new/channel`);
router.push(`/organizations/${organizationId}/products/new/mode`);
};
const mainNavigation = useMemo(
@@ -15,7 +15,7 @@ export const WidgetStatusIndicator = ({ environment, size, type }: WidgetStatusI
notImplemented: {
icon: AlertTriangleIcon,
title: `Your ${type} is not yet connected.`,
subtitle: `Connect your ${type} with Formbricks to get started. To run ${type === "app" ? "in-app" : "website"} surveys follow the setup guide.`,
subtitle: ``,
shortText: `Connect your ${type} with Formbricks`,
},
running: {
@@ -47,6 +47,7 @@ export type IntegrationModalInputs = {
table: string;
survey: string;
questions: string[];
includeVariables: boolean;
includeHiddenFields: boolean;
includeMetadata: boolean;
};
@@ -91,8 +92,9 @@ export const AddIntegrationModal = ({
const { index: _index, ...rest } = defaultData;
reset(rest);
fetchTable(defaultData.base);
setIncludeHiddenFields(defaultData.includeHiddenFields);
setIncludeMetadata(defaultData.includeMetadata);
setIncludeVariables(!!defaultData.includeVariables);
setIncludeHiddenFields(!!defaultData.includeHiddenFields);
setIncludeMetadata(!!defaultData.includeMetadata);
} else {
reset();
}
@@ -100,6 +102,12 @@ export const AddIntegrationModal = ({
}, [isEditMode]);
const survey = watch("survey");
const includeVariables = watch("includeVariables");
const setIncludeVariables = (includeVariables: boolean) => {
setValue("includeVariables", includeVariables);
};
const selectedSurvey = surveys.find((item) => item.id === survey);
const submitHandler = async (data: IntegrationModalInputs) => {
try {
@@ -130,6 +138,7 @@ export const AddIntegrationModal = ({
baseId: data.base,
tableId: data.table,
tableName: currentTable?.name ?? "",
includeVariables: data.includeVariables,
includeHiddenFields,
includeMetadata,
};
@@ -329,6 +338,8 @@ export const AddIntegrationModal = ({
</div>
</div>
<AdditionalIntegrationSettings
includeVariables={includeVariables}
setIncludeVariables={setIncludeVariables}
includeHiddenFields={includeHiddenFields}
includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}
@@ -108,6 +108,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
questions: data.questionIds,
survey: data.surveyId,
table: data.tableId,
includeVariables: !!data.includeVariables,
includeHiddenFields: !!data.includeHiddenFields,
includeMetadata: !!data.includeMetadata,
index,
@@ -62,6 +62,7 @@ export const AddIntegrationModal = ({
const [spreadsheetUrl, setSpreadsheetUrl] = useState("");
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const existingIntegrationData = googleSheetIntegration?.config?.data;
const [includeVariables, setIncludeVariables] = useState(false);
const [includeHiddenFields, setIncludeHiddenFields] = useState(false);
const [includeMetadata, setIncludeMetadata] = useState(false);
const googleSheetIntegrationData: TIntegrationGoogleSheetsInput = {
@@ -89,6 +90,7 @@ export const AddIntegrationModal = ({
})!
);
setSelectedQuestions(selectedIntegration.questionIds);
setIncludeVariables(!!selectedIntegration.includeVariables);
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
setIncludeMetadata(!!selectedIntegration.includeMetadata);
return;
@@ -127,6 +129,7 @@ export const AddIntegrationModal = ({
? "All questions"
: "Selected questions";
integrationData.createdAt = new Date();
integrationData.includeVariables = includeVariables;
integrationData.includeHiddenFields = includeHiddenFields;
integrationData.includeMetadata = includeMetadata;
if (selectedIntegration) {
@@ -256,6 +259,8 @@ export const AddIntegrationModal = ({
</div>
</div>
<AdditionalIntegrationSettings
includeVariables={includeVariables}
setIncludeVariables={setIncludeVariables}
includeHiddenFields={includeHiddenFields}
includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}
@@ -4,7 +4,6 @@ import {
TYPE_MAPPING,
UNSUPPORTED_TYPES_BY_NOTION,
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
import { questionTypes } from "@/app/lib/questions";
import NotionLogo from "@/images/notion.png";
import { PlusIcon, XIcon } from "lucide-react";
import Image from "next/image";
@@ -13,6 +12,7 @@ import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { questionTypes } from "@formbricks/lib/utils/questions";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TIntegrationInput } from "@formbricks/types/integration";
@@ -118,6 +118,13 @@ export const AddIntegrationModal = ({
}))
: [];
const variables =
selectedSurvey?.variables.map((variable) => ({
id: variable.id,
name: variable.name,
type: TSurveyQuestionTypeEnum.OpenText,
})) || [];
const hiddenFields = selectedSurvey?.hiddenFields.enabled
? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
id: fId,
@@ -133,7 +140,7 @@ export const AddIntegrationModal = ({
},
];
return [...questions, ...hiddenFields, ...Metadata];
return [...questions, ...variables, ...hiddenFields, ...Metadata];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSurvey?.id]);
@@ -49,6 +49,7 @@ export const AddChannelMappingModal = ({
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
const [selectedChannel, setSelectedChannel] = useState<TIntegrationItem | null>(null);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const [includeVariables, setIncludeVariables] = useState(false);
const [includeHiddenFields, setIncludeHiddenFields] = useState(false);
const [includeMetadata, setIncludeMetadata] = useState(false);
const existingIntegrationData = slackIntegration?.config?.data;
@@ -81,6 +82,7 @@ export const AddChannelMappingModal = ({
})!
);
setSelectedQuestions(selectedIntegration.questionIds);
setIncludeVariables(!!selectedIntegration.includeVariables);
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
setIncludeMetadata(!!selectedIntegration.includeMetadata);
return;
@@ -112,6 +114,7 @@ export const AddChannelMappingModal = ({
? "All questions"
: "Selected questions",
createdAt: new Date(),
includeVariables,
includeHiddenFields,
includeMetadata,
};
@@ -258,6 +261,8 @@ export const AddChannelMappingModal = ({
</div>
</div>
<AdditionalIntegrationSettings
includeVariables={includeVariables}
setIncludeVariables={setIncludeVariables}
includeHiddenFields={includeHiddenFields}
includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}

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