Compare commits

...

80 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
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
pandeymangg
350c895d8c fixes 2024-09-20 09:51:35 +05:30
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
118 changed files with 10954 additions and 1934 deletions

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

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

View File

@@ -106,6 +106,7 @@ export const navigation: Array<NavGroup> = [
links: [
{ title: "Access Roles", href: "/global/access-roles" },
{ title: "Styling Theme", href: "/global/styling-theme" },
{ title: "Logic Editor", href: "/global/logic-editor" },
],
},
{

View File

@@ -12,215 +12,395 @@ export const XMSurveyDefault: TXMTemplate = {
},
};
const NPSSurvey: TXMTemplate = {
...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 = {
...XMSurveyDefault,
name: "{{productName}}'s Rating Survey",
questions: [
{
id: createId(),
type: TSurveyQuestionTypeEnum.Rating,
logic: [{ value: 3, condition: "lessEqual", destination: "tk9wpw2gxgb8fa6pbpp3qq5l" }],
range: 5,
scale: "number",
headline: { default: "How do you like {{productName}}?" },
required: true,
lowerLabel: { default: "Extremely dissatisfied" },
upperLabel: { default: "Extremely satisfied" },
isColorCodingEnabled: false,
},
{
id: createId(),
html: { default: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>' },
type: TSurveyQuestionTypeEnum.CTA,
logic: [{ condition: "clicked", destination: 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: "tk9wpw2gxgb8fa6pbpp3qq5l",
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 = {
...XMSurveyDefault,
name: "{{productName}} CSAT",
questions: [
{
id: createId(),
type: TSurveyQuestionTypeEnum.Rating,
logic: [{ value: 3, condition: "lessEqual", destination: "vyo4mkw4ln95ts4ya7qp2tth" }],
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: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
logic: [{ condition: "submitted", destination: 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: "vyo4mkw4ln95ts4ya7qp2tth",
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 = {
...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 = {
...XMSurveyDefault,
name: "Smileys Survey",
questions: [
{
id: createId(),
type: TSurveyQuestionTypeEnum.Rating,
logic: [{ value: 3, condition: "lessEqual", destination: "tk9wpw2gxgb8fa6pbpp3qq5l" }],
range: 5,
scale: "smiley",
headline: { default: "How do you like {{productName}}?" },
required: true,
lowerLabel: { default: "Not good" },
upperLabel: { default: "Very satisfied" },
isColorCodingEnabled: false,
},
{
id: createId(),
html: { default: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>' },
type: TSurveyQuestionTypeEnum.CTA,
logic: [{ condition: "clicked", destination: 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: "tk9wpw2gxgb8fa6pbpp3qq5l",
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 = {
...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?",
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,
},
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",
},
],
{
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,
NPSSurvey(),
StarRatingSurvey(),
CSATSurvey(),
CESSurvey(),
SmileysRatingSurvey(),
eNPSSurvey(),
];

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,6 @@ import {
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveyRedirectUrlCard,
ZSurveyQuestion,
} from "@formbricks/types/surveys/types";
import { ConfirmationModal } from "@formbricks/ui/ConfirmationModal";
import {
@@ -60,9 +59,13 @@ export const EditorCardMenu = ({
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
@@ -71,65 +74,57 @@ export const EditorCardMenu = ({
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 = () => {
@@ -177,28 +172,24 @@ export const EditorCardMenu = ({
<DropdownMenuSubContent className="ml-2 border border-slate-200 text-slate-600 hover:text-slate-700">
{Object.entries(availableQuestionTypes).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;
}
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>

View File

@@ -1,5 +1,6 @@
"use client";
import { findHiddenFieldUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
import * as Collapsible from "@radix-ui/react-collapsible";
import { EyeOff } from "lucide-react";
import { useState } from "react";
@@ -65,6 +66,25 @@ export const HiddenFieldsCard = ({
});
};
const handleDeleteHiddenField = (fieldId: string) => {
const quesIdx = findHiddenFieldUsedInLogic(localSurvey, fieldId);
if (quesIdx !== -1) {
toast.error(
`${fieldId} is used in logic of question ${quesIdx + 1}. Please remove it from logic first.`
);
return;
}
updateSurvey(
{
enabled: true,
fieldIds: localSurvey.hiddenFields?.fieldIds?.filter((q) => q !== fieldId),
},
fieldId
);
};
return (
<div className={cn(open ? "shadow-lg" : "shadow-md", "group z-10 flex flex-row rounded-lg bg-white")}>
<div
@@ -111,15 +131,7 @@ export const HiddenFieldsCard = ({
return (
<Tag
key={fieldId}
onDelete={() => {
updateSurvey(
{
enabled: true,
fieldIds: localSurvey.hiddenFields?.fieldIds?.filter((q) => q !== fieldId),
},
fieldId
);
}}
onDelete={(fieldId) => handleDeleteHiddenField(fieldId)}
tagId={fieldId}
tagName={fieldId}
/>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,13 @@
"use client";
import { findOptionUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
import { DndContext } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { createId } from "@paralleldrive/cuid2";
import { PlusIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { createI18nString, extractLanguageCodes, getLocalizedValue } from "@formbricks/lib/i18n/utils";
import toast from "react-hot-toast";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import {
TI18nString,
@@ -68,8 +70,6 @@ export const MultipleChoiceQuestionForm = ({
};
const updateChoice = (choiceIdx: number, updatedAttributes: { label: TI18nString }) => {
const newLabel = updatedAttributes.label.en;
const oldLabel = question.choices[choiceIdx].label;
let newChoices: any[] = [];
if (question.choices) {
newChoices = question.choices.map((choice, idx) => {
@@ -78,19 +78,9 @@ export const MultipleChoiceQuestionForm = ({
});
}
let newLogic: any[] = [];
question.logic?.forEach((logic) => {
let newL: string | string[] | undefined = logic.value;
if (Array.isArray(logic.value)) {
newL = logic.value.map((value) =>
value === getLocalizedValue(oldLabel, selectedLanguageCode) ? newLabel : value
);
} else {
newL = logic.value === getLocalizedValue(oldLabel, selectedLanguageCode) ? newLabel : logic.value;
}
newLogic.push({ ...logic, value: newL });
updateQuestion(questionIdx, {
choices: newChoices,
});
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
};
const addChoice = (choiceIdx?: number) => {
@@ -132,23 +122,27 @@ export const MultipleChoiceQuestionForm = ({
};
const deleteChoice = (choiceIdx: number) => {
const choiceToDelete = question.choices[choiceIdx].id;
if (choiceToDelete !== "other") {
const questionIdx = findOptionUsedInLogic(localSurvey, question.id, choiceToDelete);
if (questionIdx !== -1) {
toast.error(
`This option is used in logic for question ${questionIdx + 1}. Please fix the logic first before deleting.`
);
return;
}
}
const newChoices = !question.choices ? [] : question.choices.filter((_, idx) => idx !== choiceIdx);
const choiceValue = question.choices[choiceIdx].label[selectedLanguageCode];
if (isInvalidValue === choiceValue) {
setisInvalidValue(null);
}
let newLogic: any[] = [];
question.logic?.forEach((logic) => {
let newL: string | string[] | undefined = logic.value;
if (Array.isArray(logic.value)) {
newL = logic.value.filter((value) => value !== choiceValue);
} else {
newL = logic.value !== choiceValue ? logic.value : undefined;
}
newLogic.push({ ...logic, value: newL });
});
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
updateQuestion(questionIdx, {
choices: newChoices,
});
};
useEffect(() => {

View File

@@ -39,22 +39,10 @@ export const PictureSelectionForm = ({
// Filter out the deleted choice from the choices array
const newChoices = question.choices?.filter((choice) => choice.id !== choiceValue) || [];
// Update the logic, removing the deleted choice value
const newLogic =
question.logic?.map((logic) => {
let updatedValue = logic.value;
if (Array.isArray(logic.value)) {
updatedValue = logic.value.filter((value) => value !== choiceValue);
} else if (logic.value === choiceValue) {
updatedValue = undefined;
}
return { ...logic, value: updatedValue };
}) || [];
// Update the question with new choices and logic
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
updateQuestion(questionIdx, {
choices: newChoices,
});
};
const handleFileInputChanges = (urls: string[]) => {

View File

@@ -1,7 +1,7 @@
"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 { 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";

View File

@@ -1,6 +1,8 @@
"use client";
import { AddEndingCardButton } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddEndingCardButton";
import { SurveyVariablesCard } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCard";
import { findQuestionUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
import {
DndContext,
DragEndEvent,
@@ -16,11 +18,18 @@ import toast from "react-hot-toast";
import { MultiLanguageCard } from "@formbricks/ee/multi-language/components/multi-language-card";
import { addMultiLanguageLabels, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { isConditionGroup } from "@formbricks/lib/surveyLogic/utils";
import { getDefaultEndingCard } from "@formbricks/lib/templates";
import { checkForEmptyFallBackValue, extractRecallInfo } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
import { TProduct } from "@formbricks/types/product";
import {
TConditionGroup,
TSingleCondition,
TSurveyLogic,
TSurveyLogicAction,
} from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { findQuestionsWithCyclicLogic } from "@formbricks/types/surveys/validation";
import {
@@ -77,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(() => {
@@ -202,6 +264,7 @@ export const QuestionsView = ({
}
}
});
setLocalSurvey(updatedSurvey);
validateSurveyQuestion(updatedSurvey.questions[questionIdx]);
};
@@ -211,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)) {
@@ -223,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];
@@ -448,12 +519,12 @@ export const QuestionsView = ({
activeQuestionId={activeQuestionId}
/>
{/* <SurveyVariablesCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
/> */}
<SurveyVariablesCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
/>
<MultiLanguageCard
localSurvey={localSurvey}

View File

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

View File

@@ -1,6 +1,7 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { FileDigitIcon } from "lucide-react";
import { cn } from "@formbricks/lib/cn";
import { TSurvey } from "@formbricks/types/surveys/types";
import { SurveyVariablesCardItem } from "./SurveyVariablesCardItem";
@@ -37,7 +38,9 @@ export const SurveyVariablesCard = ({
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
)}>
<p>🪣</p>
<div className="flex w-full justify-center">
<FileDigitIcon className="h-4 w-4" />
</div>
</div>
<Collapsible.Root
open={open}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -64,6 +64,12 @@ const mapResponsesToTableData = (responses: TResponse[], survey: TSurvey): TResp
responseId: response.id,
tags: response.tags,
notes: response.notes,
variables: survey.variables.reduce(
(acc, curr) => {
return Object.assign(acc, { [curr.id]: response.variables[curr.id] });
},
{} as Record<string, string | number>
),
verifiedEmail: typeof response.data["verifiedEmail"] === "string" ? response.data["verifiedEmail"] : "",
language: response.language,
person: response.person,

View File

@@ -6,7 +6,7 @@ import Link from "next/link";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
import { processResponseData } from "@formbricks/lib/responses";
import { QUESTIONS_ICON_MAP } from "@formbricks/lib/utils/questions";
import { QUESTIONS_ICON_MAP, VARIABLES_ICON_MAP } from "@formbricks/lib/utils/questions";
import { recallToHeadline } from "@formbricks/lib/utils/recall";
import { TResponseTableData } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
@@ -240,6 +240,24 @@ export const generateResponseTableColumns = (
},
};
const variableColumns: ColumnDef<TResponseTableData>[] = survey.variables.map((variable) => {
return {
accessorKey: variable.id,
header: () => (
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{VARIABLES_ICON_MAP[variable.type]}</span>
<span className="truncate">{variable.name}</span>
</div>
),
cell: ({ row }) => {
const variableResponse = row.original.variables[variable.id];
if (typeof variableResponse === "string" || typeof variableResponse === "number") {
return <div className="text-slate-900">{variableResponse}</div>;
}
},
};
});
const hiddenFieldColumns: ColumnDef<TResponseTableData>[] = survey.hiddenFields.fieldIds
? survey.hiddenFields.fieldIds.map((hiddenFieldId) => {
return {
@@ -282,6 +300,7 @@ export const generateResponseTableColumns = (
statusColumn,
...(survey.isVerifyEmailEnabled ? [verifiedEmailColumn] : []),
...questionColumns,
...variableColumns,
...hiddenFieldColumns,
tagsColumn,
notesColumn,

View File

@@ -31,6 +31,7 @@ const convertMetaObjectToString = (metadata: TResponseMeta): string => {
const processDataForIntegration = async (
data: TPipelineInput,
survey: TSurvey,
includeVariables: boolean,
includeMetadata: boolean,
includeHiddenFields: boolean,
questionIds: string[]
@@ -44,6 +45,16 @@ const processDataForIntegration = async (
values[0].push(convertMetaObjectToString(data.response.meta));
values[1].push("Metadata");
}
if (includeVariables) {
survey.variables.forEach((variable) => {
const value = data.response.variables[variable.id];
if (value !== undefined) {
values[0].push(String(data.response.variables[variable.id]));
values[1].push(variable.name);
}
});
}
return values;
};
@@ -102,6 +113,7 @@ const handleAirtableIntegration = async (
const values = await processDataForIntegration(
data,
survey,
!!element.includeVariables,
!!element.includeMetadata,
!!element.includeHiddenFields,
element.questionIds
@@ -135,6 +147,7 @@ const handleGoogleSheetsIntegration = async (
const values = await processDataForIntegration(
data,
survey,
!!element.includeVariables,
!!element.includeMetadata,
!!element.includeHiddenFields,
element.questionIds
@@ -173,6 +186,7 @@ const handleSlackIntegration = async (
const values = await processDataForIntegration(
data,
survey,
!!element.includeVariables,
!!element.includeMetadata,
!!element.includeHiddenFields,
element.questionIds

View File

@@ -1,54 +1,8 @@
import { FormbricksAPI } from "@formbricks/api";
import formbricks from "@formbricks/js/app";
import { env } from "@formbricks/lib/env";
export const formbricksEnabled =
typeof env.NEXT_PUBLIC_FORMBRICKS_API_HOST && env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID;
const ttc = { onboarding: 0 };
const getFormbricksApi = () => {
const environmentId = env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID;
const apiHost = env.NEXT_PUBLIC_FORMBRICKS_API_HOST;
if (typeof environmentId !== "string" || typeof apiHost !== "string") {
throw new Error("Formbricks environment ID or API host is not defined");
}
return new FormbricksAPI({
environmentId,
apiHost,
});
};
export const createResponse = async (
surveyId: string,
userId: string,
data: { [questionId: string]: any },
finished: boolean = false
): Promise<any> => {
const api = getFormbricksApi();
return await api.client.response.create({
surveyId,
userId,
finished,
data,
ttc,
});
};
export const updateResponse = async (
responseId: string,
data: { [questionId: string]: any },
finished: boolean = false
): Promise<any> => {
const api = getFormbricksApi();
return await api.client.response.update({
responseId,
finished,
data,
ttc,
});
};
export const formbricksLogout = async () => {
localStorage.clear();

View File

@@ -0,0 +1,19 @@
export const getCSPHeaderValues = () => {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const cspHeader = `
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`
.replace(/\s{2,}/g, " ")
.trim();
return { nonce, cspHeader };
};

View File

@@ -278,6 +278,7 @@ export const LinkSurvey = ({
url: window.location.href,
source: sourceParam || "",
},
variables: responseUpdate.variables,
displayId: surveyState.displayId,
...(Object.keys(hiddenFieldsRecord).length > 0 && { hiddenFields: hiddenFieldsRecord }),
});

View File

@@ -1,9 +1,9 @@
import { surveys } from "@/playwright/utils/mock";
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
import { createSurvey } from "./utils/helper";
import { createSurvey, createSurveyWithLogic } from "./utils/helper";
test.describe("Survey Create & Submit Response", async () => {
test.describe("Survey Create & Submit Response without logic", async () => {
let url: string | null;
test("Create survey and submit response", async ({ page, users }) => {
@@ -453,3 +453,256 @@ test.describe("Multi Language Survey Create", async () => {
expect(germanSurveyUrl).toContain("lang=de");
});
});
test.describe("Testing Survey with advanced logic", async () => {
test.setTimeout(180000);
let url: string | null;
test("Create survey and submit response", async ({ page, users }) => {
const user = await users.create();
await user.login();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
await test.step("Create Survey", async () => {
await createSurveyWithLogic(page, surveys.createWithLogicAndSubmit);
// Save & Publish Survey
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.locator("#howToSendCardTrigger").click();
await expect(page.locator("#howToSendCardOption-link")).toBeVisible();
await page.locator("#howToSendCardOption-link").click();
await page.getByRole("button", { name: "Publish" }).click();
// Get URL
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/);
await page.getByLabel("Copy survey link to clipboard").click();
url = await page.evaluate("navigator.clipboard.readText()");
});
await test.step("Submit Survey Response", async () => {
await page.goto(url!);
await page.waitForURL(/\/s\/[A-Za-z0-9]+$/);
// Welcome Card
await expect(page.getByText(surveys.createWithLogicAndSubmit.welcomeCard.headline)).toBeVisible();
await expect(page.getByText(surveys.createWithLogicAndSubmit.welcomeCard.description)).toBeVisible();
await page.locator("#questionCard--1").getByRole("button", { name: "Next" }).click();
// Open Text Question
await expect(page.getByText(surveys.createWithLogicAndSubmit.openTextQuestion.question)).toBeVisible();
await expect(
page.getByText(surveys.createWithLogicAndSubmit.openTextQuestion.description)
).toBeVisible();
await expect(
page.getByPlaceholder(surveys.createWithLogicAndSubmit.openTextQuestion.placeholder)
).toBeVisible();
await page
.getByPlaceholder(surveys.createWithLogicAndSubmit.openTextQuestion.placeholder)
.fill("This is my Open Text answer");
await page.locator("#questionCard-0").getByRole("button", { name: "Next" }).click();
// Single Select Question
await expect(
page.getByText(surveys.createWithLogicAndSubmit.singleSelectQuestion.question)
).toBeVisible();
await expect(
page.getByText(surveys.createWithLogicAndSubmit.singleSelectQuestion.description)
).toBeVisible();
for (let i = 0; i < surveys.createWithLogicAndSubmit.singleSelectQuestion.options.length; i++) {
await expect(
page
.locator("#questionCard-1 label")
.filter({ hasText: surveys.createWithLogicAndSubmit.singleSelectQuestion.options[i] })
).toBeVisible();
}
await expect(page.getByText("Other")).toBeVisible();
await expect(page.locator("#questionCard-1").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-1").getByRole("button", { name: "Back" })).toBeVisible();
await page
.locator("#questionCard-1 label")
.filter({ hasText: surveys.createWithLogicAndSubmit.singleSelectQuestion.options[0] })
.click();
await page.locator("#questionCard-1").getByRole("button", { name: "Next" }).click();
// Multi Select Question
await expect(
page.getByText(surveys.createWithLogicAndSubmit.multiSelectQuestion.question)
).toBeVisible();
await expect(
page.getByText(surveys.createWithLogicAndSubmit.multiSelectQuestion.description)
).toBeVisible();
for (let i = 0; i < surveys.createWithLogicAndSubmit.singleSelectQuestion.options.length; i++) {
await expect(
page
.locator("#questionCard-2 label")
.filter({ hasText: surveys.createWithLogicAndSubmit.multiSelectQuestion.options[i] })
).toBeVisible();
}
await expect(page.locator("#questionCard-2").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-2").getByRole("button", { name: "Back" })).toBeVisible();
for (let i = 0; i < surveys.createWithLogicAndSubmit.multiSelectQuestion.options.length; i++) {
await page
.locator("#questionCard-2 label")
.filter({ hasText: surveys.createWithLogicAndSubmit.multiSelectQuestion.options[i] })
.click();
}
await page.locator("#questionCard-2").getByRole("button", { name: "Next" }).click();
// Picture Select Question
await expect(
page.getByText(surveys.createWithLogicAndSubmit.pictureSelectQuestion.question)
).toBeVisible();
await expect(
page.getByText(surveys.createWithLogicAndSubmit.pictureSelectQuestion.description)
).toBeVisible();
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Back" })).toBeVisible();
await expect(page.getByRole("img", { name: "puppy-1-small.jpg" })).toBeVisible();
await expect(page.getByRole("img", { name: "puppy-2-small.jpg" })).toBeVisible();
await page.getByRole("img", { name: "puppy-1-small.jpg" }).click();
await page.locator("#questionCard-3").getByRole("button", { name: "Next" }).click();
// Rating Question
await expect(page.getByText(surveys.createWithLogicAndSubmit.ratingQuestion.question)).toBeVisible();
await expect(page.getByText(surveys.createWithLogicAndSubmit.ratingQuestion.description)).toBeVisible();
await expect(
page.locator("#questionCard-4").getByText(surveys.createWithLogicAndSubmit.ratingQuestion.lowLabel)
).toBeVisible();
await expect(
page.locator("#questionCard-4").getByText(surveys.createWithLogicAndSubmit.ratingQuestion.highLabel)
).toBeVisible();
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5);
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).not.toBeVisible();
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Back" })).toBeVisible();
await page.getByRole("group", { name: "Choices" }).locator("path").nth(3).click();
// NPS Question
await expect(page.getByText(surveys.createWithLogicAndSubmit.npsQuestion.question)).toBeVisible();
await expect(
page.locator("#questionCard-5").getByText(surveys.createWithLogicAndSubmit.npsQuestion.lowLabel)
).toBeVisible();
await expect(
page.locator("#questionCard-5").getByText(surveys.createWithLogicAndSubmit.npsQuestion.highLabel)
).toBeVisible();
await expect(page.locator("#questionCard-5").getByRole("button", { name: "Next" })).not.toBeVisible();
await expect(page.locator("#questionCard-5").getByRole("button", { name: "Back" })).toBeVisible();
for (let i = 0; i < 11; i++) {
await expect(page.locator("#questionCard-5").getByText(`${i}`, { exact: true })).toBeVisible();
}
await page.locator("#questionCard-5").getByText("5", { exact: true }).click();
// Ranking Question
await expect(page.getByText(surveys.createWithLogicAndSubmit.ranking.question)).toBeVisible();
await page.locator("#questionCard-6").getByRole("button", { name: "Next" }).click();
// Matrix Question
await expect(page.getByText(surveys.createWithLogicAndSubmit.matrix.question)).toBeVisible();
await expect(page.getByText(surveys.createWithLogicAndSubmit.matrix.description)).toBeVisible();
await expect(
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.rows[0] })
).toBeVisible();
await expect(
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.rows[1] })
).toBeVisible();
await expect(
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.rows[2] })
).toBeVisible();
await expect(
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[0] })
).toBeVisible();
await expect(
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[1] })
).toBeVisible();
await expect(
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[2] })
).toBeVisible();
await expect(
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[3] })
).toBeVisible();
await expect(page.locator("#questionCard-7").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-7").getByRole("button", { name: "Back" })).toBeVisible();
await page.getByRole("row", { name: "Roses" }).getByRole("cell").nth(1).click();
await page.getByRole("row", { name: "Trees" }).getByRole("cell").nth(1).click();
await page.getByRole("row", { name: "Ocean" }).getByRole("cell").nth(1).click();
await page.locator("#questionCard-7").getByRole("button", { name: "Next" }).click();
// CTA Question
await expect(page.getByText(surveys.createWithLogicAndSubmit.ctaQuestion.question)).toBeVisible();
await expect(
page.getByRole("button", { name: surveys.createWithLogicAndSubmit.ctaQuestion.buttonLabel })
).toBeVisible();
await page
.getByRole("button", { name: surveys.createWithLogicAndSubmit.ctaQuestion.buttonLabel })
.click();
// Consent Question
await expect(page.getByText(surveys.createWithLogicAndSubmit.consentQuestion.question)).toBeVisible();
await expect(
page.getByText(surveys.createWithLogicAndSubmit.consentQuestion.checkboxLabel)
).toBeVisible();
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Back" })).toBeVisible();
await page.getByText(surveys.createWithLogicAndSubmit.consentQuestion.checkboxLabel).check();
await page.locator("#questionCard-9").getByRole("button", { name: "Next" }).click();
// File Upload Question
await expect(
page.getByText(surveys.createWithLogicAndSubmit.fileUploadQuestion.question)
).toBeVisible();
await expect(page.locator("#questionCard-10").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-10").getByRole("button", { name: "Back" })).toBeVisible();
await expect(
page.locator("label").filter({ hasText: "Click or drag to upload files." }).locator("div").nth(0)
).toBeVisible();
await page.locator("input[type=file]").setInputFiles({
name: "file.txt",
mimeType: "text/plain",
buffer: Buffer.from("this is test"),
});
await page.getByText("Uploading...").waitFor({ state: "hidden" });
await page.locator("#questionCard-10").getByRole("button", { name: "Next" }).click();
// Date Question
await expect(page.getByText(surveys.createWithLogicAndSubmit.date.question)).toBeVisible();
await page.getByText("Select a date").click();
const date = new Date().getDate();
const month = new Date().toLocaleString("default", { month: "long" });
await page.getByRole("button", { name: `${month} ${date},` }).click();
await page.locator("#questionCard-11").getByRole("button", { name: "Next" }).click();
// Cal Question
await expect(page.getByText(surveys.createWithLogicAndSubmit.cal.question)).toBeVisible();
await page.locator("#questionCard-12").getByRole("button", { name: "Next" }).click();
// Address Question
await expect(page.getByText(surveys.createWithLogicAndSubmit.address.question)).toBeVisible();
await expect(page.getByPlaceholder(surveys.createWithLogicAndSubmit.address.placeholder)).toBeVisible();
await page
.getByPlaceholder(surveys.createWithLogicAndSubmit.address.placeholder)
.fill("This is my Address");
await page.getByRole("button", { name: "Finish" }).click();
// loading spinner -> wait for it to disappear
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
// Thank You Card
await expect(page.getByText(surveys.createWithLogicAndSubmit.thankYouCard.headline)).toBeVisible();
await expect(page.getByText(surveys.createWithLogicAndSubmit.thankYouCard.description)).toBeVisible();
});
await test.step("Verify Survey Response", async () => {
await page.goBack();
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/);
await page.getByRole("button", { name: "Close" }).click();
await page.getByRole("link").filter({ hasText: "Responses" }).click();
await expect(page.getByRole("table")).toBeVisible();
await expect(page.getByRole("cell", { name: "score" })).toBeVisible();
await page.waitForTimeout(5000);
await expect(page.getByRole("cell", { name: "32", exact: true })).toBeVisible();
});
});
});

View File

@@ -1,4 +1,4 @@
import { CreateSurveyParams } from "@/playwright/utils/mock";
import { CreateSurveyParams, CreateSurveyWithLogicParams } from "@/playwright/utils/mock";
import { expect } from "@playwright/test";
import { readFileSync, writeFileSync } from "fs";
import { Page } from "playwright";
@@ -304,3 +304,619 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
await page.getByLabel("Note*").fill(params.thankYouCard.headline);
await page.getByLabel("Description").fill(params.thankYouCard.description);
};
export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWithLogicParams) => {
const addQuestion = "Add QuestionAdd a new question to your survey";
await page.getByRole("button", { name: "Start from scratch Create a" }).click();
await page.getByRole("button", { name: "Create survey", exact: true }).click();
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/edit$/);
// Add variables
await page.getByText("Variables").click();
await page.getByPlaceholder("Field name e.g, score, price").click();
await page.getByPlaceholder("Field name e.g, score, price").fill("score");
await page.getByRole("button", { name: "Add variable" }).click();
await page
.locator("form")
.filter({ hasText: "Add variable" })
.getByPlaceholder("Field name e.g, score, price")
.fill("secret");
await page.locator("form").filter({ hasText: "Add variable" }).getByRole("combobox").click();
await page.getByLabel("Text", { exact: true }).click();
await page.getByRole("button", { name: "Add variable" }).click();
// Welcome Card
await expect(page.locator("#welcome-toggle")).toBeVisible();
await page.getByText("Welcome Card").click();
await page.locator("#welcome-toggle").check();
await page.getByLabel("Note*").fill(params.welcomeCard.headline);
await page.locator("form").getByText("Thanks for providing your").fill(params.welcomeCard.description);
await page.getByText("Welcome CardOn").click();
// Open Text Question
await page.getByRole("main").getByText("What would you like to know?").click();
await page.getByLabel("Question*").fill(params.openTextQuestion.question);
await page.getByRole("button", { name: "Add Description", exact: true }).click();
await page.getByLabel("Description").fill(params.openTextQuestion.description);
await page.getByLabel("Placeholder").fill(params.openTextQuestion.placeholder);
await page.locator("p").filter({ hasText: params.openTextQuestion.question }).click();
// Single Select Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Single-Select" }).click();
await page.getByLabel("Question*").fill(params.singleSelectQuestion.question);
await page.getByRole("button", { name: "Add Description", exact: true }).click();
await page.getByLabel("Description").fill(params.singleSelectQuestion.description);
await page.getByPlaceholder("Option 1").fill(params.singleSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
await page.getByRole("button", { name: 'Add "Other"', exact: true }).click();
await page.getByLabel("Required").click();
// Multi Select Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Multi-Select" }).click();
await page.getByLabel("Question*").fill(params.multiSelectQuestion.question);
await page.getByRole("button", { name: "Add Description", exact: true }).click();
await page.getByLabel("Description").fill(params.multiSelectQuestion.description);
await page.getByPlaceholder("Option 1").fill(params.multiSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(params.multiSelectQuestion.options[1]);
await page.getByPlaceholder("Option 3").fill(params.multiSelectQuestion.options[2]);
// Picture Select Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Picture Selection" }).click();
await page.getByLabel("Question*").fill(params.pictureSelectQuestion.question);
await page.getByRole("button", { name: "Add Description", exact: true }).click();
await page.getByLabel("Description").fill(params.pictureSelectQuestion.description);
await page.getByLabel("Required").click();
// Rating Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Rating" }).click();
await page.getByLabel("Question*").fill(params.ratingQuestion.question);
await page.getByRole("button", { name: "Add Description", exact: true }).click();
await page.getByLabel("Description").fill(params.ratingQuestion.description);
await page.getByPlaceholder("Not good").fill(params.ratingQuestion.lowLabel);
await page.getByPlaceholder("Very satisfied").fill(params.ratingQuestion.highLabel);
// NPS Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Net Promoter Score (NPS)" }).click();
await page.getByLabel("Question*").fill(params.npsQuestion.question);
await page.getByLabel("Lower label").fill(params.npsQuestion.lowLabel);
await page.getByLabel("Upper label").fill(params.npsQuestion.highLabel);
// Fill Ranking question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Ranking" }).click();
await page.getByLabel("Question*").fill(params.ranking.question);
await page.getByLabel("Required").click();
// Matrix Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Matrix" }).click();
await page.getByLabel("Question*").fill(params.matrix.question);
await page.getByRole("button", { name: "Add Description", exact: true }).click();
await page.getByLabel("Description").fill(params.matrix.description);
await page.locator("#row-0").click();
await page.locator("#row-0").fill(params.matrix.rows[0]);
await page.locator("#row-1").click();
await page.locator("#row-1").fill(params.matrix.rows[1]);
await page.locator("#row-2").click();
await page.locator("#row-2").fill(params.matrix.rows[2]);
await page.locator("#column-0").click();
await page.locator("#column-0").fill(params.matrix.columns[0]);
await page.locator("#column-1").click();
await page.locator("#column-1").fill(params.matrix.columns[1]);
await page.locator("#column-2").click();
await page.locator("#column-2").fill(params.matrix.columns[2]);
await page.locator("#column-3").click();
await page.locator("#column-3").fill(params.matrix.columns[3]);
// CTA Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Statement (Call to Action)" }).click();
await page.getByPlaceholder("Your question here. Recall").fill(params.ctaQuestion.question);
await page.getByPlaceholder("Finish").fill(params.ctaQuestion.buttonLabel);
await page.getByLabel("Required").click();
// Consent Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Consent" }).click();
await page.getByLabel("Question*").fill(params.consentQuestion.question);
await page.getByPlaceholder("I agree to the terms and").fill(params.consentQuestion.checkboxLabel);
// File Upload Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "File Upload" }).click();
await page.getByLabel("Question*").fill(params.fileUploadQuestion.question);
// Date Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Date" }).click();
await page.getByLabel("Question*").fill(params.date.question);
// Cal Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Schedule a meeting" }).click();
await page.getByLabel("Question*").fill(params.cal.question);
await page.getByLabel("Required").click();
// Fill Address Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Address" }).click();
await page.getByLabel("Question*").fill(params.address.question);
// Adding logic
// Open Text Question
await page.locator("p", { hasText: params.openTextQuestion.question }).click();
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
await page.getByRole("button", { name: "Add logic" }).click();
await page.locator("#condition-0-0-conditionOperator").click();
await page.getByRole("option", { name: "is submitted" }).click();
await page.locator("#action-0-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-0-variableId").click();
await page.getByRole("option", { name: "score" }).click();
await page.locator("#action-0-operator").click();
await page.getByRole("option", { name: "Assign =" }).click();
await page.locator("#action-0-value-input").click();
await page.locator("#action-0-value-input").fill("1");
await page.locator("#actions-0-dropdown").click();
await page.getByRole("menuitem", { name: "Add action below" }).click();
await page.locator("#action-1-objective").click();
await page.getByRole("option", { name: "Require Answer" }).click();
await page.locator("#action-1-target").click();
await page.getByRole("option", { name: params.singleSelectQuestion.question }).click();
await page.locator("#actions-1-dropdown").click();
await page.getByRole("menuitem", { name: "Add action below" }).click();
await page.locator("#action-2-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-2-variableId").click();
await page.getByRole("option", { name: "secret" }).click();
await page.locator("#action-2-operator").click();
await page.getByRole("option", { name: "Assign =" }).click();
await page.getByRole("textbox", { name: "Value" }).click();
await page.getByRole("textbox", { name: "Value" }).fill("This ");
// Single Select Question
await page.locator("p", { hasText: params.singleSelectQuestion.question }).click();
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
await page.getByRole("button", { name: "Add logic" }).click();
await page.locator("#condition-0-0-conditionOperator").click();
await page.getByRole("option", { name: "equals one of" }).click();
await page.locator("#condition-0-0-conditionMatchValue").click();
await page.getByRole("option", { name: params.singleSelectQuestion.options[0] }).click();
await page.getByRole("option", { name: params.singleSelectQuestion.options[1] }).click();
await page.locator("#action-0-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-0-variableId").click();
await page.getByRole("option", { name: "score" }).click();
await page.locator("#action-0-operator").click();
await page.getByRole("option", { name: "Add +" }).click();
await page.getByPlaceholder("Value").click();
await page.getByPlaceholder("Value").fill("1");
await page.locator("#actions-0-dropdown").click();
await page.getByRole("menuitem", { name: "Add action below" }).click();
await page.locator("#action-1-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-1-variableId").click();
await page.getByRole("option", { name: "secret" }).click();
await page.locator("#action-1-operator").click();
await page.getByRole("option", { name: "Concat +" }).click();
await page.getByRole("textbox", { name: "Value" }).click();
await page.getByRole("textbox", { name: "Value" }).fill("is ");
// Multi Select Question
await page.locator("p", { hasText: params.multiSelectQuestion.question }).click();
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
await page.getByRole("button", { name: "Add logic" }).click();
await page.locator("#condition-0-0-conditionOperator").click();
await page.getByRole("option", { name: "includes all of" }).click();
await page.locator("#condition-0-0-conditionMatchValue").click();
await page.getByRole("option", { name: params.multiSelectQuestion.options[0] }).click();
await page.getByRole("option", { name: params.multiSelectQuestion.options[1] }).click();
await page.getByRole("option", { name: params.multiSelectQuestion.options[2] }).click();
await page.locator("#condition-0-0-dropdown").click();
await page.getByRole("menuitem", { name: "Add condition below" }).click();
await page.locator("#condition-0-1-conditionValue").click();
await page.getByRole("option", { name: params.singleSelectQuestion.question }).click();
await page.locator("#condition-0-1-conditionOperator").click();
await page.getByRole("option", { name: "is submitted" }).click();
await page.locator("#action-0-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-0-variableId").click();
await page.getByRole("option", { name: "score" }).click();
await page.locator("#action-0-operator").click();
await page.getByRole("option", { name: "Add +" }).click();
await page.getByPlaceholder("Value").click();
await page.getByPlaceholder("Value").fill("1");
await page.locator("#actions-0-dropdown").click();
await page.getByRole("menuitem", { name: "Add action below" }).click();
await page.locator("#action-1-objective").click();
await page.getByRole("option", { name: "Require Answer" }).click();
await page.locator("#action-1-target").click();
await page.getByRole("option", { name: params.pictureSelectQuestion.question }).click();
await page.locator("#actions-1-dropdown").click();
await page.getByRole("menuitem", { name: "Add action below" }).click();
await page.locator("#action-2-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-2-variableId").click();
await page.getByRole("option", { name: "secret" }).click();
await page.locator("#action-2-operator").click();
await page.getByRole("option", { name: "Concat +" }).click();
await page.getByRole("textbox", { name: "Value" }).click();
await page.getByRole("textbox", { name: "Value" }).fill("a ");
// Picture Select Question
await page.locator("p", { hasText: params.pictureSelectQuestion.question }).click();
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
await page.getByRole("button", { name: "Add logic" }).click();
await page.locator("#condition-0-0-conditionOperator").click();
await page.getByRole("option", { name: "is submitted" }).click();
await page.locator("#action-0-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-0-variableId").click();
await page.getByRole("option", { name: "score" }).click();
await page.locator("#action-0-operator").click();
await page.getByRole("option", { name: "Add +" }).click();
await page.getByPlaceholder("Value").click();
await page.getByPlaceholder("Value").fill("1");
await page.locator("#actions-0-dropdown").click();
await page.getByRole("menuitem", { name: "Add action below" }).click();
await page.locator("#action-1-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-1-variableId").click();
await page.getByRole("option", { name: "secret" }).click();
await page.locator("#action-1-operator").click();
await page.getByRole("option", { name: "Concat +" }).click();
await page.getByRole("textbox", { name: "Value" }).click();
await page.getByRole("textbox", { name: "Value" }).fill("secret ");
// Rating Question
await page.locator("p", { hasText: params.ratingQuestion.question }).click();
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
await page.getByRole("button", { name: "Add logic" }).click();
await page.locator("#condition-0-0-conditionOperator").click();
await page.getByRole("option", { name: ">=" }).click();
await page.locator("#condition-0-0-conditionMatchValue").click();
await page.getByRole("option", { name: "3" }).click();
await page.locator("#action-0-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-0-variableId").click();
await page.getByRole("option", { name: "score" }).click();
await page.locator("#action-0-operator").click();
await page.getByRole("option", { name: "Add +" }).click();
await page.getByPlaceholder("Value").click();
await page.getByPlaceholder("Value").fill("1");
await page.locator("#actions-0-dropdown").click();
await page.getByRole("menuitem", { name: "Add action below" }).click();
await page.locator("#action-1-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-1-variableId").click();
await page.getByRole("option", { name: "secret" }).click();
await page.locator("#action-1-operator").click();
await page.getByRole("option", { name: "Concat +" }).click();
await page.getByRole("textbox", { name: "Value" }).click();
await page.getByRole("textbox", { name: "Value" }).fill("message ");
// NPS Question
await page.locator("p", { hasText: params.npsQuestion.question }).click();
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
await page.getByRole("button", { name: "Add logic" }).click();
await page.locator("#condition-0-0-conditionOperator").click();
await page.getByRole("option", { name: ">", exact: true }).click();
await page.locator("#condition-0-0-conditionMatchValue").click();
await page.getByRole("option", { name: "2" }).click();
await page.locator("#condition-0-0-dropdown").click();
await page.getByRole("menuitem", { name: "Add condition below" }).click();
await page.locator("#condition-0-1-conditionOperator").click();
await page.getByRole("option", { name: "<", exact: true }).click();
await page.locator("#condition-0-1-conditionMatchValue").click();
await page.getByRole("option", { name: "8" }).click();
await page.locator("#condition-0-1-dropdown").click();
await page.getByRole("menuitem", { name: "Add condition below" }).click();
await page.locator("#condition-0-2-conditionValue").click();
await page.getByRole("option", { name: params.ratingQuestion.question }).click();
await page.locator("#condition-0-2-conditionOperator").click();
await page.getByRole("option", { name: "=", exact: true }).click();
await page.locator("#condition-0-2-conditionMatchValue").click();
await page.getByRole("option", { name: "4" }).click();
await page.locator("#condition-0-2-dropdown").click();
await page.getByRole("menuitem", { name: "Add condition below" }).click();
await page.locator("#condition-0-3-conditionValue").click();
await page.getByRole("option", { name: params.ratingQuestion.question }).click();
await page.locator("#condition-0-3-conditionOperator").click();
await page.getByRole("option", { name: "<=" }).click();
await page.locator("#condition-0-3-conditionMatchValue").click();
await page.getByRole("option", { name: "1" }).click();
await page.locator("#condition-0-3-dropdown").click();
await page.getByRole("menuitem", { name: "Create group" }).click();
await page.locator("#condition-1-0-dropdown").click();
await page.getByRole("menuitem", { name: "Add condition below" }).click();
await page.getByText("and").nth(4).click();
await page.locator("#condition-1-1-conditionValue").click();
await page
.getByRole("option")
.filter({ hasText: new RegExp(`^${params.pictureSelectQuestion.question}$`) })
.click();
await page.locator("#condition-1-1-conditionOperator").click();
await page.getByRole("option", { name: "is submitted" }).click();
await page.locator("#action-0-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-0-variableId").click();
await page.getByRole("option", { name: "score" }).click();
await page.locator("#action-0-operator").click();
await page.getByRole("option", { name: "Add +" }).click();
await page.getByPlaceholder("Value").click();
await page.getByPlaceholder("Value").fill("1");
await page.locator("#actions-0-dropdown").click();
await page.getByRole("menuitem", { name: "Add action below" }).click();
await page.locator("#action-1-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-1-variableId").click();
await page.getByRole("option", { name: "secret" }).click();
await page.locator("#action-1-operator").click();
await page.getByRole("option", { name: "Concat +" }).click();
await page.getByRole("textbox", { name: "Value" }).click();
await page.getByRole("textbox", { name: "Value" }).fill("for ");
// Ranking Question
await page.locator("p", { hasText: params.ranking.question }).click();
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
await page.getByRole("button", { name: "Add logic" }).click();
await page.locator("#condition-0-0-conditionOperator").click();
await page.getByRole("option", { name: "is skipped" }).click();
await page.locator("#action-0-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-0-variableId").click();
await page.getByRole("option", { name: "score" }).click();
await page.locator("#action-0-operator").click();
await page.getByRole("option", { name: "Add +" }).click();
await page.getByPlaceholder("Value").click();
await page.getByPlaceholder("Value").fill("1");
await page.locator("#actions-0-dropdown").click();
await page.getByRole("menuitem", { name: "Add action below" }).click();
await page.locator("#action-1-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-1-variableId").click();
await page.getByRole("option", { name: "secret" }).click();
await page.locator("#action-1-operator").click();
await page.getByRole("option", { name: "Concat +" }).click();
await page.getByRole("textbox", { name: "Value" }).click();
await page.getByRole("textbox", { name: "Value" }).fill("e2e ");
// Matrix Question
await page.locator("p", { hasText: params.matrix.question }).click();
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
await page.getByRole("button", { name: "Add logic" }).click();
await page.locator("#condition-0-0-conditionOperator").click();
await page.getByRole("option", { name: "is completely submitted" }).click();
await page.locator("#action-0-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-0-variableId").click();
await page.getByRole("option", { name: "score" }).click();
await page.locator("#action-0-operator").click();
await page.getByRole("option", { name: "Add +" }).click();
await page.getByPlaceholder("Value").click();
await page.getByPlaceholder("Value").fill("1");
await page.locator("#actions-0-dropdown").click();
await page.getByRole("menuitem", { name: "Add action below" }).click();
await page.locator("#action-1-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-1-variableId").click();
await page.getByRole("option", { name: "score" }).click();
await page.locator("#action-1-variableId").click();
await page.getByRole("option", { name: "secret" }).click();
await page.locator("#action-1-operator").click();
await page.getByRole("option", { name: "Concat +" }).click();
await page.getByRole("textbox", { name: "Value" }).click();
await page.getByRole("textbox", { name: "Value" }).fill("tests");
await page.locator("#actions-1-dropdown").click();
await page.getByRole("menuitem", { name: "Add action below" }).click();
await page.locator("#action-2-objective").click();
await page.getByRole("option", { name: "Require Answer" }).click();
await page.locator("#action-2-target").click();
await page.getByRole("option", { name: params.ctaQuestion.question }).click();
// CTA Question
await page.locator("p", { hasText: params.ctaQuestion.question }).click();
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
await page.getByRole("button", { name: "Add logic" }).click();
await page.locator("#condition-0-0-conditionOperator").click();
await page.getByRole("option", { name: "is skipped" }).click();
await page.locator("#condition-0-0-dropdown").click();
await page.getByRole("menuitem", { name: "Add condition below" }).click();
await page.getByText("and", { exact: true }).click();
await page.locator("#condition-0-1-dropdown").click();
await page.getByRole("menuitem", { name: "Create group" }).click();
await page.locator("#condition-1-0-dropdown").click();
await page.getByRole("menuitem", { name: "Add condition below" }).click();
await page.locator("#condition-1-1-conditionValue").click();
await page.getByRole("option", { name: "secret" }).click();
await page.locator("#condition-1-1-conditionOperator").click();
await page.getByRole("option", { name: "contains" }).click();
await page.getByPlaceholder("Value").click();
await page.getByPlaceholder("Value").fill("test");
await page.locator("#action-0-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-0-variableId").click();
await page.getByRole("option", { name: "score" }).click();
await page.locator("#action-0-operator").click();
await page.getByRole("option", { name: "Add +" }).click();
await page.locator("#action-0-value-input").click();
await page.locator("#action-0-value-input").fill("1");
// Consent Question
await page.locator("p", { hasText: params.consentQuestion.question }).click();
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
await page.getByRole("button", { name: "Add logic" }).click();
await page.locator("#action-0-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-0-variableId").click();
await page.getByRole("option", { name: "score" }).click();
await page.locator("#action-0-operator").click();
await page.getByRole("option", { name: "Add +" }).click();
await page.locator("#action-0-value-input").click();
await page.locator("#action-0-value-input").fill("2");
// File Upload Question
await page.locator("p", { hasText: params.fileUploadQuestion.question }).click();
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
await page.getByRole("button", { name: "Add logic" }).click();
await page.locator("#action-0-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-0-variableId").click();
await page.getByRole("option", { name: "score" }).click();
await page.locator("#action-0-operator").click();
await page.getByRole("option", { name: "Add +" }).click();
await page.locator("#action-0-value-input").click();
await page.locator("#action-0-value-input").fill("1");
// Date Question
const today = new Date().toISOString().split("T")[0];
const yesterday = new Date(new Date().setDate(new Date().getDate() - 1)).toISOString().split("T")[0];
const tomorrow = new Date(new Date().setDate(new Date().getDate() + 1)).toISOString().split("T")[0];
await page.getByRole("main").getByText(params.date.question).click();
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
await page.getByRole("button", { name: "Add logic" }).click();
await page.getByPlaceholder("Value").fill(today);
await page.locator("#condition-0-0-dropdown").click();
await page.getByRole("menuitem", { name: "Add condition below" }).click();
await page.locator("#condition-0-1-conditionOperator").click();
await page.getByRole("option", { name: "does not equal" }).click();
await page.locator("#condition-0-1-conditionMatchValue-input").fill(yesterday);
await page.locator("#condition-0-1-dropdown").click();
await page.getByRole("menuitem", { name: "Add condition below" }).click();
await page.locator("#condition-0-2-conditionOperator").click();
await page.getByRole("option", { name: "is before" }).click();
await page.locator("#condition-0-2-conditionMatchValue-input").fill(tomorrow);
await page.locator("#condition-0-2-dropdown").click();
await page.getByRole("menuitem", { name: "Add condition below" }).click();
await page.locator("#condition-0-3-conditionOperator").click();
await page.getByRole("option", { name: "is after" }).click();
await page.locator("#condition-0-3-conditionMatchValue-input").fill(yesterday);
await page.locator("#action-0-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-0-variableId").click();
await page.getByRole("option", { name: "score" }).click();
await page.locator("#action-0-operator").click();
await page.getByRole("option", { name: "Add +" }).click();
await page.locator("#action-0-value-input").click();
await page.locator("#action-0-value-input").fill("1");
// Cal Question
await page.locator("p", { hasText: params.cal.question }).click();
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
await page.getByRole("button", { name: "Add logic" }).click();
await page.locator("#condition-0-0-conditionOperator").click();
await page.getByRole("option", { name: "is skipped" }).click();
await page.locator("#action-0-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-0-variableId").click();
await page.getByRole("option", { name: "score" }).click();
await page.locator("#action-0-operator").click();
await page.getByRole("option", { name: "Add +" }).click();
await page.locator("#action-0-value-input").click();
await page.locator("#action-0-value-input").fill("1");
// Address Question
await page.locator("p", { hasText: params.address.question }).click();
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
await page.getByRole("button", { name: "Add logic" }).click();
await page.locator("#action-0-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-0-variableId").click();
await page.getByRole("option", { name: "score" }).click();
await page.locator("#action-0-operator").click();
await page.getByRole("option", { name: "Add +" }).click();
await page.locator("#action-0-value-input").click();
await page.locator("#action-0-value-input").fill("1");
await page.locator("#actions-0-dropdown").click();
await page.getByRole("menuitem", { name: "Add action below" }).click();
await page.locator("#action-1-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-1-variableId").click();
await page.getByRole("option", { name: "score" }).click();
await page.locator("#action-1-operator").click();
await page.getByRole("option", { name: "Add +" }).click();
await page.locator("#action-1-value-input").click();
await page.locator("#action-1-value-input").fill("1");
await page.locator("#actions-1-dropdown").click();
await page.getByRole("menuitem", { name: "Add action below" }).click();
await page.locator("#action-2-objective").click();
await page.getByRole("option", { name: "Calculate" }).click();
await page.locator("#action-2-variableId").click();
await page.getByRole("option", { name: "score" }).click();
await page.locator("#action-2-operator").click();
await page.getByRole("option", { name: "Multiply *" }).click();
await page.locator("#action-2-value-input").click();
await page.locator("#action-2-value-input").fill("2");
// Thank You Card
await page
.locator("div")
.filter({ hasText: /^Thank you!Ending card$/ })
.nth(1)
.click();
await page.getByLabel("Note*").fill(params.thankYouCard.headline);
await page.getByLabel("Description").fill(params.thankYouCard.description);
};

View File

@@ -166,6 +166,77 @@ export const surveys = {
description: "This is my Thank you Card Description!",
},
},
createWithLogicAndSubmit: {
welcomeCard: {
headline: "Welcome to My Testing Survey Welcome Card!",
description: "This is the description of my Welcome Card!",
},
openTextQuestion: {
question: "This is my Open Text Question",
description: "This is my Open Text Description",
placeholder: "This is my Placeholder",
},
singleSelectQuestion: {
question: "This is my Single Select Question",
description: "This is my Single Select Description",
options: ["Option 1", "Option 2"],
},
multiSelectQuestion: {
question: "This is my Multi Select Question",
description: "This is Multi Select Description",
options: ["Option 1", "Option 2", "Option 3"],
},
ratingQuestion: {
question: "This is my Rating Question",
description: "This is Rating Description",
lowLabel: "My Lower Label",
highLabel: "My Upper Label",
},
npsQuestion: {
question: "This is my NPS Question",
lowLabel: "My Lower Label",
highLabel: "My Upper Label",
},
ctaQuestion: {
question: "This is my CTA Question",
buttonLabel: "My Button Label",
},
consentQuestion: {
question: "This is my Consent Question",
checkboxLabel: "My Checkbox Label",
},
pictureSelectQuestion: {
question: "This is my Picture Select Question",
description: "This is Picture Select Description",
},
fileUploadQuestion: {
question: "This is my File Upload Question",
},
date: {
question: "This is my Date Question",
},
cal: {
question: "This is my cal Question",
},
matrix: {
question: "This is my Matrix Question",
description: "0: Not at all, 3: Love it",
rows: ["Roses", "Trees", "Ocean"],
columns: ["0", "1", "2", "3"],
},
address: {
question: "Where do you live?",
placeholder: "Address Line 1",
},
ranking: {
question: "This is my Ranking Question",
choices: ["Work", "Money", "Travel", "Family", "Friends"],
},
thankYouCard: {
headline: "This is my Thank You Card Headline!",
description: "This is my Thank you Card Description!",
},
},
germanCreate: {
welcomeCard: {
headline: "Willkommen zu meiner Testumfrage Willkommenskarte!", // German translation
@@ -240,6 +311,7 @@ export const surveys = {
};
export type CreateSurveyParams = typeof surveys.createAndSubmit;
export type CreateSurveyWithLogicParams = typeof surveys.createWithLogicAndSubmit;
export const actions = {
create: {

View File

@@ -25,12 +25,14 @@ export class ResponseAPI {
finished,
data,
ttc,
variables,
language,
}: TResponseUpdateInputWithResponseId): Promise<Result<object, NetworkError | Error>> {
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/responses/${responseId}`, "PUT", {
finished,
data,
ttc,
variables,
language,
});
}

View File

@@ -0,0 +1,316 @@
/* eslint-disable @typescript-eslint/restrict-template-expressions -- string interpolation is allowed in migration scripts */
/* eslint-disable no-console -- logging is allowed in migration scripts */
import { createId } from "@paralleldrive/cuid2";
import { PrismaClient } from "@prisma/client";
import {
type TRightOperand,
type TSingleCondition,
type TSurveyEndings,
type TSurveyLogic,
type TSurveyLogicAction,
type TSurveyLogicConditionsOperator,
type TSurveyMultipleChoiceQuestion,
type TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
const prisma = new PrismaClient();
interface TOldLogic {
condition: string;
value?: string | string[];
destination: string;
}
const isOldLogic = (logic: TOldLogic | TSurveyLogic): logic is TOldLogic => {
return Object.keys(logic).some((key) => ["condition", "destination", "value"].includes(key));
};
const doesRightOperandExist = (operator: TSurveyLogicConditionsOperator): boolean => {
return ![
"isAccepted",
"isBooked",
"isClicked",
"isCompletelySubmitted",
"isPartiallySubmitted",
"isSkipped",
"isSubmitted",
].includes(operator);
};
const getChoiceId = (question: TSurveyMultipleChoiceQuestion, choiceText: string): string | undefined => {
const choiceOption = question.choices.find((choice) => choice.label.default === choiceText);
if (choiceOption) {
return choiceOption.id;
}
if (question.choices.at(-1)?.id === "other") {
return "other";
}
};
const getRightOperandValue = (
oldCondition: string,
oldValue: string | string[] | undefined,
question: TSurveyQuestion
): TRightOperand | undefined => {
if (["lessThan", "lessEqual", "greaterThan", "greaterEqual"].includes(oldCondition)) {
return {
type: "static",
value: parseInt(oldValue as string),
};
}
if (["equals", "notEquals"].includes(oldCondition)) {
if (["string", "number"].includes(typeof oldValue)) {
if (question.type === TSurveyQuestionTypeEnum.Rating || question.type === TSurveyQuestionTypeEnum.NPS) {
return {
type: "static",
value: parseInt(oldValue as string),
};
} else if (
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
) {
const choiceId = getChoiceId(question, oldValue as string);
if (choiceId) {
return {
type: "static",
value: choiceId,
};
}
return undefined;
} else if (question.type === TSurveyQuestionTypeEnum.PictureSelection) {
return {
type: "static",
value: oldValue as string,
};
}
}
return undefined;
}
if (["includesAll", "includesOne"].includes(oldCondition)) {
let choiceIds: string[] = [];
if (oldValue && Array.isArray(oldValue)) {
if (
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle
) {
oldValue.forEach((choiceText) => {
const choiceId = getChoiceId(question, choiceText);
if (choiceId) {
choiceIds.push(choiceId);
}
});
choiceIds = Array.from(new Set(choiceIds));
return {
type: "static",
value: choiceIds,
};
}
return {
type: "static",
value: oldValue,
};
}
return undefined;
}
return undefined;
};
// Helper function to convert old logic condition to new format
function convertLogicCondition(
oldCondition: string,
oldValue: string | string[] | undefined,
question: TSurveyQuestion
): TSingleCondition | undefined {
const operator = mapOldOperatorToNew(oldCondition, question.type);
let rightOperandValue: TRightOperand | undefined;
const doesRightOperandExistResult = doesRightOperandExist(operator);
if (doesRightOperandExistResult) {
rightOperandValue = getRightOperandValue(oldCondition, oldValue, question);
if (!rightOperandValue) {
return undefined;
}
}
const newCondition: TSingleCondition = {
id: createId(),
leftOperand: {
type: "question",
value: question.id,
},
operator,
...(doesRightOperandExistResult ? { rightOperand: rightOperandValue } : {}),
};
return newCondition;
}
// Helper function to map old conditions to new ones
function mapOldOperatorToNew(
oldCondition: string,
questionType: TSurveyQuestionTypeEnum
): TSurveyLogicConditionsOperator {
const conditionMap: Record<string, TSurveyLogicConditionsOperator> = {
accepted: "isAccepted",
clicked: "isClicked",
submitted: "isSubmitted",
skipped: "isSkipped",
equals: "equals",
notEquals: "doesNotEqual",
lessThan: "isLessThan",
lessEqual: "isLessThanOrEqual",
greaterThan: "isGreaterThan",
greaterEqual: "isGreaterThanOrEqual",
includesAll: "includesAllOf",
includesOne: "includesOneOf",
uploaded: "isSubmitted", // Assuming 'uploaded' maps to 'isSubmitted'
notUploaded: "isSkipped", // Assuming 'notUploaded' maps to 'isSkipped'
booked: "isBooked",
isCompletelySubmitted: "isCompletelySubmitted",
isPartiallySubmitted: "isPartiallySubmitted",
};
const newOpeator = conditionMap[oldCondition];
if (questionType === TSurveyQuestionTypeEnum.MultipleChoiceSingle && newOpeator === "includesOneOf") {
return "equalsOneOf";
}
return newOpeator;
}
// Helper function to convert old logic to new format
function convertLogic(
surveyEndings: TSurveyEndings,
oldLogic: TOldLogic,
question: TSurveyQuestion
): TSurveyLogic | undefined {
if (!oldLogic.condition || !oldLogic.destination) {
return undefined;
}
const condition = convertLogicCondition(oldLogic.condition, oldLogic.value, question);
if (!condition) {
return undefined;
}
let actionTarget = oldLogic.destination;
if (actionTarget === "end") {
if (surveyEndings.length > 0) {
actionTarget = surveyEndings[0].id;
} else {
return undefined;
}
}
const action: TSurveyLogicAction = {
id: createId(),
objective: "jumpToQuestion",
target: actionTarget,
};
return {
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [condition],
},
actions: [action],
};
}
async function runMigration(): Promise<void> {
await prisma.$transaction(
async (tx) => {
const startTime = Date.now();
console.log("Starting survey logic migration...");
// Get all surveys with questions containing old logic
const relevantSurveys = await tx.survey.findMany({
select: {
id: true,
questions: true,
endings: true,
},
});
// Process each survey
const migrationPromises = relevantSurveys
.map((survey) => {
let doesThisSurveyHasOldLogic = false;
const questions: TSurveyQuestion[] = [];
for (const question of survey.questions) {
if (question.logic && Array.isArray(question.logic) && question.logic.some(isOldLogic)) {
doesThisSurveyHasOldLogic = true;
const newLogic = (question.logic as unknown as TOldLogic[])
.map((oldLogic) => convertLogic(survey.endings, oldLogic, question))
.filter((logic) => logic !== undefined);
questions.push({ ...question, logic: newLogic });
} else {
questions.push(question);
}
}
if (!doesThisSurveyHasOldLogic) {
return null;
}
return tx.survey.update({
where: { id: survey.id },
data: { questions },
});
})
.filter((promise) => promise !== null);
console.log(`Found ${migrationPromises.length} surveys with old logic`);
await Promise.all(migrationPromises);
const endTime = Date.now();
console.log(
`Survey logic migration completed. Total time: ${((endTime - startTime) / 1000).toString()}s`
);
},
{
timeout: 300000, // 5 minutes
}
);
}
function handleError(error: unknown): void {
console.error("An error occurred during migration:", error);
process.exit(1);
}
function handleDisconnectError(): void {
console.error("Failed to disconnect Prisma client");
process.exit(1);
}
function main(): void {
runMigration()
.catch(handleError)
.finally(() => {
prisma.$disconnect().catch(handleDisconnectError);
});
}
main();

View File

@@ -83,7 +83,7 @@ async function runMigration(): Promise<void> {
console.log("Displays where the response was missing have been deleted.");
console.log("Data migration completed.");
console.log(`Affected rows: ${rawQueryResult + displayIdsToDelete.length}`);
console.log(`Affected rows: ${String(rawQueryResult + displayIdsToDelete.length)}`);
},
{
timeout: TRANSACTION_TIMEOUT,

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Response" ADD COLUMN "variables" JSONB NOT NULL DEFAULT '{}';

View File

@@ -48,7 +48,8 @@
"data-migration:v2.4": "pnpm data-migration:segments-cleanup && pnpm data-migration:multiple-endings && pnpm data-migration:simplified-email-verification && pnpm data-migration:fix-logic-end-destination",
"data-migration:remove-dismissed-value-inconsistency": "ts-node ./data-migrations/20240807120500_cta_consent_dismissed_inconsistency/data-migration.ts",
"data-migration:v2.5": "pnpm data-migration:remove-dismissed-value-inconsistency",
"data-migration:add-display-id-to-response": "ts-node ./data-migrations/20240905120500_refactor_display_response_relationship/data-migration.ts"
"data-migration:add-display-id-to-response": "ts-node ./data-migrations/20240905120500_refactor_display_response_relationship/data-migration.ts",
"data-migration:advanced-logic": "ts-node ./data-migrations/20240828122408_advanced_logic_editor/data-migration.ts"
},
"dependencies": {
"@prisma/client": "^5.18.0",

View File

@@ -119,6 +119,9 @@ model Response {
/// @zod.custom(imports.ZResponseData)
/// [ResponseData]
data Json @default("{}")
/// @zod.custom(imports.ZResponseVariables)
/// [ResponseVariables]
variables Json @default("{}")
/// @zod.custom(imports.ZResponseTtc)
/// [ResponseTtc]
ttc Json @default("{}")

View File

@@ -653,7 +653,6 @@ function ActionSegmentFilter({
setSegment={setSegment}
viewOnly={viewOnly}
/>
<Select
disabled={viewOnly}
onValueChange={(value) => {
@@ -676,7 +675,6 @@ function ActionSegmentFilter({
))}
</SelectContent>
</Select>
<Select
disabled={viewOnly}
onValueChange={(value: TActionMetric) => {
@@ -695,7 +693,6 @@ function ActionSegmentFilter({
))}
</SelectContent>
</Select>
<Select
disabled={viewOnly}
onValueChange={(operator: TBaseOperator) => {
@@ -718,7 +715,6 @@ function ActionSegmentFilter({
))}
</SelectContent>
</Select>
<div className="relative flex flex-col gap-1">
<Input
className={cn("w-auto bg-white", valueError && "border border-red-500 focus:border-red-500")}
@@ -734,7 +730,6 @@ function ActionSegmentFilter({
<p className="absolute right-2 -mt-1 rounded-md bg-white px-2 text-xs text-red-500">{valueError}</p>
) : null}
</div>
<SegmentFilterItemContextMenu
filterId={resource.id}
onAddFilterBelow={onAddFilterBelow}

View File

@@ -1,4 +1,5 @@
import { Column, Container, Hr, Img, Link, Row, Section, Text } from "@react-email/components";
import { FileDigitIcon, FileType2Icon } from "lucide-react";
import { getQuestionResponseMapping } from "@formbricks/lib/responses";
import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils";
import type { TOrganization } from "@formbricks/types/organizations";
@@ -108,6 +109,27 @@ export function ResponseFinishedEmail({
</Row>
);
})}
{survey.variables.map((variable) => {
const variableResponse = response.variables[variable.id];
if (variableResponse && ["number", "string"].includes(typeof variable)) {
return (
<Row key={variable.id}>
<Column className="w-full">
<Text className="mb-2 flex items-center gap-2 font-medium">
{variable.type === "number" ? (
<FileDigitIcon className="h-4 w-4" />
) : (
<FileType2Icon className="h-4 w-4" />
)}
{variable.name}
</Text>
<Text className="mt-0 whitespace-pre-wrap break-words font-bold">{variableResponse}</Text>
</Column>
</Row>
);
}
return null;
})}
{survey.hiddenFields.fieldIds?.map((hiddenFieldId) => {
const hiddenFieldResponse = response.data[hiddenFieldId];
if (hiddenFieldResponse && typeof hiddenFieldResponse === "string") {

View File

@@ -192,6 +192,7 @@ const renderWidget = async (
url: window.location.href,
action,
},
variables: responseUpdate.variables,
hiddenFields,
displayId: surveyState.displayId,
});

View File

@@ -187,6 +187,7 @@ const renderWidget = async (
url: window.location.href,
action,
},
variables: responseUpdate.variables,
hiddenFields,
displayId: surveyState.displayId,
});

View File

@@ -105,6 +105,7 @@ export const env = createEnv({
NEXT_PUBLIC_POSTHOG_API_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_API_HOST: z.string().optional(),
NEXT_PUBLIC_SENTRY_DSN: z.string().optional(),
NEXT_PUBLIC_E2E_TESTING: z.enum(["1", "0"]).optional(),
},
/*
* Due to how Next.js bundles environment variables on Edge and Client,
@@ -187,5 +188,6 @@ export const env = createEnv({
VERCEL_URL: process.env.VERCEL_URL,
WEBAPP_URL: process.env.WEBAPP_URL,
UNSPLASH_ACCESS_KEY: process.env.UNSPLASH_ACCESS_KEY,
NEXT_PUBLIC_E2E_TESTING: process.env.NEXT_PUBLIC_E2E_TESTING,
},
});

View File

@@ -59,6 +59,7 @@ export const responseSelection = {
data: true,
meta: true,
ttc: true,
variables: true,
personAttributes: true,
singleUseId: true,
language: true,
@@ -260,6 +261,7 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
data,
meta,
singleUseId,
variables,
ttc: initialTtc,
createdAt,
updatedAt,
@@ -308,7 +310,7 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
}),
...(meta && ({ meta } as Prisma.JsonObject)),
singleUseId,
...(variables && { variables }),
ttc: ttc,
createdAt,
updatedAt,
@@ -591,7 +593,7 @@ export const getResponseDownloadUrl = async (
);
const responses = responsesArray.flat();
const { metaDataFields, questions, hiddenFields, userAttributes } = extractSurveyDetails(
const { metaDataFields, questions, hiddenFields, variables, userAttributes } = extractSurveyDetails(
survey,
responses
);
@@ -608,6 +610,7 @@ export const getResponseDownloadUrl = async (
"Tags",
...metaDataFields,
...questions,
...variables,
...hiddenFields,
...userAttributes,
];
@@ -718,6 +721,10 @@ export const updateResponse = async (
: responseInput.ttc
: {};
const language = responseInput.language;
const variables = {
...currentResponse.variables,
...responseInput.variables,
};
const responsePrisma = await prisma.response.update({
where: {
@@ -728,6 +735,7 @@ export const updateResponse = async (
data,
ttc,
language,
variables,
},
select: responseSelection,
});

View File

@@ -99,6 +99,7 @@ export const mockResponse: ResponseMock = {
updatedAt: new Date(),
language: "English",
ttc: {},
variables: {},
};
const getMockTags = (tags: string[]): { tag: TTag }[] => {
@@ -126,6 +127,7 @@ export const mockResponses: ResponseMock[] = [
},
meta: mockMeta,
ttc: {},
variables: {},
personAttributes: {
Plan: "Paid",
"Init Attribute 1": "six",
@@ -150,6 +152,7 @@ export const mockResponses: ResponseMock[] = [
},
meta: mockMeta,
ttc: {},
variables: {},
personAttributes: {
Plan: "Paid",
"Init Attribute 1": "six",
@@ -173,6 +176,7 @@ export const mockResponses: ResponseMock[] = [
},
meta: mockMeta,
ttc: {},
variables: {},
personAttributes: {
Plan: "Paid",
"Init Attribute 1": "six",
@@ -196,6 +200,7 @@ export const mockResponses: ResponseMock[] = [
},
meta: mockMeta,
ttc: {},
variables: {},
personAttributes: {
Plan: "Paid",
"Init Attribute 1": "eight",
@@ -219,6 +224,7 @@ export const mockResponses: ResponseMock[] = [
},
meta: mockMeta,
ttc: {},
variables: {},
personAttributes: {
Plan: "Paid",
"Init Attribute 1": "eight",

View File

@@ -2,9 +2,11 @@ import "server-only";
import { Prisma } from "@prisma/client";
import {
TResponse,
TResponseData,
TResponseFilterCriteria,
TResponseHiddenFieldsFilter,
TResponseTtc,
TResponseVariables,
TSurveyMetaFieldFilter,
TSurveyPersonAttributes,
} from "@formbricks/types/responses";
@@ -12,6 +14,7 @@ import {
TSurvey,
TSurveyLanguage,
TSurveyMultipleChoiceQuestion,
TSurveyQuestion,
TSurveyQuestionSummaryAddress,
TSurveyQuestionSummaryDate,
TSurveyQuestionSummaryFileUpload,
@@ -25,9 +28,10 @@ import {
TSurveySummary,
} from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "../i18n/utils";
import { structuredClone } from "../pollyfills/structuredClone";
import { processResponseData } from "../responses";
import { evaluateLogic, performActions } from "../surveyLogic/utils";
import { getTodaysDateTimeFormatted } from "../time";
import { evaluateCondition } from "../utils/evaluateLogic";
import { sanitizeString } from "../utils/strings";
export const calculateTtcTotal = (ttc: TResponseTtc) => {
@@ -483,11 +487,13 @@ export const extractSurveyDetails = (survey: TSurvey, responses: TResponse[]) =>
return `${idx + 1}. ${headline}`;
});
const hiddenFields = survey.hiddenFields?.fieldIds || [];
const userAttributes = Array.from(
new Set(responses.map((response) => Object.keys(response.personAttributes ?? {})).flat())
);
const userAttributes =
survey.type === "app"
? Array.from(new Set(responses.map((response) => Object.keys(response.personAttributes ?? {})).flat()))
: [];
const variables = survey.variables?.map((variable) => variable.name) || [];
return { metaDataFields, questions, hiddenFields, userAttributes };
return { metaDataFields, questions, hiddenFields, variables, userAttributes };
};
export const getResponsesJson = (
@@ -531,6 +537,11 @@ export const getResponsesJson = (
jsonData[idx][question] = processResponseData(answer);
});
survey.variables?.forEach((variable) => {
const answer = response.variables[variable.id];
jsonData[idx][variable.name] = answer;
});
// user attributes
userAttributes.forEach((attribute) => {
jsonData[idx][attribute] = response.personAttributes?.[attribute] || "";
@@ -610,6 +621,14 @@ export const getSurveySummaryDropOff = (
let impressionsArr = new Array(survey.questions.length).fill(0) as number[];
let dropOffPercentageArr = new Array(survey.questions.length).fill(0) as number[];
const surveyVariablesData = survey.variables?.reduce(
(acc, variable) => {
acc[variable.id] = variable.value;
return acc;
},
{} as Record<string, string | number>
);
responses.forEach((response) => {
// Calculate total time-to-completion
Object.keys(totalTtc).forEach((questionId) => {
@@ -619,44 +638,20 @@ export const getSurveySummaryDropOff = (
}
});
let localSurvey = structuredClone(survey);
let localResponseData: TResponseData = { ...response.data };
let localVariables: TResponseVariables = {
...surveyVariablesData,
};
let currQuesIdx = 0;
while (currQuesIdx < survey.questions.length) {
const currQues = survey.questions[currQuesIdx];
while (currQuesIdx < localSurvey.questions.length) {
const currQues = localSurvey.questions[currQuesIdx];
if (!currQues) break;
if (!currQues.required) {
if (!response.data[currQues.id]) {
impressionsArr[currQuesIdx]++;
if (currQuesIdx === survey.questions.length - 1 && !response.finished) {
dropOffArr[currQuesIdx]++;
break;
}
const questionHasCustomLogic = currQues.logic;
if (questionHasCustomLogic) {
let didLogicPass = false;
for (let logic of questionHasCustomLogic) {
if (!logic.destination) continue;
if (evaluateCondition(logic, response.data[currQues.id] ?? null)) {
didLogicPass = true;
currQuesIdx = survey.questions.findIndex((q) => q.id === logic.destination);
break;
}
}
if (!didLogicPass) currQuesIdx++;
} else {
currQuesIdx++;
}
continue;
}
}
if (
(response.data[currQues.id] === undefined && !response.finished) ||
(currQues.required && !response.data[currQues.id])
) {
// question is not answered and required
if (response.data[currQues.id] === undefined && currQues.required) {
dropOffArr[currQuesIdx]++;
impressionsArr[currQuesIdx]++;
break;
@@ -664,26 +659,29 @@ export const getSurveySummaryDropOff = (
impressionsArr[currQuesIdx]++;
let nextQuesIdx = currQuesIdx + 1;
const questionHasCustomLogic = currQues.logic;
const { nextQuestionId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextQuestionId(
localSurvey,
localResponseData,
localVariables,
currQuesIdx,
currQues,
response.language
);
if (questionHasCustomLogic) {
for (let logic of questionHasCustomLogic) {
if (!logic.destination) continue;
if (evaluateCondition(logic, response.data[currQues.id])) {
nextQuesIdx = survey.questions.findIndex((q) => q.id === logic.destination);
break;
}
localSurvey = updatedSurvey;
localVariables = updatedVariables;
if (nextQuestionId) {
const nextQuesIdx = survey.questions.findIndex((q) => q.id === nextQuestionId);
if (!response.data[nextQuestionId] && !response.finished) {
dropOffArr[nextQuesIdx]++;
impressionsArr[nextQuesIdx]++;
break;
}
currQuesIdx = nextQuesIdx;
} else {
currQuesIdx++;
}
if (!response.data[survey.questions[nextQuesIdx]?.id] && !response.finished) {
dropOffArr[nextQuesIdx]++;
impressionsArr[nextQuesIdx]++;
break;
}
currQuesIdx = nextQuesIdx;
}
});
@@ -727,6 +725,51 @@ export const getSurveySummaryDropOff = (
return dropOff;
};
const evaluateLogicAndGetNextQuestionId = (
localSurvey: TSurvey,
data: TResponseData,
localVariables: TResponseVariables,
currentQuestionIndex: number,
currQuesTemp: TSurveyQuestion,
selectedLanguage: string | null
): { nextQuestionId: string | undefined; updatedSurvey: TSurvey; updatedVariables: TResponseVariables } => {
const questions = localSurvey.questions;
let updatedSurvey = { ...localSurvey };
let updatedVariables = { ...localVariables };
let firstJumpTarget: string | undefined;
if (currQuesTemp.logic && currQuesTemp.logic.length > 0) {
for (const logic of currQuesTemp.logic) {
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
const { jumpTarget, requiredQuestionIds, calculations } = performActions(
updatedSurvey,
logic.actions,
data,
updatedVariables
);
if (requiredQuestionIds.length > 0) {
updatedSurvey.questions = updatedSurvey.questions.map((q) =>
requiredQuestionIds.includes(q.id) ? { ...q, required: true } : q
);
}
updatedVariables = { ...updatedVariables, ...calculations };
if (jumpTarget && !firstJumpTarget) {
firstJumpTarget = jumpTarget;
}
}
}
}
// Return the first jump target if found, otherwise go to the next question
const nextQuestionId = firstJumpTarget || questions[currentQuestionIndex + 1]?.id || undefined;
return { nextQuestionId, updatedSurvey, updatedVariables };
};
const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: string | null) => {
if (!surveyLanguages?.length || !languageCode) return "default";
const language = surveyLanguages.find((surveyLanguage) => surveyLanguage.language.code === languageCode);

View File

@@ -908,6 +908,7 @@ export const copySurveyToOtherEnvironment = async (
welcomeCard: structuredClone(existingSurvey.welcomeCard),
questions: structuredClone(existingSurvey.questions),
endings: structuredClone(existingSurvey.endings),
variables: structuredClone(existingSurvey.variables),
hiddenFields: structuredClone(existingSurvey.hiddenFields),
languages: hasLanguages
? {

View File

@@ -290,3 +290,236 @@ export const mockTransformedSurveyOutput = {
export const mockTransformedSyncSurveyOutput = {
...mockSyncSurveyOutput,
};
export const mockSurveyWithLogic: TSurvey = {
...mockSyncSurveyOutput,
...baseSurveyProperties,
displayPercentage: null,
segment: null,
type: "link",
endings: [],
hiddenFields: { enabled: true, fieldIds: ["name"] },
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
inputType: "text",
headline: { default: "What is your favorite color?" },
required: true,
logic: [
{
id: "cdu9vgtmmd9b24l35pp9bodk",
conditions: {
id: "d21qg6x5fk65pf592jys5rcz",
connector: "and",
conditions: [
{
id: "swlje0bsnh6lkyk8vqs13oyr",
leftOperand: { type: "question", value: "q1" },
operator: "equals",
rightOperand: { type: "static", value: "blue" },
},
],
},
actions: [],
},
],
},
{
id: "q2",
type: TSurveyQuestionTypeEnum.OpenText,
inputType: "text",
headline: { default: "What is your favorite food?" },
required: true,
logic: [
{
id: "uwlm6kazj5pbt6licpa1hw5c",
conditions: {
id: "cvqxpbjydwktz4f9mvit2i11",
connector: "and",
conditions: [
{
id: "n74oght3ozqgwm9rifp2fxrr",
leftOperand: { type: "question", value: "q1" },
operator: "equals",
rightOperand: { type: "static", value: "blue" },
},
{
id: "fg4c9dwt9qjy8aba7zxbfdqd",
leftOperand: { type: "question", value: "q2" },
operator: "equals",
rightOperand: { type: "static", value: "pizza" },
},
],
},
actions: [],
},
],
},
{
id: "q3",
type: TSurveyQuestionTypeEnum.OpenText,
inputType: "text",
headline: { default: "What is your favorite movie?" },
required: true,
logic: [
{
id: "dpi3zipezuo1idplztb1abes",
conditions: {
id: "h3tp53lf8lri4pjcqc1xz3d8",
connector: "or",
conditions: [
{
id: "tmj7p9d3kpz1v4mcgpguqytw",
leftOperand: { type: "question", value: "q2" },
operator: "equals",
rightOperand: { type: "static", value: "pizza" },
},
{
id: "rs7v5mmoetff7x8lo1gdsgpr",
leftOperand: { type: "question", value: "q3" },
operator: "equals",
rightOperand: { type: "static", value: "Inception" },
},
],
},
actions: [],
},
],
},
{
id: "q4",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Select a number:" },
choices: [
{ id: "mvedaklp0gxxycprpyhhwen7", label: { default: "lol" } },
{ id: "i7ws8uqyj66q5x086vbqtm8n", label: { default: "lmao" } },
{ id: "cy8hbbr9e2q6ywbfjbzwdsqn", label: { default: "XD" } },
{ id: "sojc5wwxc5gxrnuib30w7t6s", label: { default: "hehe" } },
],
required: true,
logic: [
{
id: "fbim31ttxe1s7qkrjzkj1mtc",
conditions: {
id: "db44yagvr140wahafu0n11x6",
connector: "and",
conditions: [
{
id: "ddhaccfqy7rr3d5jdswl8yl8",
leftOperand: { type: "variable", value: "siog1dabtpo3l0a3xoxw2922" },
operator: "equals",
rightOperand: { type: "question", value: "q4" },
},
],
},
actions: [],
},
],
},
{
id: "q5",
type: TSurveyQuestionTypeEnum.OpenText,
inputType: "number",
headline: { default: "Select your age group:" },
required: true,
logic: [
{
id: "o6n73uq9rysih9mpcbzlehfs",
conditions: {
id: "szdkmtz17j9008n4i2d1t040",
connector: "and",
conditions: [
{
id: "rb223vmzuuzo3ag1bp2m3i69",
leftOperand: { type: "variable", value: "km1srr55owtn2r7lkoh5ny1u" },
operator: "isGreaterThan",
rightOperand: { type: "static", value: 30 },
},
{
id: "ot894j7nwna24i6jo2zpk59o",
leftOperand: { type: "variable", value: "km1srr55owtn2r7lkoh5ny1u" },
operator: "isLessThan",
rightOperand: { type: "question", value: "q5" },
},
],
},
actions: [],
},
],
},
{
id: "q6",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Select your age group:" },
required: true,
choices: [
{ id: "mvedaklp0gxxycprpyhhwen7", label: { default: "lol" } },
{ id: "i7ws8uqyj66q5x086vbqtm8n", label: { default: "lmao" } },
{ id: "cy8hbbr9e2q6ywbfjbzwdsqn", label: { default: "XD" } },
{ id: "sojc5wwxc5gxrnuib30w7t6s", label: { default: "hehe" } },
],
logic: [
{
id: "o6n73uq9rysih9mpcbzlehfs",
conditions: {
id: "szdkmtz17j9008n4i2d1t040",
connector: "and",
conditions: [
{
id: "rb223vmzuuzo3ag1bp2m3i69",
leftOperand: { type: "question", value: "q6" },
operator: "includesOneOf",
rightOperand: {
type: "static",
value: ["i7ws8uqyj66q5x086vbqtm8n", "cy8hbbr9e2q6ywbfjbzwdsqn"],
},
},
{
id: "ot894j7nwna24i6jo2zpk59o",
leftOperand: { type: "question", value: "q1" },
operator: "doesNotEqual",
rightOperand: { type: "static", value: "teal" },
},
{
id: "j1appouxk700of7u8m15z625",
connector: "or",
conditions: [
{
id: "gy6xowchkv8bp1qj7ur79jvc",
leftOperand: { type: "question", value: "q2" },
operator: "doesNotEqual",
rightOperand: { type: "static", value: "pizza" },
},
{
id: "vxyccgwsbq34s3l0syom7y2w",
leftOperand: { type: "hiddenField", value: "name" },
operator: "contains",
rightOperand: { type: "question", value: "q2" },
},
],
},
{
id: "yunz0k9w0xwparogz2n1twoy",
leftOperand: { type: "question", value: "q3" },
operator: "doesNotEqual",
rightOperand: { type: "static", value: "Inception" },
},
{
id: "x2j6qz3z7x9m3q5jz9x7c7v4",
leftOperand: { type: "variable", value: "siog1dabtpo3l0a3xoxw2922" },
operator: "endsWith",
rightOperand: { type: "static", value: "yo" },
},
],
},
actions: [],
},
],
},
],
variables: [
{ id: "siog1dabtpo3l0a3xoxw2922", type: "text", name: "var1", value: "lmao" },
{ id: "km1srr55owtn2r7lkoh5ny1u", type: "number", name: "var2", value: 32 },
],
};

View File

@@ -1,6 +1,7 @@
import { prisma } from "../../__mocks__/database";
import { mockResponseNote, mockResponseWithMockPerson } from "../../response/tests/__mocks__/data.mock";
import { Prisma } from "@prisma/client";
import { evaluateLogic } from "surveyLogic/utils";
import { beforeEach, describe, expect, it } from "vitest";
import { testInputValidation } from "vitestSetup";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -26,6 +27,7 @@ import {
mockPrismaPerson,
mockProduct,
mockSurveyOutput,
mockSurveyWithLogic,
mockSyncSurveyOutput,
mockTransformedSurveyOutput,
mockTransformedSyncSurveyOutput,
@@ -37,6 +39,148 @@ beforeEach(() => {
prisma.survey.count.mockResolvedValue(1);
});
describe("evaluateLogic with mockSurveyWithLogic", () => {
it("should return true when q1 answer is blue", () => {
const data = { q1: "blue" };
const variablesData = {};
const result = evaluateLogic(
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[0].logic![0].conditions,
"default"
);
expect(result).toBe(true);
});
it("should return false when q1 answer is not blue", () => {
const data = { q1: "red" };
const variablesData = {};
const result = evaluateLogic(
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[0].logic![0].conditions,
"default"
);
expect(result).toBe(false);
});
it("should return true when q1 is blue and q2 is pizza", () => {
const data = { q1: "blue", q2: "pizza" };
const variablesData = {};
const result = evaluateLogic(
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[1].logic![0].conditions,
"default"
);
expect(result).toBe(true);
});
it("should return false when q1 is blue but q2 is not pizza", () => {
const data = { q1: "blue", q2: "burger" };
const variablesData = {};
const result = evaluateLogic(
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[1].logic![0].conditions,
"default"
);
expect(result).toBe(false);
});
it("should return true when q2 is pizza or q3 is Inception", () => {
const data = { q2: "pizza", q3: "Inception" };
const variablesData = {};
const result = evaluateLogic(
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[2].logic![0].conditions,
"default"
);
expect(result).toBe(true);
});
it("should return true when var1 is equal to single select question value", () => {
const data = { q4: "lmao" };
const variablesData = { siog1dabtpo3l0a3xoxw2922: "lmao" };
const result = evaluateLogic(
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[3].logic![0].conditions,
"default"
);
expect(result).toBe(true);
});
it("should return false when var1 is not equal to single select question value", () => {
const data = { q4: "lol" };
const variablesData = { siog1dabtpo3l0a3xoxw2922: "damn" };
const result = evaluateLogic(
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[3].logic![0].conditions,
"default"
);
expect(result).toBe(false);
});
it("should return true when var2 is greater than 30 and less than open text number value", () => {
const data = { q5: "40" };
const variablesData = { km1srr55owtn2r7lkoh5ny1u: 35 };
const result = evaluateLogic(
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[4].logic![0].conditions,
"default"
);
expect(result).toBe(true);
});
it("should return false when var2 is not greater than 30 or greater than open text number value", () => {
const data = { q5: "40" };
const variablesData = { km1srr55owtn2r7lkoh5ny1u: 25 };
const result = evaluateLogic(
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[4].logic![0].conditions,
"default"
);
expect(result).toBe(false);
});
it("should return for complex condition", () => {
const data = { q6: ["lmao", "XD"], q1: "green", q2: "pizza", q3: "inspection", name: "pizza" };
const variablesData = { siog1dabtpo3l0a3xoxw2922: "tokyo" };
const result = evaluateLogic(
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[5].logic![0].conditions,
"default"
);
expect(result).toBe(true);
});
});
describe("Tests for getSurvey", () => {
describe("Happy Path", () => {
it("Returns a survey", async () => {
@@ -263,7 +407,7 @@ describe("Tests for duplicateSurvey", () => {
prisma.actionClass.findFirst.mockResolvedValueOnce(mockActionClass);
prisma.actionClass.create.mockResolvedValueOnce(mockActionClass);
const createdSurvey = await copySurveyToOtherEnvironment(mockId, mockId, mockId, mockId, mockId);
const createdSurvey = await copySurveyToOtherEnvironment(mockId, mockId, mockId, mockId);
expect(createdSurvey).toEqual(mockSurveyOutput);
});
});
@@ -273,7 +417,7 @@ describe("Tests for duplicateSurvey", () => {
it("Throws ResourceNotFoundError if the survey does not exist", async () => {
prisma.survey.findUnique.mockRejectedValueOnce(new ResourceNotFoundError("Survey", mockId));
await expect(copySurveyToOtherEnvironment(mockId, mockId, mockId, mockId, mockId)).rejects.toThrow(
await expect(copySurveyToOtherEnvironment(mockId, mockId, mockId, mockId)).rejects.toThrow(
ResourceNotFoundError
);
});
@@ -281,9 +425,7 @@ describe("Tests for duplicateSurvey", () => {
it("should throw an error if there is an unknown error", async () => {
const mockErrorMessage = "Unknown error occurred";
prisma.survey.create.mockRejectedValue(new Error(mockErrorMessage));
await expect(copySurveyToOtherEnvironment(mockId, mockId, mockId, mockId, mockId)).rejects.toThrow(
Error
);
await expect(copySurveyToOtherEnvironment(mockId, mockId, mockId, mockId)).rejects.toThrow(Error);
});
});
});

View File

@@ -0,0 +1,655 @@
import { createId } from "@paralleldrive/cuid2";
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
import {
TActionCalculate,
TActionObjective,
TConditionGroup,
TSingleCondition,
TSurvey,
TSurveyLogic,
TSurveyLogicAction,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveyVariable,
} from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "../i18n/utils";
type TCondition = TSingleCondition | TConditionGroup;
export const isConditionGroup = (condition: TCondition): condition is TConditionGroup => {
return (condition as TConditionGroup).connector !== undefined;
};
export const duplicateLogicItem = (logicItem: TSurveyLogic): TSurveyLogic => {
const duplicateConditionGroup = (group: TConditionGroup): TConditionGroup => {
return {
...group,
id: createId(),
conditions: group.conditions.map((condition) => {
if (isConditionGroup(condition)) {
return duplicateConditionGroup(condition);
} else {
return duplicateCondition(condition);
}
}),
};
};
const duplicateCondition = (condition: TSingleCondition): TSingleCondition => {
return {
...condition,
id: createId(),
};
};
const duplicateAction = (action: TSurveyLogicAction): TSurveyLogicAction => {
return {
...action,
id: createId(),
};
};
return {
...logicItem,
id: createId(),
conditions: duplicateConditionGroup(logicItem.conditions),
actions: logicItem.actions.map(duplicateAction),
};
};
export const addConditionBelow = (
group: TConditionGroup,
resourceId: string,
condition: TSingleCondition
) => {
for (let i = 0; i < group.conditions.length; i++) {
const item = group.conditions[i];
if (isConditionGroup(item)) {
if (item.id === resourceId) {
group.conditions.splice(i + 1, 0, condition);
break;
} else {
addConditionBelow(item, resourceId, condition);
}
} else {
if (item.id === resourceId) {
group.conditions.splice(i + 1, 0, condition);
break;
}
}
}
};
export const toggleGroupConnector = (group: TConditionGroup, resourceId: string) => {
if (group.id === resourceId) {
group.connector = group.connector === "and" ? "or" : "and";
return;
}
for (const condition of group.conditions) {
if (condition.connector) {
toggleGroupConnector(condition, resourceId);
}
}
};
export const removeCondition = (group: TConditionGroup, resourceId: string) => {
for (let i = 0; i < group.conditions.length; i++) {
const item = group.conditions[i];
if (item.id === resourceId) {
group.conditions.splice(i, 1);
return;
}
if (isConditionGroup(item)) {
removeCondition(item, resourceId);
}
}
deleteEmptyGroups(group);
};
export const duplicateCondition = (group: TConditionGroup, resourceId: string) => {
for (let i = 0; i < group.conditions.length; i++) {
const item = group.conditions[i];
if (item.id === resourceId) {
const newItem: TCondition = {
...item,
id: createId(),
};
group.conditions.splice(i + 1, 0, newItem);
return;
}
if (item.connector) {
duplicateCondition(item, resourceId);
}
}
};
export const deleteEmptyGroups = (group: TConditionGroup) => {
for (let i = 0; i < group.conditions.length; i++) {
const resource = group.conditions[i];
if (isConditionGroup(resource) && resource.conditions.length === 0) {
group.conditions.splice(i, 1);
} else if (isConditionGroup(resource)) {
deleteEmptyGroups(resource);
}
}
};
export const createGroupFromResource = (group: TConditionGroup, resourceId: string) => {
for (let i = 0; i < group.conditions.length; i++) {
const item = group.conditions[i];
if (item.id === resourceId) {
const newGroup: TConditionGroup = {
id: createId(),
connector: "and",
conditions: [item],
};
group.conditions[i] = newGroup;
group.connector = group.connector ?? "and";
return;
}
if (isConditionGroup(item)) {
createGroupFromResource(item, resourceId);
}
}
};
export const updateCondition = (
group: TConditionGroup,
resourceId: string,
condition: Partial<TSingleCondition>
) => {
for (let i = 0; i < group.conditions.length; i++) {
const item = group.conditions[i];
if (item.id === resourceId && !("connector" in item)) {
group.conditions[i] = { ...item, ...condition } as TSingleCondition;
return;
}
if (isConditionGroup(item)) {
updateCondition(item, resourceId, condition);
}
}
};
export const getUpdatedActionBody = (
action: TSurveyLogicAction,
objective: TActionObjective
): TSurveyLogicAction => {
if (objective === action.objective) return action;
switch (objective) {
case "calculate":
return {
id: action.id,
objective: "calculate",
variableId: "",
operator: "assign",
value: { type: "static", value: "" },
};
case "requireAnswer":
return {
id: action.id,
objective: "requireAnswer",
target: "",
};
case "jumpToQuestion":
return {
id: action.id,
objective: "jumpToQuestion",
target: "",
};
}
};
export const evaluateLogic = (
localSurvey: TSurvey,
data: TResponseData,
variablesData: TResponseVariables,
conditions: TConditionGroup,
selectedLanguage: string
): boolean => {
const evaluateConditionGroup = (group: TConditionGroup): boolean => {
const results = group.conditions.map((condition) => {
if (isConditionGroup(condition)) {
return evaluateConditionGroup(condition);
} else {
return evaluateSingleCondition(localSurvey, data, variablesData, condition, selectedLanguage);
}
});
return group.connector === "or" ? results.some((r) => r) : results.every((r) => r);
};
return evaluateConditionGroup(conditions);
};
const evaluateSingleCondition = (
localSurvey: TSurvey,
data: TResponseData,
variablesData: TResponseVariables,
condition: TSingleCondition,
selectedLanguage: string
): boolean => {
try {
let leftValue = getLeftOperandValue(
localSurvey,
data,
variablesData,
condition.leftOperand,
selectedLanguage
);
let rightValue = condition.rightOperand
? getRightOperandValue(localSurvey, data, variablesData, condition.rightOperand)
: undefined;
let leftField: TSurveyQuestion | TSurveyVariable | string;
if (condition.leftOperand?.type === "question") {
leftField = localSurvey.questions.find((q) => q.id === condition.leftOperand?.value) as TSurveyQuestion;
} else if (condition.leftOperand?.type === "variable") {
leftField = localSurvey.variables.find((v) => v.id === condition.leftOperand?.value) as TSurveyVariable;
} else if (condition.leftOperand?.type === "hiddenField") {
leftField = condition.leftOperand.value as string;
} else {
leftField = "";
}
let rightField: TSurveyQuestion | TSurveyVariable | string;
if (condition.rightOperand?.type === "question") {
rightField = localSurvey.questions.find(
(q) => q.id === condition.rightOperand?.value
) as TSurveyQuestion;
} else if (condition.rightOperand?.type === "variable") {
rightField = localSurvey.variables.find(
(v) => v.id === condition.rightOperand?.value
) as TSurveyVariable;
} else if (condition.rightOperand?.type === "hiddenField") {
rightField = condition.rightOperand.value as string;
} else {
rightField = "";
}
if (
condition.leftOperand.type === "variable" &&
(leftField as TSurveyVariable).type === "number" &&
condition.rightOperand?.type === "hiddenField"
) {
rightValue = Number(rightValue as string);
}
switch (condition.operator) {
case "equals":
if (condition.leftOperand.type === "question") {
if (
(leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date &&
typeof leftValue === "string" &&
typeof rightValue === "string"
) {
// when left value is of date question and right value is string
return new Date(leftValue).getTime() === new Date(rightValue).getTime();
}
}
// when left value is of openText, hiddenField, variable and right value is of multichoice
if (condition.rightOperand?.type === "question") {
if ((rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) {
return rightValue.includes(leftValue as string);
} else return false;
} else if (
(rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date &&
typeof leftValue === "string" &&
typeof rightValue === "string"
) {
return new Date(leftValue).getTime() === new Date(rightValue).getTime();
}
}
return (
(Array.isArray(leftValue) &&
leftValue.length === 1 &&
typeof rightValue === "string" &&
leftValue.includes(rightValue)) ||
leftValue === rightValue
);
case "doesNotEqual":
// when left value is of picture selection question and right value is its option
if (
condition.leftOperand.type === "question" &&
(leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.PictureSelection &&
Array.isArray(leftValue) &&
leftValue.length > 0 &&
typeof rightValue === "string"
) {
return !leftValue.includes(rightValue);
}
// when left value is of date question and right value is string
if (
condition.leftOperand.type === "question" &&
(leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date &&
typeof leftValue === "string" &&
typeof rightValue === "string"
) {
return new Date(leftValue).getTime() !== new Date(rightValue).getTime();
}
// when left value is of openText, hiddenField, variable and right value is of multichoice
if (condition.rightOperand?.type === "question") {
if ((rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) {
return !rightValue.includes(leftValue as string);
} else return false;
} else if (
(rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date &&
typeof leftValue === "string" &&
typeof rightValue === "string"
) {
return new Date(leftValue).getTime() !== new Date(rightValue).getTime();
}
}
return (
(Array.isArray(leftValue) &&
leftValue.length === 1 &&
typeof rightValue === "string" &&
!leftValue.includes(rightValue)) ||
leftValue !== rightValue
);
case "contains":
return String(leftValue).includes(String(rightValue));
case "doesNotContain":
return !String(leftValue).includes(String(rightValue));
case "startsWith":
return String(leftValue).startsWith(String(rightValue));
case "doesNotStartWith":
return !String(leftValue).startsWith(String(rightValue));
case "endsWith":
return String(leftValue).endsWith(String(rightValue));
case "doesNotEndWith":
return !String(leftValue).endsWith(String(rightValue));
case "isSubmitted":
if (typeof leftValue === "string") {
if (
condition.leftOperand.type === "question" &&
(leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.FileUpload &&
leftValue
) {
return leftValue !== "skipped";
}
return leftValue !== "" && leftValue !== null;
} else if (Array.isArray(leftValue)) {
return leftValue.length > 0;
} else if (typeof leftValue === "number") {
return leftValue !== null;
}
return false;
case "isSkipped":
return (
(Array.isArray(leftValue) && leftValue.length === 0) ||
leftValue === "" ||
leftValue === null ||
leftValue === undefined ||
(typeof leftValue === "object" && Object.entries(leftValue).length === 0)
);
case "isGreaterThan":
return Number(leftValue) > Number(rightValue);
case "isLessThan":
return Number(leftValue) < Number(rightValue);
case "isGreaterThanOrEqual":
return Number(leftValue) >= Number(rightValue);
case "isLessThanOrEqual":
return Number(leftValue) <= Number(rightValue);
case "equalsOneOf":
return Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.includes(leftValue);
case "includesAllOf":
return (
Array.isArray(leftValue) &&
Array.isArray(rightValue) &&
rightValue.every((v) => leftValue.includes(v))
);
case "includesOneOf":
return (
Array.isArray(leftValue) &&
Array.isArray(rightValue) &&
rightValue.some((v) => leftValue.includes(v))
);
case "isAccepted":
return leftValue === "accepted";
case "isClicked":
return leftValue === "clicked";
case "isAfter":
return new Date(String(leftValue)) > new Date(String(rightValue));
case "isBefore":
return new Date(String(leftValue)) < new Date(String(rightValue));
case "isBooked":
return leftValue === "booked" || !!(leftValue && leftValue !== "");
case "isPartiallySubmitted":
if (typeof leftValue === "object") {
return Object.values(leftValue).includes("");
} else return false;
case "isCompletelySubmitted":
if (typeof leftValue === "object") {
const values = Object.values(leftValue);
return values.length > 0 && !values.includes("");
} else return false;
default:
return false;
}
} catch (e) {
return false;
}
};
const getVariableValue = (
variables: TSurveyVariable[],
variableId: string,
variablesData: TResponseVariables
) => {
const variable = variables.find((v) => v.id === variableId);
if (!variable) return undefined;
const variableValue = variablesData[variableId];
return variable.type === "number" ? Number(variableValue) || 0 : variableValue || "";
};
const getLeftOperandValue = (
localSurvey: TSurvey,
data: TResponseData,
variablesData: TResponseVariables,
leftOperand: TSingleCondition["leftOperand"],
selectedLanguage: string
) => {
switch (leftOperand.type) {
case "question":
const currentQuestion = localSurvey.questions.find((q) => q.id === leftOperand.value);
if (!currentQuestion) return undefined;
const responseValue = data[leftOperand.value];
if (currentQuestion.type === "openText" && currentQuestion.inputType === "number") {
return Number(responseValue) || undefined;
}
if (currentQuestion.type === "multipleChoiceSingle" || currentQuestion.type === "multipleChoiceMulti") {
const isOthersEnabled = currentQuestion.choices.at(-1)?.id === "other";
if (typeof responseValue === "string") {
const choice = currentQuestion.choices.find((choice) => {
return getLocalizedValue(choice.label, selectedLanguage) === responseValue;
});
if (!choice) {
if (isOthersEnabled) {
return "other";
}
return undefined;
}
return choice.id;
} else if (Array.isArray(responseValue)) {
let choices: string[] = [];
responseValue.forEach((value) => {
const foundChoice = currentQuestion.choices.find((choice) => {
return getLocalizedValue(choice.label, selectedLanguage) === value;
});
if (foundChoice) {
choices.push(foundChoice.id);
} else if (isOthersEnabled) {
choices.push("other");
}
});
if (choices) {
return Array.from(new Set(choices));
}
}
}
return data[leftOperand.value];
case "variable":
const variables = localSurvey.variables || [];
return getVariableValue(variables, leftOperand.value, variablesData);
case "hiddenField":
return data[leftOperand.value];
default:
return undefined;
}
};
const getRightOperandValue = (
localSurvey: TSurvey,
data: TResponseData,
variablesData: TResponseVariables,
rightOperand: TSingleCondition["rightOperand"]
) => {
if (!rightOperand) return undefined;
switch (rightOperand.type) {
case "question":
return data[rightOperand.value];
case "variable":
const variables = localSurvey.variables || [];
return getVariableValue(variables, rightOperand.value, variablesData);
case "hiddenField":
return data[rightOperand.value];
case "static":
return rightOperand.value;
default:
return undefined;
}
};
export const performActions = (
survey: TSurvey,
actions: TSurveyLogicAction[],
data: TResponseData,
calculationResults: TResponseVariables
): {
jumpTarget: string | undefined;
requiredQuestionIds: string[];
calculations: TResponseVariables;
} => {
let jumpTarget: string | undefined;
const requiredQuestionIds: string[] = [];
const calculations: TResponseVariables = { ...calculationResults };
actions.forEach((action) => {
switch (action.objective) {
case "calculate":
const result = performCalculation(survey, action, data, calculations);
if (result !== undefined) calculations[action.variableId] = result;
break;
case "requireAnswer":
requiredQuestionIds.push(action.target);
break;
case "jumpToQuestion":
if (!jumpTarget) {
jumpTarget = action.target;
}
break;
}
});
return { jumpTarget, requiredQuestionIds, calculations };
};
const performCalculation = (
survey: TSurvey,
action: TActionCalculate,
data: TResponseData,
calculations: Record<string, number | string>
): number | string | undefined => {
const variables = survey.variables || [];
const variable = variables.find((v) => v.id === action.variableId);
if (!variable) return undefined;
let currentValue = calculations[action.variableId];
if (currentValue === undefined) {
currentValue = variable.type === "number" ? 0 : "";
}
let operandValue: string | number | undefined;
// Determine the operand value based on the action.value type
switch (action.value.type) {
case "static":
operandValue = action.value.value;
break;
case "variable":
const value = calculations[action.value.value];
if (typeof value === "number" || typeof value === "string") {
operandValue = value;
}
break;
case "question":
case "hiddenField":
const val = data[action.value.value];
if (typeof val === "number" || typeof val === "string") {
if (variable.type === "number" && !isNaN(Number(val))) {
operandValue = Number(val);
}
operandValue = val;
}
break;
}
if (operandValue === undefined || operandValue === null) return undefined;
let result: number | string;
switch (action.operator) {
case "add":
result = Number(currentValue) + Number(operandValue);
break;
case "subtract":
result = Number(currentValue) - Number(operandValue);
break;
case "multiply":
result = Number(currentValue) * Number(operandValue);
break;
case "divide":
if (Number(operandValue) === 0) return undefined;
result = Number(currentValue) / Number(operandValue);
break;
case "assign":
result = operandValue;
break;
case "concat":
result = String(currentValue) + String(operandValue);
break;
}
return result;
};

View File

@@ -5,7 +5,7 @@ export class SurveyState {
displayId: string | null = null;
userId: string | null = null;
surveyId: string;
responseAcc: TResponseUpdate = { finished: false, data: {}, ttc: {} };
responseAcc: TResponseUpdate = { finished: false, data: {}, ttc: {}, variables: {} };
singleUseId: string | null;
constructor(
@@ -76,6 +76,7 @@ export class SurveyState {
finished: responseUpdate.finished,
ttc: responseUpdate.ttc,
data: { ...this.responseAcc.data, ...responseUpdate.data },
variables: responseUpdate.variables,
displayId: responseUpdate.displayId,
};
}
@@ -92,6 +93,6 @@ export class SurveyState {
*/
clear() {
this.responseId = null;
this.responseAcc = { finished: false, data: {}, ttc: {} };
this.responseAcc = { finished: false, data: {}, ttc: {}, variables: {} };
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,54 +0,0 @@
import { TSurveyLogic } from "@formbricks/types/surveys/types";
export const evaluateCondition = (logic: TSurveyLogic, responseValue: any): boolean => {
switch (logic.condition) {
case "equals":
return (
(Array.isArray(responseValue) && responseValue.length === 1 && responseValue.includes(logic.value)) ||
responseValue?.toString() === logic.value
);
case "notEquals":
return responseValue !== logic.value;
case "lessThan":
return logic.value !== undefined && responseValue < logic.value;
case "lessEqual":
return logic.value !== undefined && responseValue <= logic.value;
case "greaterThan":
return logic.value !== undefined && responseValue > logic.value;
case "greaterEqual":
return logic.value !== undefined && responseValue >= logic.value;
case "includesAll":
return (
Array.isArray(responseValue) &&
Array.isArray(logic.value) &&
logic.value.every((v) => responseValue.includes(v))
);
case "includesOne":
return (
Array.isArray(responseValue) &&
Array.isArray(logic.value) &&
logic.value.some((v) => responseValue.includes(v))
);
case "accepted":
return responseValue === "accepted";
case "clicked":
return responseValue === "clicked";
case "submitted":
if (typeof responseValue === "string") {
return responseValue !== "" && responseValue !== null;
} else if (Array.isArray(responseValue)) {
return responseValue.length > 0;
} else if (typeof responseValue === "number") {
return responseValue !== null;
}
return false;
case "skipped":
return (
(Array.isArray(responseValue) && responseValue.length === 0) ||
responseValue === "" ||
responseValue === null
);
default:
return false;
}
};

View File

@@ -3,6 +3,8 @@ import {
ArrowUpFromLineIcon,
CalendarDaysIcon,
CheckIcon,
FileDigitIcon,
FileType2Icon,
Grid3X3Icon,
HomeIcon,
ImageIcon,
@@ -229,6 +231,14 @@ export const questionTypes: TQuestion[] = [
},
];
export const questionIconMapping = questionTypes.reduce(
(prev, curr) => ({
...prev,
[curr.id]: curr.icon,
}),
{}
);
export const CXQuestionTypes = questionTypes.filter((questionType) => {
return [
TSurveyQuestionTypeEnum.OpenText,
@@ -257,6 +267,11 @@ export const QUESTIONS_NAME_MAP = questionTypes.reduce(
{}
) as Record<TSurveyQuestionTypeEnum, string>;
export const VARIABLES_ICON_MAP = {
text: <FileType2Icon className="h-4 w-4" />,
number: <FileDigitIcon className="h-4 w-4" />,
};
export const CX_QUESTIONS_NAME_MAP = CXQuestionTypes.reduce(
(prev, curr) => ({
...prev,

View File

@@ -12,7 +12,7 @@ import { getLocalizedValue } from "../i18n/utils";
import { structuredClone } from "../pollyfills/structuredClone";
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
export interface fallbacks {
export interface TFallbackString {
[id: string]: string;
}
@@ -209,17 +209,19 @@ export const getRecallItems = (
};
// Constructs a fallbacks object from a text containing multiple recall and fallback patterns.
export const getFallbackValues = (text: string): fallbacks => {
export const getFallbackValues = (text: string): TFallbackString => {
if (!text.includes("#recall:")) return {};
const pattern = /#recall:([A-Za-z0-9_-]+)\/fallback:([\S*]+)#/g;
let match;
const fallbacks: fallbacks = {};
while ((match = pattern.exec(text)) !== null) {
const pattern = /#recall:([A-Za-z0-9_-]+)\/fallback:([\S*]+)#/g;
const fallbacks: TFallbackString = {};
let match = pattern.exec(text);
while (match !== null) {
const id = match[1];
const fallbackValue = match[2];
fallbacks[id] = fallbackValue;
}
return fallbacks;
};
@@ -227,7 +229,7 @@ export const getFallbackValues = (text: string): fallbacks => {
export const headlineToRecall = (
text: string,
recallItems: TSurveyRecallItem[],
fallbacks: fallbacks
fallbacks: TFallbackString
): string => {
recallItems.forEach((recallItem) => {
const recallInfo = `#recall:${recallItem.id}/fallback:${fallbacks[recallItem.id]}#`;

View File

@@ -7,7 +7,7 @@ import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { replaceRecallInfo } from "@/lib/recall";
import { useEffect } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TResponseData } from "@formbricks/types/responses";
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
import { TSurvey, TSurveyEndScreenCard, TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
interface EndingCardProps {
@@ -19,6 +19,7 @@ interface EndingCardProps {
isCurrent: boolean;
languageCode: string;
responseData: TResponseData;
variablesData: TResponseVariables;
}
export const EndingCard = ({
@@ -30,6 +31,7 @@ export const EndingCard = ({
isCurrent,
languageCode,
responseData,
variablesData,
}: EndingCardProps) => {
const media =
endingCard.type === "endScreen" && (endingCard.imageUrl || endingCard.videoUrl) ? (
@@ -99,7 +101,7 @@ export const EndingCard = ({
? replaceRecallInfo(
getLocalizedValue(endingCard.headline, languageCode),
responseData,
survey.variables
variablesData
)
: "Respondants will not see this card"
}
@@ -111,7 +113,7 @@ export const EndingCard = ({
? replaceRecallInfo(
getLocalizedValue(endingCard.subheader, languageCode),
responseData,
survey.variables
variablesData
)
: "They will be forwarded immediately"
}
@@ -123,7 +125,7 @@ export const EndingCard = ({
buttonLabel={replaceRecallInfo(
getLocalizedValue(endingCard.buttonLabel, languageCode),
responseData,
survey.variables
variablesData
)}
isLastQuestion={false}
focus={autoFocusEnabled}

View File

@@ -8,13 +8,23 @@ import { SurveyCloseButton } from "@/components/general/SurveyCloseButton";
import { WelcomeCard } from "@/components/general/WelcomeCard";
import { AutoCloseWrapper } from "@/components/wrappers/AutoCloseWrapper";
import { StackedCardsContainer } from "@/components/wrappers/StackedCardsContainer";
import { evaluateCondition } from "@/lib/logicEvaluator";
import { parseRecallInformation, replaceRecallInfo } from "@/lib/recall";
import { parseRecallInformation } from "@/lib/recall";
import { cn } from "@/lib/utils";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { evaluateLogic, performActions } from "@formbricks/lib/surveyLogic/utils";
import { SurveyBaseProps } from "@formbricks/types/formbricks-surveys";
import type { TResponseData, TResponseDataValue, TResponseTtc } from "@formbricks/types/responses";
import type {
TResponseData,
TResponseDataValue,
TResponseTtc,
TResponseVariables,
} from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
interface VariableStackEntry {
questionId: string;
variables: TResponseVariables;
}
export const Survey = ({
survey,
@@ -42,15 +52,17 @@ export const Survey = ({
fullSizeCards = false,
autoFocus,
}: SurveyBaseProps) => {
const [localSurvey, setlocalSurvey] = useState<TSurvey>(survey);
const autoFocusEnabled = autoFocus !== undefined ? autoFocus : window.self === window.top;
const [questionId, setQuestionId] = useState(() => {
if (startAtQuestionId) {
return startAtQuestionId;
} else if (survey.welcomeCard.enabled) {
} else if (localSurvey.welcomeCard.enabled) {
return "start";
} else {
return survey?.questions[0]?.id;
return localSurvey?.questions[0]?.id;
}
});
const [showError, setShowError] = useState(false);
@@ -62,34 +74,45 @@ export const Survey = ({
const [loadingElement, setLoadingElement] = useState(false);
const [history, setHistory] = useState<string[]>([]);
const [responseData, setResponseData] = useState<TResponseData>(hiddenFieldsRecord ?? {});
const [_variableStack, setVariableStack] = useState<VariableStackEntry[]>([]);
const [currentVariables, setCurrentVariables] = useState<TResponseVariables>(() => {
return localSurvey.variables.reduce((acc, variable) => {
acc[variable.id] = variable.value;
return acc;
}, {} as TResponseVariables);
});
const [ttc, setTtc] = useState<TResponseTtc>({});
const questionIds = useMemo(() => survey.questions.map((question) => question.id), [survey.questions]);
const questionIds = useMemo(
() => localSurvey.questions.map((question) => question.id),
[localSurvey.questions]
);
const cardArrangement = useMemo(() => {
if (survey.type === "link") {
if (localSurvey.type === "link") {
return styling.cardArrangement?.linkSurveys ?? "straight";
} else {
return styling.cardArrangement?.appSurveys ?? "straight";
}
}, [survey.type, styling.cardArrangement?.linkSurveys, styling.cardArrangement?.appSurveys]);
}, [localSurvey.type, styling.cardArrangement?.linkSurveys, styling.cardArrangement?.appSurveys]);
const currentQuestionIndex = survey.questions.findIndex((q) => q.id === questionId);
const currentQuestionIndex = localSurvey.questions.findIndex((q) => q.id === questionId);
const currentQuestion = useMemo(() => {
if (!questionIds.includes(questionId)) {
const newHistory = [...history];
const prevQuestionId = newHistory.pop();
return survey.questions.find((q) => q.id === prevQuestionId);
return localSurvey.questions.find((q) => q.id === prevQuestionId);
} else {
return survey.questions.find((q) => q.id === questionId);
return localSurvey.questions.find((q) => q.id === questionId);
}
}, [questionId, survey, history]);
}, [questionId, localSurvey, history]);
const contentRef = useRef<HTMLDivElement | null>(null);
const showProgressBar = !styling.hideProgressBar;
const getShowSurveyCloseButton = (offset: number) => {
return offset === 0 && survey.type !== "link" && (clickOutside === undefined ? true : clickOutside);
return offset === 0 && localSurvey.type !== "link" && (clickOutside === undefined ? true : clickOutside);
};
const getShowLanguageSwitch = (offset: number) => {
return survey.showLanguageSwitch && survey.languages.length > 0 && offset <= 0;
return localSurvey.showLanguageSwitch && localSurvey.languages.length > 0 && offset <= 0;
};
useEffect(() => {
@@ -145,82 +168,122 @@ export const Survey = ({
let currIdxTemp = currentQuestionIndex;
let currQuesTemp = currentQuestion;
const getNextQuestionId = (data: TResponseData): string | undefined => {
const questions = survey.questions;
const responseValue = data[questionId];
const firstEndingId = survey.endings.length > 0 ? survey.endings[0].id : undefined;
if (questionId === "start") return questions[0]?.id || firstEndingId;
if (currIdxTemp === -1) throw new Error("Question not found");
if (currQuesTemp?.logic && currQuesTemp?.logic.length > 0 && currentQuestion) {
for (let logic of currQuesTemp.logic) {
if (!logic.destination) continue;
// Check if the current question is of type 'multipleChoiceSingle' or 'multipleChoiceMulti'
if (
currentQuestion.type === "multipleChoiceSingle" ||
currentQuestion.type === "multipleChoiceMulti"
) {
let choice;
// Check if the response is a string (applies to single choice questions)
// Sonne -> sun
if (typeof responseValue === "string") {
// Find the choice in currentQuestion.choices that matches the responseValue after localization
choice = currentQuestion.choices.find((choice) => {
return getLocalizedValue(choice.label, selectedLanguage) === responseValue;
})?.label;
// If a matching choice is found, get its default localized value
if (choice) {
choice = getLocalizedValue(choice, "default");
}
}
// Check if the response is an array (applies to multiple choices questions)
// ["Sonne","Mond"]->["sun","moon"]
else if (Array.isArray(responseValue)) {
// Filter and map the choices in currentQuestion.choices that are included in responseValue after localization
choice = currentQuestion.choices
.filter((choice) => {
return responseValue.includes(getLocalizedValue(choice.label, selectedLanguage));
})
.map((choice) => getLocalizedValue(choice.label, "default"));
}
// If a choice is determined (either single or multiple), evaluate the logic condition with that choice
if (choice) {
if (evaluateCondition(logic, choice)) {
return logic.destination;
}
}
// If choice is undefined, it implies an "other" option is selected. Evaluate the logic condition for "Other"
else {
if (evaluateCondition(logic, "Other")) {
return logic.destination;
}
}
}
if (evaluateCondition(logic, responseValue)) {
return logic.destination;
}
}
}
return questions[currIdxTemp + 1]?.id || firstEndingId;
};
const onChange = (responseDataUpdate: TResponseData) => {
const updatedResponseData = { ...responseData, ...responseDataUpdate };
setResponseData(updatedResponseData);
};
const onChangeVariables = (variables: TResponseVariables) => {
const updatedVariables = { ...currentVariables, ...variables };
setCurrentVariables(updatedVariables);
};
const makeQuestionsRequired = (questionIds: string[]): void => {
setlocalSurvey((prevSurvey) => ({
...prevSurvey,
questions: prevSurvey.questions.map((question) => {
if (questionIds.includes(question.id)) {
return {
...question,
required: true,
};
}
return question;
}),
}));
};
const pushVariableState = (questionId: string) => {
setVariableStack((prevStack) => [...prevStack, { questionId, variables: { ...currentVariables } }]);
};
const popVariableState = () => {
setVariableStack((prevStack) => {
const newStack = [...prevStack];
const poppedState = newStack.pop();
if (poppedState) {
setCurrentVariables(poppedState.variables);
}
return newStack;
});
};
const evaluateLogicAndGetNextQuestionId = (
data: TResponseData
): { nextQuestionId: string | undefined; calculatedVariables: TResponseVariables } => {
const questions = survey.questions;
const firstEndingId = survey.endings.length > 0 ? survey.endings[0].id : undefined;
if (questionId === "start")
return { nextQuestionId: questions[0]?.id || firstEndingId, calculatedVariables: {} };
if (!currQuesTemp) throw new Error("Question not found");
let firstJumpTarget: string | undefined;
const allRequiredQuestionIds: string[] = [];
let calculationResults = { ...currentVariables };
const localResponseData = { ...responseData, ...data };
if (currQuesTemp.logic && currQuesTemp.logic.length > 0) {
for (const logic of currQuesTemp.logic) {
if (
evaluateLogic(
localSurvey,
localResponseData,
calculationResults,
logic.conditions,
selectedLanguage
)
) {
const { jumpTarget, requiredQuestionIds, calculations } = performActions(
localSurvey,
logic.actions,
localResponseData,
calculationResults
);
if (jumpTarget && !firstJumpTarget) {
firstJumpTarget = jumpTarget;
}
allRequiredQuestionIds.push(...requiredQuestionIds);
calculationResults = { ...calculationResults, ...calculations };
}
}
}
// Make all collected questions required
if (allRequiredQuestionIds.length > 0) {
makeQuestionsRequired(allRequiredQuestionIds);
}
// Return the first jump target if found, otherwise go to the next question or ending
const nextQuestionId = firstJumpTarget || questions[currentQuestionIndex + 1]?.id || firstEndingId;
return { nextQuestionId, calculatedVariables: calculationResults };
};
const onSubmit = (responseData: TResponseData, ttc: TResponseTtc) => {
const questionId = Object.keys(responseData)[0];
setLoadingElement(true);
const nextQuestionId = getNextQuestionId(responseData);
pushVariableState(questionId);
const { nextQuestionId, calculatedVariables } = evaluateLogicAndGetNextQuestionId(responseData);
const finished =
nextQuestionId === undefined ||
!survey.questions.map((question) => question.id).includes(nextQuestionId);
!localSurvey.questions.map((question) => question.id).includes(nextQuestionId);
onChange(responseData);
onResponse({ data: responseData, ttc, finished, language: selectedLanguage });
onChangeVariables(calculatedVariables);
onResponse({
data: responseData,
ttc,
finished,
variables: calculatedVariables,
language: selectedLanguage,
});
if (finished) {
// Post a message to the parent window indicating that the survey is completed.
window.parent.postMessage("formbricksSurveyCompleted", "*");
@@ -243,8 +306,9 @@ export const Survey = ({
setHistory(newHistory);
} else {
// otherwise go back to previous question in array
prevQuestionId = survey.questions[currIdxTemp - 1]?.id;
prevQuestionId = localSurvey.questions[currIdxTemp - 1]?.id;
}
popVariableState();
if (!prevQuestionId) throw new Error("Question not found");
setQuestionId(prevQuestionId);
};
@@ -259,7 +323,11 @@ export const Survey = ({
const getCardContent = (questionIdx: number, offset: number): JSX.Element | undefined => {
if (showError) {
return (
<ResponseErrorComponent responseData={responseData} questions={survey.questions} onRetry={onRetry} />
<ResponseErrorComponent
responseData={responseData}
questions={localSurvey.questions}
onRetry={onRetry}
/>
);
}
@@ -268,28 +336,28 @@ export const Survey = ({
return (
<WelcomeCard
key="start"
headline={survey.welcomeCard.headline}
html={survey.welcomeCard.html}
fileUrl={survey.welcomeCard.fileUrl}
buttonLabel={survey.welcomeCard.buttonLabel}
headline={localSurvey.welcomeCard.headline}
html={localSurvey.welcomeCard.html}
fileUrl={localSurvey.welcomeCard.fileUrl}
buttonLabel={localSurvey.welcomeCard.buttonLabel}
onSubmit={onSubmit}
survey={survey}
survey={localSurvey}
languageCode={selectedLanguage}
responseCount={responseCount}
autoFocusEnabled={autoFocusEnabled}
replaceRecallInfo={replaceRecallInfo}
isCurrent={offset === 0}
responseData={responseData}
variablesData={currentVariables}
/>
);
} else if (questionIdx >= survey.questions.length) {
const endingCard = survey.endings.find((ending) => {
} else if (questionIdx >= localSurvey.questions.length) {
const endingCard = localSurvey.endings.find((ending) => {
return ending.id === questionId;
});
if (endingCard) {
return (
<EndingCard
survey={survey}
survey={localSurvey}
endingCard={endingCard}
isRedirectDisabled={isRedirectDisabled}
autoFocusEnabled={autoFocusEnabled}
@@ -297,17 +365,18 @@ export const Survey = ({
languageCode={selectedLanguage}
isResponseSendingFinished={isResponseSendingFinished}
responseData={responseData}
variablesData={currentVariables}
/>
);
}
} else {
const question = survey.questions[questionIdx];
const question = localSurvey.questions[questionIdx];
return (
question && (
<QuestionConditional
key={question.id}
surveyId={survey.id}
question={parseRecallInformation(question, selectedLanguage, responseData, survey.variables)}
surveyId={localSurvey.id}
question={parseRecallInformation(question, selectedLanguage, responseData, currentVariables)}
value={responseData[question.id]}
onChange={onChange}
onSubmit={onSubmit}
@@ -315,10 +384,10 @@ export const Survey = ({
ttc={ttc}
setTtc={setTtc}
onFileUpload={onFileUpload}
isFirstQuestion={question.id === survey?.questions[0]?.id}
isFirstQuestion={question.id === localSurvey?.questions[0]?.id}
skipPrefilled={skipPrefilled}
prefilledQuestionValue={getQuestionPrefillData(question.id, offset)}
isLastQuestion={question.id === survey.questions[survey.questions.length - 1].id}
isLastQuestion={question.id === localSurvey.questions[localSurvey.questions.length - 1].id}
languageCode={selectedLanguage}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={questionId}
@@ -329,7 +398,7 @@ export const Survey = ({
};
return (
<AutoCloseWrapper survey={survey} onClose={onClose} offset={offset}>
<AutoCloseWrapper survey={localSurvey} onClose={onClose} offset={offset}>
<div
className={cn(
"fb-no-scrollbar md:fb-rounded-custom fb-rounded-t-custom fb-bg-survey-bg fb-flex fb-h-full fb-w-full fb-flex-col fb-justify-between fb-overflow-hidden fb-transition-all fb-duration-1000 fb-ease-in-out",
@@ -339,7 +408,7 @@ export const Survey = ({
<div className="fb-flex fb-h-6 fb-justify-end fb-pr-2 fb-pt-2">
{getShowLanguageSwitch(offset) && (
<LanguageSwitch
surveyLanguages={survey.languages}
surveyLanguages={localSurvey.languages}
setSelectedLanguageCode={setselectedLanguage}
/>
)}
@@ -355,7 +424,7 @@ export const Survey = ({
</div>
<div className="fb-mx-6 fb-mb-10 fb-mt-2 fb-space-y-3 md:fb-mb-6 md:fb-mt-6">
{isBrandingEnabled && <FormbricksBranding />}
{showProgressBar && <ProgressBar survey={survey} questionId={questionId} />}
{showProgressBar && <ProgressBar survey={localSurvey} questionId={questionId} />}
</div>
</div>
</AutoCloseWrapper>
@@ -368,7 +437,7 @@ export const Survey = ({
cardArrangement={cardArrangement}
currentQuestionId={questionId}
getCardContent={getCardContent}
survey={survey}
survey={localSurvey}
styling={styling}
setQuestionId={setQuestionId}
shouldResetQuestionId={shouldResetQuestionId}

View File

@@ -1,10 +1,11 @@
import { SubmitButton } from "@/components/buttons/SubmitButton";
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { replaceRecallInfo } from "@/lib/recall";
import { calculateElementIdx } from "@/lib/utils";
import { useEffect } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
import { TI18nString, TSurvey, TSurveyVariables } from "@formbricks/types/surveys/types";
import { TResponseData, TResponseTtc, TResponseVariables } from "@formbricks/types/responses";
import { TI18nString, TSurvey } from "@formbricks/types/surveys/types";
import { Headline } from "./Headline";
import { HtmlBody } from "./HtmlBody";
@@ -18,9 +19,9 @@ interface WelcomeCardProps {
languageCode: string;
responseCount?: number;
autoFocusEnabled: boolean;
replaceRecallInfo: (text: string, responseData: TResponseData, variables: TSurveyVariables) => string;
isCurrent: boolean;
responseData: TResponseData;
variablesData: TResponseVariables;
}
const TimerIcon = () => {
@@ -70,9 +71,9 @@ export const WelcomeCard = ({
survey,
responseCount,
autoFocusEnabled,
replaceRecallInfo,
isCurrent,
responseData,
variablesData,
}: WelcomeCardProps) => {
const calculateTimeToComplete = () => {
let idx = calculateElementIdx(survey, 0);
@@ -145,16 +146,12 @@ export const WelcomeCard = ({
headline={replaceRecallInfo(
getLocalizedValue(headline, languageCode),
responseData,
survey.variables
variablesData
)}
questionId="welcomeCard"
/>
<HtmlBody
htmlString={replaceRecallInfo(
getLocalizedValue(html, languageCode),
responseData,
survey.variables
)}
htmlString={replaceRecallInfo(getLocalizedValue(html, languageCode), responseData, variablesData)}
questionId="welcomeCard"
/>
</div>

View File

@@ -1,86 +0,0 @@
import { TSurveyLogic } from "@formbricks/types/surveys/types";
export const evaluateCondition = (
logic: TSurveyLogic,
responseValue: string | number | string[] | Record<string, string>
): boolean => {
const isObject = typeof responseValue === "object" && responseValue !== null;
switch (logic.condition) {
case "equals":
return (
(Array.isArray(responseValue) &&
responseValue.length === 1 &&
typeof logic.value === "string" &&
responseValue.includes(logic.value)) ||
responseValue?.toString() === logic.value
);
case "notEquals":
return responseValue !== logic.value;
case "lessThan":
return logic.value !== undefined && responseValue < logic.value;
case "lessEqual":
return logic.value !== undefined && responseValue <= logic.value;
case "greaterThan":
return logic.value !== undefined && responseValue > logic.value;
case "greaterEqual":
return logic.value !== undefined && responseValue >= logic.value;
case "includesAll":
return (
Array.isArray(responseValue) &&
Array.isArray(logic.value) &&
logic.value.every((v) => responseValue.includes(v))
);
case "includesOne":
if (!Array.isArray(logic.value)) return false;
return Array.isArray(responseValue)
? logic.value.some((v) => responseValue.includes(v))
: typeof responseValue === "string" && logic.value.includes(responseValue);
case "accepted":
return responseValue === "accepted";
case "clicked":
return responseValue === "clicked";
case "submitted":
if (typeof responseValue === "string") {
return responseValue !== "" && responseValue !== null;
} else if (Array.isArray(responseValue)) {
return responseValue.length > 0;
} else if (typeof responseValue === "number") {
return responseValue !== null;
}
return false;
case "skipped":
return (
(Array.isArray(responseValue) && responseValue.length === 0) ||
responseValue === "" ||
responseValue === null ||
responseValue === undefined ||
(isObject && Object.entries(responseValue).length === 0)
);
case "uploaded":
if (Array.isArray(responseValue)) {
return responseValue.length > 0;
} else {
return responseValue !== "skipped" && responseValue !== "" && responseValue !== null;
}
case "notUploaded":
return (
(Array.isArray(responseValue) && responseValue.length === 0) ||
responseValue === "" ||
responseValue === null ||
responseValue === "skipped"
);
case "isCompletelySubmitted":
if (isObject) {
const values = Object.values(responseValue);
return values.length > 0 && !values.includes("");
} else return false;
case "isPartiallySubmitted":
if (isObject) {
return Object.values(responseValue).includes("");
} else return false;
default:
return false;
}
};

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