Compare commits
80 Commits
4.1.0
...
testing/su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed383e7862 | ||
|
|
3db7c7946b | ||
|
|
2f63659739 | ||
|
|
b3757ae7c1 | ||
|
|
942bf818a5 | ||
|
|
74b03a54e1 | ||
|
|
5282637772 | ||
|
|
9165daffe9 | ||
|
|
de05d2abdf | ||
|
|
a907179c7d | ||
|
|
bc7d3e5da4 | ||
|
|
7042d73e99 | ||
|
|
fe7ca5a923 | ||
|
|
960edfd3f0 | ||
|
|
2b0df8280d | ||
|
|
13ce552a39 | ||
|
|
4d6665ab3e | ||
|
|
483bdc0eff | ||
|
|
8238b502fe | ||
|
|
35ff935a27 | ||
|
|
901cd42f56 | ||
|
|
63e1ac11cf | ||
|
|
744f3410ae | ||
|
|
0e73d81999 | ||
|
|
ba46782da4 | ||
|
|
350c895d8c | ||
|
|
4ed1747ee2 | ||
|
|
88c492afd8 | ||
|
|
c7e0b02595 | ||
|
|
15aa9b2731 | ||
|
|
6f7359abf6 | ||
|
|
0bf5e0fd4c | ||
|
|
887c5c0eef | ||
|
|
af4ae38564 | ||
|
|
6b829744a1 | ||
|
|
7b0d4926e8 | ||
|
|
334166b4b1 | ||
|
|
1685e77a35 | ||
|
|
927f97e9ad | ||
|
|
76c437b16a | ||
|
|
0c2d425a45 | ||
|
|
7aa827d1e1 | ||
|
|
d4961f1840 | ||
|
|
6772ea6be4 | ||
|
|
9bacc88063 | ||
|
|
005c777c9c | ||
|
|
560dce3bbf | ||
|
|
ffd45a6f20 | ||
|
|
1ddd82c084 | ||
|
|
647539c617 | ||
|
|
a5a3161f7c | ||
|
|
0535581f6d | ||
|
|
97b04f9e43 | ||
|
|
6ec0861c49 | ||
|
|
8542320a8e | ||
|
|
7d3c8d35e1 | ||
|
|
6abcf91a07 | ||
|
|
9a7887d9fd | ||
|
|
7dd8bb95bd | ||
|
|
df40c0ef11 | ||
|
|
7e14b86a63 | ||
|
|
a1ba3af439 | ||
|
|
0a8c5e384d | ||
|
|
b20ce46a7b | ||
|
|
58636a9d51 | ||
|
|
328e3d0b9a | ||
|
|
f1a2ecaa3a | ||
|
|
d56f05fb19 | ||
|
|
c32ced20f1 | ||
|
|
387590986a | ||
|
|
4a5c0b1409 | ||
|
|
275731e381 | ||
|
|
71c3ac0e4e | ||
|
|
d876c495be | ||
|
|
1ba885e5dc | ||
|
|
245972234e | ||
|
|
f743709908 | ||
|
|
79603293a0 | ||
|
|
29e0cf96d4 | ||
|
|
4eea6a11c8 |
1
.github/workflows/e2e.yml
vendored
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/docs/app/global/logic-editor/images/action-calculate.webp
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
apps/docs/app/global/logic-editor/images/action-jump.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/docs/app/global/logic-editor/images/action-options.webp
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
apps/docs/app/global/logic-editor/images/action-require.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
apps/docs/app/global/logic-editor/images/add-logic.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
apps/docs/app/global/logic-editor/images/condition-chaining.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 34 KiB |
BIN
apps/docs/app/global/logic-editor/images/condition-options.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
apps/docs/app/global/logic-editor/images/condition-value.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
apps/docs/app/global/logic-editor/images/conditions.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
apps/docs/app/global/logic-editor/images/editor.webp
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
apps/docs/app/global/logic-editor/images/question-logic.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
171
apps/docs/app/global/logic-editor/page.mdx
Normal 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"
|
||||
/>
|
||||
|
Before Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -1,15 +1,15 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
|
||||
import EntraIDAppReg01 from "./images/entra_app_reg_01.png";
|
||||
import EntraIDAppReg02 from "./images/entra_app_reg_02.png";
|
||||
import EntraIDAppReg03 from "./images/entra_app_reg_03.png";
|
||||
import EntraIDAppReg04 from "./images/entra_app_reg_04.png";
|
||||
import EntraIDAppReg05 from "./images/entra_app_reg_05.png";
|
||||
import EntraIDAppReg06 from "./images/entra_app_reg_06.png";
|
||||
import EntraIDAppReg07 from "./images/entra_app_reg_07.png";
|
||||
import EntraIDAppReg08 from "./images/entra_app_reg_08.png";
|
||||
import EntraIDAppReg09 from "./images/entra_app_reg_09.png";
|
||||
import EntraIDAppReg10 from "./images/entra_app_reg_10.png";
|
||||
import EntraIDAppReg01 from "./images/entra_app_reg_01.webp";
|
||||
import EntraIDAppReg02 from "./images/entra_app_reg_02.webp";
|
||||
import EntraIDAppReg03 from "./images/entra_app_reg_03.webp";
|
||||
import EntraIDAppReg04 from "./images/entra_app_reg_04.webp";
|
||||
import EntraIDAppReg05 from "./images/entra_app_reg_05.webp";
|
||||
import EntraIDAppReg06 from "./images/entra_app_reg_06.webp";
|
||||
import EntraIDAppReg07 from "./images/entra_app_reg_07.webp";
|
||||
import EntraIDAppReg08 from "./images/entra_app_reg_08.webp";
|
||||
import EntraIDAppReg09 from "./images/entra_app_reg_09.webp";
|
||||
import EntraIDAppReg10 from "./images/entra_app_reg_10.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Configure Formbricks with External auth providers",
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ConditionalLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConditionalLogic";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { LogicEditor } from "./LogicEditor";
|
||||
import { UpdateQuestionId } from "./UpdateQuestionId";
|
||||
|
||||
interface AdvancedSettingsProps {
|
||||
@@ -19,16 +19,14 @@ export const AdvancedSettings = ({
|
||||
attributeClasses,
|
||||
}: AdvancedSettingsProps) => {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<LogicEditor
|
||||
question={question}
|
||||
updateQuestion={updateQuestion}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<ConditionalLogic
|
||||
question={question}
|
||||
updateQuestion={updateQuestion}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
|
||||
<UpdateQuestionId
|
||||
question={question}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { LogicEditor } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor";
|
||||
import {
|
||||
getDefaultOperatorForQuestion,
|
||||
replaceEndingCardHeadlineRecall,
|
||||
} from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { debounce } from "lodash";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CopyIcon,
|
||||
EllipsisVerticalIcon,
|
||||
PlusIcon,
|
||||
SplitIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { duplicateLogicItem } from "@formbricks/lib/surveyLogic/utils";
|
||||
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
interface ConditionalLogicProps {
|
||||
localSurvey: TSurvey;
|
||||
questionIdx: number;
|
||||
question: TSurveyQuestion;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
attributeClasses: TAttributeClass[];
|
||||
}
|
||||
|
||||
export function ConditionalLogic({
|
||||
attributeClasses,
|
||||
localSurvey,
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
}: ConditionalLogicProps) {
|
||||
const [questionLogic, setQuestionLogic] = useState(question.logic);
|
||||
|
||||
const debouncedUpdateQuestion = useMemo(() => debounce(updateQuestion, 500), [updateQuestion]);
|
||||
|
||||
useEffect(() => {
|
||||
debouncedUpdateQuestion(questionIdx, {
|
||||
logic: questionLogic,
|
||||
});
|
||||
}, [questionLogic]);
|
||||
|
||||
const transformedSurvey = useMemo(() => {
|
||||
let modifiedSurvey = replaceHeadlineRecall(localSurvey, "default", attributeClasses);
|
||||
modifiedSurvey = replaceEndingCardHeadlineRecall(modifiedSurvey, "default", attributeClasses);
|
||||
|
||||
return modifiedSurvey;
|
||||
}, [localSurvey, attributeClasses]);
|
||||
|
||||
const updateQuestionLogic = (_questionIdx: number, updatedAttributes: any) => {
|
||||
setQuestionLogic(updatedAttributes.logic);
|
||||
};
|
||||
|
||||
const addLogic = () => {
|
||||
const operator = getDefaultOperatorForQuestion(question);
|
||||
|
||||
const initialCondition: TSurveyLogic = {
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: question.id,
|
||||
type: "question",
|
||||
},
|
||||
operator,
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
updateQuestionLogic(questionIdx, {
|
||||
logic: [...(questionLogic ?? []), initialCondition],
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveLogic = (logicItemIdx: number) => {
|
||||
const logicCopy = structuredClone(questionLogic ?? []);
|
||||
logicCopy.splice(logicItemIdx, 1);
|
||||
|
||||
updateQuestionLogic(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const moveLogic = (from: number, to: number) => {
|
||||
const logicCopy = structuredClone(questionLogic ?? []);
|
||||
const [movedItem] = logicCopy.splice(from, 1);
|
||||
logicCopy.splice(to, 0, movedItem);
|
||||
|
||||
updateQuestionLogic(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const duplicateLogic = (logicItemIdx: number) => {
|
||||
const logicCopy = structuredClone(questionLogic ?? []);
|
||||
const logicItem = logicCopy[logicItemIdx];
|
||||
const newLogicItem = duplicateLogicItem(logicItem);
|
||||
logicCopy.splice(logicItemIdx + 1, 0, newLogicItem);
|
||||
|
||||
updateQuestionLogic(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<Label className="flex gap-2">
|
||||
Conditional Logic
|
||||
<SplitIcon className="h-4 w-4 rotate-90" />
|
||||
</Label>
|
||||
|
||||
{questionLogic && questionLogic.length > 0 && (
|
||||
<div className="mt-2 flex flex-col gap-4">
|
||||
{questionLogic.map((logicItem, logicItemIdx) => (
|
||||
<div
|
||||
key={logicItem.id}
|
||||
className="flex w-full grow items-start gap-2 rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<LogicEditor
|
||||
localSurvey={transformedSurvey}
|
||||
logicItem={logicItem}
|
||||
updateQuestion={updateQuestionLogic}
|
||||
question={question}
|
||||
questionLogic={questionLogic}
|
||||
questionIdx={questionIdx}
|
||||
logicIdx={logicItemIdx}
|
||||
isLast={logicItemIdx === (questionLogic ?? []).length - 1}
|
||||
/>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<EllipsisVerticalIcon className="h-4 w-4 text-slate-700 hover:text-slate-950" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
duplicateLogic(logicItemIdx);
|
||||
}}>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
disabled={logicItemIdx === 0}
|
||||
onClick={() => {
|
||||
moveLogic(logicItemIdx, logicItemIdx - 1);
|
||||
}}>
|
||||
<ArrowUpIcon className="h-4 w-4" />
|
||||
Move up
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
disabled={logicItemIdx === (questionLogic ?? []).length - 1}
|
||||
onClick={() => {
|
||||
moveLogic(logicItemIdx, logicItemIdx + 1);
|
||||
}}>
|
||||
<ArrowDownIcon className="h-4 w-4" />
|
||||
Move down
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
handleRemoveLogic(logicItemIdx);
|
||||
}}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<Button
|
||||
id="logicJumps"
|
||||
className="bg-slate-100 hover:bg-slate-50"
|
||||
type="button"
|
||||
name="logicJumps"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
EndIcon={PlusIcon}
|
||||
onClick={addLogic}>
|
||||
Add logic
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { EditorCardMenu } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditorCardMenu";
|
||||
import { EndScreenForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EndScreenForm";
|
||||
import { RedirectUrlForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RedirectUrlForm";
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util";
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -1,477 +1,53 @@
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ChevronDown,
|
||||
CornerDownRightIcon,
|
||||
HelpCircle,
|
||||
SplitIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicCondition,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
|
||||
import { LogicEditorActions } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditorActions";
|
||||
import { LogicEditorConditions } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditorConditions";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface LogicEditorProps {
|
||||
localSurvey: TSurvey;
|
||||
questionIdx: number;
|
||||
question: TSurveyQuestion;
|
||||
logicItem: TSurveyLogic;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
attributeClasses: TAttributeClass[];
|
||||
question: TSurveyQuestion;
|
||||
questionLogic: TSurveyLogic[];
|
||||
questionIdx: number;
|
||||
logicIdx: number;
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
type LogicConditions = {
|
||||
[K in TSurveyLogicCondition]: {
|
||||
label: string;
|
||||
values: string[] | null;
|
||||
unique?: boolean;
|
||||
multiSelect?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const conditions = {
|
||||
openText: ["submitted", "skipped"],
|
||||
multipleChoiceSingle: ["submitted", "skipped", "equals", "notEquals", "includesOne"],
|
||||
multipleChoiceMulti: ["submitted", "skipped", "includesAll", "includesOne", "equals"],
|
||||
nps: [
|
||||
"equals",
|
||||
"notEquals",
|
||||
"lessThan",
|
||||
"lessEqual",
|
||||
"greaterThan",
|
||||
"greaterEqual",
|
||||
"submitted",
|
||||
"skipped",
|
||||
],
|
||||
rating: [
|
||||
"equals",
|
||||
"notEquals",
|
||||
"lessThan",
|
||||
"lessEqual",
|
||||
"greaterThan",
|
||||
"greaterEqual",
|
||||
"submitted",
|
||||
"skipped",
|
||||
],
|
||||
cta: ["clicked", "skipped"],
|
||||
consent: ["skipped", "accepted"],
|
||||
pictureSelection: ["submitted", "skipped", "includesAll", "includesOne", "equals"],
|
||||
fileUpload: ["uploaded", "notUploaded"],
|
||||
cal: ["skipped", "booked"],
|
||||
matrix: ["isCompletelySubmitted", "isPartiallySubmitted", "skipped"],
|
||||
address: ["submitted", "skipped"],
|
||||
ranking: ["submitted", "skipped"],
|
||||
};
|
||||
|
||||
export const LogicEditor = ({
|
||||
export function LogicEditor({
|
||||
localSurvey,
|
||||
question,
|
||||
questionIdx,
|
||||
logicItem,
|
||||
updateQuestion,
|
||||
attributeClasses,
|
||||
}: LogicEditorProps) => {
|
||||
const [searchValue, setSearchValue] = useState<string>("");
|
||||
const showDropdownSearch = question.type !== "pictureSelection";
|
||||
const transformedSurvey = useMemo(() => {
|
||||
return replaceHeadlineRecall(localSurvey, "default", attributeClasses);
|
||||
}, [localSurvey, attributeClasses]);
|
||||
|
||||
const questionValues: string[] = useMemo(() => {
|
||||
if ("choices" in question) {
|
||||
if (question.type === "pictureSelection") {
|
||||
return question.choices.map((choice) => choice.id);
|
||||
} else {
|
||||
return question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
|
||||
}
|
||||
} else if ("range" in question) {
|
||||
return Array.from({ length: question.range ? question.range : 0 }, (_, i) => (i + 1).toString());
|
||||
} else if (question.type === TSurveyQuestionTypeEnum.NPS) {
|
||||
return Array.from({ length: 11 }, (_, i) => (i + 0).toString());
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [question]);
|
||||
|
||||
const logicConditions: LogicConditions = {
|
||||
submitted: {
|
||||
label: "is submitted",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
skipped: {
|
||||
label: "is skipped",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
accepted: {
|
||||
label: "is accepted",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
clicked: {
|
||||
label: "is clicked",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
equals: {
|
||||
label: "equals",
|
||||
values: questionValues,
|
||||
},
|
||||
notEquals: {
|
||||
label: "does not equal",
|
||||
values: questionValues,
|
||||
},
|
||||
lessThan: {
|
||||
label: "is less than",
|
||||
values: questionValues,
|
||||
},
|
||||
lessEqual: {
|
||||
label: "is less or equal to",
|
||||
values: questionValues,
|
||||
},
|
||||
greaterThan: {
|
||||
label: "is greater than",
|
||||
values: questionValues,
|
||||
},
|
||||
greaterEqual: {
|
||||
label: "is greater or equal to",
|
||||
values: questionValues,
|
||||
},
|
||||
includesAll: {
|
||||
label: "includes all of",
|
||||
values: questionValues,
|
||||
multiSelect: true,
|
||||
},
|
||||
includesOne: {
|
||||
label: "includes one of",
|
||||
values: questionValues,
|
||||
multiSelect: true,
|
||||
},
|
||||
uploaded: {
|
||||
label: "has uploaded file",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
notUploaded: {
|
||||
label: "has not uploaded file",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
booked: {
|
||||
label: "has a call booked",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
isCompletelySubmitted: {
|
||||
label: "is completely submitted",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
isPartiallySubmitted: {
|
||||
label: "is partially submitted",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
};
|
||||
|
||||
const addLogic = () => {
|
||||
if (question.logic && question.logic?.length >= 0) {
|
||||
const hasUndefinedLogic = question.logic.some(
|
||||
(logic) =>
|
||||
logic.condition === undefined && logic.value === undefined && logic.destination === undefined
|
||||
);
|
||||
if (hasUndefinedLogic) {
|
||||
toast("Please fill current logic jumps first.", {
|
||||
icon: "🤓",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newLogic: TSurveyLogic[] = !question.logic ? [] : question.logic;
|
||||
newLogic.push({
|
||||
condition: undefined,
|
||||
value: undefined,
|
||||
destination: undefined,
|
||||
});
|
||||
updateQuestion(questionIdx, { logic: newLogic });
|
||||
};
|
||||
|
||||
const updateLogic = (logicIdx: number, updatedAttributes: any) => {
|
||||
const currentLogic: any = question.logic ? question.logic[logicIdx] : undefined;
|
||||
if (!currentLogic) return;
|
||||
|
||||
// clean logic value if not needed or if condition changed between multiSelect and singleSelect conditions
|
||||
const updatedCondition = updatedAttributes?.condition;
|
||||
const currentCondition = currentLogic?.condition;
|
||||
const updatedLogicCondition = logicConditions[updatedCondition];
|
||||
const currentLogicCondition = logicConditions[currentCondition];
|
||||
if (updatedCondition) {
|
||||
if (updatedLogicCondition?.multiSelect && !currentLogicCondition?.multiSelect) {
|
||||
updatedAttributes.value = [];
|
||||
} else if (
|
||||
(!updatedLogicCondition?.multiSelect && currentLogicCondition?.multiSelect) ||
|
||||
updatedLogicCondition?.values === null
|
||||
) {
|
||||
updatedAttributes.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const newLogic = !question.logic
|
||||
? []
|
||||
: question.logic.map((logic, idx) => {
|
||||
if (idx === logicIdx) {
|
||||
return { ...logic, ...updatedAttributes };
|
||||
}
|
||||
return logic;
|
||||
});
|
||||
|
||||
updateQuestion(questionIdx, { logic: newLogic });
|
||||
};
|
||||
|
||||
const updateMultiSelectLogic = (logicIdx: number, checked: boolean, value: string) => {
|
||||
const newLogic = !question.logic
|
||||
? []
|
||||
: question.logic.map((logic, idx) => {
|
||||
if (idx === logicIdx) {
|
||||
const newValues = !logic.value ? [] : logic.value;
|
||||
if (checked) {
|
||||
newValues.push(value);
|
||||
} else {
|
||||
newValues.splice(newValues.indexOf(value), 1);
|
||||
}
|
||||
return { ...logic, value: Array.from(new Set(newValues)) };
|
||||
}
|
||||
return logic;
|
||||
});
|
||||
|
||||
updateQuestion(questionIdx, { logic: newLogic });
|
||||
};
|
||||
|
||||
const deleteLogic = (logicIdx: number) => {
|
||||
const updatedLogic = !question.logic ? [] : structuredClone(question.logic);
|
||||
updatedLogic.splice(logicIdx, 1);
|
||||
updateQuestion(questionIdx, { logic: updatedLogic });
|
||||
};
|
||||
|
||||
if (!(question.type in conditions)) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const getLogicDisplayValue = (value: string | string[]): string => {
|
||||
if (question.type === "pictureSelection") {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((val) => {
|
||||
const choiceIndex = question.choices.findIndex((choice) => choice.id === val);
|
||||
return `Picture ${choiceIndex + 1}`;
|
||||
})
|
||||
.join(", ");
|
||||
} else {
|
||||
const choiceIndex = question.choices.findIndex((choice) => choice.id === value);
|
||||
return `Picture ${choiceIndex + 1}`;
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
return value.join(", ");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const getOptionPreview = (value: string) => {
|
||||
if (question.type === "pictureSelection") {
|
||||
const choice = question.choices.find((choice) => choice.id === value);
|
||||
if (choice) {
|
||||
return <Image src={choice.imageUrl} alt={"Picture"} width={20} height={12} className="rounded-xs" />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
question,
|
||||
questionLogic,
|
||||
questionIdx,
|
||||
logicIdx,
|
||||
isLast,
|
||||
}: LogicEditorProps) {
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<Label>Logic Jumps</Label>
|
||||
|
||||
{question?.logic && question?.logic?.length !== 0 && (
|
||||
<div className="mt-2 space-y-3">
|
||||
{question?.logic?.map((logic, logicIdx) => (
|
||||
<div key={logicIdx} className="flex items-center space-x-2 space-y-1 text-xs xl:text-sm">
|
||||
<div>
|
||||
<CornerDownRightIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-slate-800">If this answer</p>
|
||||
|
||||
<Select value={logic.condition} onValueChange={(e) => updateLogic(logicIdx, { condition: e })}>
|
||||
<SelectTrigger className="min-w-fit flex-1">
|
||||
<SelectValue placeholder="Select condition" className="text-xs lg:text-sm" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{conditions[question.type].map(
|
||||
(condition) =>
|
||||
!(question.required && (condition === "skipped" || condition === "notUploaded")) && (
|
||||
<SelectItem
|
||||
key={condition}
|
||||
value={condition}
|
||||
title={logicConditions[condition].label}
|
||||
className="text-xs lg:text-sm">
|
||||
{logicConditions[condition].label}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{logic.condition && logicConditions[logic.condition].values != null && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
|
||||
<div className="flex h-10 w-full items-center justify-between overflow-hidden rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
{!logic.value || logic.value?.length === 0 ? (
|
||||
<p className="line-clamp-1 text-slate-400" title="Select match type">
|
||||
Select match type
|
||||
</p>
|
||||
) : (
|
||||
<p className="line-clamp-1" title={getLogicDisplayValue(logic.value)}>
|
||||
{getLogicDisplayValue(logic.value)}
|
||||
</p>
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-40 bg-slate-50 text-slate-700"
|
||||
align="start"
|
||||
side="bottom">
|
||||
{showDropdownSearch && (
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search options"
|
||||
className="mb-1 w-full bg-white"
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
value={searchValue}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<div className="max-h-72 overflow-y-auto overflow-x-hidden">
|
||||
{logicConditions[logic.condition].values
|
||||
?.filter((value) => value.includes(searchValue))
|
||||
?.map((value) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={value}
|
||||
title={value}
|
||||
checked={
|
||||
!logicConditions[logic.condition].multiSelect
|
||||
? logic.value === value
|
||||
: logic.value?.includes(value)
|
||||
}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onCheckedChange={(e) =>
|
||||
!logicConditions[logic.condition].multiSelect
|
||||
? updateLogic(logicIdx, { value })
|
||||
: updateMultiSelectLogic(logicIdx, e, value)
|
||||
}>
|
||||
<div className="flex space-x-2">
|
||||
{question.type === "pictureSelection" && getOptionPreview(value)}
|
||||
<p>{getLogicDisplayValue(value)}</p>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<p className="text-slate-800">jump to</p>
|
||||
|
||||
<Select
|
||||
value={logic.destination}
|
||||
onValueChange={(e) => updateLogic(logicIdx, { destination: e })}>
|
||||
<SelectTrigger className="w-fit overflow-hidden">
|
||||
<SelectValue placeholder="Select question" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{transformedSurvey.questions.map(
|
||||
(question, idx) =>
|
||||
idx !== questionIdx && (
|
||||
<SelectItem
|
||||
key={question.id}
|
||||
value={question.id}
|
||||
title={getLocalizedValue(question.headline, "default")}>
|
||||
<div className="w-96">
|
||||
<p className="truncate text-left">
|
||||
{idx + 1}
|
||||
{". "}
|
||||
{getLocalizedValue(question.headline, "default")}
|
||||
</p>
|
||||
</div>
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
{localSurvey.endings.map((ending) => {
|
||||
return (
|
||||
<SelectItem value={ending.id}>
|
||||
{ending.type === "endScreen"
|
||||
? getLocalizedValue(ending.headline, "default")
|
||||
: ending.label}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div>
|
||||
<TrashIcon
|
||||
className="h-4 w-4 cursor-pointer text-slate-400"
|
||||
onClick={() => deleteLogic(logicIdx)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap items-center space-x-2 py-1 text-sm">
|
||||
<ArrowDownIcon className="h-4 w-4" />
|
||||
<p className="text-slate-700">All other answers will continue to the next question</p>
|
||||
</div>
|
||||
<div className="flex w-full grow flex-col gap-4 overflow-x-auto pb-2 text-sm">
|
||||
<LogicEditorConditions
|
||||
conditions={logicItem.conditions}
|
||||
updateQuestion={updateQuestion}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
localSurvey={localSurvey}
|
||||
logicIdx={logicIdx}
|
||||
/>
|
||||
<LogicEditorActions
|
||||
logicItem={logicItem}
|
||||
logicIdx={logicIdx}
|
||||
questionLogic={questionLogic}
|
||||
updateQuestion={updateQuestion}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
/>
|
||||
{isLast ? (
|
||||
<div className="flex flex-wrap items-center space-x-2">
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
<p className="text-slate-700">All other answers will continue to the next question</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<Button
|
||||
id="logicJumps"
|
||||
type="button"
|
||||
name="logicJumps"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
StartIcon={SplitIcon}
|
||||
onClick={() => addLogic()}>
|
||||
Add logic
|
||||
</Button>
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="ml-2 inline h-4 w-4 cursor-default text-slate-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[300px]" side="top">
|
||||
With logic jumps you can skip questions based on the responses users give.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import {
|
||||
actionObjectiveOptions,
|
||||
getActionOperatorOptions,
|
||||
} from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import {
|
||||
CopyIcon,
|
||||
EllipsisVerticalIcon,
|
||||
EyeOffIcon,
|
||||
FileDigitIcon,
|
||||
FileType2Icon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { questionIconMapping } from "@formbricks/lib/utils/questions";
|
||||
import {
|
||||
TActionNumberVariableCalculateOperator,
|
||||
TActionObjective,
|
||||
TActionTextVariableCalculateOperator,
|
||||
TActionVariableValueType,
|
||||
TSurvey,
|
||||
TSurveyLogicAction,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
import { InputCombobox, TComboboxGroupedOption, TComboboxOption } from "@formbricks/ui/InputCombobox";
|
||||
|
||||
interface LogicEditorActionProps {
|
||||
action: TSurveyLogicAction;
|
||||
actionIdx: number;
|
||||
handleObjectiveChange: (actionIdx: number, val: TActionObjective) => void;
|
||||
handleValuesChange: (actionIdx: number, values: any) => void;
|
||||
handleActionsChange: (operation: "remove" | "addBelow" | "duplicate", actionIdx: number) => void;
|
||||
isRemoveDisabled: boolean;
|
||||
questions: TSurveyQuestion[];
|
||||
endings: TSurvey["endings"];
|
||||
variables: TSurvey["variables"];
|
||||
questionIdx: number;
|
||||
hiddenFields: {
|
||||
enabled: boolean;
|
||||
fieldIds?: string[] | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
const _LogicEditorAction = ({
|
||||
action,
|
||||
actionIdx,
|
||||
handleActionsChange,
|
||||
handleObjectiveChange,
|
||||
handleValuesChange,
|
||||
isRemoveDisabled,
|
||||
questions,
|
||||
endings,
|
||||
variables,
|
||||
questionIdx,
|
||||
hiddenFields,
|
||||
}: LogicEditorActionProps) => {
|
||||
const actionTargetOptions = useMemo((): TComboboxOption[] => {
|
||||
let filteredQuestions = questions.filter((_, idx) => idx !== questionIdx);
|
||||
|
||||
if (action.objective === "requireAnswer") {
|
||||
filteredQuestions = filteredQuestions.filter((question) => !question.required);
|
||||
}
|
||||
|
||||
const questionOptions = filteredQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
};
|
||||
});
|
||||
|
||||
if (action.objective === "requireAnswer") return questionOptions;
|
||||
|
||||
const endingCardOptions = endings.map((ending) => {
|
||||
return {
|
||||
label:
|
||||
ending.type === "endScreen"
|
||||
? getLocalizedValue(ending.headline, "default") || "End Screen"
|
||||
: ending.label || "Redirect Thank you card",
|
||||
value: ending.id,
|
||||
};
|
||||
});
|
||||
|
||||
return [...questionOptions, ...endingCardOptions];
|
||||
}, [action.objective, endings, questionIdx, questions]);
|
||||
|
||||
const actionVariableOptions = useMemo((): TComboboxOption[] => {
|
||||
return variables.map((variable) => {
|
||||
return {
|
||||
icon: variable.type === "number" ? FileDigitIcon : FileType2Icon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
variableType: variable.type,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [variables]);
|
||||
|
||||
const getActionValueOptions = useCallback(
|
||||
(variableId: string): TComboboxGroupedOption[] => {
|
||||
const hiddenFieldIds = hiddenFields?.fieldIds ?? [];
|
||||
|
||||
const hiddenFieldsOptions = hiddenFieldIds.map((field) => {
|
||||
return {
|
||||
icon: EyeOffIcon,
|
||||
label: field,
|
||||
value: field,
|
||||
meta: {
|
||||
type: "hiddenField",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const selectedVariable = variables.find((variable) => variable.id === variableId);
|
||||
const filteredVariables = variables.filter((variable) => variable.id !== variableId);
|
||||
|
||||
if (!selectedVariable) return [];
|
||||
|
||||
if (selectedVariable.type === "text") {
|
||||
const allowedQuestions = questions.filter((question) =>
|
||||
[
|
||||
TSurveyQuestionTypeEnum.OpenText,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyQuestionTypeEnum.Rating,
|
||||
TSurveyQuestionTypeEnum.NPS,
|
||||
TSurveyQuestionTypeEnum.Date,
|
||||
].includes(question.type)
|
||||
);
|
||||
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const stringVariables = filteredVariables.filter((variable) => variable.type === "text");
|
||||
const variableOptions = stringVariables.map((variable) => {
|
||||
return {
|
||||
icon: FileType2Icon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return groupedOptions;
|
||||
} else if (selectedVariable.type === "number") {
|
||||
const allowedQuestions = questions.filter((question) =>
|
||||
[TSurveyQuestionTypeEnum.Rating, TSurveyQuestionTypeEnum.NPS].includes(question.type)
|
||||
);
|
||||
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const numberVariables = filteredVariables.filter((variable) => variable.type === "number");
|
||||
const variableOptions = numberVariables.map((variable) => {
|
||||
return {
|
||||
icon: FileDigitIcon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return groupedOptions;
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
[hiddenFields?.fieldIds, questions, variables]
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={action.id} className="flex grow items-center justify-between gap-x-2">
|
||||
<div className="block w-9 shrink-0">{actionIdx === 0 ? "Then" : "and"}</div>
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<InputCombobox
|
||||
id={`action-${actionIdx}-objective`}
|
||||
key={`objective-${action.id}`}
|
||||
showSearch={false}
|
||||
options={actionObjectiveOptions}
|
||||
value={action.objective}
|
||||
onChangeValue={(val: TActionObjective) => {
|
||||
handleObjectiveChange(actionIdx, val);
|
||||
}}
|
||||
comboboxClasses="grow"
|
||||
/>
|
||||
{action.objective !== "calculate" && (
|
||||
<InputCombobox
|
||||
id={`action-${actionIdx}-target`}
|
||||
key={`target-${action.id}`}
|
||||
showSearch={false}
|
||||
options={actionTargetOptions}
|
||||
value={action.target}
|
||||
onChangeValue={(val: string) => {
|
||||
handleValuesChange(actionIdx, {
|
||||
target: val,
|
||||
});
|
||||
}}
|
||||
comboboxClasses="grow"
|
||||
/>
|
||||
)}
|
||||
{action.objective === "calculate" && (
|
||||
<>
|
||||
<InputCombobox
|
||||
id={`action-${actionIdx}-variableId`}
|
||||
key={`variableId-${action.id}`}
|
||||
showSearch={false}
|
||||
options={actionVariableOptions}
|
||||
value={action.variableId}
|
||||
onChangeValue={(val: string) => {
|
||||
handleValuesChange(actionIdx, {
|
||||
variableId: val,
|
||||
value: {
|
||||
type: "static",
|
||||
value: "",
|
||||
},
|
||||
});
|
||||
}}
|
||||
comboboxClasses="grow"
|
||||
emptyDropdownText="Add a variable to calculate"
|
||||
/>
|
||||
<InputCombobox
|
||||
id={`action-${actionIdx}-operator`}
|
||||
key={`operator-${action.id}`}
|
||||
showSearch={false}
|
||||
options={getActionOperatorOptions(variables.find((v) => v.id === action.variableId)?.type)}
|
||||
value={action.operator}
|
||||
onChangeValue={(
|
||||
val: TActionTextVariableCalculateOperator | TActionNumberVariableCalculateOperator
|
||||
) => {
|
||||
handleValuesChange(actionIdx, {
|
||||
operator: val,
|
||||
});
|
||||
}}
|
||||
comboboxClasses="grow"
|
||||
/>
|
||||
<InputCombobox
|
||||
id={`action-${actionIdx}-value`}
|
||||
key={`value-${action.id}`}
|
||||
withInput={true}
|
||||
clearable={true}
|
||||
value={action.value?.value ?? ""}
|
||||
inputProps={{
|
||||
placeholder: "Value",
|
||||
type: variables.find((v) => v.id === action.variableId)?.type || "text",
|
||||
}}
|
||||
groupedOptions={getActionValueOptions(action.variableId)}
|
||||
onChangeValue={(val, option, fromInput) => {
|
||||
const fieldType = option?.meta?.type as TActionVariableValueType;
|
||||
|
||||
if (!fromInput && fieldType !== "static") {
|
||||
handleValuesChange(actionIdx, {
|
||||
value: {
|
||||
type: fieldType,
|
||||
value: val as string,
|
||||
},
|
||||
});
|
||||
} else if (fromInput) {
|
||||
handleValuesChange(actionIdx, {
|
||||
value: {
|
||||
type: "static",
|
||||
value: val as string,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
comboboxClasses="grow shrink-0"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger id={`actions-${actionIdx}-dropdown`}>
|
||||
<EllipsisVerticalIcon className="h-4 w-4 text-slate-700 hover:text-slate-950" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
handleActionsChange("addBelow", actionIdx);
|
||||
}}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add action below
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
disabled={isRemoveDisabled}
|
||||
onClick={() => {
|
||||
handleActionsChange("remove", actionIdx);
|
||||
}}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
handleActionsChange("duplicate", actionIdx);
|
||||
}}>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LogicEditorAction = React.memo(_LogicEditorAction);
|
||||
@@ -0,0 +1,110 @@
|
||||
import { LogicEditorAction } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditorAction";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { CornerDownRightIcon } from "lucide-react";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { getUpdatedActionBody } from "@formbricks/lib/surveyLogic/utils";
|
||||
import { TActionObjective, TSurvey, TSurveyLogic, TSurveyLogicAction } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface LogicEditorActions {
|
||||
localSurvey: TSurvey;
|
||||
logicItem: TSurveyLogic;
|
||||
logicIdx: number;
|
||||
questionLogic: TSurveyLogic[];
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
questionIdx: number;
|
||||
}
|
||||
|
||||
export const LogicEditorActions = ({
|
||||
localSurvey,
|
||||
logicItem,
|
||||
logicIdx,
|
||||
questionLogic,
|
||||
updateQuestion,
|
||||
questionIdx,
|
||||
}: LogicEditorActions) => {
|
||||
const actions = logicItem.actions;
|
||||
|
||||
const handleActionsChange = useCallback(
|
||||
(
|
||||
operation: "remove" | "addBelow" | "duplicate" | "update",
|
||||
actionIdx: number,
|
||||
action?: TSurveyLogicAction
|
||||
) => {
|
||||
const currentLogicCopy = structuredClone(logicItem);
|
||||
const actionsClone = currentLogicCopy.actions;
|
||||
|
||||
switch (operation) {
|
||||
case "remove":
|
||||
actionsClone.splice(actionIdx, 1);
|
||||
break;
|
||||
case "addBelow":
|
||||
actionsClone.splice(actionIdx + 1, 0, {
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: "",
|
||||
});
|
||||
break;
|
||||
case "duplicate":
|
||||
actionsClone.splice(actionIdx + 1, 0, { ...actionsClone[actionIdx], id: createId() });
|
||||
break;
|
||||
case "update":
|
||||
if (!action) return;
|
||||
actionsClone[actionIdx] = action;
|
||||
break;
|
||||
}
|
||||
|
||||
const updatedLogic = questionLogic.map((item, idx) => (idx === logicIdx ? currentLogicCopy : item));
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: updatedLogic,
|
||||
});
|
||||
},
|
||||
[logicIdx, logicItem, questionIdx, questionLogic]
|
||||
);
|
||||
|
||||
const handleObjectiveChange = useCallback(
|
||||
(actionIdx: number, objective: TActionObjective) => {
|
||||
const action = actions[actionIdx];
|
||||
const actionBody = getUpdatedActionBody(action, objective);
|
||||
handleActionsChange("update", actionIdx, actionBody);
|
||||
},
|
||||
[actions]
|
||||
);
|
||||
|
||||
const handleValuesChange = useCallback(
|
||||
(actionIdx: number, values: Partial<TSurveyLogicAction>) => {
|
||||
const action = actions[actionIdx];
|
||||
const actionBody = { ...action, ...values } as TSurveyLogicAction;
|
||||
handleActionsChange("update", actionIdx, actionBody);
|
||||
},
|
||||
[actions]
|
||||
);
|
||||
|
||||
const questions = useMemo(() => localSurvey.questions, [localSurvey.questions]);
|
||||
const endings = useMemo(() => localSurvey.endings, [localSurvey.endings]);
|
||||
const variables = useMemo(() => localSurvey.variables, [localSurvey.variables]);
|
||||
const hiddenFields = useMemo(() => localSurvey.hiddenFields, [localSurvey.hiddenFields]);
|
||||
|
||||
return (
|
||||
<div className="flex grow gap-2">
|
||||
<CornerDownRightIcon className="mt-3 h-4 w-4 shrink-0" />
|
||||
<div className="flex grow flex-col gap-y-2">
|
||||
{actions?.map((action, idx) => (
|
||||
<LogicEditorAction
|
||||
action={action}
|
||||
actionIdx={idx}
|
||||
handleActionsChange={handleActionsChange}
|
||||
handleObjectiveChange={handleObjectiveChange}
|
||||
handleValuesChange={handleValuesChange}
|
||||
endings={endings}
|
||||
isRemoveDisabled={actions.length === 1}
|
||||
questions={questions}
|
||||
variables={variables}
|
||||
questionIdx={questionIdx}
|
||||
hiddenFields={hiddenFields}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,347 @@
|
||||
import {
|
||||
getConditionOperatorOptions,
|
||||
getConditionValueOptions,
|
||||
getDefaultOperatorForQuestion,
|
||||
getMatchValueProps,
|
||||
} from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { CopyIcon, EllipsisVerticalIcon, PlusIcon, TrashIcon, WorkflowIcon } from "lucide-react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import {
|
||||
addConditionBelow,
|
||||
createGroupFromResource,
|
||||
duplicateCondition,
|
||||
isConditionGroup,
|
||||
removeCondition,
|
||||
toggleGroupConnector,
|
||||
updateCondition,
|
||||
} from "@formbricks/lib/surveyLogic/utils";
|
||||
import {
|
||||
TConditionGroup,
|
||||
TDynamicLogicField,
|
||||
TRightOperand,
|
||||
TSingleCondition,
|
||||
TSurvey,
|
||||
TSurveyLogicConditionsOperator,
|
||||
TSurveyQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
import { InputCombobox, TComboboxOption } from "@formbricks/ui/InputCombobox";
|
||||
|
||||
interface LogicEditorConditionsProps {
|
||||
conditions: TConditionGroup;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
question: TSurveyQuestion;
|
||||
localSurvey: TSurvey;
|
||||
questionIdx: number;
|
||||
logicIdx: number;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export function LogicEditorConditions({
|
||||
conditions,
|
||||
logicIdx,
|
||||
question,
|
||||
localSurvey,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
depth = 0,
|
||||
}: LogicEditorConditionsProps) {
|
||||
const handleAddConditionBelow = (resourceId: string) => {
|
||||
const operator = getDefaultOperatorForQuestion(question);
|
||||
|
||||
const condition: TSingleCondition = {
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: question.id,
|
||||
type: "question",
|
||||
},
|
||||
operator,
|
||||
};
|
||||
|
||||
const logicCopy = structuredClone(question.logic) ?? [];
|
||||
const logicItem = logicCopy[logicIdx];
|
||||
addConditionBelow(logicItem.conditions, resourceId, condition);
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleConnectorChange = (groupId: string) => {
|
||||
const logicCopy = structuredClone(question.logic) ?? [];
|
||||
const logicItem = logicCopy[logicIdx];
|
||||
toggleGroupConnector(logicItem.conditions, groupId);
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveCondition = (resourceId: string) => {
|
||||
const logicCopy = structuredClone(question.logic) ?? [];
|
||||
const logicItem = logicCopy[logicIdx];
|
||||
removeCondition(logicItem.conditions, resourceId);
|
||||
|
||||
// Remove the logic item if there are no conditions left
|
||||
if (logicItem.conditions.conditions.length === 0) {
|
||||
logicCopy.splice(logicIdx, 1);
|
||||
}
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDuplicateCondition = (resourceId: string) => {
|
||||
const logicCopy = structuredClone(question.logic) ?? [];
|
||||
const logicItem = logicCopy[logicIdx];
|
||||
duplicateCondition(logicItem.conditions, resourceId);
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateGroup = (resourceId: string) => {
|
||||
const logicCopy = structuredClone(question.logic) ?? [];
|
||||
const logicItem = logicCopy[logicIdx];
|
||||
createGroupFromResource(logicItem.conditions, resourceId);
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateCondition = (resourceId: string, updateConditionBody: Partial<TSingleCondition>) => {
|
||||
const logicCopy = structuredClone(question.logic) ?? [];
|
||||
const logicItem = logicCopy[logicIdx];
|
||||
updateCondition(logicItem.conditions, resourceId, updateConditionBody);
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleQuestionChange = (condition: TSingleCondition, value: string, option?: TComboboxOption) => {
|
||||
handleUpdateCondition(condition.id, {
|
||||
leftOperand: {
|
||||
value,
|
||||
type: option?.meta?.type as TDynamicLogicField,
|
||||
},
|
||||
operator: "isSkipped",
|
||||
rightOperand: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOperatorChange = (condition: TSingleCondition, value: TSurveyLogicConditionsOperator) => {
|
||||
if (value !== condition.operator) {
|
||||
handleUpdateCondition(condition.id, {
|
||||
operator: value,
|
||||
rightOperand: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRightOperandChange = (
|
||||
condition: TSingleCondition,
|
||||
value: string | number | string[],
|
||||
option?: TComboboxOption
|
||||
) => {
|
||||
const type = (option?.meta?.type as TRightOperand["type"]) || "static";
|
||||
|
||||
switch (type) {
|
||||
case "question":
|
||||
case "hiddenField":
|
||||
case "variable":
|
||||
handleUpdateCondition(condition.id, {
|
||||
rightOperand: {
|
||||
value: value as string,
|
||||
type,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "static":
|
||||
handleUpdateCondition(condition.id, {
|
||||
rightOperand: {
|
||||
value,
|
||||
type,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const renderCondition = (
|
||||
condition: TSingleCondition | TConditionGroup,
|
||||
index: number,
|
||||
parentConditionGroup: TConditionGroup
|
||||
) => {
|
||||
const connector = parentConditionGroup.connector;
|
||||
if (isConditionGroup(condition)) {
|
||||
return (
|
||||
<div key={condition.id} className="flex items-start justify-between gap-4">
|
||||
{index === 0 ? (
|
||||
<div>When</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn("w-14", index === 1 && "cursor-pointer underline")}
|
||||
onClick={() => {
|
||||
if (index !== 1) return;
|
||||
handleConnectorChange(parentConditionGroup.id);
|
||||
}}>
|
||||
{connector}
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-lg border border-slate-400 p-3">
|
||||
<LogicEditorConditions
|
||||
conditions={condition}
|
||||
updateQuestion={updateQuestion}
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
logicIdx={logicIdx}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<EllipsisVerticalIcon className="h-4 w-4 text-slate-700 hover:text-slate-950" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
handleAddConditionBelow(condition.id);
|
||||
}}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add condition below
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
disabled={depth === 0 && conditions.conditions.length === 1}
|
||||
onClick={() => handleRemoveCondition(condition.id)}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const conditionValueOptions = getConditionValueOptions(localSurvey, questionIdx);
|
||||
const conditionOperatorOptions = getConditionOperatorOptions(condition, localSurvey);
|
||||
const { show, options, showInput = false, inputType } = getMatchValueProps(condition, localSurvey);
|
||||
|
||||
const allowMultiSelect = ["equalsOneOf", "includesAllOf", "includesOneOf"].includes(condition.operator);
|
||||
return (
|
||||
<div key={condition.id} className="flex items-center gap-x-2">
|
||||
<div className="w-10 shrink-0">
|
||||
{index === 0 ? (
|
||||
"When"
|
||||
) : (
|
||||
<div
|
||||
className={cn("w-14", index === 1 && "cursor-pointer underline")}
|
||||
onClick={() => {
|
||||
if (index !== 1) return;
|
||||
handleConnectorChange(parentConditionGroup.id);
|
||||
}}>
|
||||
{connector}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<InputCombobox
|
||||
id={`condition-${depth}-${index}-conditionValue`}
|
||||
key="conditionValue"
|
||||
showSearch={false}
|
||||
groupedOptions={conditionValueOptions}
|
||||
value={condition.leftOperand.value}
|
||||
onChangeValue={(val: string, option) => {
|
||||
handleQuestionChange(condition, val, option);
|
||||
}}
|
||||
comboboxClasses="grow"
|
||||
/>
|
||||
<InputCombobox
|
||||
id={`condition-${depth}-${index}-conditionOperator`}
|
||||
key="conditionOperator"
|
||||
showSearch={false}
|
||||
options={conditionOperatorOptions}
|
||||
value={condition.operator}
|
||||
onChangeValue={(val: TSurveyLogicConditionsOperator) => {
|
||||
handleOperatorChange(condition, val);
|
||||
}}
|
||||
comboboxClasses="grow min-w-[150px]"
|
||||
/>
|
||||
{show && (
|
||||
<InputCombobox
|
||||
id={`condition-${depth}-${index}-conditionMatchValue`}
|
||||
withInput={showInput}
|
||||
inputProps={{
|
||||
type: inputType,
|
||||
placeholder: "Value",
|
||||
}}
|
||||
key="conditionMatchValue"
|
||||
showSearch={false}
|
||||
groupedOptions={options}
|
||||
allowMultiSelect={allowMultiSelect}
|
||||
showCheckIcon={allowMultiSelect}
|
||||
comboboxClasses="grow min-w-[180px] max-w-[300px]"
|
||||
value={condition.rightOperand?.value}
|
||||
clearable={true}
|
||||
onChangeValue={(val, option) => {
|
||||
handleRightOperandChange(condition, val, option);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger id={`condition-${depth}-${index}-dropdown`}>
|
||||
<EllipsisVerticalIcon className="h-4 w-4 text-slate-700 hover:text-slate-950" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
handleAddConditionBelow(condition.id);
|
||||
}}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add condition below
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
disabled={depth === 0 && conditions.conditions.length === 1}
|
||||
onClick={() => handleRemoveCondition(condition.id)}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => handleDuplicateCondition(condition.id)}>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => handleCreateGroup(condition.id)}>
|
||||
<WorkflowIcon className="h-4 w-4" />
|
||||
Create group
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{conditions?.conditions.map((condition, index) => renderCondition(condition, index, conditions))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { findOptionUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { DndContext } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createI18nString, extractLanguageCodes, getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import toast from "react-hot-toast";
|
||||
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import {
|
||||
TI18nString,
|
||||
@@ -68,8 +70,6 @@ export const MultipleChoiceQuestionForm = ({
|
||||
};
|
||||
|
||||
const updateChoice = (choiceIdx: number, updatedAttributes: { label: TI18nString }) => {
|
||||
const newLabel = updatedAttributes.label.en;
|
||||
const oldLabel = question.choices[choiceIdx].label;
|
||||
let newChoices: any[] = [];
|
||||
if (question.choices) {
|
||||
newChoices = question.choices.map((choice, idx) => {
|
||||
@@ -78,19 +78,9 @@ export const MultipleChoiceQuestionForm = ({
|
||||
});
|
||||
}
|
||||
|
||||
let newLogic: any[] = [];
|
||||
question.logic?.forEach((logic) => {
|
||||
let newL: string | string[] | undefined = logic.value;
|
||||
if (Array.isArray(logic.value)) {
|
||||
newL = logic.value.map((value) =>
|
||||
value === getLocalizedValue(oldLabel, selectedLanguageCode) ? newLabel : value
|
||||
);
|
||||
} else {
|
||||
newL = logic.value === getLocalizedValue(oldLabel, selectedLanguageCode) ? newLabel : logic.value;
|
||||
}
|
||||
newLogic.push({ ...logic, value: newL });
|
||||
updateQuestion(questionIdx, {
|
||||
choices: newChoices,
|
||||
});
|
||||
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
|
||||
};
|
||||
|
||||
const addChoice = (choiceIdx?: number) => {
|
||||
@@ -132,23 +122,27 @@ export const MultipleChoiceQuestionForm = ({
|
||||
};
|
||||
|
||||
const deleteChoice = (choiceIdx: number) => {
|
||||
const choiceToDelete = question.choices[choiceIdx].id;
|
||||
|
||||
if (choiceToDelete !== "other") {
|
||||
const questionIdx = findOptionUsedInLogic(localSurvey, question.id, choiceToDelete);
|
||||
if (questionIdx !== -1) {
|
||||
toast.error(
|
||||
`This option is used in logic for question ${questionIdx + 1}. Please fix the logic first before deleting.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newChoices = !question.choices ? [] : question.choices.filter((_, idx) => idx !== choiceIdx);
|
||||
const choiceValue = question.choices[choiceIdx].label[selectedLanguageCode];
|
||||
if (isInvalidValue === choiceValue) {
|
||||
setisInvalidValue(null);
|
||||
}
|
||||
let newLogic: any[] = [];
|
||||
question.logic?.forEach((logic) => {
|
||||
let newL: string | string[] | undefined = logic.value;
|
||||
if (Array.isArray(logic.value)) {
|
||||
newL = logic.value.filter((value) => value !== choiceValue);
|
||||
} else {
|
||||
newL = logic.value !== choiceValue ? logic.value : undefined;
|
||||
}
|
||||
newLogic.push({ ...logic, value: newL });
|
||||
});
|
||||
|
||||
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
|
||||
updateQuestion(questionIdx, {
|
||||
choices: newChoices,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -39,22 +39,10 @@ export const PictureSelectionForm = ({
|
||||
// Filter out the deleted choice from the choices array
|
||||
const newChoices = question.choices?.filter((choice) => choice.id !== choiceValue) || [];
|
||||
|
||||
// Update the logic, removing the deleted choice value
|
||||
const newLogic =
|
||||
question.logic?.map((logic) => {
|
||||
let updatedValue = logic.value;
|
||||
|
||||
if (Array.isArray(logic.value)) {
|
||||
updatedValue = logic.value.filter((value) => value !== choiceValue);
|
||||
} else if (logic.value === choiceValue) {
|
||||
updatedValue = undefined;
|
||||
}
|
||||
|
||||
return { ...logic, value: updatedValue };
|
||||
}) || [];
|
||||
|
||||
// Update the question with new choices and logic
|
||||
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
|
||||
updateQuestion(questionIdx, {
|
||||
choices: newChoices,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileInputChanges = (urls: string[]) => {
|
||||
|
||||
@@ -1,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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { FileDigitIcon } from "lucide-react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { SurveyVariablesCardItem } from "./SurveyVariablesCardItem";
|
||||
@@ -37,7 +38,9 @@ export const SurveyVariablesCard = ({
|
||||
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
|
||||
)}>
|
||||
<p>🪣</p>
|
||||
<div className="flex w-full justify-center">
|
||||
<FileDigitIcon className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { findVariableUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { extractRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
import { TSurvey, TSurveyVariable } from "@formbricks/types/surveys/types";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
@@ -72,11 +74,19 @@ export const SurveyVariablesCardItem = ({
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, mode, editSurveyVariable]);
|
||||
|
||||
const onVaribleDelete = (variable: TSurveyVariable) => {
|
||||
const onVariableDelete = (variable: TSurveyVariable) => {
|
||||
const questions = [...localSurvey.questions];
|
||||
|
||||
// find if this variable is used in any question's recall and remove it for every language
|
||||
const quesIdx = findVariableUsedInLogic(localSurvey, variable.id);
|
||||
|
||||
if (quesIdx !== -1) {
|
||||
toast.error(
|
||||
`${variable.name} is used in logic of question ${quesIdx + 1}. Please remove it from logic first.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// find if this variable is used in any question's recall and remove it for every language
|
||||
questions.forEach((question) => {
|
||||
for (const [languageCode, headline] of Object.entries(question.headline)) {
|
||||
if (headline.includes(`recall:${variable.id}`)) {
|
||||
@@ -210,7 +220,7 @@ export const SurveyVariablesCardItem = ({
|
||||
type="button"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => onVaribleDelete(variable)}>
|
||||
onClick={() => onVariableDelete(variable)}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
import { TSurveyQuestionTypeEnum, ZSurveyLogicConditionsOperator } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const logicRules = {
|
||||
question: {
|
||||
[`${TSurveyQuestionTypeEnum.OpenText}.text`]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "contains",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.contains,
|
||||
},
|
||||
{
|
||||
label: "does not contain",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotContain,
|
||||
},
|
||||
{
|
||||
label: "starts with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.startsWith,
|
||||
},
|
||||
{
|
||||
label: "does not start with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotStartWith,
|
||||
},
|
||||
{
|
||||
label: "ends with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.endsWith,
|
||||
},
|
||||
{
|
||||
label: "does not end with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEndWith,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[`${TSurveyQuestionTypeEnum.OpenText}.number`]: {
|
||||
options: [
|
||||
{
|
||||
label: "=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "!=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: ">",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThan,
|
||||
},
|
||||
{
|
||||
label: "<",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThan,
|
||||
},
|
||||
{
|
||||
label: ">=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "<=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "equals one of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equalsOneOf,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "includes all of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.includesAllOf,
|
||||
},
|
||||
{
|
||||
label: "includes one of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.includesOneOf,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.PictureSelection]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "includes all of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.includesAllOf,
|
||||
},
|
||||
{
|
||||
label: "includes one of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.includesOneOf,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Rating]: {
|
||||
options: [
|
||||
{
|
||||
label: "=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "!=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: ">",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThan,
|
||||
},
|
||||
{
|
||||
label: "<",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThan,
|
||||
},
|
||||
{
|
||||
label: ">=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "<=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.NPS]: {
|
||||
options: [
|
||||
{
|
||||
label: "=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "!=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: ">",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThan,
|
||||
},
|
||||
{
|
||||
label: "<",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThan,
|
||||
},
|
||||
{
|
||||
label: ">=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "<=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.CTA]: {
|
||||
options: [
|
||||
{
|
||||
label: "is clicked",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isClicked,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Consent]: {
|
||||
options: [
|
||||
{
|
||||
label: "is accepted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isAccepted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Date]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "is before",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isBefore,
|
||||
},
|
||||
{
|
||||
label: "is after",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isAfter,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.FileUpload]: {
|
||||
options: [
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Ranking]: {
|
||||
options: [
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Cal]: {
|
||||
options: [
|
||||
{
|
||||
label: "is booked",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isBooked,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Matrix]: {
|
||||
options: [
|
||||
{
|
||||
label: "is partially submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isPartiallySubmitted,
|
||||
},
|
||||
{
|
||||
label: "is completely submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isCompletelySubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Address]: {
|
||||
options: [
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
["variable.text"]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "contains",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.contains,
|
||||
},
|
||||
{
|
||||
label: "does not contain",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotContain,
|
||||
},
|
||||
{
|
||||
label: "starts with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.startsWith,
|
||||
},
|
||||
{
|
||||
label: "does not start with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotStartWith,
|
||||
},
|
||||
{
|
||||
label: "ends with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.endsWith,
|
||||
},
|
||||
{
|
||||
label: "does not end with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEndWith,
|
||||
},
|
||||
],
|
||||
},
|
||||
["variable.number"]: {
|
||||
options: [
|
||||
{
|
||||
label: "=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "!=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: ">",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThan,
|
||||
},
|
||||
{
|
||||
label: "<",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThan,
|
||||
},
|
||||
{
|
||||
label: ">=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "<=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThanOrEqual,
|
||||
},
|
||||
],
|
||||
},
|
||||
hiddenField: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "contains",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.contains,
|
||||
},
|
||||
{
|
||||
label: "does not contain",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotContain,
|
||||
},
|
||||
{
|
||||
label: "starts with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.startsWith,
|
||||
},
|
||||
{
|
||||
label: "does not start with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotStartWith,
|
||||
},
|
||||
{
|
||||
label: "ends with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.endsWith,
|
||||
},
|
||||
{
|
||||
label: "does not end with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEndWith,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export type TLogicRuleOption = (typeof logicRules.question)[keyof typeof logicRules.question]["options"];
|
||||
@@ -1,18 +0,0 @@
|
||||
// formats the text to highlight specific parts of the text with slashes
|
||||
export const formatTextWithSlashes = (text: string) => {
|
||||
const regex = /\/(.*?)\\/g;
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
// Check if the part was inside slashes
|
||||
if (index % 2 !== 0) {
|
||||
return (
|
||||
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-xs">
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return part;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,930 @@
|
||||
import { EyeOffIcon, FileDigitIcon, FileType2Icon } from "lucide-react";
|
||||
import { HTMLInputTypeAttribute, useMemo } from "react";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { isConditionGroup } from "@formbricks/lib/surveyLogic/utils";
|
||||
import { questionIconMapping, questionTypes } from "@formbricks/lib/utils/questions";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import {
|
||||
TConditionGroup,
|
||||
TLeftOperand,
|
||||
TRightOperand,
|
||||
TSingleCondition,
|
||||
TSurvey,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicAction,
|
||||
TSurveyLogicConditionsOperator,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyVariable,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TComboboxGroupedOption, TComboboxOption } from "@formbricks/ui/InputCombobox";
|
||||
import { TLogicRuleOption, logicRules } from "./logicRuleEngine";
|
||||
|
||||
// formats the text to highlight specific parts of the text with slashes
|
||||
export const formatTextWithSlashes = (text: string) => {
|
||||
const regex = /\/(.*?)\\/g;
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
// Check if the part was inside slashes
|
||||
if (index % 2 !== 0) {
|
||||
return (
|
||||
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-xs">
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return part;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const getConditionValueOptions = (
|
||||
localSurvey: TSurvey,
|
||||
currQuestionIdx: number
|
||||
): TComboboxGroupedOption[] => {
|
||||
const hiddenFields = localSurvey.hiddenFields?.fieldIds ?? [];
|
||||
const variables = localSurvey.variables ?? [];
|
||||
const questions = localSurvey.questions;
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
const questionOptions = questions
|
||||
.filter((_, idx) => idx <= currQuestionIdx)
|
||||
.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const variableOptions = variables.map((variable) => {
|
||||
return {
|
||||
icon: variable.type === "number" ? FileDigitIcon : FileType2Icon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hiddenFieldsOptions = hiddenFields.map((field) => {
|
||||
return {
|
||||
icon: EyeOffIcon,
|
||||
label: field,
|
||||
value: field,
|
||||
meta: {
|
||||
type: "hiddenField",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return groupedOptions;
|
||||
};
|
||||
|
||||
export const replaceEndingCardHeadlineRecall = (
|
||||
survey: TSurvey,
|
||||
language: string,
|
||||
attributeClasses: TAttributeClass[]
|
||||
) => {
|
||||
const modifiedSurvey = structuredClone(survey);
|
||||
modifiedSurvey.endings.forEach((ending) => {
|
||||
if (ending.type === "endScreen") {
|
||||
ending.headline = recallToHeadline(
|
||||
ending.headline || {},
|
||||
modifiedSurvey,
|
||||
false,
|
||||
language,
|
||||
attributeClasses
|
||||
);
|
||||
}
|
||||
});
|
||||
return modifiedSurvey;
|
||||
};
|
||||
|
||||
export const actionObjectiveOptions: TComboboxOption[] = [
|
||||
{ label: "Calculate", value: "calculate" },
|
||||
{ label: "Require Answer", value: "requireAnswer" },
|
||||
{ label: "Jump to question", value: "jumpToQuestion" },
|
||||
];
|
||||
|
||||
const getQuestionOperatorOptions = (question: TSurveyQuestion): TComboboxOption[] => {
|
||||
let options: TLogicRuleOption;
|
||||
|
||||
if (question.type === "openText") {
|
||||
const inputType = question.inputType === "number" ? "number" : "text";
|
||||
options = logicRules.question[`openText.${inputType}`].options;
|
||||
} else {
|
||||
options = logicRules.question[question.type].options;
|
||||
}
|
||||
|
||||
if (question.required) {
|
||||
options = options.filter((option) => option.value !== "isSkipped") as TLogicRuleOption;
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
export const getDefaultOperatorForQuestion = (question: TSurveyQuestion): TSurveyLogicConditionsOperator => {
|
||||
const options = getQuestionOperatorOptions(question);
|
||||
|
||||
return options[0].value.toString() as TSurveyLogicConditionsOperator;
|
||||
};
|
||||
|
||||
export const getConditionOperatorOptions = (
|
||||
condition: TSingleCondition,
|
||||
localSurvey: TSurvey
|
||||
): TComboboxOption[] => {
|
||||
if (condition.leftOperand.type === "variable") {
|
||||
const variables = localSurvey.variables ?? [];
|
||||
const variableType =
|
||||
variables.find((variable) => variable.id === condition.leftOperand.value)?.type || "text";
|
||||
return logicRules[`variable.${variableType}`].options;
|
||||
} else if (condition.leftOperand.type === "hiddenField") {
|
||||
return logicRules.hiddenField.options;
|
||||
} else if (condition.leftOperand.type === "question") {
|
||||
const questions = localSurvey.questions ?? [];
|
||||
const question = questions.find((question) => question.id === condition.leftOperand.value);
|
||||
|
||||
if (!question) return [];
|
||||
|
||||
return getQuestionOperatorOptions(question);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const getMatchValueProps = (
|
||||
condition: TSingleCondition,
|
||||
localSurvey: TSurvey
|
||||
): {
|
||||
show?: boolean;
|
||||
showInput?: boolean;
|
||||
inputType?: HTMLInputTypeAttribute;
|
||||
options: TComboboxGroupedOption[];
|
||||
} => {
|
||||
if (
|
||||
[
|
||||
"isAccepted",
|
||||
"isBooked",
|
||||
"isClicked",
|
||||
"isCompletelySubmitted",
|
||||
"isPartiallySubmitted",
|
||||
"isSkipped",
|
||||
"isSubmitted",
|
||||
].includes(condition.operator)
|
||||
) {
|
||||
return { show: false, options: [] };
|
||||
}
|
||||
|
||||
let questions = localSurvey.questions ?? [];
|
||||
let variables = localSurvey.variables ?? [];
|
||||
let hiddenFields = localSurvey.hiddenFields?.fieldIds ?? [];
|
||||
|
||||
const selectedQuestion = questions.find((question) => question.id === condition.leftOperand.value);
|
||||
const selectedVariable = variables.find((variable) => variable.id === condition.leftOperand.value);
|
||||
|
||||
if (condition.leftOperand.type === "question") {
|
||||
questions = questions.filter((question) => question.id !== condition.leftOperand.value);
|
||||
} else if (condition.leftOperand.type === "variable") {
|
||||
variables = variables.filter((variable) => variable.id !== condition.leftOperand.value);
|
||||
} else if (condition.leftOperand.type === "hiddenField") {
|
||||
hiddenFields = hiddenFields.filter((field) => field !== condition.leftOperand.value);
|
||||
}
|
||||
|
||||
if (condition.leftOperand.type === "question") {
|
||||
if (selectedQuestion?.type === TSurveyQuestionTypeEnum.OpenText) {
|
||||
const allowedQuestionTypes = [TSurveyQuestionTypeEnum.OpenText];
|
||||
|
||||
if (selectedQuestion.inputType === "number") {
|
||||
allowedQuestionTypes.push(TSurveyQuestionTypeEnum.Rating, TSurveyQuestionTypeEnum.NPS);
|
||||
}
|
||||
|
||||
if (["equals", "doesNotEqual"].includes(condition.operator)) {
|
||||
if (selectedQuestion.inputType !== "number") {
|
||||
allowedQuestionTypes.push(
|
||||
TSurveyQuestionTypeEnum.Date,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceMulti
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allowedQuestions = questions.filter((question) => allowedQuestionTypes.includes(question.type));
|
||||
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const variableOptions = variables
|
||||
.filter((variable) =>
|
||||
selectedQuestion.inputType !== "number" ? variable.type === "text" : variable.type === "number"
|
||||
)
|
||||
.map((variable) => {
|
||||
return {
|
||||
icon: variable.type === "number" ? FileDigitIcon : FileType2Icon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hiddenFieldsOptions = hiddenFields.map((field) => {
|
||||
return {
|
||||
icon: EyeOffIcon,
|
||||
label: field,
|
||||
value: field,
|
||||
meta: {
|
||||
type: "hiddenField",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
return {
|
||||
show: true,
|
||||
showInput: true,
|
||||
inputType: selectedQuestion.inputType === "number" ? "number" : "text",
|
||||
options: groupedOptions,
|
||||
};
|
||||
} else if (
|
||||
selectedQuestion?.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||
selectedQuestion?.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
|
||||
) {
|
||||
const choices = selectedQuestion.choices.map((choice) => {
|
||||
return {
|
||||
label: getLocalizedValue(choice.label, "default"),
|
||||
value: choice.id,
|
||||
meta: {
|
||||
type: "static",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: false,
|
||||
options: [{ label: "Choices", value: "choices", options: choices }],
|
||||
};
|
||||
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.PictureSelection) {
|
||||
const choices = selectedQuestion.choices.map((choice, idx) => {
|
||||
return {
|
||||
imgSrc: choice.imageUrl,
|
||||
label: `Picture ${idx + 1}`,
|
||||
value: choice.id,
|
||||
meta: {
|
||||
type: "static",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: false,
|
||||
options: [{ label: "Choices", value: "choices", options: choices }],
|
||||
};
|
||||
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.Rating) {
|
||||
const choices = Array.from({ length: selectedQuestion.range }, (_, idx) => {
|
||||
return {
|
||||
label: `${idx + 1}`,
|
||||
value: idx + 1,
|
||||
meta: {
|
||||
type: "static",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const numberVariables = variables.filter((variable) => variable.type === "number");
|
||||
|
||||
const variableOptions = numberVariables.map((variable) => {
|
||||
return {
|
||||
icon: FileDigitIcon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (choices.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Choices",
|
||||
value: "choices",
|
||||
options: choices,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: false,
|
||||
options: groupedOptions,
|
||||
};
|
||||
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.NPS) {
|
||||
const choices = Array.from({ length: 11 }, (_, idx) => {
|
||||
return {
|
||||
label: `${idx}`,
|
||||
value: idx,
|
||||
meta: {
|
||||
type: "static",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const numberVariables = variables.filter((variable) => variable.type === "number");
|
||||
|
||||
const variableOptions = numberVariables.map((variable) => {
|
||||
return {
|
||||
icon: FileDigitIcon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (choices.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Choices",
|
||||
value: "choices",
|
||||
options: choices,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: false,
|
||||
options: groupedOptions,
|
||||
};
|
||||
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.Date) {
|
||||
const openTextQuestions = questions.filter((question) =>
|
||||
[TSurveyQuestionTypeEnum.OpenText, TSurveyQuestionTypeEnum.Date].includes(question.type)
|
||||
);
|
||||
|
||||
const questionOptions = openTextQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const stringVariables = variables.filter((variable) => variable.type === "text");
|
||||
|
||||
const variableOptions = stringVariables.map((variable) => {
|
||||
return {
|
||||
icon: FileType2Icon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hiddenFieldsOptions = hiddenFields.map((field) => {
|
||||
return {
|
||||
icon: EyeOffIcon,
|
||||
label: field,
|
||||
value: field,
|
||||
meta: {
|
||||
type: "hiddenField",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: true,
|
||||
inputType: "date",
|
||||
options: groupedOptions,
|
||||
};
|
||||
}
|
||||
} else if (condition.leftOperand.type === "variable") {
|
||||
if (selectedVariable?.type === "text") {
|
||||
const allowedQuestionTypes = [
|
||||
TSurveyQuestionTypeEnum.OpenText,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
];
|
||||
|
||||
if (["equals", "doesNotEqual"].includes(condition.operator)) {
|
||||
allowedQuestionTypes.push(TSurveyQuestionTypeEnum.MultipleChoiceMulti, TSurveyQuestionTypeEnum.Date);
|
||||
}
|
||||
|
||||
const allowedQuestions = questions.filter((question) => allowedQuestionTypes.includes(question.type));
|
||||
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const stringVariables = variables.filter((variable) => variable.type === "text");
|
||||
|
||||
const variableOptions = stringVariables.map((variable) => {
|
||||
return {
|
||||
icon: FileType2Icon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hiddenFieldsOptions = hiddenFields.map((field) => {
|
||||
return {
|
||||
icon: EyeOffIcon,
|
||||
label: field,
|
||||
value: field,
|
||||
meta: {
|
||||
type: "hiddenField",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: true,
|
||||
inputType: "text",
|
||||
options: groupedOptions,
|
||||
};
|
||||
} else if (selectedVariable?.type === "number") {
|
||||
const allowedQuestions = questions.filter((question) =>
|
||||
[TSurveyQuestionTypeEnum.Rating, TSurveyQuestionTypeEnum.NPS].includes(question.type)
|
||||
);
|
||||
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const numberVariables = variables.filter((variable) => variable.type === "number");
|
||||
|
||||
const variableOptions = numberVariables.map((variable) => {
|
||||
return {
|
||||
icon: FileDigitIcon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hiddenFieldsOptions = hiddenFields.map((field) => {
|
||||
return {
|
||||
icon: EyeOffIcon,
|
||||
label: field,
|
||||
value: field,
|
||||
meta: {
|
||||
type: "hiddenField",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: true,
|
||||
inputType: "number",
|
||||
options: groupedOptions,
|
||||
};
|
||||
}
|
||||
} else if (condition.leftOperand.type === "hiddenField") {
|
||||
const allowedQuestionTypes = [
|
||||
TSurveyQuestionTypeEnum.OpenText,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
];
|
||||
|
||||
if (["equals", "doesNotEqual"].includes(condition.operator)) {
|
||||
allowedQuestionTypes.push(TSurveyQuestionTypeEnum.MultipleChoiceMulti, TSurveyQuestionTypeEnum.Date);
|
||||
}
|
||||
|
||||
const allowedQuestions = questions.filter((question) => allowedQuestionTypes.includes(question.type));
|
||||
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const variableOptions = variables
|
||||
.filter((variable) => variable.type === "text")
|
||||
.map((variable) => {
|
||||
return {
|
||||
icon: FileType2Icon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hiddenFieldsOptions = hiddenFields.map((field) => {
|
||||
return {
|
||||
icon: EyeOffIcon,
|
||||
label: field,
|
||||
value: field,
|
||||
meta: {
|
||||
type: "hiddenField",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: true,
|
||||
inputType: "text",
|
||||
options: groupedOptions,
|
||||
};
|
||||
}
|
||||
|
||||
return { show: false, options: [] };
|
||||
};
|
||||
|
||||
export const getActionOperatorOptions = (variableType?: TSurveyVariable["type"]): TComboboxOption[] => {
|
||||
if (variableType === "number") {
|
||||
return [
|
||||
{
|
||||
label: "Add +",
|
||||
value: "add",
|
||||
},
|
||||
{
|
||||
label: "Subtract -",
|
||||
value: "subtract",
|
||||
},
|
||||
{
|
||||
label: "Multiply *",
|
||||
value: "multiply",
|
||||
},
|
||||
{
|
||||
label: "Divide /",
|
||||
value: "divide",
|
||||
},
|
||||
{
|
||||
label: "Assign =",
|
||||
value: "assign",
|
||||
},
|
||||
];
|
||||
} else if (variableType === "text") {
|
||||
return [
|
||||
{
|
||||
label: "Assign =",
|
||||
value: "assign",
|
||||
},
|
||||
{
|
||||
label: "Concat +",
|
||||
value: "concat",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const isUsedInLeftOperand = (
|
||||
leftOperand: TLeftOperand,
|
||||
type: "question" | "hiddenField" | "variable",
|
||||
id: string
|
||||
): boolean => {
|
||||
switch (type) {
|
||||
case "question":
|
||||
return leftOperand.type === "question" && leftOperand.value === id;
|
||||
case "hiddenField":
|
||||
return leftOperand.type === "hiddenField" && leftOperand.value === id;
|
||||
case "variable":
|
||||
return leftOperand.type === "variable" && leftOperand.value === id;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isUsedInRightOperand = (
|
||||
rightOperand: TRightOperand,
|
||||
type: "question" | "hiddenField" | "variable",
|
||||
id: string
|
||||
): boolean => {
|
||||
switch (type) {
|
||||
case "question":
|
||||
return rightOperand.type === "question" && rightOperand.value === id;
|
||||
case "hiddenField":
|
||||
return rightOperand.type === "hiddenField" && rightOperand.value === id;
|
||||
case "variable":
|
||||
return rightOperand.type === "variable" && rightOperand.value === id;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const findQuestionUsedInLogic = (survey: TSurvey, questionId: string): number => {
|
||||
const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => {
|
||||
if (isConditionGroup(condition)) {
|
||||
// It's a TConditionGroup
|
||||
return condition.conditions.some(isUsedInCondition);
|
||||
} else {
|
||||
// It's a TSingleCondition
|
||||
return (
|
||||
(condition.rightOperand && isUsedInRightOperand(condition.rightOperand, "question", questionId)) ||
|
||||
isUsedInLeftOperand(condition.leftOperand, "question", questionId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isUsedInAction = (action: TSurveyLogicAction): boolean => {
|
||||
return (
|
||||
(action.objective === "jumpToQuestion" && action.target === questionId) ||
|
||||
(action.objective === "requireAnswer" && action.target === questionId)
|
||||
);
|
||||
};
|
||||
|
||||
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
|
||||
return isUsedInCondition(logicRule.conditions) || logicRule.actions.some(isUsedInAction);
|
||||
};
|
||||
|
||||
return survey.questions.findIndex(
|
||||
(question) => question.logic && question.id !== questionId && question.logic.some(isUsedInLogicRule)
|
||||
);
|
||||
};
|
||||
|
||||
export const findOptionUsedInLogic = (survey: TSurvey, questionId: string, optionId: string): number => {
|
||||
const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => {
|
||||
if (isConditionGroup(condition)) {
|
||||
// It's a TConditionGroup
|
||||
return condition.conditions.some(isUsedInCondition);
|
||||
} else {
|
||||
// It's a TSingleCondition
|
||||
return isUsedInOperand(condition);
|
||||
}
|
||||
};
|
||||
|
||||
const isUsedInOperand = (condition: TSingleCondition): boolean => {
|
||||
if (condition.leftOperand.type === "question" && condition.leftOperand.value === questionId) {
|
||||
if (condition.rightOperand && condition.rightOperand.type === "static") {
|
||||
if (Array.isArray(condition.rightOperand.value)) {
|
||||
return condition.rightOperand.value.includes(optionId);
|
||||
} else {
|
||||
return condition.rightOperand.value === optionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
|
||||
return isUsedInCondition(logicRule.conditions);
|
||||
};
|
||||
|
||||
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
|
||||
};
|
||||
|
||||
export const findVariableUsedInLogic = (survey: TSurvey, variableId: string): number => {
|
||||
const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => {
|
||||
if (isConditionGroup(condition)) {
|
||||
// It's a TConditionGroup
|
||||
return condition.conditions.some(isUsedInCondition);
|
||||
} else {
|
||||
// It's a TSingleCondition
|
||||
return (
|
||||
(condition.rightOperand && isUsedInRightOperand(condition.rightOperand, "variable", variableId)) ||
|
||||
isUsedInLeftOperand(condition.leftOperand, "variable", variableId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isUsedInAction = (action: TSurveyLogicAction): boolean => {
|
||||
return action.objective === "calculate" && action.variableId === variableId;
|
||||
};
|
||||
|
||||
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
|
||||
return isUsedInCondition(logicRule.conditions) || logicRule.actions.some(isUsedInAction);
|
||||
};
|
||||
|
||||
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
|
||||
};
|
||||
|
||||
export const findHiddenFieldUsedInLogic = (survey: TSurvey, hiddenFieldId: string): number => {
|
||||
const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => {
|
||||
if (isConditionGroup(condition)) {
|
||||
// It's a TConditionGroup
|
||||
return condition.conditions.some(isUsedInCondition);
|
||||
} else {
|
||||
// It's a TSingleCondition
|
||||
return (
|
||||
(condition.rightOperand &&
|
||||
isUsedInRightOperand(condition.rightOperand, "hiddenField", hiddenFieldId)) ||
|
||||
isUsedInLeftOperand(condition.leftOperand, "hiddenField", hiddenFieldId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
|
||||
return isUsedInCondition(logicRule.conditions);
|
||||
};
|
||||
|
||||
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
|
||||
};
|
||||
@@ -47,6 +47,7 @@ export type IntegrationModalInputs = {
|
||||
table: string;
|
||||
survey: string;
|
||||
questions: string[];
|
||||
includeVariables: boolean;
|
||||
includeHiddenFields: boolean;
|
||||
includeMetadata: boolean;
|
||||
};
|
||||
@@ -91,8 +92,9 @@ export const AddIntegrationModal = ({
|
||||
const { index: _index, ...rest } = defaultData;
|
||||
reset(rest);
|
||||
fetchTable(defaultData.base);
|
||||
setIncludeHiddenFields(defaultData.includeHiddenFields);
|
||||
setIncludeMetadata(defaultData.includeMetadata);
|
||||
setIncludeVariables(!!defaultData.includeVariables);
|
||||
setIncludeHiddenFields(!!defaultData.includeHiddenFields);
|
||||
setIncludeMetadata(!!defaultData.includeMetadata);
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
@@ -100,6 +102,12 @@ export const AddIntegrationModal = ({
|
||||
}, [isEditMode]);
|
||||
|
||||
const survey = watch("survey");
|
||||
const includeVariables = watch("includeVariables");
|
||||
|
||||
const setIncludeVariables = (includeVariables: boolean) => {
|
||||
setValue("includeVariables", includeVariables);
|
||||
};
|
||||
|
||||
const selectedSurvey = surveys.find((item) => item.id === survey);
|
||||
const submitHandler = async (data: IntegrationModalInputs) => {
|
||||
try {
|
||||
@@ -130,6 +138,7 @@ export const AddIntegrationModal = ({
|
||||
baseId: data.base,
|
||||
tableId: data.table,
|
||||
tableName: currentTable?.name ?? "",
|
||||
includeVariables: data.includeVariables,
|
||||
includeHiddenFields,
|
||||
includeMetadata,
|
||||
};
|
||||
@@ -329,6 +338,8 @@ export const AddIntegrationModal = ({
|
||||
</div>
|
||||
</div>
|
||||
<AdditionalIntegrationSettings
|
||||
includeVariables={includeVariables}
|
||||
setIncludeVariables={setIncludeVariables}
|
||||
includeHiddenFields={includeHiddenFields}
|
||||
includeMetadata={includeMetadata}
|
||||
setIncludeHiddenFields={setIncludeHiddenFields}
|
||||
|
||||
@@ -108,6 +108,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
questions: data.questionIds,
|
||||
survey: data.surveyId,
|
||||
table: data.tableId,
|
||||
includeVariables: !!data.includeVariables,
|
||||
includeHiddenFields: !!data.includeHiddenFields,
|
||||
includeMetadata: !!data.includeMetadata,
|
||||
index,
|
||||
|
||||
@@ -62,6 +62,7 @@ export const AddIntegrationModal = ({
|
||||
const [spreadsheetUrl, setSpreadsheetUrl] = useState("");
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
const existingIntegrationData = googleSheetIntegration?.config?.data;
|
||||
const [includeVariables, setIncludeVariables] = useState(false);
|
||||
const [includeHiddenFields, setIncludeHiddenFields] = useState(false);
|
||||
const [includeMetadata, setIncludeMetadata] = useState(false);
|
||||
const googleSheetIntegrationData: TIntegrationGoogleSheetsInput = {
|
||||
@@ -89,6 +90,7 @@ export const AddIntegrationModal = ({
|
||||
})!
|
||||
);
|
||||
setSelectedQuestions(selectedIntegration.questionIds);
|
||||
setIncludeVariables(!!selectedIntegration.includeVariables);
|
||||
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
|
||||
setIncludeMetadata(!!selectedIntegration.includeMetadata);
|
||||
return;
|
||||
@@ -127,6 +129,7 @@ export const AddIntegrationModal = ({
|
||||
? "All questions"
|
||||
: "Selected questions";
|
||||
integrationData.createdAt = new Date();
|
||||
integrationData.includeVariables = includeVariables;
|
||||
integrationData.includeHiddenFields = includeHiddenFields;
|
||||
integrationData.includeMetadata = includeMetadata;
|
||||
if (selectedIntegration) {
|
||||
@@ -256,6 +259,8 @@ export const AddIntegrationModal = ({
|
||||
</div>
|
||||
</div>
|
||||
<AdditionalIntegrationSettings
|
||||
includeVariables={includeVariables}
|
||||
setIncludeVariables={setIncludeVariables}
|
||||
includeHiddenFields={includeHiddenFields}
|
||||
includeMetadata={includeMetadata}
|
||||
setIncludeHiddenFields={setIncludeHiddenFields}
|
||||
|
||||
@@ -118,6 +118,13 @@ export const AddIntegrationModal = ({
|
||||
}))
|
||||
: [];
|
||||
|
||||
const variables =
|
||||
selectedSurvey?.variables.map((variable) => ({
|
||||
id: variable.id,
|
||||
name: variable.name,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
})) || [];
|
||||
|
||||
const hiddenFields = selectedSurvey?.hiddenFields.enabled
|
||||
? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
|
||||
id: fId,
|
||||
@@ -133,7 +140,7 @@ export const AddIntegrationModal = ({
|
||||
},
|
||||
];
|
||||
|
||||
return [...questions, ...hiddenFields, ...Metadata];
|
||||
return [...questions, ...variables, ...hiddenFields, ...Metadata];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedSurvey?.id]);
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ export const AddChannelMappingModal = ({
|
||||
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
|
||||
const [selectedChannel, setSelectedChannel] = useState<TIntegrationItem | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
const [includeVariables, setIncludeVariables] = useState(false);
|
||||
const [includeHiddenFields, setIncludeHiddenFields] = useState(false);
|
||||
const [includeMetadata, setIncludeMetadata] = useState(false);
|
||||
const existingIntegrationData = slackIntegration?.config?.data;
|
||||
@@ -81,6 +82,7 @@ export const AddChannelMappingModal = ({
|
||||
})!
|
||||
);
|
||||
setSelectedQuestions(selectedIntegration.questionIds);
|
||||
setIncludeVariables(!!selectedIntegration.includeVariables);
|
||||
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
|
||||
setIncludeMetadata(!!selectedIntegration.includeMetadata);
|
||||
return;
|
||||
@@ -112,6 +114,7 @@ export const AddChannelMappingModal = ({
|
||||
? "All questions"
|
||||
: "Selected questions",
|
||||
createdAt: new Date(),
|
||||
includeVariables,
|
||||
includeHiddenFields,
|
||||
includeMetadata,
|
||||
};
|
||||
@@ -258,6 +261,8 @@ export const AddChannelMappingModal = ({
|
||||
</div>
|
||||
</div>
|
||||
<AdditionalIntegrationSettings
|
||||
includeVariables={includeVariables}
|
||||
setIncludeVariables={setIncludeVariables}
|
||||
includeHiddenFields={includeHiddenFields}
|
||||
includeMetadata={includeMetadata}
|
||||
setIncludeHiddenFields={setIncludeHiddenFields}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
19
apps/web/app/middleware/responseHeaders.ts
Normal 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 };
|
||||
};
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Response" ADD COLUMN "variables" JSONB NOT NULL DEFAULT '{}';
|
||||
@@ -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",
|
||||
|
||||
@@ -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("{}")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -192,6 +192,7 @@ const renderWidget = async (
|
||||
url: window.location.href,
|
||||
action,
|
||||
},
|
||||
variables: responseUpdate.variables,
|
||||
hiddenFields,
|
||||
displayId: surveyState.displayId,
|
||||
});
|
||||
|
||||
@@ -187,6 +187,7 @@ const renderWidget = async (
|
||||
url: window.location.href,
|
||||
action,
|
||||
},
|
||||
variables: responseUpdate.variables,
|
||||
hiddenFields,
|
||||
displayId: surveyState.displayId,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
655
packages/lib/surveyLogic/utils.ts
Normal 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;
|
||||
};
|
||||
@@ -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: {} };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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]}#`;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||