Compare commits
127 Commits
v2.5.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 | ||
|
|
01ceaa13ec | ||
|
|
35ff935a27 | ||
|
|
901cd42f56 | ||
|
|
63e1ac11cf | ||
|
|
744f3410ae | ||
|
|
0e73d81999 | ||
|
|
ba46782da4 | ||
|
|
25774f6f08 | ||
|
|
9a98772210 | ||
|
|
59a29dd3d6 | ||
|
|
e4fceb2e5e | ||
|
|
350c895d8c | ||
|
|
0b553447e0 | ||
|
|
6b64367d99 | ||
|
|
fe9746ba67 | ||
|
|
e4009d5951 | ||
|
|
b1ed61c247 | ||
|
|
10255aa102 | ||
|
|
774c6f19a5 | ||
|
|
ebf35ea582 | ||
|
|
f13efc954e | ||
|
|
9ee052a229 | ||
|
|
152fbede90 | ||
|
|
c9b8ffa9ef | ||
|
|
4ed1747ee2 | ||
|
|
88c492afd8 | ||
|
|
c7e0b02595 | ||
|
|
15aa9b2731 | ||
|
|
6f7359abf6 | ||
|
|
04e16d44a1 | ||
|
|
29131f93c2 | ||
|
|
1e2fe7b066 | ||
|
|
426a0a3847 | ||
|
|
80ef504bef | ||
|
|
6ab83e25d3 | ||
|
|
0bf5e0fd4c | ||
|
|
688dc25990 | ||
|
|
887c5c0eef | ||
|
|
c53b58c64f | ||
|
|
61d18edb5d | ||
|
|
af4ae38564 | ||
|
|
07b5dfe28a | ||
|
|
6b829744a1 | ||
|
|
f55cad0121 | ||
|
|
494299cd89 | ||
|
|
7b0d4926e8 | ||
|
|
334166b4b1 | ||
|
|
1685e77a35 | ||
|
|
927f97e9ad | ||
|
|
5cc071e5a8 | ||
|
|
76c437b16a | ||
|
|
0532f2744b | ||
|
|
43ea26a33a | ||
|
|
ec54e40a8b | ||
|
|
0c2d425a45 | ||
|
|
7aa827d1e1 | ||
|
|
d4961f1840 | ||
|
|
4b508f02e3 | ||
|
|
eec7e1b62a | ||
|
|
39e87eb8d3 | ||
|
|
6772ea6be4 | ||
|
|
9bacc88063 | ||
|
|
005c777c9c | ||
|
|
560dce3bbf | ||
|
|
ffd45a6f20 | ||
|
|
1ddd82c084 | ||
|
|
780115ffb8 | ||
|
|
647539c617 | ||
|
|
a5a3161f7c | ||
|
|
0535581f6d | ||
|
|
97b04f9e43 | ||
|
|
c7c4ba6e49 | ||
|
|
b9a7edf1f5 | ||
|
|
86bf2accc9 | ||
|
|
6ec0861c49 | ||
|
|
954c435404 | ||
|
|
538c1bd809 | ||
|
|
8542320a8e | ||
|
|
e0767881f2 | ||
|
|
7d3c8d35e1 | ||
|
|
7d0cbad326 | ||
|
|
0ce7703ab8 | ||
|
|
4362fdf35a | ||
|
|
4bcca2daf4 | ||
|
|
6abcf91a07 | ||
|
|
9a7887d9fd | ||
|
|
7dd8bb95bd | ||
|
|
df40c0ef11 | ||
|
|
7e14b86a63 | ||
|
|
a1ba3af439 | ||
|
|
0a8c5e384d | ||
|
|
b20ce46a7b | ||
|
|
58636a9d51 | ||
|
|
328e3d0b9a | ||
|
|
f1a2ecaa3a | ||
|
|
d56f05fb19 | ||
|
|
c32ced20f1 | ||
|
|
387590986a | ||
|
|
4a5c0b1409 | ||
|
|
275731e381 | ||
|
|
71c3ac0e4e | ||
|
|
d876c495be | ||
|
|
1ba885e5dc | ||
|
|
245972234e | ||
|
|
f743709908 | ||
|
|
79603293a0 | ||
|
|
29e0cf96d4 | ||
|
|
4eea6a11c8 |
33
.github/ISSUE_TEMPLATE/oss-gg-hack-submission.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: oss.gg hack submission 🕹️
|
||||
description: "Submit your contribution for the for the oss.gg hackathon"
|
||||
title: "[oss.gg hackathon]"
|
||||
labels: 🕹️ oss.gg, player submission
|
||||
assignees: []
|
||||
body:
|
||||
- type: textarea
|
||||
id: contribution-name
|
||||
attributes:
|
||||
label: What side quest or challenge are you solving?
|
||||
description: Add the name of the side quest or challenge.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: points
|
||||
attributes:
|
||||
label: Points
|
||||
description: How many points are assigned to this contribution?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What's the task your performed?
|
||||
validations:
|
||||
- type: textarea
|
||||
id: proof
|
||||
attributes:
|
||||
label: Provide proof that you've completed the task
|
||||
description: Screenshots, loom recordings, links to the content you shared or interacted with.
|
||||
validations:
|
||||
required: true
|
||||
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
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function App(): JSX.Element {
|
||||
const config = {
|
||||
environmentId: process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
|
||||
apiHost: process.env.EXPO_PUBLIC_API_HOST,
|
||||
userId: "random user id",
|
||||
userId: "random-user-id",
|
||||
attributes: {
|
||||
language: "en",
|
||||
testAttr: "attr-test",
|
||||
|
||||
@@ -8,9 +8,9 @@ import RideHailing from "./ride-hailing.webp";
|
||||
import UpsellMiro from "./upsell-miro.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Advanced Targeting in Surveys | Formbricks",
|
||||
title: "Advanced Targeting for In-app Surveys | Formbricks",
|
||||
description:
|
||||
"Advanced Targeting allows you to show surveys to just the right group of people. You can target surveys based on user attributes, user events, metadata , literally anything! This helps you get more relevant feedback and make data-driven decisions. All of this without writing a single line of code.",
|
||||
"Advanced Targeting allows you to show surveys to just the right group of people. You can target surveys based on user attributes, user events, and metadata. This helps you get more relevant feedback and make data-driven decisions.",
|
||||
};
|
||||
|
||||
#### App Surveys
|
||||
@@ -32,8 +32,7 @@ Advanced Targeting allows you to show surveys to the right group of people. You
|
||||
## How to setup Advanced Targeting
|
||||
|
||||
<Note>
|
||||
Advanced Targeting is available on the Pro plan! Don't worry, you just need to enter your credit card
|
||||
details to start the freemium plan.
|
||||
Advanced Targeting is available on the Pro plan!
|
||||
</Note>
|
||||
|
||||
1. On the Formbricks dashboard, click on **People** tab from the top navigation bar.
|
||||
@@ -72,25 +71,7 @@ Advanced Targeting allows you to show surveys to the right group of people. You
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
3. Target High Value users who have $100k+ in their bank account, own 20+ stocks, and have are an active user.
|
||||
|
||||
<MdxImage
|
||||
src={Hni}
|
||||
alt="Target Active High Net Worth Individuals"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
4. Target Germans on mobile phones who have regenerated chatGPT answers frequently in the last quarter and did so today.
|
||||
|
||||
<MdxImage
|
||||
src={GermansGpt}
|
||||
alt="Target Germans on Mobile Phones who have regenerated chatGPT answers frequently in the last quarter and did so today"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
5. Sneak Peak: How we at Formbricks automate inviting power users to chat with us
|
||||
3. Sneak Peak: How we at Formbricks automate inviting power users to chat with us
|
||||
|
||||
<MdxImage
|
||||
src={PowerUsers}
|
||||
|
||||
@@ -79,28 +79,6 @@ Promise<{ id: string }, NetworkError | Error>
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
- Update Display
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Update Display">
|
||||
|
||||
```javascript {{ title: 'Update Display Method Call'}}
|
||||
await api.client.display.update(
|
||||
displayId: "<your-display-id>",
|
||||
{
|
||||
userId: "<your-user-id>", // optional
|
||||
responseId: "<your-response-id>", // optional
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
```javascript {{ title: 'Update Display Method Return Type' }}
|
||||
Promise<{ }, NetworkError | Error]>
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
## Responses
|
||||
|
||||
- Create Response
|
||||
@@ -173,29 +151,6 @@ Promise<{ }, NetworkError | Error]>
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
## Action
|
||||
|
||||
- Create Action:
|
||||
|
||||
<Note> An environment cannot have 2 actions with the same name. </Note>
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Create Action">
|
||||
|
||||
```javascript {{ title: 'Create Action Method Call'}}
|
||||
await api.client.action.create({
|
||||
name: "<your-action-name>", // required
|
||||
userId: "<your-user-id>", // required
|
||||
});
|
||||
```
|
||||
|
||||
```javascript {{ title: 'Create Action Method Return Type' }}
|
||||
Promise<{ }, NetworkError | Error]>
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
## Attribute
|
||||
|
||||
- Update Attribute
|
||||
|
||||
@@ -14,10 +14,6 @@ export const metadata = {
|
||||
|
||||
The Formbricks React Native SDK can be used for seamlessly integrating App Surveys into your React Native Apps. Here, w'll explore how to leverage the SDK for in app surveys. The SDK is [available on npm.](https://www.npmjs.com/package/@formbricks/react-native)
|
||||
|
||||
<Note>
|
||||
Our React Native SDK is now in public beta. We're happy to **offer you a free 3-month trial for our Scale plan** if you're providing feedback on your developer experience. Reach out on Discord and shoot Johannes a message to get the extended trial started.
|
||||
</Note>
|
||||
|
||||
### Install
|
||||
|
||||
<Col>
|
||||
|
||||
|
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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -139,9 +140,9 @@ export const navigation: Array<NavGroup> = [
|
||||
{ title: "Zapier", href: "/developer-docs/integrations/zapier" },
|
||||
],
|
||||
},
|
||||
{ title: "SDK: React Native", href: "/developer-docs/react-native-in-app-surveys" },
|
||||
{ title: "SDK: Web Apps", href: "/developer-docs/app-survey-sdk" },
|
||||
{ title: "SDK: Public Websites", href: "/developer-docs/website-survey-sdk" },
|
||||
{ title: "SDK: React Native", href: "/developer-docs/react-native-in-app-surveys" },
|
||||
{ title: "SDK: Formbricks API", href: "/developer-docs/api-sdk" },
|
||||
{ title: "REST API", href: "/developer-docs/rest-api" },
|
||||
{ title: "Webhooks", href: "/developer-docs/webhooks" },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||
import { getCustomHeadline } from "@/app/(app)/(onboarding)/lib/utils";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
@@ -26,12 +25,9 @@ const Page = async ({ params }: ConnectPageProps) => {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
const channel = product.config.channel;
|
||||
const industry = product.config.industry;
|
||||
const channel = product.config.channel || null;
|
||||
const industry = product.config.industry || null;
|
||||
|
||||
if (!channel || !industry) {
|
||||
return notFound();
|
||||
}
|
||||
const customHeadline = getCustomHeadline(channel, industry);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils";
|
||||
import { XMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
|
||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||
import { ActivityIcon, ShoppingCartIcon, SmileIcon, StarIcon, ThumbsUpIcon, UsersIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { createSurveyAction } from "@formbricks/ui/TemplateList/actions";
|
||||
|
||||
interface XMTemplateListProps {
|
||||
product: TProduct;
|
||||
user: TUser;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const XMTemplateList = ({ product, user, environmentId }: XMTemplateListProps) => {
|
||||
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const createSurvey = async (activeTemplate: TXMTemplate) => {
|
||||
const augmentedTemplate: TSurveyCreateInput = {
|
||||
...activeTemplate,
|
||||
type: "link",
|
||||
createdBy: user.id,
|
||||
};
|
||||
const createSurveyResponse = await createSurveyAction({
|
||||
environmentId: environmentId,
|
||||
surveyBody: augmentedTemplate,
|
||||
});
|
||||
|
||||
if (createSurveyResponse?.data) {
|
||||
router.push(`/environments/${environmentId}/surveys/${createSurveyResponse.data.id}/edit?mode=cx`);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createSurveyResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTemplateClick = (templateIdx) => {
|
||||
setActiveTemplateId(templateIdx);
|
||||
const template = XMTemplates[templateIdx];
|
||||
const newTemplate = replacePresetPlaceholders(template, product);
|
||||
createSurvey(newTemplate);
|
||||
};
|
||||
|
||||
const XMTemplateOptions = [
|
||||
{
|
||||
title: "NPS",
|
||||
description: "Implement proven best practices to understand WHY people buy.",
|
||||
icon: ShoppingCartIcon,
|
||||
onClick: () => handleTemplateClick(0),
|
||||
isLoading: activeTemplateId === 0,
|
||||
},
|
||||
{
|
||||
title: "5-Star Rating",
|
||||
description: "Universal feedback solution to gauge overall satisfaction.",
|
||||
icon: StarIcon,
|
||||
onClick: () => handleTemplateClick(1),
|
||||
isLoading: activeTemplateId === 1,
|
||||
},
|
||||
{
|
||||
title: "CSAT",
|
||||
description: "Implement best practices to measure customer satisfaction.",
|
||||
icon: ThumbsUpIcon,
|
||||
onClick: () => handleTemplateClick(2),
|
||||
isLoading: activeTemplateId === 2,
|
||||
},
|
||||
{
|
||||
title: "CES",
|
||||
description: "Leverage every touchpoint to understand ease of customer interaction.",
|
||||
icon: ActivityIcon,
|
||||
onClick: () => handleTemplateClick(3),
|
||||
isLoading: activeTemplateId === 3,
|
||||
},
|
||||
{
|
||||
title: "Smileys",
|
||||
description: "Use visual indicators to capture feedback across customer touchpoints.",
|
||||
icon: SmileIcon,
|
||||
onClick: () => handleTemplateClick(4),
|
||||
isLoading: activeTemplateId === 4,
|
||||
},
|
||||
{
|
||||
title: "eNPS",
|
||||
description: "Universal feedback to understand employee engagement and satisfaction.",
|
||||
icon: UsersIcon,
|
||||
onClick: () => handleTemplateClick(5),
|
||||
isLoading: activeTemplateId === 5,
|
||||
},
|
||||
];
|
||||
|
||||
return <OnboardingOptionsContainer options={XMTemplateOptions} />;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { replaceQuestionPresetPlaceholders } from "@formbricks/lib/utils/templates";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
|
||||
// replace all occurences of productName with the actual product name in the current template
|
||||
export const replacePresetPlaceholders = (template: TXMTemplate, product: TProduct) => {
|
||||
const survey = structuredClone(template);
|
||||
survey.name = survey.name.replace("{{productName}}", product.name);
|
||||
survey.questions = survey.questions.map((question) => {
|
||||
return replaceQuestionPresetPlaceholders(question, product);
|
||||
});
|
||||
return { ...template, ...survey };
|
||||
};
|
||||
@@ -0,0 +1,406 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { getDefaultEndingCard } from "@formbricks/lib/templates";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
|
||||
export const XMSurveyDefault: TXMTemplate = {
|
||||
name: "",
|
||||
endings: [getDefaultEndingCard([])],
|
||||
questions: [],
|
||||
styling: {
|
||||
overwriteThemeStyling: true,
|
||||
},
|
||||
};
|
||||
|
||||
const NPSSurvey = (): TXMTemplate => {
|
||||
return {
|
||||
...XMSurveyDefault,
|
||||
name: "NPS Survey",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: { default: "How likely are you to recommend {{productName}} to a friend or colleague?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Not at all likely" },
|
||||
upperLabel: { default: "Extremely likely" },
|
||||
isColorCodingEnabled: true,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "To help us improve, can you describe the reason(s) for your rating?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Any other comments, feedback, or concerns?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const StarRatingSurvey = (): TXMTemplate => {
|
||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
||||
|
||||
return {
|
||||
...XMSurveyDefault,
|
||||
name: "{{productName}}'s Rating Survey",
|
||||
questions: [
|
||||
{
|
||||
id: reusableQuestionIds[0],
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[0],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isLessThanOrEqual",
|
||||
rightOperand: {
|
||||
type: "static",
|
||||
value: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: reusableQuestionIds[2],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
range: 5,
|
||||
scale: "number",
|
||||
headline: { default: "How do you like {{productName}}?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Extremely dissatisfied" },
|
||||
upperLabel: { default: "Extremely satisfied" },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[1],
|
||||
html: { default: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>' },
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[1],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isClicked",
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: XMSurveyDefault.endings[0].id,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
headline: { default: "Happy to hear 🙏 Please write a review for us!" },
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonLabel: { default: "Write review" },
|
||||
buttonExternal: true,
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[2],
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Sorry to hear! What is ONE thing we can do better?" },
|
||||
required: true,
|
||||
subheader: { default: "Help us improve your experience." },
|
||||
buttonLabel: { default: "Send" },
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const CSATSurvey = (): TXMTemplate => {
|
||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
||||
|
||||
return {
|
||||
...XMSurveyDefault,
|
||||
name: "{{productName}} CSAT",
|
||||
questions: [
|
||||
{
|
||||
id: reusableQuestionIds[0],
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[0],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isLessThanOrEqual",
|
||||
rightOperand: {
|
||||
type: "static",
|
||||
value: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: reusableQuestionIds[2],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
headline: { default: "How satisfied are you with your {{productName}} experience?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Extremely dissatisfied" },
|
||||
upperLabel: { default: "Extremely satisfied" },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[1],
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[1],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isSubmitted",
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: XMSurveyDefault.endings[0].id,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
headline: { default: "Lovely! Is there anything we can do to improve your experience?" },
|
||||
required: false,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[2],
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Ugh, sorry! Is there anything we can do to improve your experience?" },
|
||||
required: false,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const CESSurvey = (): TXMTemplate => {
|
||||
return {
|
||||
...XMSurveyDefault,
|
||||
name: "CES Survey",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
range: 5,
|
||||
scale: "number",
|
||||
headline: { default: "{{productName}} makes it easy for me to [ADD GOAL]" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Disagree strongly" },
|
||||
upperLabel: { default: "Agree strongly" },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Thanks! How could we make it easier for you to [ADD GOAL]?" },
|
||||
required: true,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const SmileysRatingSurvey = (): TXMTemplate => {
|
||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
||||
|
||||
return {
|
||||
...XMSurveyDefault,
|
||||
name: "Smileys Survey",
|
||||
questions: [
|
||||
{
|
||||
id: reusableQuestionIds[0],
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[0],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isLessThanOrEqual",
|
||||
rightOperand: {
|
||||
type: "static",
|
||||
value: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: reusableQuestionIds[2],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
headline: { default: "How do you like {{productName}}?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Not good" },
|
||||
upperLabel: { default: "Very satisfied" },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[1],
|
||||
html: { default: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>' },
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[1],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isClicked",
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: XMSurveyDefault.endings[0].id,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
headline: { default: "Happy to hear 🙏 Please write a review for us!" },
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonLabel: { default: "Write review" },
|
||||
buttonExternal: true,
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[2],
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Sorry to hear! What is ONE thing we can do better?" },
|
||||
required: true,
|
||||
subheader: { default: "Help us improve your experience." },
|
||||
buttonLabel: { default: "Send" },
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const eNPSSurvey = (): TXMTemplate => {
|
||||
return {
|
||||
...XMSurveyDefault,
|
||||
name: "eNPS Survey",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: {
|
||||
default: "How likely are you to recommend working at this company to a friend or colleague?",
|
||||
},
|
||||
required: false,
|
||||
lowerLabel: { default: "Not at all likely" },
|
||||
upperLabel: { default: "Extremely likely" },
|
||||
isColorCodingEnabled: true,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "To help us improve, can you describe the reason(s) for your rating?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Any other comments, feedback, or concerns?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const XMTemplates: TXMTemplate[] = [
|
||||
NPSSurvey(),
|
||||
StarRatingSurvey(),
|
||||
CSATSurvey(),
|
||||
CESSurvey(),
|
||||
SmileysRatingSurvey(),
|
||||
eNPSSurvey(),
|
||||
];
|
||||
@@ -0,0 +1,60 @@
|
||||
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
|
||||
import { getProductByEnvironmentId, getProducts } from "@formbricks/lib/product/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Header } from "@formbricks/ui/Header";
|
||||
|
||||
interface XMTemplatePageProps {
|
||||
params: {
|
||||
environmentId: string;
|
||||
};
|
||||
}
|
||||
|
||||
const Page = async ({ params }: XMTemplatePageProps) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||
|
||||
const product = await getProductByEnvironmentId(environment.id);
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
const products = await getProducts(organizationId);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header title="What kind of feedback would you like to get?" />
|
||||
<XMTemplateList product={product} user={user} environmentId={environment.id} />
|
||||
{products.length >= 2 && (
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="minimal"
|
||||
href={`/environments/${environment.id}/surveys`}>
|
||||
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
|
||||
|
||||
export const getCustomHeadline = (channel: TProductConfigChannel, industry: TProductConfigIndustry) => {
|
||||
export const getCustomHeadline = (channel?: TProductConfigChannel, industry?: TProductConfigIndustry) => {
|
||||
const combinations = {
|
||||
"website+eCommerce": "web shop",
|
||||
"website+saas": "landing page",
|
||||
|
||||
@@ -17,14 +17,14 @@ const Page = async ({ params }: ChannelPageProps) => {
|
||||
description: "Run well-timed pop-up surveys.",
|
||||
icon: GlobeIcon,
|
||||
iconText: "Built for scale",
|
||||
href: `/organizations/${params.organizationId}/products/new/industry?channel=website`,
|
||||
href: `/organizations/${params.organizationId}/products/new/settings?channel=website`,
|
||||
},
|
||||
{
|
||||
title: "App with sign up",
|
||||
description: "Run highly-targeted micro-surveys.",
|
||||
icon: GlobeLockIcon,
|
||||
iconText: "Enrich user profiles",
|
||||
href: `/organizations/${params.organizationId}/products/new/industry?channel=app`,
|
||||
href: `/organizations/${params.organizationId}/products/new/settings?channel=app`,
|
||||
},
|
||||
{
|
||||
channel: "link",
|
||||
@@ -32,7 +32,7 @@ const Page = async ({ params }: ChannelPageProps) => {
|
||||
description: "Reach people anywhere online.",
|
||||
icon: LinkIcon,
|
||||
iconText: "Anywhere online",
|
||||
href: `/organizations/${params.organizationId}/products/new/industry?channel=link`,
|
||||
href: `/organizations/${params.organizationId}/products/new/settings?channel=link`,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||
import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
|
||||
import { getProducts } from "@formbricks/lib/product/service";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Header } from "@formbricks/ui/Header";
|
||||
|
||||
interface ModePageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
};
|
||||
}
|
||||
|
||||
const Page = async ({ params }: ModePageProps) => {
|
||||
const channelOptions = [
|
||||
{
|
||||
title: "Formbricks Surveys",
|
||||
description: "Multi-purpose survey platform for web, app and email surveys.",
|
||||
icon: ListTodoIcon,
|
||||
href: `/organizations/${params.organizationId}/products/new/channel`,
|
||||
},
|
||||
{
|
||||
title: "Formbricks CX",
|
||||
description: "Surveys and reports to understand what your customers need.",
|
||||
icon: HeartIcon,
|
||||
href: `/organizations/${params.organizationId}/products/new/settings?mode=cx`,
|
||||
},
|
||||
];
|
||||
|
||||
const products = await getProducts(params.organizationId);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header title="What are you here for?" />
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{products.length >= 1 && (
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="minimal"
|
||||
href={"/"}>
|
||||
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -13,6 +13,7 @@ import { PREVIEW_SURVEY } from "@formbricks/lib/styling/constants";
|
||||
import {
|
||||
TProductConfigChannel,
|
||||
TProductConfigIndustry,
|
||||
TProductMode,
|
||||
TProductUpdateInput,
|
||||
ZProductUpdateInput,
|
||||
} from "@formbricks/types/product";
|
||||
@@ -32,6 +33,7 @@ import { SurveyInline } from "@formbricks/ui/Survey";
|
||||
|
||||
interface ProductSettingsProps {
|
||||
organizationId: string;
|
||||
productMode: TProductMode;
|
||||
channel: TProductConfigChannel;
|
||||
industry: TProductConfigIndustry;
|
||||
defaultBrandColor: string;
|
||||
@@ -39,6 +41,7 @@ interface ProductSettingsProps {
|
||||
|
||||
export const ProductSettings = ({
|
||||
organizationId,
|
||||
productMode,
|
||||
channel,
|
||||
industry,
|
||||
defaultBrandColor,
|
||||
@@ -68,10 +71,12 @@ export const ProductSettings = ({
|
||||
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||
}
|
||||
}
|
||||
if (channel !== "link") {
|
||||
if (channel === "app" || channel === "website") {
|
||||
router.push(`/environments/${productionEnvironment?.id}/connect`);
|
||||
} else {
|
||||
} else if (channel === "link") {
|
||||
router.push(`/environments/${productionEnvironment?.id}/surveys`);
|
||||
} else if (productMode === "cx") {
|
||||
router.push(`/environments/${productionEnvironment?.id}/xm-templates`);
|
||||
}
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createProductResponse);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { getCustomHeadline } from "@/app/(app)/(onboarding)/lib/utils";
|
||||
import { ProductSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
|
||||
import { getProducts } from "@formbricks/lib/product/service";
|
||||
import { startsWithVowel } from "@formbricks/lib/utils/strings";
|
||||
import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
|
||||
import { TProductConfigChannel, TProductConfigIndustry, TProductMode } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Header } from "@formbricks/ui/Header";
|
||||
|
||||
@@ -16,19 +15,21 @@ interface ProductSettingsPageProps {
|
||||
searchParams: {
|
||||
channel?: TProductConfigChannel;
|
||||
industry?: TProductConfigIndustry;
|
||||
mode?: TProductMode;
|
||||
};
|
||||
}
|
||||
|
||||
const Page = async ({ params, searchParams }: ProductSettingsPageProps) => {
|
||||
const channel = searchParams.channel;
|
||||
const industry = searchParams.industry;
|
||||
if (!channel || !industry) return notFound();
|
||||
const channel = searchParams.channel || null;
|
||||
const industry = searchParams.industry || null;
|
||||
const mode = searchParams.mode || "surveys";
|
||||
|
||||
const customHeadline = getCustomHeadline(channel, industry);
|
||||
const products = await getProducts(params.organizationId);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
{channel === "link" ? (
|
||||
{channel === "link" || mode === "cx" ? (
|
||||
<Header
|
||||
title="Match your brand, get 2x more responses."
|
||||
subtitle="When people recognize your brand, they are much more likely to start and complete responses."
|
||||
@@ -41,6 +42,7 @@ const Page = async ({ params, searchParams }: ProductSettingsPageProps) => {
|
||||
)}
|
||||
<ProductSettings
|
||||
organizationId={params.organizationId}
|
||||
productMode={mode}
|
||||
channel={channel}
|
||||
industry={industry}
|
||||
defaultBrandColor={DEFAULT_BRAND_COLOR}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LucideProps } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ForwardRefExoticComponent, RefAttributes } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { OptionCard } from "@formbricks/ui/OptionCard";
|
||||
|
||||
interface OnboardingOptionsContainerProps {
|
||||
@@ -8,34 +9,51 @@ interface OnboardingOptionsContainerProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ForwardRefExoticComponent<Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>>;
|
||||
iconText: string;
|
||||
href: string;
|
||||
iconText?: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
isLoading?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => {
|
||||
const getOptionCard = (option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<OptionCard
|
||||
size="md"
|
||||
key={option.title}
|
||||
title={option.title}
|
||||
onSelect={option.onClick}
|
||||
description={option.description}
|
||||
loading={option.isLoading || false}>
|
||||
<div className="flex flex-col items-center">
|
||||
<Icon className="h-16 w-16 text-slate-600" strokeWidth={0.5} />
|
||||
{option.iconText && (
|
||||
<p className="mt-4 w-fit rounded-xl bg-slate-200 px-4 text-sm text-slate-700">
|
||||
{option.iconText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</OptionCard>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid w-5/6 grid-cols-3 gap-8 text-center lg:w-2/3">
|
||||
{options.map((option, index) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<Link href={option.href}>
|
||||
<OptionCard
|
||||
size="md"
|
||||
key={index}
|
||||
title={option.title}
|
||||
description={option.description}
|
||||
loading={false}>
|
||||
<div className="flex flex-col items-center">
|
||||
<Icon className="h-16 w-16 text-slate-600" strokeWidth={0.5} />
|
||||
<p className="mt-4 w-fit rounded-xl bg-slate-200 px-4 text-sm text-slate-700">
|
||||
{option.iconText}
|
||||
</p>
|
||||
</div>
|
||||
</OptionCard>
|
||||
<div
|
||||
className={cn({
|
||||
"grid w-5/6 grid-cols-3 gap-8 text-center lg:w-2/3": options.length >= 3,
|
||||
"flex justify-center gap-8": options.length < 3,
|
||||
})}>
|
||||
{options.map((option) =>
|
||||
option.href ? (
|
||||
<Link key={option.title} href={option.href}>
|
||||
{getOptionCard(option)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
) : (
|
||||
getOptionCard(option)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod";
|
||||
import { createActionClass } from "@formbricks/lib/actionClass/service";
|
||||
import { actionClient, authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { UNSPLASH_ACCESS_KEY } from "@formbricks/lib/constants";
|
||||
import { UNSPLASH_ACCESS_KEY, UNSPLASH_ALLOWED_DOMAINS } from "@formbricks/lib/constants";
|
||||
import {
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
getOrganizationIdFromProductId,
|
||||
@@ -227,13 +227,26 @@ export const getImagesFromUnsplashAction = actionClient
|
||||
});
|
||||
});
|
||||
|
||||
const isValidUnsplashUrl = (url: string): boolean => {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.protocol === "https:" && UNSPLASH_ALLOWED_DOMAINS.includes(parsedUrl.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const ZTriggerDownloadUnsplashImageAction = z.object({
|
||||
downloadUrl: z.string(),
|
||||
downloadUrl: z.string().url(),
|
||||
});
|
||||
|
||||
export const triggerDownloadUnsplashImageAction = actionClient
|
||||
.schema(ZTriggerDownloadUnsplashImageAction)
|
||||
.action(async ({ parsedInput }) => {
|
||||
if (!isValidUnsplashUrl(parsedInput.downloadUrl)) {
|
||||
throw new Error("Invalid Unsplash URL");
|
||||
}
|
||||
|
||||
const response = await fetch(`${parsedInput.downloadUrl}/?client_id=${UNSPLASH_ACCESS_KEY}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -56,6 +56,7 @@ export const AddActionModal = ({
|
||||
return (
|
||||
<ModalWithTabs
|
||||
label="Add action"
|
||||
description="Capture a new action to trigger a survey on."
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
tabs={tabs}
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { getQuestionDefaults, questionTypes, universalQuestionPresets } from "@/app/lib/questions";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import {
|
||||
CXQuestionTypes,
|
||||
getQuestionDefaults,
|
||||
questionTypes,
|
||||
universalQuestionPresets,
|
||||
} from "@formbricks/lib/utils/questions";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
|
||||
interface AddQuestionButtonProps {
|
||||
addQuestion: (question: any) => void;
|
||||
product: TProduct;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const AddQuestionButton = ({ addQuestion, product }: AddQuestionButtonProps) => {
|
||||
export const AddQuestionButton = ({ addQuestion, product, isCxMode }: AddQuestionButtonProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const availableQuestionTypes = isCxMode ? CXQuestionTypes : questionTypes;
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
@@ -37,7 +45,7 @@ export const AddQuestionButton = ({ addQuestion, product }: AddQuestionButtonPro
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="justify-left flex flex-col">
|
||||
{/* <hr className="py-1 text-slate-600" /> */}
|
||||
{questionTypes.map((questionType) => (
|
||||
{availableQuestionTypes.map((questionType) => (
|
||||
<button
|
||||
type="button"
|
||||
key={questionType.id}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ConditionalLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConditionalLogic";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { LogicEditor } from "./LogicEditor";
|
||||
import { UpdateQuestionId } from "./UpdateQuestionId";
|
||||
|
||||
interface AdvancedSettingsProps {
|
||||
@@ -19,16 +19,14 @@ export const AdvancedSettings = ({
|
||||
attributeClasses,
|
||||
}: AdvancedSettingsProps) => {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<LogicEditor
|
||||
question={question}
|
||||
updateQuestion={updateQuestion}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<ConditionalLogic
|
||||
question={question}
|
||||
updateQuestion={updateQuestion}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
|
||||
<UpdateQuestionId
|
||||
question={question}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { LogicEditor } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor";
|
||||
import {
|
||||
getDefaultOperatorForQuestion,
|
||||
replaceEndingCardHeadlineRecall,
|
||||
} from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { debounce } from "lodash";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CopyIcon,
|
||||
EllipsisVerticalIcon,
|
||||
PlusIcon,
|
||||
SplitIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { duplicateLogicItem } from "@formbricks/lib/surveyLogic/utils";
|
||||
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
interface ConditionalLogicProps {
|
||||
localSurvey: TSurvey;
|
||||
questionIdx: number;
|
||||
question: TSurveyQuestion;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
attributeClasses: TAttributeClass[];
|
||||
}
|
||||
|
||||
export function ConditionalLogic({
|
||||
attributeClasses,
|
||||
localSurvey,
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
}: ConditionalLogicProps) {
|
||||
const [questionLogic, setQuestionLogic] = useState(question.logic);
|
||||
|
||||
const debouncedUpdateQuestion = useMemo(() => debounce(updateQuestion, 500), [updateQuestion]);
|
||||
|
||||
useEffect(() => {
|
||||
debouncedUpdateQuestion(questionIdx, {
|
||||
logic: questionLogic,
|
||||
});
|
||||
}, [questionLogic]);
|
||||
|
||||
const transformedSurvey = useMemo(() => {
|
||||
let modifiedSurvey = replaceHeadlineRecall(localSurvey, "default", attributeClasses);
|
||||
modifiedSurvey = replaceEndingCardHeadlineRecall(modifiedSurvey, "default", attributeClasses);
|
||||
|
||||
return modifiedSurvey;
|
||||
}, [localSurvey, attributeClasses]);
|
||||
|
||||
const updateQuestionLogic = (_questionIdx: number, updatedAttributes: any) => {
|
||||
setQuestionLogic(updatedAttributes.logic);
|
||||
};
|
||||
|
||||
const addLogic = () => {
|
||||
const operator = getDefaultOperatorForQuestion(question);
|
||||
|
||||
const initialCondition: TSurveyLogic = {
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: question.id,
|
||||
type: "question",
|
||||
},
|
||||
operator,
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
updateQuestionLogic(questionIdx, {
|
||||
logic: [...(questionLogic ?? []), initialCondition],
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveLogic = (logicItemIdx: number) => {
|
||||
const logicCopy = structuredClone(questionLogic ?? []);
|
||||
logicCopy.splice(logicItemIdx, 1);
|
||||
|
||||
updateQuestionLogic(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const moveLogic = (from: number, to: number) => {
|
||||
const logicCopy = structuredClone(questionLogic ?? []);
|
||||
const [movedItem] = logicCopy.splice(from, 1);
|
||||
logicCopy.splice(to, 0, movedItem);
|
||||
|
||||
updateQuestionLogic(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const duplicateLogic = (logicItemIdx: number) => {
|
||||
const logicCopy = structuredClone(questionLogic ?? []);
|
||||
const logicItem = logicCopy[logicItemIdx];
|
||||
const newLogicItem = duplicateLogicItem(logicItem);
|
||||
logicCopy.splice(logicItemIdx + 1, 0, newLogicItem);
|
||||
|
||||
updateQuestionLogic(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<Label className="flex gap-2">
|
||||
Conditional Logic
|
||||
<SplitIcon className="h-4 w-4 rotate-90" />
|
||||
</Label>
|
||||
|
||||
{questionLogic && questionLogic.length > 0 && (
|
||||
<div className="mt-2 flex flex-col gap-4">
|
||||
{questionLogic.map((logicItem, logicItemIdx) => (
|
||||
<div
|
||||
key={logicItem.id}
|
||||
className="flex w-full grow items-start gap-2 rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<LogicEditor
|
||||
localSurvey={transformedSurvey}
|
||||
logicItem={logicItem}
|
||||
updateQuestion={updateQuestionLogic}
|
||||
question={question}
|
||||
questionLogic={questionLogic}
|
||||
questionIdx={questionIdx}
|
||||
logicIdx={logicItemIdx}
|
||||
isLast={logicItemIdx === (questionLogic ?? []).length - 1}
|
||||
/>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<EllipsisVerticalIcon className="h-4 w-4 text-slate-700 hover:text-slate-950" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
duplicateLogic(logicItemIdx);
|
||||
}}>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
disabled={logicItemIdx === 0}
|
||||
onClick={() => {
|
||||
moveLogic(logicItemIdx, logicItemIdx - 1);
|
||||
}}>
|
||||
<ArrowUpIcon className="h-4 w-4" />
|
||||
Move up
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
disabled={logicItemIdx === (questionLogic ?? []).length - 1}
|
||||
onClick={() => {
|
||||
moveLogic(logicItemIdx, logicItemIdx + 1);
|
||||
}}>
|
||||
<ArrowDownIcon className="h-4 w-4" />
|
||||
Move down
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
handleRemoveLogic(logicItemIdx);
|
||||
}}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<Button
|
||||
id="logicJumps"
|
||||
className="bg-slate-100 hover:bg-slate-50"
|
||||
type="button"
|
||||
name="logicJumps"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
EndIcon={PlusIcon}
|
||||
onClick={addLogic}>
|
||||
Add logic
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -171,14 +171,14 @@ export const CreateNewActionTab = ({
|
||||
<div>
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(submitHandler)}>
|
||||
<div className="max-h-[400px] w-full space-y-4 overflow-y-auto">
|
||||
<div className="max-h-[500px] w-full space-y-4 overflow-y-auto pr-4">
|
||||
<div className="w-3/5">
|
||||
<FormField
|
||||
name={`type`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div>
|
||||
<Label className="font-semibold">Type</Label>
|
||||
<Label className="font-semibold">Action Type</Label>
|
||||
<TabToggle
|
||||
id="type"
|
||||
options={[
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { EditorCardMenu } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditorCardMenu";
|
||||
import { EndScreenForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EndScreenForm";
|
||||
import { RedirectUrlForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RedirectUrlForm";
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util";
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
@@ -32,11 +32,6 @@ interface EditEndingCardProps {
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
const endingCardTypes = [
|
||||
{ value: "endScreen", label: "Ending card" },
|
||||
{ value: "redirectToUrl", label: "Redirect to Url" },
|
||||
];
|
||||
|
||||
export const EditEndingCard = ({
|
||||
localSurvey,
|
||||
endingCardIndex,
|
||||
@@ -52,9 +47,16 @@ export const EditEndingCard = ({
|
||||
isFormbricksCloud,
|
||||
}: EditEndingCardProps) => {
|
||||
const endingCard = localSurvey.endings[endingCardIndex];
|
||||
|
||||
const isRedirectToUrlDisabled = isFormbricksCloud
|
||||
? plan === "free" && endingCard.type !== "redirectToUrl"
|
||||
: false;
|
||||
|
||||
const endingCardTypes = [
|
||||
{ value: "endScreen", label: "Ending card" },
|
||||
{ value: "redirectToUrl", label: "Redirect to Url", disabled: isRedirectToUrlDisabled },
|
||||
];
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: endingCard.id,
|
||||
});
|
||||
@@ -204,14 +206,16 @@ export const EditEndingCard = ({
|
||||
<OptionsSwitch
|
||||
options={endingCardTypes}
|
||||
currentOption={endingCard.type}
|
||||
handleOptionChange={() => {
|
||||
if (endingCard.type === "endScreen") {
|
||||
updateSurvey({ type: "redirectToUrl" });
|
||||
} else {
|
||||
updateSurvey({ type: "endScreen" });
|
||||
handleOptionChange={(newType) => {
|
||||
const selectedOption = endingCardTypes.find((option) => option.value === newType);
|
||||
if (!selectedOption?.disabled) {
|
||||
if (newType === "redirectToUrl") {
|
||||
updateSurvey({ type: "redirectToUrl" });
|
||||
} else {
|
||||
updateSurvey({ type: "endScreen" });
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={isRedirectToUrlDisabled}
|
||||
/>
|
||||
</TooltipRenderer>
|
||||
{endingCard.type === "endScreen" && (
|
||||
|
||||
@@ -110,7 +110,7 @@ export const EditWelcomeCard = ({
|
||||
<div className="mt-3 flex w-full items-center justify-center">
|
||||
<FileInput
|
||||
id="welcome-card-image"
|
||||
allowedFileExtensions={["png", "jpeg", "jpg"]}
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
|
||||
environmentId={environmentId}
|
||||
onFileUpload={(url: string[]) => {
|
||||
updateSurvey({ fileUrl: url[0] });
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { QUESTIONS_ICON_MAP, QUESTIONS_NAME_MAP, getQuestionDefaults } from "@/app/lib/questions";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import {
|
||||
CX_QUESTIONS_NAME_MAP,
|
||||
QUESTIONS_ICON_MAP,
|
||||
QUESTIONS_NAME_MAP,
|
||||
getQuestionDefaults,
|
||||
} from "@formbricks/lib/utils/questions";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import {
|
||||
TSurvey,
|
||||
@@ -12,7 +17,6 @@ import {
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyRedirectUrlCard,
|
||||
ZSurveyQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { ConfirmationModal } from "@formbricks/ui/ConfirmationModal";
|
||||
import {
|
||||
@@ -37,6 +41,7 @@ interface EditorCardMenuProps {
|
||||
addCard: (question: any, index?: number) => void;
|
||||
cardType: "question" | "ending";
|
||||
product?: TProduct;
|
||||
isCxMode?: boolean;
|
||||
}
|
||||
|
||||
export const EditorCardMenu = ({
|
||||
@@ -51,76 +56,75 @@ export const EditorCardMenu = ({
|
||||
updateCard,
|
||||
addCard,
|
||||
cardType,
|
||||
isCxMode = false,
|
||||
}: EditorCardMenuProps) => {
|
||||
const [logicWarningModal, setLogicWarningModal] = useState(false);
|
||||
const [changeToType, setChangeToType] = useState(
|
||||
card.type !== "endScreen" && card.type !== "redirectToUrl" ? card.type : undefined
|
||||
);
|
||||
const [changeToType, setChangeToType] = useState(() => {
|
||||
if (card.type !== "endScreen" && card.type !== "redirectToUrl") {
|
||||
return card.type;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
const isDeleteDisabled =
|
||||
cardType === "question"
|
||||
? survey.questions.length === 1
|
||||
: survey.type === "link" && survey.endings.length === 1;
|
||||
|
||||
const availableQuestionTypes = isCxMode ? CX_QUESTIONS_NAME_MAP : QUESTIONS_NAME_MAP;
|
||||
|
||||
const changeQuestionType = (type?: TSurveyQuestionTypeEnum) => {
|
||||
const parseResult = ZSurveyQuestion.safeParse(card);
|
||||
if (parseResult.success && type) {
|
||||
const question = parseResult.data;
|
||||
const { headline, required, subheader, imageUrl, videoUrl, buttonLabel, backButtonLabel } = question;
|
||||
if (!type) return;
|
||||
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
const { headline, required, subheader, imageUrl, videoUrl, buttonLabel, backButtonLabel } =
|
||||
card as TSurveyQuestion;
|
||||
|
||||
// if going from single select to multi select or vice versa, we need to keep the choices as well
|
||||
|
||||
if (
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceSingle &&
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) ||
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle)
|
||||
) {
|
||||
updateCard(cardIdx, {
|
||||
choices: question.choices,
|
||||
type,
|
||||
logic: undefined,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
|
||||
if (
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceSingle &&
|
||||
card.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) ||
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
|
||||
card.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle)
|
||||
) {
|
||||
updateCard(cardIdx, {
|
||||
...questionDefaults,
|
||||
choices: card.choices,
|
||||
type,
|
||||
headline,
|
||||
subheader,
|
||||
required,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
logic: undefined,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
updateCard(cardIdx, {
|
||||
...questionDefaults,
|
||||
type,
|
||||
headline,
|
||||
subheader,
|
||||
required,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
logic: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const addQuestionCardBelow = (type: TSurveyQuestionTypeEnum) => {
|
||||
const parseResult = ZSurveyQuestion.safeParse(card);
|
||||
if (parseResult.success) {
|
||||
const question = parseResult.data;
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
|
||||
addCard(
|
||||
{
|
||||
...questionDefaults,
|
||||
type,
|
||||
id: createId(),
|
||||
required: true,
|
||||
},
|
||||
cardIdx + 1
|
||||
);
|
||||
addCard(
|
||||
{
|
||||
...questionDefaults,
|
||||
type,
|
||||
id: createId(),
|
||||
required: true,
|
||||
},
|
||||
cardIdx + 1
|
||||
);
|
||||
|
||||
// scroll to the new question
|
||||
const section = document.getElementById(`${question.id}`);
|
||||
section?.scrollIntoView({ behavior: "smooth", block: "end", inline: "end" });
|
||||
}
|
||||
const section = document.getElementById(`${card.id}`);
|
||||
section?.scrollIntoView({ behavior: "smooth", block: "end", inline: "end" });
|
||||
};
|
||||
|
||||
const addEndingCardBelow = () => {
|
||||
@@ -167,29 +171,25 @@ export const EditorCardMenu = ({
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent className="ml-2 border border-slate-200 text-slate-600 hover:text-slate-700">
|
||||
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
|
||||
const parsedResult = ZSurveyQuestion.safeParse(card);
|
||||
if (parsedResult.success) {
|
||||
const question = parsedResult.data;
|
||||
if (type === question.type) return null;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
className="min-h-8 cursor-pointer"
|
||||
onClick={() => {
|
||||
setChangeToType(type as TSurveyQuestionTypeEnum);
|
||||
if (question.logic) {
|
||||
setLogicWarningModal(true);
|
||||
return;
|
||||
}
|
||||
{Object.entries(availableQuestionTypes).map(([type, name]) => {
|
||||
if (type === card.type) return null;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
className="min-h-8 cursor-pointer"
|
||||
onClick={() => {
|
||||
setChangeToType(type as TSurveyQuestionTypeEnum);
|
||||
if ((card as TSurveyQuestion).logic) {
|
||||
setLogicWarningModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
changeQuestionType(type as TSurveyQuestionTypeEnum);
|
||||
}}>
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
|
||||
<span className="ml-2">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
changeQuestionType(type as TSurveyQuestionTypeEnum);
|
||||
}}>
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
|
||||
<span className="ml-2">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
@@ -212,7 +212,7 @@ export const EditorCardMenu = ({
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent className="ml-4 border border-slate-200">
|
||||
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
|
||||
{Object.entries(availableQuestionTypes).map(([type, name]) => {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
|
||||
@@ -72,7 +72,7 @@ export const EndScreenForm = ({
|
||||
} else {
|
||||
updateSurvey({
|
||||
buttonLabel: { default: "Create your own Survey" },
|
||||
buttonLink: "https://formbricks.com/signup",
|
||||
buttonLink: "https://formbricks.com",
|
||||
});
|
||||
}
|
||||
setshowEndingCardCTA(!showEndingCardCTA);
|
||||
@@ -111,7 +111,7 @@ export const EndScreenForm = ({
|
||||
id="buttonLink"
|
||||
name="buttonLink"
|
||||
className="bg-white"
|
||||
placeholder="https://formbricks.com/signup"
|
||||
placeholder="https://formbricks.com"
|
||||
value={endingCard.buttonLink}
|
||||
onChange={(e) => updateSurvey({ buttonLink: e.target.value })}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { findHiddenFieldUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { EyeOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -65,6 +66,25 @@ export const HiddenFieldsCard = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteHiddenField = (fieldId: string) => {
|
||||
const quesIdx = findHiddenFieldUsedInLogic(localSurvey, fieldId);
|
||||
|
||||
if (quesIdx !== -1) {
|
||||
toast.error(
|
||||
`${fieldId} is used in logic of question ${quesIdx + 1}. Please remove it from logic first.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
updateSurvey(
|
||||
{
|
||||
enabled: true,
|
||||
fieldIds: localSurvey.hiddenFields?.fieldIds?.filter((q) => q !== fieldId),
|
||||
},
|
||||
fieldId
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(open ? "shadow-lg" : "shadow-md", "group z-10 flex flex-row rounded-lg bg-white")}>
|
||||
<div
|
||||
@@ -111,15 +131,7 @@ export const HiddenFieldsCard = ({
|
||||
return (
|
||||
<Tag
|
||||
key={fieldId}
|
||||
onDelete={() => {
|
||||
updateSurvey(
|
||||
{
|
||||
enabled: true,
|
||||
fieldIds: localSurvey.hiddenFields?.fieldIds?.filter((q) => q !== fieldId),
|
||||
},
|
||||
fieldId
|
||||
);
|
||||
}}
|
||||
onDelete={(fieldId) => handleDeleteHiddenField(fieldId)}
|
||||
tagId={fieldId}
|
||||
tagName={fieldId}
|
||||
/>
|
||||
|
||||
@@ -231,7 +231,7 @@ export const HowToSendCard = ({
|
||||
You can also use Formbricks to run {promotedFeaturesString} surveys.{" "}
|
||||
<Link
|
||||
target="_blank"
|
||||
href={`/organizations/${organizationId}/products/new/channel`}
|
||||
href={`/organizations/${organizationId}/products/new/mode`}
|
||||
className="font-medium underline decoration-slate-400 underline-offset-2">
|
||||
Create a new product
|
||||
</Link>{" "}
|
||||
|
||||
@@ -16,7 +16,7 @@ export const UploadImageSurveyBg = ({
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<FileInput
|
||||
id="survey-bg-file-input"
|
||||
allowedFileExtensions={["png", "jpeg", "jpg"]}
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
|
||||
environmentId={environmentId}
|
||||
onFileUpload={(url: string[]) => {
|
||||
if (url.length > 0) {
|
||||
|
||||
@@ -1,477 +1,53 @@
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ChevronDown,
|
||||
CornerDownRightIcon,
|
||||
HelpCircle,
|
||||
SplitIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicCondition,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
|
||||
import { LogicEditorActions } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditorActions";
|
||||
import { LogicEditorConditions } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditorConditions";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface LogicEditorProps {
|
||||
localSurvey: TSurvey;
|
||||
questionIdx: number;
|
||||
question: TSurveyQuestion;
|
||||
logicItem: TSurveyLogic;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
attributeClasses: TAttributeClass[];
|
||||
question: TSurveyQuestion;
|
||||
questionLogic: TSurveyLogic[];
|
||||
questionIdx: number;
|
||||
logicIdx: number;
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
type LogicConditions = {
|
||||
[K in TSurveyLogicCondition]: {
|
||||
label: string;
|
||||
values: string[] | null;
|
||||
unique?: boolean;
|
||||
multiSelect?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const conditions = {
|
||||
openText: ["submitted", "skipped"],
|
||||
multipleChoiceSingle: ["submitted", "skipped", "equals", "notEquals", "includesOne"],
|
||||
multipleChoiceMulti: ["submitted", "skipped", "includesAll", "includesOne", "equals"],
|
||||
nps: [
|
||||
"equals",
|
||||
"notEquals",
|
||||
"lessThan",
|
||||
"lessEqual",
|
||||
"greaterThan",
|
||||
"greaterEqual",
|
||||
"submitted",
|
||||
"skipped",
|
||||
],
|
||||
rating: [
|
||||
"equals",
|
||||
"notEquals",
|
||||
"lessThan",
|
||||
"lessEqual",
|
||||
"greaterThan",
|
||||
"greaterEqual",
|
||||
"submitted",
|
||||
"skipped",
|
||||
],
|
||||
cta: ["clicked", "skipped"],
|
||||
consent: ["skipped", "accepted"],
|
||||
pictureSelection: ["submitted", "skipped", "includesAll", "includesOne", "equals"],
|
||||
fileUpload: ["uploaded", "notUploaded"],
|
||||
cal: ["skipped", "booked"],
|
||||
matrix: ["isCompletelySubmitted", "isPartiallySubmitted", "skipped"],
|
||||
address: ["submitted", "skipped"],
|
||||
ranking: ["submitted", "skipped"],
|
||||
};
|
||||
|
||||
export const LogicEditor = ({
|
||||
export function LogicEditor({
|
||||
localSurvey,
|
||||
question,
|
||||
questionIdx,
|
||||
logicItem,
|
||||
updateQuestion,
|
||||
attributeClasses,
|
||||
}: LogicEditorProps) => {
|
||||
const [searchValue, setSearchValue] = useState<string>("");
|
||||
const showDropdownSearch = question.type !== "pictureSelection";
|
||||
const transformedSurvey = useMemo(() => {
|
||||
return replaceHeadlineRecall(localSurvey, "default", attributeClasses);
|
||||
}, [localSurvey, attributeClasses]);
|
||||
|
||||
const questionValues: string[] = useMemo(() => {
|
||||
if ("choices" in question) {
|
||||
if (question.type === "pictureSelection") {
|
||||
return question.choices.map((choice) => choice.id);
|
||||
} else {
|
||||
return question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
|
||||
}
|
||||
} else if ("range" in question) {
|
||||
return Array.from({ length: question.range ? question.range : 0 }, (_, i) => (i + 1).toString());
|
||||
} else if (question.type === TSurveyQuestionTypeEnum.NPS) {
|
||||
return Array.from({ length: 11 }, (_, i) => (i + 0).toString());
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [question]);
|
||||
|
||||
const logicConditions: LogicConditions = {
|
||||
submitted: {
|
||||
label: "is submitted",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
skipped: {
|
||||
label: "is skipped",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
accepted: {
|
||||
label: "is accepted",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
clicked: {
|
||||
label: "is clicked",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
equals: {
|
||||
label: "equals",
|
||||
values: questionValues,
|
||||
},
|
||||
notEquals: {
|
||||
label: "does not equal",
|
||||
values: questionValues,
|
||||
},
|
||||
lessThan: {
|
||||
label: "is less than",
|
||||
values: questionValues,
|
||||
},
|
||||
lessEqual: {
|
||||
label: "is less or equal to",
|
||||
values: questionValues,
|
||||
},
|
||||
greaterThan: {
|
||||
label: "is greater than",
|
||||
values: questionValues,
|
||||
},
|
||||
greaterEqual: {
|
||||
label: "is greater or equal to",
|
||||
values: questionValues,
|
||||
},
|
||||
includesAll: {
|
||||
label: "includes all of",
|
||||
values: questionValues,
|
||||
multiSelect: true,
|
||||
},
|
||||
includesOne: {
|
||||
label: "includes one of",
|
||||
values: questionValues,
|
||||
multiSelect: true,
|
||||
},
|
||||
uploaded: {
|
||||
label: "has uploaded file",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
notUploaded: {
|
||||
label: "has not uploaded file",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
booked: {
|
||||
label: "has a call booked",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
isCompletelySubmitted: {
|
||||
label: "is completely submitted",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
isPartiallySubmitted: {
|
||||
label: "is partially submitted",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
};
|
||||
|
||||
const addLogic = () => {
|
||||
if (question.logic && question.logic?.length >= 0) {
|
||||
const hasUndefinedLogic = question.logic.some(
|
||||
(logic) =>
|
||||
logic.condition === undefined && logic.value === undefined && logic.destination === undefined
|
||||
);
|
||||
if (hasUndefinedLogic) {
|
||||
toast("Please fill current logic jumps first.", {
|
||||
icon: "🤓",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newLogic: TSurveyLogic[] = !question.logic ? [] : question.logic;
|
||||
newLogic.push({
|
||||
condition: undefined,
|
||||
value: undefined,
|
||||
destination: undefined,
|
||||
});
|
||||
updateQuestion(questionIdx, { logic: newLogic });
|
||||
};
|
||||
|
||||
const updateLogic = (logicIdx: number, updatedAttributes: any) => {
|
||||
const currentLogic: any = question.logic ? question.logic[logicIdx] : undefined;
|
||||
if (!currentLogic) return;
|
||||
|
||||
// clean logic value if not needed or if condition changed between multiSelect and singleSelect conditions
|
||||
const updatedCondition = updatedAttributes?.condition;
|
||||
const currentCondition = currentLogic?.condition;
|
||||
const updatedLogicCondition = logicConditions[updatedCondition];
|
||||
const currentLogicCondition = logicConditions[currentCondition];
|
||||
if (updatedCondition) {
|
||||
if (updatedLogicCondition?.multiSelect && !currentLogicCondition?.multiSelect) {
|
||||
updatedAttributes.value = [];
|
||||
} else if (
|
||||
(!updatedLogicCondition?.multiSelect && currentLogicCondition?.multiSelect) ||
|
||||
updatedLogicCondition?.values === null
|
||||
) {
|
||||
updatedAttributes.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const newLogic = !question.logic
|
||||
? []
|
||||
: question.logic.map((logic, idx) => {
|
||||
if (idx === logicIdx) {
|
||||
return { ...logic, ...updatedAttributes };
|
||||
}
|
||||
return logic;
|
||||
});
|
||||
|
||||
updateQuestion(questionIdx, { logic: newLogic });
|
||||
};
|
||||
|
||||
const updateMultiSelectLogic = (logicIdx: number, checked: boolean, value: string) => {
|
||||
const newLogic = !question.logic
|
||||
? []
|
||||
: question.logic.map((logic, idx) => {
|
||||
if (idx === logicIdx) {
|
||||
const newValues = !logic.value ? [] : logic.value;
|
||||
if (checked) {
|
||||
newValues.push(value);
|
||||
} else {
|
||||
newValues.splice(newValues.indexOf(value), 1);
|
||||
}
|
||||
return { ...logic, value: Array.from(new Set(newValues)) };
|
||||
}
|
||||
return logic;
|
||||
});
|
||||
|
||||
updateQuestion(questionIdx, { logic: newLogic });
|
||||
};
|
||||
|
||||
const deleteLogic = (logicIdx: number) => {
|
||||
const updatedLogic = !question.logic ? [] : structuredClone(question.logic);
|
||||
updatedLogic.splice(logicIdx, 1);
|
||||
updateQuestion(questionIdx, { logic: updatedLogic });
|
||||
};
|
||||
|
||||
if (!(question.type in conditions)) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const getLogicDisplayValue = (value: string | string[]): string => {
|
||||
if (question.type === "pictureSelection") {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((val) => {
|
||||
const choiceIndex = question.choices.findIndex((choice) => choice.id === val);
|
||||
return `Picture ${choiceIndex + 1}`;
|
||||
})
|
||||
.join(", ");
|
||||
} else {
|
||||
const choiceIndex = question.choices.findIndex((choice) => choice.id === value);
|
||||
return `Picture ${choiceIndex + 1}`;
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
return value.join(", ");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const getOptionPreview = (value: string) => {
|
||||
if (question.type === "pictureSelection") {
|
||||
const choice = question.choices.find((choice) => choice.id === value);
|
||||
if (choice) {
|
||||
return <Image src={choice.imageUrl} alt={"Picture"} width={20} height={12} className="rounded-xs" />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
question,
|
||||
questionLogic,
|
||||
questionIdx,
|
||||
logicIdx,
|
||||
isLast,
|
||||
}: LogicEditorProps) {
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<Label>Logic Jumps</Label>
|
||||
|
||||
{question?.logic && question?.logic?.length !== 0 && (
|
||||
<div className="mt-2 space-y-3">
|
||||
{question?.logic?.map((logic, logicIdx) => (
|
||||
<div key={logicIdx} className="flex items-center space-x-2 space-y-1 text-xs xl:text-sm">
|
||||
<div>
|
||||
<CornerDownRightIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-slate-800">If this answer</p>
|
||||
|
||||
<Select value={logic.condition} onValueChange={(e) => updateLogic(logicIdx, { condition: e })}>
|
||||
<SelectTrigger className="min-w-fit flex-1">
|
||||
<SelectValue placeholder="Select condition" className="text-xs lg:text-sm" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{conditions[question.type].map(
|
||||
(condition) =>
|
||||
!(question.required && (condition === "skipped" || condition === "notUploaded")) && (
|
||||
<SelectItem
|
||||
key={condition}
|
||||
value={condition}
|
||||
title={logicConditions[condition].label}
|
||||
className="text-xs lg:text-sm">
|
||||
{logicConditions[condition].label}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{logic.condition && logicConditions[logic.condition].values != null && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
|
||||
<div className="flex h-10 w-full items-center justify-between overflow-hidden rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
{!logic.value || logic.value?.length === 0 ? (
|
||||
<p className="line-clamp-1 text-slate-400" title="Select match type">
|
||||
Select match type
|
||||
</p>
|
||||
) : (
|
||||
<p className="line-clamp-1" title={getLogicDisplayValue(logic.value)}>
|
||||
{getLogicDisplayValue(logic.value)}
|
||||
</p>
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-40 bg-slate-50 text-slate-700"
|
||||
align="start"
|
||||
side="bottom">
|
||||
{showDropdownSearch && (
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search options"
|
||||
className="mb-1 w-full bg-white"
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
value={searchValue}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<div className="max-h-72 overflow-y-auto overflow-x-hidden">
|
||||
{logicConditions[logic.condition].values
|
||||
?.filter((value) => value.includes(searchValue))
|
||||
?.map((value) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={value}
|
||||
title={value}
|
||||
checked={
|
||||
!logicConditions[logic.condition].multiSelect
|
||||
? logic.value === value
|
||||
: logic.value?.includes(value)
|
||||
}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onCheckedChange={(e) =>
|
||||
!logicConditions[logic.condition].multiSelect
|
||||
? updateLogic(logicIdx, { value })
|
||||
: updateMultiSelectLogic(logicIdx, e, value)
|
||||
}>
|
||||
<div className="flex space-x-2">
|
||||
{question.type === "pictureSelection" && getOptionPreview(value)}
|
||||
<p>{getLogicDisplayValue(value)}</p>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<p className="text-slate-800">jump to</p>
|
||||
|
||||
<Select
|
||||
value={logic.destination}
|
||||
onValueChange={(e) => updateLogic(logicIdx, { destination: e })}>
|
||||
<SelectTrigger className="w-fit overflow-hidden">
|
||||
<SelectValue placeholder="Select question" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{transformedSurvey.questions.map(
|
||||
(question, idx) =>
|
||||
idx !== questionIdx && (
|
||||
<SelectItem
|
||||
key={question.id}
|
||||
value={question.id}
|
||||
title={getLocalizedValue(question.headline, "default")}>
|
||||
<div className="w-96">
|
||||
<p className="truncate text-left">
|
||||
{idx + 1}
|
||||
{". "}
|
||||
{getLocalizedValue(question.headline, "default")}
|
||||
</p>
|
||||
</div>
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
{localSurvey.endings.map((ending) => {
|
||||
return (
|
||||
<SelectItem value={ending.id}>
|
||||
{ending.type === "endScreen"
|
||||
? getLocalizedValue(ending.headline, "default")
|
||||
: ending.label}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div>
|
||||
<TrashIcon
|
||||
className="h-4 w-4 cursor-pointer text-slate-400"
|
||||
onClick={() => deleteLogic(logicIdx)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap items-center space-x-2 py-1 text-sm">
|
||||
<ArrowDownIcon className="h-4 w-4" />
|
||||
<p className="text-slate-700">All other answers will continue to the next question</p>
|
||||
</div>
|
||||
<div className="flex w-full grow flex-col gap-4 overflow-x-auto pb-2 text-sm">
|
||||
<LogicEditorConditions
|
||||
conditions={logicItem.conditions}
|
||||
updateQuestion={updateQuestion}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
localSurvey={localSurvey}
|
||||
logicIdx={logicIdx}
|
||||
/>
|
||||
<LogicEditorActions
|
||||
logicItem={logicItem}
|
||||
logicIdx={logicIdx}
|
||||
questionLogic={questionLogic}
|
||||
updateQuestion={updateQuestion}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
/>
|
||||
{isLast ? (
|
||||
<div className="flex flex-wrap items-center space-x-2">
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
<p className="text-slate-700">All other answers will continue to the next question</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<Button
|
||||
id="logicJumps"
|
||||
type="button"
|
||||
name="logicJumps"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
StartIcon={SplitIcon}
|
||||
onClick={() => addLogic()}>
|
||||
Add logic
|
||||
</Button>
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="ml-2 inline h-4 w-4 cursor-default text-slate-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[300px]" side="top">
|
||||
With logic jumps you can skip questions based on the responses users give.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import {
|
||||
actionObjectiveOptions,
|
||||
getActionOperatorOptions,
|
||||
} from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import {
|
||||
CopyIcon,
|
||||
EllipsisVerticalIcon,
|
||||
EyeOffIcon,
|
||||
FileDigitIcon,
|
||||
FileType2Icon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { questionIconMapping } from "@formbricks/lib/utils/questions";
|
||||
import {
|
||||
TActionNumberVariableCalculateOperator,
|
||||
TActionObjective,
|
||||
TActionTextVariableCalculateOperator,
|
||||
TActionVariableValueType,
|
||||
TSurvey,
|
||||
TSurveyLogicAction,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
import { InputCombobox, TComboboxGroupedOption, TComboboxOption } from "@formbricks/ui/InputCombobox";
|
||||
|
||||
interface LogicEditorActionProps {
|
||||
action: TSurveyLogicAction;
|
||||
actionIdx: number;
|
||||
handleObjectiveChange: (actionIdx: number, val: TActionObjective) => void;
|
||||
handleValuesChange: (actionIdx: number, values: any) => void;
|
||||
handleActionsChange: (operation: "remove" | "addBelow" | "duplicate", actionIdx: number) => void;
|
||||
isRemoveDisabled: boolean;
|
||||
questions: TSurveyQuestion[];
|
||||
endings: TSurvey["endings"];
|
||||
variables: TSurvey["variables"];
|
||||
questionIdx: number;
|
||||
hiddenFields: {
|
||||
enabled: boolean;
|
||||
fieldIds?: string[] | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
const _LogicEditorAction = ({
|
||||
action,
|
||||
actionIdx,
|
||||
handleActionsChange,
|
||||
handleObjectiveChange,
|
||||
handleValuesChange,
|
||||
isRemoveDisabled,
|
||||
questions,
|
||||
endings,
|
||||
variables,
|
||||
questionIdx,
|
||||
hiddenFields,
|
||||
}: LogicEditorActionProps) => {
|
||||
const actionTargetOptions = useMemo((): TComboboxOption[] => {
|
||||
let filteredQuestions = questions.filter((_, idx) => idx !== questionIdx);
|
||||
|
||||
if (action.objective === "requireAnswer") {
|
||||
filteredQuestions = filteredQuestions.filter((question) => !question.required);
|
||||
}
|
||||
|
||||
const questionOptions = filteredQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
};
|
||||
});
|
||||
|
||||
if (action.objective === "requireAnswer") return questionOptions;
|
||||
|
||||
const endingCardOptions = endings.map((ending) => {
|
||||
return {
|
||||
label:
|
||||
ending.type === "endScreen"
|
||||
? getLocalizedValue(ending.headline, "default") || "End Screen"
|
||||
: ending.label || "Redirect Thank you card",
|
||||
value: ending.id,
|
||||
};
|
||||
});
|
||||
|
||||
return [...questionOptions, ...endingCardOptions];
|
||||
}, [action.objective, endings, questionIdx, questions]);
|
||||
|
||||
const actionVariableOptions = useMemo((): TComboboxOption[] => {
|
||||
return variables.map((variable) => {
|
||||
return {
|
||||
icon: variable.type === "number" ? FileDigitIcon : FileType2Icon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
variableType: variable.type,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [variables]);
|
||||
|
||||
const getActionValueOptions = useCallback(
|
||||
(variableId: string): TComboboxGroupedOption[] => {
|
||||
const hiddenFieldIds = hiddenFields?.fieldIds ?? [];
|
||||
|
||||
const hiddenFieldsOptions = hiddenFieldIds.map((field) => {
|
||||
return {
|
||||
icon: EyeOffIcon,
|
||||
label: field,
|
||||
value: field,
|
||||
meta: {
|
||||
type: "hiddenField",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const selectedVariable = variables.find((variable) => variable.id === variableId);
|
||||
const filteredVariables = variables.filter((variable) => variable.id !== variableId);
|
||||
|
||||
if (!selectedVariable) return [];
|
||||
|
||||
if (selectedVariable.type === "text") {
|
||||
const allowedQuestions = questions.filter((question) =>
|
||||
[
|
||||
TSurveyQuestionTypeEnum.OpenText,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyQuestionTypeEnum.Rating,
|
||||
TSurveyQuestionTypeEnum.NPS,
|
||||
TSurveyQuestionTypeEnum.Date,
|
||||
].includes(question.type)
|
||||
);
|
||||
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const stringVariables = filteredVariables.filter((variable) => variable.type === "text");
|
||||
const variableOptions = stringVariables.map((variable) => {
|
||||
return {
|
||||
icon: FileType2Icon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return groupedOptions;
|
||||
} else if (selectedVariable.type === "number") {
|
||||
const allowedQuestions = questions.filter((question) =>
|
||||
[TSurveyQuestionTypeEnum.Rating, TSurveyQuestionTypeEnum.NPS].includes(question.type)
|
||||
);
|
||||
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const numberVariables = filteredVariables.filter((variable) => variable.type === "number");
|
||||
const variableOptions = numberVariables.map((variable) => {
|
||||
return {
|
||||
icon: FileDigitIcon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return groupedOptions;
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
[hiddenFields?.fieldIds, questions, variables]
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={action.id} className="flex grow items-center justify-between gap-x-2">
|
||||
<div className="block w-9 shrink-0">{actionIdx === 0 ? "Then" : "and"}</div>
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<InputCombobox
|
||||
id={`action-${actionIdx}-objective`}
|
||||
key={`objective-${action.id}`}
|
||||
showSearch={false}
|
||||
options={actionObjectiveOptions}
|
||||
value={action.objective}
|
||||
onChangeValue={(val: TActionObjective) => {
|
||||
handleObjectiveChange(actionIdx, val);
|
||||
}}
|
||||
comboboxClasses="grow"
|
||||
/>
|
||||
{action.objective !== "calculate" && (
|
||||
<InputCombobox
|
||||
id={`action-${actionIdx}-target`}
|
||||
key={`target-${action.id}`}
|
||||
showSearch={false}
|
||||
options={actionTargetOptions}
|
||||
value={action.target}
|
||||
onChangeValue={(val: string) => {
|
||||
handleValuesChange(actionIdx, {
|
||||
target: val,
|
||||
});
|
||||
}}
|
||||
comboboxClasses="grow"
|
||||
/>
|
||||
)}
|
||||
{action.objective === "calculate" && (
|
||||
<>
|
||||
<InputCombobox
|
||||
id={`action-${actionIdx}-variableId`}
|
||||
key={`variableId-${action.id}`}
|
||||
showSearch={false}
|
||||
options={actionVariableOptions}
|
||||
value={action.variableId}
|
||||
onChangeValue={(val: string) => {
|
||||
handleValuesChange(actionIdx, {
|
||||
variableId: val,
|
||||
value: {
|
||||
type: "static",
|
||||
value: "",
|
||||
},
|
||||
});
|
||||
}}
|
||||
comboboxClasses="grow"
|
||||
emptyDropdownText="Add a variable to calculate"
|
||||
/>
|
||||
<InputCombobox
|
||||
id={`action-${actionIdx}-operator`}
|
||||
key={`operator-${action.id}`}
|
||||
showSearch={false}
|
||||
options={getActionOperatorOptions(variables.find((v) => v.id === action.variableId)?.type)}
|
||||
value={action.operator}
|
||||
onChangeValue={(
|
||||
val: TActionTextVariableCalculateOperator | TActionNumberVariableCalculateOperator
|
||||
) => {
|
||||
handleValuesChange(actionIdx, {
|
||||
operator: val,
|
||||
});
|
||||
}}
|
||||
comboboxClasses="grow"
|
||||
/>
|
||||
<InputCombobox
|
||||
id={`action-${actionIdx}-value`}
|
||||
key={`value-${action.id}`}
|
||||
withInput={true}
|
||||
clearable={true}
|
||||
value={action.value?.value ?? ""}
|
||||
inputProps={{
|
||||
placeholder: "Value",
|
||||
type: variables.find((v) => v.id === action.variableId)?.type || "text",
|
||||
}}
|
||||
groupedOptions={getActionValueOptions(action.variableId)}
|
||||
onChangeValue={(val, option, fromInput) => {
|
||||
const fieldType = option?.meta?.type as TActionVariableValueType;
|
||||
|
||||
if (!fromInput && fieldType !== "static") {
|
||||
handleValuesChange(actionIdx, {
|
||||
value: {
|
||||
type: fieldType,
|
||||
value: val as string,
|
||||
},
|
||||
});
|
||||
} else if (fromInput) {
|
||||
handleValuesChange(actionIdx, {
|
||||
value: {
|
||||
type: "static",
|
||||
value: val as string,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
comboboxClasses="grow shrink-0"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger id={`actions-${actionIdx}-dropdown`}>
|
||||
<EllipsisVerticalIcon className="h-4 w-4 text-slate-700 hover:text-slate-950" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
handleActionsChange("addBelow", actionIdx);
|
||||
}}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add action below
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
disabled={isRemoveDisabled}
|
||||
onClick={() => {
|
||||
handleActionsChange("remove", actionIdx);
|
||||
}}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
handleActionsChange("duplicate", actionIdx);
|
||||
}}>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LogicEditorAction = React.memo(_LogicEditorAction);
|
||||
@@ -0,0 +1,110 @@
|
||||
import { LogicEditorAction } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditorAction";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { CornerDownRightIcon } from "lucide-react";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { getUpdatedActionBody } from "@formbricks/lib/surveyLogic/utils";
|
||||
import { TActionObjective, TSurvey, TSurveyLogic, TSurveyLogicAction } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface LogicEditorActions {
|
||||
localSurvey: TSurvey;
|
||||
logicItem: TSurveyLogic;
|
||||
logicIdx: number;
|
||||
questionLogic: TSurveyLogic[];
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
questionIdx: number;
|
||||
}
|
||||
|
||||
export const LogicEditorActions = ({
|
||||
localSurvey,
|
||||
logicItem,
|
||||
logicIdx,
|
||||
questionLogic,
|
||||
updateQuestion,
|
||||
questionIdx,
|
||||
}: LogicEditorActions) => {
|
||||
const actions = logicItem.actions;
|
||||
|
||||
const handleActionsChange = useCallback(
|
||||
(
|
||||
operation: "remove" | "addBelow" | "duplicate" | "update",
|
||||
actionIdx: number,
|
||||
action?: TSurveyLogicAction
|
||||
) => {
|
||||
const currentLogicCopy = structuredClone(logicItem);
|
||||
const actionsClone = currentLogicCopy.actions;
|
||||
|
||||
switch (operation) {
|
||||
case "remove":
|
||||
actionsClone.splice(actionIdx, 1);
|
||||
break;
|
||||
case "addBelow":
|
||||
actionsClone.splice(actionIdx + 1, 0, {
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: "",
|
||||
});
|
||||
break;
|
||||
case "duplicate":
|
||||
actionsClone.splice(actionIdx + 1, 0, { ...actionsClone[actionIdx], id: createId() });
|
||||
break;
|
||||
case "update":
|
||||
if (!action) return;
|
||||
actionsClone[actionIdx] = action;
|
||||
break;
|
||||
}
|
||||
|
||||
const updatedLogic = questionLogic.map((item, idx) => (idx === logicIdx ? currentLogicCopy : item));
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: updatedLogic,
|
||||
});
|
||||
},
|
||||
[logicIdx, logicItem, questionIdx, questionLogic]
|
||||
);
|
||||
|
||||
const handleObjectiveChange = useCallback(
|
||||
(actionIdx: number, objective: TActionObjective) => {
|
||||
const action = actions[actionIdx];
|
||||
const actionBody = getUpdatedActionBody(action, objective);
|
||||
handleActionsChange("update", actionIdx, actionBody);
|
||||
},
|
||||
[actions]
|
||||
);
|
||||
|
||||
const handleValuesChange = useCallback(
|
||||
(actionIdx: number, values: Partial<TSurveyLogicAction>) => {
|
||||
const action = actions[actionIdx];
|
||||
const actionBody = { ...action, ...values } as TSurveyLogicAction;
|
||||
handleActionsChange("update", actionIdx, actionBody);
|
||||
},
|
||||
[actions]
|
||||
);
|
||||
|
||||
const questions = useMemo(() => localSurvey.questions, [localSurvey.questions]);
|
||||
const endings = useMemo(() => localSurvey.endings, [localSurvey.endings]);
|
||||
const variables = useMemo(() => localSurvey.variables, [localSurvey.variables]);
|
||||
const hiddenFields = useMemo(() => localSurvey.hiddenFields, [localSurvey.hiddenFields]);
|
||||
|
||||
return (
|
||||
<div className="flex grow gap-2">
|
||||
<CornerDownRightIcon className="mt-3 h-4 w-4 shrink-0" />
|
||||
<div className="flex grow flex-col gap-y-2">
|
||||
{actions?.map((action, idx) => (
|
||||
<LogicEditorAction
|
||||
action={action}
|
||||
actionIdx={idx}
|
||||
handleActionsChange={handleActionsChange}
|
||||
handleObjectiveChange={handleObjectiveChange}
|
||||
handleValuesChange={handleValuesChange}
|
||||
endings={endings}
|
||||
isRemoveDisabled={actions.length === 1}
|
||||
questions={questions}
|
||||
variables={variables}
|
||||
questionIdx={questionIdx}
|
||||
hiddenFields={hiddenFields}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,347 @@
|
||||
import {
|
||||
getConditionOperatorOptions,
|
||||
getConditionValueOptions,
|
||||
getDefaultOperatorForQuestion,
|
||||
getMatchValueProps,
|
||||
} from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { CopyIcon, EllipsisVerticalIcon, PlusIcon, TrashIcon, WorkflowIcon } from "lucide-react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import {
|
||||
addConditionBelow,
|
||||
createGroupFromResource,
|
||||
duplicateCondition,
|
||||
isConditionGroup,
|
||||
removeCondition,
|
||||
toggleGroupConnector,
|
||||
updateCondition,
|
||||
} from "@formbricks/lib/surveyLogic/utils";
|
||||
import {
|
||||
TConditionGroup,
|
||||
TDynamicLogicField,
|
||||
TRightOperand,
|
||||
TSingleCondition,
|
||||
TSurvey,
|
||||
TSurveyLogicConditionsOperator,
|
||||
TSurveyQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
import { InputCombobox, TComboboxOption } from "@formbricks/ui/InputCombobox";
|
||||
|
||||
interface LogicEditorConditionsProps {
|
||||
conditions: TConditionGroup;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
question: TSurveyQuestion;
|
||||
localSurvey: TSurvey;
|
||||
questionIdx: number;
|
||||
logicIdx: number;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export function LogicEditorConditions({
|
||||
conditions,
|
||||
logicIdx,
|
||||
question,
|
||||
localSurvey,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
depth = 0,
|
||||
}: LogicEditorConditionsProps) {
|
||||
const handleAddConditionBelow = (resourceId: string) => {
|
||||
const operator = getDefaultOperatorForQuestion(question);
|
||||
|
||||
const condition: TSingleCondition = {
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: question.id,
|
||||
type: "question",
|
||||
},
|
||||
operator,
|
||||
};
|
||||
|
||||
const logicCopy = structuredClone(question.logic) ?? [];
|
||||
const logicItem = logicCopy[logicIdx];
|
||||
addConditionBelow(logicItem.conditions, resourceId, condition);
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleConnectorChange = (groupId: string) => {
|
||||
const logicCopy = structuredClone(question.logic) ?? [];
|
||||
const logicItem = logicCopy[logicIdx];
|
||||
toggleGroupConnector(logicItem.conditions, groupId);
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveCondition = (resourceId: string) => {
|
||||
const logicCopy = structuredClone(question.logic) ?? [];
|
||||
const logicItem = logicCopy[logicIdx];
|
||||
removeCondition(logicItem.conditions, resourceId);
|
||||
|
||||
// Remove the logic item if there are no conditions left
|
||||
if (logicItem.conditions.conditions.length === 0) {
|
||||
logicCopy.splice(logicIdx, 1);
|
||||
}
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDuplicateCondition = (resourceId: string) => {
|
||||
const logicCopy = structuredClone(question.logic) ?? [];
|
||||
const logicItem = logicCopy[logicIdx];
|
||||
duplicateCondition(logicItem.conditions, resourceId);
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateGroup = (resourceId: string) => {
|
||||
const logicCopy = structuredClone(question.logic) ?? [];
|
||||
const logicItem = logicCopy[logicIdx];
|
||||
createGroupFromResource(logicItem.conditions, resourceId);
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateCondition = (resourceId: string, updateConditionBody: Partial<TSingleCondition>) => {
|
||||
const logicCopy = structuredClone(question.logic) ?? [];
|
||||
const logicItem = logicCopy[logicIdx];
|
||||
updateCondition(logicItem.conditions, resourceId, updateConditionBody);
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleQuestionChange = (condition: TSingleCondition, value: string, option?: TComboboxOption) => {
|
||||
handleUpdateCondition(condition.id, {
|
||||
leftOperand: {
|
||||
value,
|
||||
type: option?.meta?.type as TDynamicLogicField,
|
||||
},
|
||||
operator: "isSkipped",
|
||||
rightOperand: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOperatorChange = (condition: TSingleCondition, value: TSurveyLogicConditionsOperator) => {
|
||||
if (value !== condition.operator) {
|
||||
handleUpdateCondition(condition.id, {
|
||||
operator: value,
|
||||
rightOperand: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRightOperandChange = (
|
||||
condition: TSingleCondition,
|
||||
value: string | number | string[],
|
||||
option?: TComboboxOption
|
||||
) => {
|
||||
const type = (option?.meta?.type as TRightOperand["type"]) || "static";
|
||||
|
||||
switch (type) {
|
||||
case "question":
|
||||
case "hiddenField":
|
||||
case "variable":
|
||||
handleUpdateCondition(condition.id, {
|
||||
rightOperand: {
|
||||
value: value as string,
|
||||
type,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "static":
|
||||
handleUpdateCondition(condition.id, {
|
||||
rightOperand: {
|
||||
value,
|
||||
type,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const renderCondition = (
|
||||
condition: TSingleCondition | TConditionGroup,
|
||||
index: number,
|
||||
parentConditionGroup: TConditionGroup
|
||||
) => {
|
||||
const connector = parentConditionGroup.connector;
|
||||
if (isConditionGroup(condition)) {
|
||||
return (
|
||||
<div key={condition.id} className="flex items-start justify-between gap-4">
|
||||
{index === 0 ? (
|
||||
<div>When</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn("w-14", index === 1 && "cursor-pointer underline")}
|
||||
onClick={() => {
|
||||
if (index !== 1) return;
|
||||
handleConnectorChange(parentConditionGroup.id);
|
||||
}}>
|
||||
{connector}
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-lg border border-slate-400 p-3">
|
||||
<LogicEditorConditions
|
||||
conditions={condition}
|
||||
updateQuestion={updateQuestion}
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
logicIdx={logicIdx}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<EllipsisVerticalIcon className="h-4 w-4 text-slate-700 hover:text-slate-950" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
handleAddConditionBelow(condition.id);
|
||||
}}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add condition below
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
disabled={depth === 0 && conditions.conditions.length === 1}
|
||||
onClick={() => handleRemoveCondition(condition.id)}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const conditionValueOptions = getConditionValueOptions(localSurvey, questionIdx);
|
||||
const conditionOperatorOptions = getConditionOperatorOptions(condition, localSurvey);
|
||||
const { show, options, showInput = false, inputType } = getMatchValueProps(condition, localSurvey);
|
||||
|
||||
const allowMultiSelect = ["equalsOneOf", "includesAllOf", "includesOneOf"].includes(condition.operator);
|
||||
return (
|
||||
<div key={condition.id} className="flex items-center gap-x-2">
|
||||
<div className="w-10 shrink-0">
|
||||
{index === 0 ? (
|
||||
"When"
|
||||
) : (
|
||||
<div
|
||||
className={cn("w-14", index === 1 && "cursor-pointer underline")}
|
||||
onClick={() => {
|
||||
if (index !== 1) return;
|
||||
handleConnectorChange(parentConditionGroup.id);
|
||||
}}>
|
||||
{connector}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<InputCombobox
|
||||
id={`condition-${depth}-${index}-conditionValue`}
|
||||
key="conditionValue"
|
||||
showSearch={false}
|
||||
groupedOptions={conditionValueOptions}
|
||||
value={condition.leftOperand.value}
|
||||
onChangeValue={(val: string, option) => {
|
||||
handleQuestionChange(condition, val, option);
|
||||
}}
|
||||
comboboxClasses="grow"
|
||||
/>
|
||||
<InputCombobox
|
||||
id={`condition-${depth}-${index}-conditionOperator`}
|
||||
key="conditionOperator"
|
||||
showSearch={false}
|
||||
options={conditionOperatorOptions}
|
||||
value={condition.operator}
|
||||
onChangeValue={(val: TSurveyLogicConditionsOperator) => {
|
||||
handleOperatorChange(condition, val);
|
||||
}}
|
||||
comboboxClasses="grow min-w-[150px]"
|
||||
/>
|
||||
{show && (
|
||||
<InputCombobox
|
||||
id={`condition-${depth}-${index}-conditionMatchValue`}
|
||||
withInput={showInput}
|
||||
inputProps={{
|
||||
type: inputType,
|
||||
placeholder: "Value",
|
||||
}}
|
||||
key="conditionMatchValue"
|
||||
showSearch={false}
|
||||
groupedOptions={options}
|
||||
allowMultiSelect={allowMultiSelect}
|
||||
showCheckIcon={allowMultiSelect}
|
||||
comboboxClasses="grow min-w-[180px] max-w-[300px]"
|
||||
value={condition.rightOperand?.value}
|
||||
clearable={true}
|
||||
onChangeValue={(val, option) => {
|
||||
handleRightOperandChange(condition, val, option);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger id={`condition-${depth}-${index}-dropdown`}>
|
||||
<EllipsisVerticalIcon className="h-4 w-4 text-slate-700 hover:text-slate-950" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
handleAddConditionBelow(condition.id);
|
||||
}}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add condition below
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
disabled={depth === 0 && conditions.conditions.length === 1}
|
||||
onClick={() => handleRemoveCondition(condition.id)}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => handleDuplicateCondition(condition.id)}>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => handleCreateGroup(condition.id)}>
|
||||
<WorkflowIcon className="h-4 w-4" />
|
||||
Create group
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{conditions?.conditions.map((condition, index) => renderCondition(condition, index, conditions))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { findOptionUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { DndContext } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createI18nString, extractLanguageCodes, getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import toast from "react-hot-toast";
|
||||
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import {
|
||||
TI18nString,
|
||||
@@ -68,8 +70,6 @@ export const MultipleChoiceQuestionForm = ({
|
||||
};
|
||||
|
||||
const updateChoice = (choiceIdx: number, updatedAttributes: { label: TI18nString }) => {
|
||||
const newLabel = updatedAttributes.label.en;
|
||||
const oldLabel = question.choices[choiceIdx].label;
|
||||
let newChoices: any[] = [];
|
||||
if (question.choices) {
|
||||
newChoices = question.choices.map((choice, idx) => {
|
||||
@@ -78,19 +78,9 @@ export const MultipleChoiceQuestionForm = ({
|
||||
});
|
||||
}
|
||||
|
||||
let newLogic: any[] = [];
|
||||
question.logic?.forEach((logic) => {
|
||||
let newL: string | string[] | undefined = logic.value;
|
||||
if (Array.isArray(logic.value)) {
|
||||
newL = logic.value.map((value) =>
|
||||
value === getLocalizedValue(oldLabel, selectedLanguageCode) ? newLabel : value
|
||||
);
|
||||
} else {
|
||||
newL = logic.value === getLocalizedValue(oldLabel, selectedLanguageCode) ? newLabel : logic.value;
|
||||
}
|
||||
newLogic.push({ ...logic, value: newL });
|
||||
updateQuestion(questionIdx, {
|
||||
choices: newChoices,
|
||||
});
|
||||
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
|
||||
};
|
||||
|
||||
const addChoice = (choiceIdx?: number) => {
|
||||
@@ -132,23 +122,27 @@ export const MultipleChoiceQuestionForm = ({
|
||||
};
|
||||
|
||||
const deleteChoice = (choiceIdx: number) => {
|
||||
const choiceToDelete = question.choices[choiceIdx].id;
|
||||
|
||||
if (choiceToDelete !== "other") {
|
||||
const questionIdx = findOptionUsedInLogic(localSurvey, question.id, choiceToDelete);
|
||||
if (questionIdx !== -1) {
|
||||
toast.error(
|
||||
`This option is used in logic for question ${questionIdx + 1}. Please fix the logic first before deleting.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newChoices = !question.choices ? [] : question.choices.filter((_, idx) => idx !== choiceIdx);
|
||||
const choiceValue = question.choices[choiceIdx].label[selectedLanguageCode];
|
||||
if (isInvalidValue === choiceValue) {
|
||||
setisInvalidValue(null);
|
||||
}
|
||||
let newLogic: any[] = [];
|
||||
question.logic?.forEach((logic) => {
|
||||
let newL: string | string[] | undefined = logic.value;
|
||||
if (Array.isArray(logic.value)) {
|
||||
newL = logic.value.filter((value) => value !== choiceValue);
|
||||
} else {
|
||||
newL = logic.value !== choiceValue ? logic.value : undefined;
|
||||
}
|
||||
newLogic.push({ ...logic, value: newL });
|
||||
});
|
||||
|
||||
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
|
||||
updateQuestion(questionIdx, {
|
||||
choices: newChoices,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -219,6 +213,7 @@ export const MultipleChoiceQuestionForm = ({
|
||||
<Label htmlFor="choices">Options*</Label>
|
||||
<div className="mt-2" id="choices">
|
||||
<DndContext
|
||||
id="multi-choice-choices"
|
||||
onDragEnd={(event) => {
|
||||
const { active, over } = event;
|
||||
|
||||
|
||||
@@ -39,22 +39,10 @@ export const PictureSelectionForm = ({
|
||||
// Filter out the deleted choice from the choices array
|
||||
const newChoices = question.choices?.filter((choice) => choice.id !== choiceValue) || [];
|
||||
|
||||
// Update the logic, removing the deleted choice value
|
||||
const newLogic =
|
||||
question.logic?.map((logic) => {
|
||||
let updatedValue = logic.value;
|
||||
|
||||
if (Array.isArray(logic.value)) {
|
||||
updatedValue = logic.value.filter((value) => value !== choiceValue);
|
||||
} else if (logic.value === choiceValue) {
|
||||
updatedValue = undefined;
|
||||
}
|
||||
|
||||
return { ...logic, value: updatedValue };
|
||||
}) || [];
|
||||
|
||||
// Update the question with new choices and logic
|
||||
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
|
||||
updateQuestion(questionIdx, {
|
||||
choices: newChoices,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileInputChanges = (urls: string[]) => {
|
||||
@@ -139,7 +127,7 @@ export const PictureSelectionForm = ({
|
||||
<div className="mt-3 flex w-full items-center justify-center">
|
||||
<FileInput
|
||||
id="choices-file-input"
|
||||
allowedFileExtensions={["png", "jpeg", "jpg"]}
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
|
||||
environmentId={environmentId}
|
||||
onFileUpload={handleFileInputChanges}
|
||||
fileUrl={question?.choices?.map((choice) => choice.imageUrl)}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { RankingQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm";
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util";
|
||||
import { QUESTIONS_ICON_MAP, getTSurveyQuestionTypeEnumName } from "@/app/lib/questions";
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { QUESTIONS_ICON_MAP, getTSurveyQuestionTypeEnumName } from "@formbricks/lib/utils/questions";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
@@ -54,6 +54,7 @@ interface QuestionCardProps {
|
||||
attributeClasses: TAttributeClass[];
|
||||
addQuestion: (question: any, index?: number) => void;
|
||||
isFormbricksCloud: boolean;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const QuestionCard = ({
|
||||
@@ -74,6 +75,7 @@ export const QuestionCard = ({
|
||||
attributeClasses,
|
||||
addQuestion,
|
||||
isFormbricksCloud,
|
||||
isCxMode,
|
||||
}: QuestionCardProps) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: question.id,
|
||||
@@ -206,6 +208,7 @@ export const QuestionCard = ({
|
||||
updateCard={updateQuestion}
|
||||
addCard={addQuestion}
|
||||
cardType="question"
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ interface QuestionsDraggableProps {
|
||||
attributeClasses: TAttributeClass[];
|
||||
addQuestion: (question: any, index?: number) => void;
|
||||
isFormbricksCloud: boolean;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const QuestionsDroppable = ({
|
||||
@@ -38,6 +39,7 @@ export const QuestionsDroppable = ({
|
||||
attributeClasses,
|
||||
addQuestion,
|
||||
isFormbricksCloud,
|
||||
isCxMode,
|
||||
}: QuestionsDraggableProps) => {
|
||||
return (
|
||||
<div className="group mb-5 flex w-full flex-col gap-5">
|
||||
@@ -62,6 +64,7 @@ export const QuestionsDroppable = ({
|
||||
attributeClasses={attributeClasses}
|
||||
addQuestion={addQuestion}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
@@ -31,12 +31,14 @@ interface QuestionsAudienceTabsProps {
|
||||
activeId: TSurveyEditorTabs;
|
||||
setActiveId: React.Dispatch<React.SetStateAction<TSurveyEditorTabs>>;
|
||||
isStylingTabVisible?: boolean;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const QuestionsAudienceTabs = ({
|
||||
activeId,
|
||||
setActiveId,
|
||||
isStylingTabVisible,
|
||||
isCxMode,
|
||||
}: QuestionsAudienceTabsProps) => {
|
||||
const tabsComputed = useMemo(() => {
|
||||
if (isStylingTabVisible) {
|
||||
@@ -45,10 +47,13 @@ export const QuestionsAudienceTabs = ({
|
||||
return tabs.filter((tab) => tab.id !== "styling");
|
||||
}, [isStylingTabVisible]);
|
||||
|
||||
// Hide settings tab in CX mode
|
||||
let tabsToDisplay = isCxMode ? tabsComputed.filter((tab) => tab.id !== "settings") : tabsComputed;
|
||||
|
||||
return (
|
||||
<div className="fixed z-30 flex h-12 w-full items-center justify-center border-b bg-white md:w-1/2">
|
||||
<nav className="flex h-full items-center space-x-4" aria-label="Tabs">
|
||||
{tabsComputed.map((tab) => (
|
||||
{tabsToDisplay.map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.id}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { AddEndingCardButton } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddEndingCardButton";
|
||||
import { SurveyVariablesCard } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCard";
|
||||
import { findQuestionUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
@@ -16,11 +18,18 @@ import toast from "react-hot-toast";
|
||||
import { MultiLanguageCard } from "@formbricks/ee/multi-language/components/multi-language-card";
|
||||
import { addMultiLanguageLabels, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { isConditionGroup } from "@formbricks/lib/surveyLogic/utils";
|
||||
import { getDefaultEndingCard } from "@formbricks/lib/templates";
|
||||
import { checkForEmptyFallBackValue, extractRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import {
|
||||
TConditionGroup,
|
||||
TSingleCondition,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicAction,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { findQuestionsWithCyclicLogic } from "@formbricks/types/surveys/validation";
|
||||
import {
|
||||
@@ -49,6 +58,7 @@ interface QuestionsViewProps {
|
||||
isFormbricksCloud: boolean;
|
||||
attributeClasses: TAttributeClass[];
|
||||
plan: TOrganizationBillingPlan;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const QuestionsView = ({
|
||||
@@ -65,6 +75,7 @@ export const QuestionsView = ({
|
||||
isFormbricksCloud,
|
||||
attributeClasses,
|
||||
plan,
|
||||
isCxMode,
|
||||
}: QuestionsViewProps) => {
|
||||
const internalQuestionIdMap = useMemo(() => {
|
||||
return localSurvey.questions.reduce((acc, question) => {
|
||||
@@ -75,22 +86,75 @@ export const QuestionsView = ({
|
||||
|
||||
const surveyLanguages = localSurvey.languages;
|
||||
const [backButtonLabel, setbackButtonLabel] = useState(null);
|
||||
|
||||
const handleQuestionLogicChange = (survey: TSurvey, compareId: string, updatedId: string): TSurvey => {
|
||||
survey.questions.forEach((question) => {
|
||||
if (question.headline[selectedLanguageCode].includes(`recall:${compareId}`)) {
|
||||
question.headline[selectedLanguageCode] = question.headline[selectedLanguageCode].replaceAll(
|
||||
`recall:${compareId}`,
|
||||
`recall:${updatedId}`
|
||||
);
|
||||
const updateConditions = (conditions: TConditionGroup): TConditionGroup => {
|
||||
return {
|
||||
...conditions,
|
||||
conditions: conditions?.conditions.map((condition) => {
|
||||
if (isConditionGroup(condition)) {
|
||||
return updateConditions(condition);
|
||||
} else {
|
||||
return updateSingleCondition(condition);
|
||||
}
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const updateSingleCondition = (condition: TSingleCondition): TSingleCondition => {
|
||||
let updatedCondition = { ...condition };
|
||||
|
||||
if (condition.leftOperand.value === compareId) {
|
||||
updatedCondition.leftOperand = { ...condition.leftOperand, value: updatedId };
|
||||
}
|
||||
if (!question.logic) return;
|
||||
question.logic.forEach((rule) => {
|
||||
if (rule.destination === compareId) {
|
||||
rule.destination = updatedId;
|
||||
|
||||
if (condition.rightOperand?.type === "question" && condition.rightOperand?.value === compareId) {
|
||||
updatedCondition.rightOperand = { ...condition.rightOperand, value: updatedId };
|
||||
}
|
||||
|
||||
return updatedCondition;
|
||||
};
|
||||
|
||||
const updateActions = (actions: TSurveyLogicAction[]): TSurveyLogicAction[] => {
|
||||
return actions.map((action) => {
|
||||
let updatedAction = { ...action };
|
||||
|
||||
if (updatedAction.objective === "jumpToQuestion" && updatedAction.target === compareId) {
|
||||
updatedAction.target = updatedId;
|
||||
}
|
||||
|
||||
if (updatedAction.objective === "requireAnswer" && updatedAction.target === compareId) {
|
||||
updatedAction.target = updatedId;
|
||||
}
|
||||
|
||||
return updatedAction;
|
||||
});
|
||||
});
|
||||
return survey;
|
||||
};
|
||||
|
||||
return {
|
||||
...survey,
|
||||
questions: survey.questions.map((question) => {
|
||||
let updatedQuestion = { ...question };
|
||||
|
||||
if (question.headline[selectedLanguageCode].includes(`recall:${compareId}`)) {
|
||||
question.headline[selectedLanguageCode] = question.headline[selectedLanguageCode].replaceAll(
|
||||
`recall:${compareId}`,
|
||||
`recall:${updatedId}`
|
||||
);
|
||||
}
|
||||
|
||||
// Update advanced logic
|
||||
if (question.logic) {
|
||||
updatedQuestion.logic = question.logic.map((logicRule: TSurveyLogic) => ({
|
||||
...logicRule,
|
||||
conditions: updateConditions(logicRule.conditions),
|
||||
actions: updateActions(logicRule.actions),
|
||||
}));
|
||||
}
|
||||
|
||||
return updatedQuestion;
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -200,6 +264,7 @@ export const QuestionsView = ({
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setLocalSurvey(updatedSurvey);
|
||||
validateSurveyQuestion(updatedSurvey.questions[questionIdx]);
|
||||
};
|
||||
@@ -209,6 +274,14 @@ export const QuestionsView = ({
|
||||
const activeQuestionIdTemp = activeQuestionId ?? localSurvey.questions[0].id;
|
||||
let updatedSurvey: TSurvey = { ...localSurvey };
|
||||
|
||||
// checking if this question is used in logic of any other question
|
||||
const quesIdx = findQuestionUsedInLogic(localSurvey, questionId);
|
||||
|
||||
if (quesIdx !== -1) {
|
||||
toast.error(`This question is used in logic of question ${quesIdx + 1}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// check if we are recalling from this question for every language
|
||||
updatedSurvey.questions.forEach((question) => {
|
||||
for (const [languageCode, headline] of Object.entries(question.headline)) {
|
||||
@@ -221,7 +294,7 @@ export const QuestionsView = ({
|
||||
}
|
||||
});
|
||||
updatedSurvey.questions.splice(questionIdx, 1);
|
||||
updatedSurvey = handleQuestionLogicChange(updatedSurvey, questionId, "");
|
||||
|
||||
const firstEndingCard = localSurvey.endings[0];
|
||||
setLocalSurvey(updatedSurvey);
|
||||
delete internalQuestionIdMap[questionId];
|
||||
@@ -359,20 +432,26 @@ export const QuestionsView = ({
|
||||
|
||||
return (
|
||||
<div className="mt-12 w-full px-5 py-4">
|
||||
<div className="mb-5 flex w-full flex-col gap-5">
|
||||
<EditWelcomeCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes("start") : false}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
</div>
|
||||
{!isCxMode && (
|
||||
<div className="mb-5 flex w-full flex-col gap-5">
|
||||
<EditWelcomeCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes("start") : false}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DndContext sensors={sensors} onDragEnd={onQuestionCardDragEnd} collisionDetection={closestCorners}>
|
||||
<DndContext
|
||||
id="questions"
|
||||
sensors={sensors}
|
||||
onDragEnd={onQuestionCardDragEnd}
|
||||
collisionDetection={closestCorners}>
|
||||
<QuestionsDroppable
|
||||
localSurvey={localSurvey}
|
||||
product={product}
|
||||
@@ -389,13 +468,18 @@ export const QuestionsView = ({
|
||||
attributeClasses={attributeClasses}
|
||||
addQuestion={addQuestion}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
</DndContext>
|
||||
|
||||
<AddQuestionButton addQuestion={addQuestion} product={product} />
|
||||
<AddQuestionButton addQuestion={addQuestion} product={product} isCxMode={isCxMode} />
|
||||
<div className="mt-5 flex flex-col gap-5">
|
||||
<hr className="border-t border-dashed" />
|
||||
<DndContext sensors={sensors} onDragEnd={onEndingCardDragEnd} collisionDetection={closestCorners}>
|
||||
<DndContext
|
||||
id="endings"
|
||||
sensors={sensors}
|
||||
onDragEnd={onEndingCardDragEnd}
|
||||
collisionDetection={closestCorners}>
|
||||
<SortableContext items={localSurvey.endings} strategy={verticalListSortingStrategy}>
|
||||
{localSurvey.endings.map((ending, index) => {
|
||||
return (
|
||||
@@ -419,37 +503,41 @@ export const QuestionsView = ({
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<AddEndingCardButton
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
addEndingCard={addEndingCard}
|
||||
/>
|
||||
<hr />
|
||||
{!isCxMode && (
|
||||
<>
|
||||
<AddEndingCardButton
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
addEndingCard={addEndingCard}
|
||||
/>
|
||||
<hr />
|
||||
|
||||
<HiddenFieldsCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
/>
|
||||
<HiddenFieldsCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
/>
|
||||
|
||||
{/* <SurveyVariablesCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
/> */}
|
||||
<SurveyVariablesCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
/>
|
||||
|
||||
<MultiLanguageCard
|
||||
localSurvey={localSurvey}
|
||||
product={product}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
isMultiLanguageAllowed={isMultiLanguageAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
/>
|
||||
<MultiLanguageCard
|
||||
localSurvey={localSurvey}
|
||||
product={product}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
isMultiLanguageAllowed={isMultiLanguageAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -167,6 +167,7 @@ export const RankingQuestionForm = ({
|
||||
<Label htmlFor="choices">Options*</Label>
|
||||
<div className="mt-2" id="choices">
|
||||
<DndContext
|
||||
id="ranking-choices"
|
||||
onDragEnd={(event) => {
|
||||
const { active, over } = event;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
@@ -17,7 +16,7 @@ export const RedirectUrlForm = ({ endingCard, updateSurvey }: RedirectUrlFormPro
|
||||
id="redirectUrl"
|
||||
name="redirectUrl"
|
||||
className="bg-white"
|
||||
placeholder="https://formbricks.com/signup"
|
||||
placeholder="https://formbricks.com"
|
||||
value={endingCard.url}
|
||||
onChange={(e) => updateSurvey({ url: e.target.value })}
|
||||
/>
|
||||
|
||||
@@ -30,7 +30,10 @@ export const ResponseOptionsCard = ({
|
||||
const [closeOnDateToggle, setCloseOnDateToggle] = useState(false);
|
||||
useState;
|
||||
const [surveyClosedMessageToggle, setSurveyClosedMessageToggle] = useState(false);
|
||||
const [verifyEmailToggle, setVerifyEmailToggle] = useState(false);
|
||||
const [verifyEmailToggle, setVerifyEmailToggle] = useState(localSurvey.isVerifyEmailEnabled);
|
||||
const [isSingleResponsePerEmailEnabledToggle, setIsSingleResponsePerEmailToggle] = useState(
|
||||
localSurvey.isSingleResponsePerEmailEnabled
|
||||
);
|
||||
|
||||
const [surveyClosedMessage, setSurveyClosedMessage] = useState({
|
||||
heading: "Survey Completed",
|
||||
@@ -114,6 +117,14 @@ export const ResponseOptionsCard = ({
|
||||
setLocalSurvey({ ...localSurvey, isVerifyEmailEnabled: !localSurvey.isVerifyEmailEnabled });
|
||||
};
|
||||
|
||||
const handleSingleResponsePerEmailToggle = () => {
|
||||
setIsSingleResponsePerEmailToggle(!isSingleResponsePerEmailEnabledToggle);
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
isSingleResponsePerEmailEnabled: !localSurvey.isSingleResponsePerEmailEnabled,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRunOnDateChange = (date: Date) => {
|
||||
const equivalentDate = date?.getDate();
|
||||
date?.setUTCHours(0, 0, 0, 0);
|
||||
@@ -213,10 +224,6 @@ export const ResponseOptionsCard = ({
|
||||
setSingleUseEncryption(localSurvey.singleUse.isEncrypted);
|
||||
}
|
||||
|
||||
if (localSurvey.isVerifyEmailEnabled) {
|
||||
setVerifyEmailToggle(true);
|
||||
}
|
||||
|
||||
if (localSurvey.runOnDate) {
|
||||
setRunOnDate(localSurvey.runOnDate);
|
||||
setRunOnDateToggle(true);
|
||||
@@ -450,8 +457,17 @@ export const ResponseOptionsCard = ({
|
||||
onToggle={handleVerifyEmailToogle}
|
||||
title="Verify email before submission"
|
||||
description="Only let people with a real email respond."
|
||||
childBorder={true}
|
||||
/>
|
||||
childBorder={true}>
|
||||
<div className="m-1">
|
||||
<AdvancedOptionToggle
|
||||
htmlId="preventDoubleSubmission"
|
||||
isChecked={isSingleResponsePerEmailEnabledToggle}
|
||||
onToggle={handleSingleResponsePerEmailToggle}
|
||||
title="Prevent double submission"
|
||||
description={"Only allow 1 response per email address"}
|
||||
/>
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
<AdvancedOptionToggle
|
||||
htmlId="protectSurveyWithPin"
|
||||
isChecked={isPinProtectionEnabled}
|
||||
|
||||
@@ -34,6 +34,7 @@ type StylingViewProps = {
|
||||
localStylingChanges: TSurveyStyling | null;
|
||||
setLocalStylingChanges: React.Dispatch<React.SetStateAction<TSurveyStyling | null>>;
|
||||
isUnsplashConfigured: boolean;
|
||||
isCxMode: boolean;
|
||||
};
|
||||
|
||||
export const StylingView = ({
|
||||
@@ -47,6 +48,7 @@ export const StylingView = ({
|
||||
localStylingChanges,
|
||||
setLocalStylingChanges,
|
||||
isUnsplashConfigured,
|
||||
isCxMode,
|
||||
}: StylingViewProps) => {
|
||||
const stylingDefaults: TBaseStyling = useMemo(() => {
|
||||
let stylingDefaults: TBaseStyling;
|
||||
@@ -197,28 +199,30 @@ export const StylingView = ({
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<div className="mt-12 space-y-3 p-5">
|
||||
<div className="flex items-center gap-4 py-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="overwriteThemeStyling"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch checked={!!field.value} onCheckedChange={handleOverwriteToggle} />
|
||||
</FormControl>
|
||||
{!isCxMode && (
|
||||
<div className="flex items-center gap-4 py-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="overwriteThemeStyling"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch checked={!!field.value} onCheckedChange={handleOverwriteToggle} />
|
||||
</FormControl>
|
||||
|
||||
<div>
|
||||
<FormLabel className="text-base font-semibold text-slate-900">
|
||||
Add custom styles
|
||||
</FormLabel>
|
||||
<FormDescription className="text-sm text-slate-800">
|
||||
Override the theme with individual styles for this survey.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel className="text-base font-semibold text-slate-900">
|
||||
Add custom styles
|
||||
</FormLabel>
|
||||
<FormDescription className="text-sm text-slate-800">
|
||||
Override the theme with individual styles for this survey.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormStylingSettings
|
||||
open={formStylingOpen}
|
||||
@@ -248,31 +252,32 @@ export const StylingView = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex h-8 items-center justify-between">
|
||||
<div>
|
||||
{overwriteThemeStyling && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setConfirmResetStylingModalOpen(true)}>
|
||||
Reset to theme styles
|
||||
<RotateCcwIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{!isCxMode && (
|
||||
<div className="mt-4 flex h-8 items-center justify-between">
|
||||
<div>
|
||||
{overwriteThemeStyling && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setConfirmResetStylingModalOpen(true)}>
|
||||
Reset to theme styles
|
||||
<RotateCcwIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">
|
||||
Adjust the theme in the{" "}
|
||||
<Link
|
||||
href={`/environments/${environment.id}/product/look`}
|
||||
target="_blank"
|
||||
className="font-semibold underline">
|
||||
Look & Feel
|
||||
</Link>{" "}
|
||||
settings
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">
|
||||
Adjust the theme in the{" "}
|
||||
<Link
|
||||
href={`/environments/${environment.id}/product/look`}
|
||||
target="_blank"
|
||||
className="font-semibold underline">
|
||||
Look & Feel
|
||||
</Link>{" "}
|
||||
settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
)}
|
||||
<AlertDialog
|
||||
open={confirmResetStylingModalOpen}
|
||||
setOpen={setConfirmResetStylingModalOpen}
|
||||
|
||||
@@ -37,6 +37,7 @@ interface SurveyEditorProps {
|
||||
isFormbricksCloud: boolean;
|
||||
isUnsplashConfigured: boolean;
|
||||
plan: TOrganizationBillingPlan;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const SurveyEditor = ({
|
||||
@@ -55,6 +56,7 @@ export const SurveyEditor = ({
|
||||
isFormbricksCloud,
|
||||
isUnsplashConfigured,
|
||||
plan,
|
||||
isCxMode = false,
|
||||
}: SurveyEditorProps) => {
|
||||
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("questions");
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
|
||||
@@ -144,6 +146,7 @@ export const SurveyEditor = ({
|
||||
responseCount={responseCount}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
<div className="relative z-0 flex flex-1 overflow-hidden">
|
||||
<main
|
||||
@@ -152,6 +155,7 @@ export const SurveyEditor = ({
|
||||
<QuestionsAudienceTabs
|
||||
activeId={activeView}
|
||||
setActiveId={setActiveView}
|
||||
isCxMode={isCxMode}
|
||||
isStylingTabVisible={!!product.styling.allowStyleOverwrite}
|
||||
/>
|
||||
|
||||
@@ -170,6 +174,7 @@ export const SurveyEditor = ({
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
attributeClasses={attributeClasses}
|
||||
plan={plan}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -185,6 +190,7 @@ export const SurveyEditor = ({
|
||||
localStylingChanges={localStylingChanges}
|
||||
setLocalStylingChanges={setLocalStylingChanges}
|
||||
isUnsplashConfigured={isUnsplashConfigured}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ interface SurveyMenuBarProps {
|
||||
responseCount: number;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (selectedLanguage: string) => void;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const SurveyMenuBar = ({
|
||||
@@ -52,6 +53,7 @@ export const SurveyMenuBar = ({
|
||||
product,
|
||||
responseCount,
|
||||
selectedLanguageCode,
|
||||
isCxMode,
|
||||
}: SurveyMenuBarProps) => {
|
||||
const router = useRouter();
|
||||
const [audiencePrompt, setAudiencePrompt] = useState(true);
|
||||
@@ -86,6 +88,13 @@ export const SurveyMenuBar = ({
|
||||
};
|
||||
}, [localSurvey, survey]);
|
||||
|
||||
const clearSurveyLocalStorage = () => {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.removeItem(`${localSurvey.id}-columnOrder`);
|
||||
localStorage.removeItem(`${localSurvey.id}-columnVisibility`);
|
||||
}
|
||||
};
|
||||
|
||||
const containsEmptyTriggers = useMemo(() => {
|
||||
if (localSurvey.type === "link") return false;
|
||||
|
||||
@@ -179,9 +188,13 @@ export const SurveyMenuBar = ({
|
||||
(invalidLanguage: string) => getLanguageLabel(invalidLanguage) ?? invalidLanguage
|
||||
);
|
||||
|
||||
toast.error(`${currentError.message} ${invalidLanguageLabels.join(", ")}`);
|
||||
const messageSplit = currentError.message.split("-fLang-")[0];
|
||||
|
||||
toast.error(`${messageSplit} ${invalidLanguageLabels.join(", ")}`);
|
||||
} else {
|
||||
toast.error(currentError.message);
|
||||
toast.error(currentError.message, {
|
||||
className: "w-fit !max-w-md",
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -224,7 +237,14 @@ export const SurveyMenuBar = ({
|
||||
}
|
||||
});
|
||||
|
||||
if (localSurvey.type !== "link" && !localSurvey.triggers?.length) {
|
||||
toast.error("Please set a survey trigger");
|
||||
setIsSurveySaving(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const segment = await handleSegmentUpdate();
|
||||
clearSurveyLocalStorage();
|
||||
const updatedSurveyResponse = await updateSurveyAction({ ...localSurvey, segment });
|
||||
|
||||
setIsSurveySaving(false);
|
||||
@@ -270,6 +290,7 @@ export const SurveyMenuBar = ({
|
||||
}
|
||||
const status = localSurvey.runOnDate ? "scheduled" : "inProgress";
|
||||
const segment = await handleSegmentUpdate();
|
||||
clearSurveyLocalStorage();
|
||||
|
||||
await updateSurveyAction({
|
||||
...localSurvey,
|
||||
@@ -288,16 +309,18 @@ export const SurveyMenuBar = ({
|
||||
<>
|
||||
<div className="border-b border-slate-200 bg-white px-5 py-2.5 sm:flex sm:items-center sm:justify-between">
|
||||
<div className="flex h-full items-center space-x-2 whitespace-nowrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-full"
|
||||
StartIcon={ArrowLeftIcon}
|
||||
onClick={() => {
|
||||
handleBack();
|
||||
}}>
|
||||
Back
|
||||
</Button>
|
||||
{!isCxMode && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-full"
|
||||
StartIcon={ArrowLeftIcon}
|
||||
onClick={() => {
|
||||
handleBack();
|
||||
}}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<p className="hidden pl-4 font-semibold md:block">{product.name} / </p>
|
||||
<Input
|
||||
defaultValue={localSurvey.name}
|
||||
@@ -333,16 +356,19 @@ export const SurveyMenuBar = ({
|
||||
updateLocalSurveyStatus={updateLocalSurveyStatus}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={disableSave}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mr-3"
|
||||
loading={isSurveySaving}
|
||||
onClick={() => handleSurveySave()}
|
||||
type="submit">
|
||||
Save
|
||||
</Button>
|
||||
{!isCxMode && (
|
||||
<Button
|
||||
disabled={disableSave}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mr-3"
|
||||
loading={isSurveySaving}
|
||||
onClick={() => handleSurveySave()}
|
||||
type="submit">
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{localSurvey.status !== "draft" && (
|
||||
<Button
|
||||
disabled={disableSave}
|
||||
@@ -371,7 +397,7 @@ export const SurveyMenuBar = ({
|
||||
disabled={isSurveySaving || containsEmptyTriggers}
|
||||
loading={isSurveyPublishing}
|
||||
onClick={handleSurveyPublish}>
|
||||
Publish
|
||||
{isCxMode ? "Save & Close" : "Publish"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -369,7 +369,7 @@ export const TargetingCard = ({
|
||||
{isFormbricksCloud ? (
|
||||
<UpgradePlanNotice
|
||||
message="For advanced targeting, please"
|
||||
textForUrl="upgrade to the User Identification plan."
|
||||
textForUrl="upgrade to the Scale plan."
|
||||
url={`/environments/${environmentId}/settings/billing`}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
import { TSurveyQuestionTypeEnum, ZSurveyLogicConditionsOperator } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const logicRules = {
|
||||
question: {
|
||||
[`${TSurveyQuestionTypeEnum.OpenText}.text`]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "contains",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.contains,
|
||||
},
|
||||
{
|
||||
label: "does not contain",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotContain,
|
||||
},
|
||||
{
|
||||
label: "starts with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.startsWith,
|
||||
},
|
||||
{
|
||||
label: "does not start with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotStartWith,
|
||||
},
|
||||
{
|
||||
label: "ends with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.endsWith,
|
||||
},
|
||||
{
|
||||
label: "does not end with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEndWith,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[`${TSurveyQuestionTypeEnum.OpenText}.number`]: {
|
||||
options: [
|
||||
{
|
||||
label: "=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "!=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: ">",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThan,
|
||||
},
|
||||
{
|
||||
label: "<",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThan,
|
||||
},
|
||||
{
|
||||
label: ">=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "<=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "equals one of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equalsOneOf,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "includes all of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.includesAllOf,
|
||||
},
|
||||
{
|
||||
label: "includes one of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.includesOneOf,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.PictureSelection]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "includes all of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.includesAllOf,
|
||||
},
|
||||
{
|
||||
label: "includes one of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.includesOneOf,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Rating]: {
|
||||
options: [
|
||||
{
|
||||
label: "=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "!=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: ">",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThan,
|
||||
},
|
||||
{
|
||||
label: "<",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThan,
|
||||
},
|
||||
{
|
||||
label: ">=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "<=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.NPS]: {
|
||||
options: [
|
||||
{
|
||||
label: "=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "!=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: ">",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThan,
|
||||
},
|
||||
{
|
||||
label: "<",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThan,
|
||||
},
|
||||
{
|
||||
label: ">=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "<=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.CTA]: {
|
||||
options: [
|
||||
{
|
||||
label: "is clicked",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isClicked,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Consent]: {
|
||||
options: [
|
||||
{
|
||||
label: "is accepted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isAccepted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Date]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "is before",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isBefore,
|
||||
},
|
||||
{
|
||||
label: "is after",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isAfter,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.FileUpload]: {
|
||||
options: [
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Ranking]: {
|
||||
options: [
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Cal]: {
|
||||
options: [
|
||||
{
|
||||
label: "is booked",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isBooked,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Matrix]: {
|
||||
options: [
|
||||
{
|
||||
label: "is partially submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isPartiallySubmitted,
|
||||
},
|
||||
{
|
||||
label: "is completely submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isCompletelySubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Address]: {
|
||||
options: [
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
["variable.text"]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "contains",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.contains,
|
||||
},
|
||||
{
|
||||
label: "does not contain",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotContain,
|
||||
},
|
||||
{
|
||||
label: "starts with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.startsWith,
|
||||
},
|
||||
{
|
||||
label: "does not start with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotStartWith,
|
||||
},
|
||||
{
|
||||
label: "ends with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.endsWith,
|
||||
},
|
||||
{
|
||||
label: "does not end with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEndWith,
|
||||
},
|
||||
],
|
||||
},
|
||||
["variable.number"]: {
|
||||
options: [
|
||||
{
|
||||
label: "=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "!=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: ">",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThan,
|
||||
},
|
||||
{
|
||||
label: "<",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThan,
|
||||
},
|
||||
{
|
||||
label: ">=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "<=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThanOrEqual,
|
||||
},
|
||||
],
|
||||
},
|
||||
hiddenField: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "contains",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.contains,
|
||||
},
|
||||
{
|
||||
label: "does not contain",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotContain,
|
||||
},
|
||||
{
|
||||
label: "starts with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.startsWith,
|
||||
},
|
||||
{
|
||||
label: "does not start with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotStartWith,
|
||||
},
|
||||
{
|
||||
label: "ends with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.endsWith,
|
||||
},
|
||||
{
|
||||
label: "does not end with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEndWith,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export type TLogicRuleOption = (typeof logicRules.question)[keyof typeof logicRules.question]["options"];
|
||||
@@ -1,18 +0,0 @@
|
||||
// formats the text to highlight specific parts of the text with slashes
|
||||
export const formatTextWithSlashes = (text: string) => {
|
||||
const regex = /\/(.*?)\\/g;
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
// Check if the part was inside slashes
|
||||
if (index % 2 !== 0) {
|
||||
return (
|
||||
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-xs">
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return part;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,930 @@
|
||||
import { EyeOffIcon, FileDigitIcon, FileType2Icon } from "lucide-react";
|
||||
import { HTMLInputTypeAttribute, useMemo } from "react";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { isConditionGroup } from "@formbricks/lib/surveyLogic/utils";
|
||||
import { questionIconMapping, questionTypes } from "@formbricks/lib/utils/questions";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import {
|
||||
TConditionGroup,
|
||||
TLeftOperand,
|
||||
TRightOperand,
|
||||
TSingleCondition,
|
||||
TSurvey,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicAction,
|
||||
TSurveyLogicConditionsOperator,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyVariable,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TComboboxGroupedOption, TComboboxOption } from "@formbricks/ui/InputCombobox";
|
||||
import { TLogicRuleOption, logicRules } from "./logicRuleEngine";
|
||||
|
||||
// formats the text to highlight specific parts of the text with slashes
|
||||
export const formatTextWithSlashes = (text: string) => {
|
||||
const regex = /\/(.*?)\\/g;
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
// Check if the part was inside slashes
|
||||
if (index % 2 !== 0) {
|
||||
return (
|
||||
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-xs">
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return part;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const getConditionValueOptions = (
|
||||
localSurvey: TSurvey,
|
||||
currQuestionIdx: number
|
||||
): TComboboxGroupedOption[] => {
|
||||
const hiddenFields = localSurvey.hiddenFields?.fieldIds ?? [];
|
||||
const variables = localSurvey.variables ?? [];
|
||||
const questions = localSurvey.questions;
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
const questionOptions = questions
|
||||
.filter((_, idx) => idx <= currQuestionIdx)
|
||||
.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const variableOptions = variables.map((variable) => {
|
||||
return {
|
||||
icon: variable.type === "number" ? FileDigitIcon : FileType2Icon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hiddenFieldsOptions = hiddenFields.map((field) => {
|
||||
return {
|
||||
icon: EyeOffIcon,
|
||||
label: field,
|
||||
value: field,
|
||||
meta: {
|
||||
type: "hiddenField",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return groupedOptions;
|
||||
};
|
||||
|
||||
export const replaceEndingCardHeadlineRecall = (
|
||||
survey: TSurvey,
|
||||
language: string,
|
||||
attributeClasses: TAttributeClass[]
|
||||
) => {
|
||||
const modifiedSurvey = structuredClone(survey);
|
||||
modifiedSurvey.endings.forEach((ending) => {
|
||||
if (ending.type === "endScreen") {
|
||||
ending.headline = recallToHeadline(
|
||||
ending.headline || {},
|
||||
modifiedSurvey,
|
||||
false,
|
||||
language,
|
||||
attributeClasses
|
||||
);
|
||||
}
|
||||
});
|
||||
return modifiedSurvey;
|
||||
};
|
||||
|
||||
export const actionObjectiveOptions: TComboboxOption[] = [
|
||||
{ label: "Calculate", value: "calculate" },
|
||||
{ label: "Require Answer", value: "requireAnswer" },
|
||||
{ label: "Jump to question", value: "jumpToQuestion" },
|
||||
];
|
||||
|
||||
const getQuestionOperatorOptions = (question: TSurveyQuestion): TComboboxOption[] => {
|
||||
let options: TLogicRuleOption;
|
||||
|
||||
if (question.type === "openText") {
|
||||
const inputType = question.inputType === "number" ? "number" : "text";
|
||||
options = logicRules.question[`openText.${inputType}`].options;
|
||||
} else {
|
||||
options = logicRules.question[question.type].options;
|
||||
}
|
||||
|
||||
if (question.required) {
|
||||
options = options.filter((option) => option.value !== "isSkipped") as TLogicRuleOption;
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
export const getDefaultOperatorForQuestion = (question: TSurveyQuestion): TSurveyLogicConditionsOperator => {
|
||||
const options = getQuestionOperatorOptions(question);
|
||||
|
||||
return options[0].value.toString() as TSurveyLogicConditionsOperator;
|
||||
};
|
||||
|
||||
export const getConditionOperatorOptions = (
|
||||
condition: TSingleCondition,
|
||||
localSurvey: TSurvey
|
||||
): TComboboxOption[] => {
|
||||
if (condition.leftOperand.type === "variable") {
|
||||
const variables = localSurvey.variables ?? [];
|
||||
const variableType =
|
||||
variables.find((variable) => variable.id === condition.leftOperand.value)?.type || "text";
|
||||
return logicRules[`variable.${variableType}`].options;
|
||||
} else if (condition.leftOperand.type === "hiddenField") {
|
||||
return logicRules.hiddenField.options;
|
||||
} else if (condition.leftOperand.type === "question") {
|
||||
const questions = localSurvey.questions ?? [];
|
||||
const question = questions.find((question) => question.id === condition.leftOperand.value);
|
||||
|
||||
if (!question) return [];
|
||||
|
||||
return getQuestionOperatorOptions(question);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const getMatchValueProps = (
|
||||
condition: TSingleCondition,
|
||||
localSurvey: TSurvey
|
||||
): {
|
||||
show?: boolean;
|
||||
showInput?: boolean;
|
||||
inputType?: HTMLInputTypeAttribute;
|
||||
options: TComboboxGroupedOption[];
|
||||
} => {
|
||||
if (
|
||||
[
|
||||
"isAccepted",
|
||||
"isBooked",
|
||||
"isClicked",
|
||||
"isCompletelySubmitted",
|
||||
"isPartiallySubmitted",
|
||||
"isSkipped",
|
||||
"isSubmitted",
|
||||
].includes(condition.operator)
|
||||
) {
|
||||
return { show: false, options: [] };
|
||||
}
|
||||
|
||||
let questions = localSurvey.questions ?? [];
|
||||
let variables = localSurvey.variables ?? [];
|
||||
let hiddenFields = localSurvey.hiddenFields?.fieldIds ?? [];
|
||||
|
||||
const selectedQuestion = questions.find((question) => question.id === condition.leftOperand.value);
|
||||
const selectedVariable = variables.find((variable) => variable.id === condition.leftOperand.value);
|
||||
|
||||
if (condition.leftOperand.type === "question") {
|
||||
questions = questions.filter((question) => question.id !== condition.leftOperand.value);
|
||||
} else if (condition.leftOperand.type === "variable") {
|
||||
variables = variables.filter((variable) => variable.id !== condition.leftOperand.value);
|
||||
} else if (condition.leftOperand.type === "hiddenField") {
|
||||
hiddenFields = hiddenFields.filter((field) => field !== condition.leftOperand.value);
|
||||
}
|
||||
|
||||
if (condition.leftOperand.type === "question") {
|
||||
if (selectedQuestion?.type === TSurveyQuestionTypeEnum.OpenText) {
|
||||
const allowedQuestionTypes = [TSurveyQuestionTypeEnum.OpenText];
|
||||
|
||||
if (selectedQuestion.inputType === "number") {
|
||||
allowedQuestionTypes.push(TSurveyQuestionTypeEnum.Rating, TSurveyQuestionTypeEnum.NPS);
|
||||
}
|
||||
|
||||
if (["equals", "doesNotEqual"].includes(condition.operator)) {
|
||||
if (selectedQuestion.inputType !== "number") {
|
||||
allowedQuestionTypes.push(
|
||||
TSurveyQuestionTypeEnum.Date,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceMulti
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allowedQuestions = questions.filter((question) => allowedQuestionTypes.includes(question.type));
|
||||
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const variableOptions = variables
|
||||
.filter((variable) =>
|
||||
selectedQuestion.inputType !== "number" ? variable.type === "text" : variable.type === "number"
|
||||
)
|
||||
.map((variable) => {
|
||||
return {
|
||||
icon: variable.type === "number" ? FileDigitIcon : FileType2Icon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hiddenFieldsOptions = hiddenFields.map((field) => {
|
||||
return {
|
||||
icon: EyeOffIcon,
|
||||
label: field,
|
||||
value: field,
|
||||
meta: {
|
||||
type: "hiddenField",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
return {
|
||||
show: true,
|
||||
showInput: true,
|
||||
inputType: selectedQuestion.inputType === "number" ? "number" : "text",
|
||||
options: groupedOptions,
|
||||
};
|
||||
} else if (
|
||||
selectedQuestion?.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||
selectedQuestion?.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
|
||||
) {
|
||||
const choices = selectedQuestion.choices.map((choice) => {
|
||||
return {
|
||||
label: getLocalizedValue(choice.label, "default"),
|
||||
value: choice.id,
|
||||
meta: {
|
||||
type: "static",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: false,
|
||||
options: [{ label: "Choices", value: "choices", options: choices }],
|
||||
};
|
||||
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.PictureSelection) {
|
||||
const choices = selectedQuestion.choices.map((choice, idx) => {
|
||||
return {
|
||||
imgSrc: choice.imageUrl,
|
||||
label: `Picture ${idx + 1}`,
|
||||
value: choice.id,
|
||||
meta: {
|
||||
type: "static",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: false,
|
||||
options: [{ label: "Choices", value: "choices", options: choices }],
|
||||
};
|
||||
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.Rating) {
|
||||
const choices = Array.from({ length: selectedQuestion.range }, (_, idx) => {
|
||||
return {
|
||||
label: `${idx + 1}`,
|
||||
value: idx + 1,
|
||||
meta: {
|
||||
type: "static",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const numberVariables = variables.filter((variable) => variable.type === "number");
|
||||
|
||||
const variableOptions = numberVariables.map((variable) => {
|
||||
return {
|
||||
icon: FileDigitIcon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (choices.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Choices",
|
||||
value: "choices",
|
||||
options: choices,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: false,
|
||||
options: groupedOptions,
|
||||
};
|
||||
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.NPS) {
|
||||
const choices = Array.from({ length: 11 }, (_, idx) => {
|
||||
return {
|
||||
label: `${idx}`,
|
||||
value: idx,
|
||||
meta: {
|
||||
type: "static",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const numberVariables = variables.filter((variable) => variable.type === "number");
|
||||
|
||||
const variableOptions = numberVariables.map((variable) => {
|
||||
return {
|
||||
icon: FileDigitIcon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (choices.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Choices",
|
||||
value: "choices",
|
||||
options: choices,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: false,
|
||||
options: groupedOptions,
|
||||
};
|
||||
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.Date) {
|
||||
const openTextQuestions = questions.filter((question) =>
|
||||
[TSurveyQuestionTypeEnum.OpenText, TSurveyQuestionTypeEnum.Date].includes(question.type)
|
||||
);
|
||||
|
||||
const questionOptions = openTextQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const stringVariables = variables.filter((variable) => variable.type === "text");
|
||||
|
||||
const variableOptions = stringVariables.map((variable) => {
|
||||
return {
|
||||
icon: FileType2Icon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hiddenFieldsOptions = hiddenFields.map((field) => {
|
||||
return {
|
||||
icon: EyeOffIcon,
|
||||
label: field,
|
||||
value: field,
|
||||
meta: {
|
||||
type: "hiddenField",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: true,
|
||||
inputType: "date",
|
||||
options: groupedOptions,
|
||||
};
|
||||
}
|
||||
} else if (condition.leftOperand.type === "variable") {
|
||||
if (selectedVariable?.type === "text") {
|
||||
const allowedQuestionTypes = [
|
||||
TSurveyQuestionTypeEnum.OpenText,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
];
|
||||
|
||||
if (["equals", "doesNotEqual"].includes(condition.operator)) {
|
||||
allowedQuestionTypes.push(TSurveyQuestionTypeEnum.MultipleChoiceMulti, TSurveyQuestionTypeEnum.Date);
|
||||
}
|
||||
|
||||
const allowedQuestions = questions.filter((question) => allowedQuestionTypes.includes(question.type));
|
||||
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const stringVariables = variables.filter((variable) => variable.type === "text");
|
||||
|
||||
const variableOptions = stringVariables.map((variable) => {
|
||||
return {
|
||||
icon: FileType2Icon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hiddenFieldsOptions = hiddenFields.map((field) => {
|
||||
return {
|
||||
icon: EyeOffIcon,
|
||||
label: field,
|
||||
value: field,
|
||||
meta: {
|
||||
type: "hiddenField",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: true,
|
||||
inputType: "text",
|
||||
options: groupedOptions,
|
||||
};
|
||||
} else if (selectedVariable?.type === "number") {
|
||||
const allowedQuestions = questions.filter((question) =>
|
||||
[TSurveyQuestionTypeEnum.Rating, TSurveyQuestionTypeEnum.NPS].includes(question.type)
|
||||
);
|
||||
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const numberVariables = variables.filter((variable) => variable.type === "number");
|
||||
|
||||
const variableOptions = numberVariables.map((variable) => {
|
||||
return {
|
||||
icon: FileDigitIcon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hiddenFieldsOptions = hiddenFields.map((field) => {
|
||||
return {
|
||||
icon: EyeOffIcon,
|
||||
label: field,
|
||||
value: field,
|
||||
meta: {
|
||||
type: "hiddenField",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: true,
|
||||
inputType: "number",
|
||||
options: groupedOptions,
|
||||
};
|
||||
}
|
||||
} else if (condition.leftOperand.type === "hiddenField") {
|
||||
const allowedQuestionTypes = [
|
||||
TSurveyQuestionTypeEnum.OpenText,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
];
|
||||
|
||||
if (["equals", "doesNotEqual"].includes(condition.operator)) {
|
||||
allowedQuestionTypes.push(TSurveyQuestionTypeEnum.MultipleChoiceMulti, TSurveyQuestionTypeEnum.Date);
|
||||
}
|
||||
|
||||
const allowedQuestions = questions.filter((question) => allowedQuestionTypes.includes(question.type));
|
||||
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const variableOptions = variables
|
||||
.filter((variable) => variable.type === "text")
|
||||
.map((variable) => {
|
||||
return {
|
||||
icon: FileType2Icon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hiddenFieldsOptions = hiddenFields.map((field) => {
|
||||
return {
|
||||
icon: EyeOffIcon,
|
||||
label: field,
|
||||
value: field,
|
||||
meta: {
|
||||
type: "hiddenField",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: true,
|
||||
inputType: "text",
|
||||
options: groupedOptions,
|
||||
};
|
||||
}
|
||||
|
||||
return { show: false, options: [] };
|
||||
};
|
||||
|
||||
export const getActionOperatorOptions = (variableType?: TSurveyVariable["type"]): TComboboxOption[] => {
|
||||
if (variableType === "number") {
|
||||
return [
|
||||
{
|
||||
label: "Add +",
|
||||
value: "add",
|
||||
},
|
||||
{
|
||||
label: "Subtract -",
|
||||
value: "subtract",
|
||||
},
|
||||
{
|
||||
label: "Multiply *",
|
||||
value: "multiply",
|
||||
},
|
||||
{
|
||||
label: "Divide /",
|
||||
value: "divide",
|
||||
},
|
||||
{
|
||||
label: "Assign =",
|
||||
value: "assign",
|
||||
},
|
||||
];
|
||||
} else if (variableType === "text") {
|
||||
return [
|
||||
{
|
||||
label: "Assign =",
|
||||
value: "assign",
|
||||
},
|
||||
{
|
||||
label: "Concat +",
|
||||
value: "concat",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const isUsedInLeftOperand = (
|
||||
leftOperand: TLeftOperand,
|
||||
type: "question" | "hiddenField" | "variable",
|
||||
id: string
|
||||
): boolean => {
|
||||
switch (type) {
|
||||
case "question":
|
||||
return leftOperand.type === "question" && leftOperand.value === id;
|
||||
case "hiddenField":
|
||||
return leftOperand.type === "hiddenField" && leftOperand.value === id;
|
||||
case "variable":
|
||||
return leftOperand.type === "variable" && leftOperand.value === id;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isUsedInRightOperand = (
|
||||
rightOperand: TRightOperand,
|
||||
type: "question" | "hiddenField" | "variable",
|
||||
id: string
|
||||
): boolean => {
|
||||
switch (type) {
|
||||
case "question":
|
||||
return rightOperand.type === "question" && rightOperand.value === id;
|
||||
case "hiddenField":
|
||||
return rightOperand.type === "hiddenField" && rightOperand.value === id;
|
||||
case "variable":
|
||||
return rightOperand.type === "variable" && rightOperand.value === id;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const findQuestionUsedInLogic = (survey: TSurvey, questionId: string): number => {
|
||||
const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => {
|
||||
if (isConditionGroup(condition)) {
|
||||
// It's a TConditionGroup
|
||||
return condition.conditions.some(isUsedInCondition);
|
||||
} else {
|
||||
// It's a TSingleCondition
|
||||
return (
|
||||
(condition.rightOperand && isUsedInRightOperand(condition.rightOperand, "question", questionId)) ||
|
||||
isUsedInLeftOperand(condition.leftOperand, "question", questionId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isUsedInAction = (action: TSurveyLogicAction): boolean => {
|
||||
return (
|
||||
(action.objective === "jumpToQuestion" && action.target === questionId) ||
|
||||
(action.objective === "requireAnswer" && action.target === questionId)
|
||||
);
|
||||
};
|
||||
|
||||
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
|
||||
return isUsedInCondition(logicRule.conditions) || logicRule.actions.some(isUsedInAction);
|
||||
};
|
||||
|
||||
return survey.questions.findIndex(
|
||||
(question) => question.logic && question.id !== questionId && question.logic.some(isUsedInLogicRule)
|
||||
);
|
||||
};
|
||||
|
||||
export const findOptionUsedInLogic = (survey: TSurvey, questionId: string, optionId: string): number => {
|
||||
const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => {
|
||||
if (isConditionGroup(condition)) {
|
||||
// It's a TConditionGroup
|
||||
return condition.conditions.some(isUsedInCondition);
|
||||
} else {
|
||||
// It's a TSingleCondition
|
||||
return isUsedInOperand(condition);
|
||||
}
|
||||
};
|
||||
|
||||
const isUsedInOperand = (condition: TSingleCondition): boolean => {
|
||||
if (condition.leftOperand.type === "question" && condition.leftOperand.value === questionId) {
|
||||
if (condition.rightOperand && condition.rightOperand.type === "static") {
|
||||
if (Array.isArray(condition.rightOperand.value)) {
|
||||
return condition.rightOperand.value.includes(optionId);
|
||||
} else {
|
||||
return condition.rightOperand.value === optionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
|
||||
return isUsedInCondition(logicRule.conditions);
|
||||
};
|
||||
|
||||
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
|
||||
};
|
||||
|
||||
export const findVariableUsedInLogic = (survey: TSurvey, variableId: string): number => {
|
||||
const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => {
|
||||
if (isConditionGroup(condition)) {
|
||||
// It's a TConditionGroup
|
||||
return condition.conditions.some(isUsedInCondition);
|
||||
} else {
|
||||
// It's a TSingleCondition
|
||||
return (
|
||||
(condition.rightOperand && isUsedInRightOperand(condition.rightOperand, "variable", variableId)) ||
|
||||
isUsedInLeftOperand(condition.leftOperand, "variable", variableId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isUsedInAction = (action: TSurveyLogicAction): boolean => {
|
||||
return action.objective === "calculate" && action.variableId === variableId;
|
||||
};
|
||||
|
||||
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
|
||||
return isUsedInCondition(logicRule.conditions) || logicRule.actions.some(isUsedInAction);
|
||||
};
|
||||
|
||||
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
|
||||
};
|
||||
|
||||
export const findHiddenFieldUsedInLogic = (survey: TSurvey, hiddenFieldId: string): number => {
|
||||
const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => {
|
||||
if (isConditionGroup(condition)) {
|
||||
// It's a TConditionGroup
|
||||
return condition.conditions.some(isUsedInCondition);
|
||||
} else {
|
||||
// It's a TSingleCondition
|
||||
return (
|
||||
(condition.rightOperand &&
|
||||
isUsedInRightOperand(condition.rightOperand, "hiddenField", hiddenFieldId)) ||
|
||||
isUsedInLeftOperand(condition.leftOperand, "hiddenField", hiddenFieldId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
|
||||
return isUsedInCondition(logicRule.conditions);
|
||||
};
|
||||
|
||||
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
|
||||
};
|
||||
@@ -22,7 +22,7 @@ export const generateMetadata = async ({ params }) => {
|
||||
};
|
||||
};
|
||||
|
||||
const Page = async ({ params }) => {
|
||||
const Page = async ({ params, searchParams }) => {
|
||||
const [
|
||||
survey,
|
||||
product,
|
||||
@@ -70,6 +70,8 @@ const Page = async ({ params }) => {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
const isCxMode = searchParams.mode === "cx";
|
||||
|
||||
return (
|
||||
<SurveyEditor
|
||||
survey={survey}
|
||||
@@ -87,6 +89,7 @@ const Page = async ({ params }) => {
|
||||
plan={organization.billing.plan}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isUnsplashConfigured={UNSPLASH_ACCESS_KEY ? true : false}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -37,5 +37,6 @@ export const minimalSurvey: TSurvey = {
|
||||
languages: [],
|
||||
showLanguageSwitch: false,
|
||||
isVerifyEmailEnabled: false,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
variables: [],
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { TagIcon } from "lucide-react";
|
||||
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||
@@ -8,7 +8,7 @@ const Loading = () => {
|
||||
<>
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="People">
|
||||
<PeopleSecondaryNavigation activeId="attributes" loading />
|
||||
<PersonSecondaryNavigation activeId="attributes" loading />
|
||||
</PageHeader>
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-5 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { CircleHelpIcon } from "lucide-react";
|
||||
import { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
@@ -42,7 +42,7 @@ const Page = async ({ params }) => {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="People" cta={HowToAddAttributesButton}>
|
||||
<PeopleSecondaryNavigation activeId="attributes" environmentId={params.environmentId} />
|
||||
<PersonSecondaryNavigation activeId="attributes" environmentId={params.environmentId} />
|
||||
</PageHeader>
|
||||
<AttributeClassesTable attributeClasses={attributeClasses} />
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { deletePersonAction } from "@/app/(app)/environments/[environmentId]/(people)/people/[personId]/actions";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { deletePersonAction } from "@formbricks/ui/DataTable/actions";
|
||||
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
|
||||
|
||||
interface DeletePersonButtonProps {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { getAttributes } from "@formbricks/lib/attribute/service";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
|
||||
import { getPeople } from "@formbricks/lib/person/service";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
const ZGetPersonsAction = z.object({
|
||||
environmentId: ZId,
|
||||
page: z.number(),
|
||||
});
|
||||
|
||||
export const getPersonsAction = authenticatedActionClient
|
||||
.schema(ZGetPersonsAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
rules: ["environment", "read"],
|
||||
});
|
||||
|
||||
return getPeople(parsedInput.environmentId, parsedInput.page);
|
||||
});
|
||||
|
||||
const ZGetPersonAttributesAction = z.object({
|
||||
environmentId: ZId,
|
||||
personId: ZId,
|
||||
});
|
||||
|
||||
export const getPersonAttributesAction = authenticatedActionClient
|
||||
.schema(ZGetPersonAttributesAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
rules: ["environment", "read"],
|
||||
});
|
||||
|
||||
return getAttributes(parsedInput.personId);
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { getAttributes } from "@formbricks/lib/attribute/service";
|
||||
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
|
||||
import { TPerson } from "@formbricks/types/people";
|
||||
import { PersonAvatar } from "@formbricks/ui/Avatars";
|
||||
|
||||
export const PersonCard = async ({ person }: { person: TPerson }) => {
|
||||
const attributes = await getAttributes(person.id);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/environments/${person.environmentId}/people/${person.id}`}
|
||||
key={person.id}
|
||||
className="w-full">
|
||||
<div className="m-2 grid h-16 grid-cols-7 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
|
||||
<div className="col-span-3 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="ph-no-capture h-10 w-10 flex-shrink-0">
|
||||
<PersonAvatar personId={person.id} />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="ph-no-capture font-medium text-slate-900">
|
||||
<span>{getPersonIdentifier({ id: person.id, userId: person.userId }, attributes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{person.userId}</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{attributes.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { getPersonsAction } from "@/app/(app)/environments/[environmentId]/(people)/people/actions";
|
||||
import { PersonTable } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonTable";
|
||||
import { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TPersonWithAttributes } from "@formbricks/types/people";
|
||||
|
||||
interface PersonDataViewProps {
|
||||
environment: TEnvironment;
|
||||
personCount: number;
|
||||
itemsPerPage: number;
|
||||
}
|
||||
|
||||
export const PersonDataView = ({ environment, personCount, itemsPerPage }: PersonDataViewProps) => {
|
||||
const [persons, setPersons] = useState<TPersonWithAttributes[]>([]);
|
||||
const [pageNumber, setPageNumber] = useState<number>(1);
|
||||
const [totalPersons, setTotalPersons] = useState<number>(0);
|
||||
const [isDataLoaded, setIsDataLoaded] = useState<boolean>(false);
|
||||
const [hasMore, setHasMore] = useState<boolean>(false);
|
||||
const [loadingNextPage, setLoadingNextPage] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTotalPersons(personCount);
|
||||
setHasMore(pageNumber < Math.ceil(personCount / itemsPerPage));
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const getPersonActionData = await getPersonsAction({
|
||||
environmentId: environment.id,
|
||||
page: pageNumber,
|
||||
});
|
||||
if (getPersonActionData?.data) {
|
||||
setPersons(getPersonActionData.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching people data:", error);
|
||||
} finally {
|
||||
setIsDataLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [pageNumber, personCount, itemsPerPage, environment.id]);
|
||||
|
||||
const fetchNextPage = async () => {
|
||||
if (hasMore && !loadingNextPage) {
|
||||
setLoadingNextPage(true);
|
||||
const getPersonsActionData = await getPersonsAction({
|
||||
environmentId: environment.id,
|
||||
page: pageNumber,
|
||||
});
|
||||
if (getPersonsActionData?.data) {
|
||||
const newData = getPersonsActionData.data;
|
||||
setPersons((prevPersonsData) => [...prevPersonsData, ...newData]);
|
||||
}
|
||||
setPageNumber((prevPage) => prevPage + 1);
|
||||
setHasMore(pageNumber + 1 < Math.ceil(totalPersons / itemsPerPage));
|
||||
setLoadingNextPage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deletePersons = (personIds: string[]) => {
|
||||
setPersons((prevPersons) => prevPersons.filter((p) => !personIds.includes(p.id)));
|
||||
};
|
||||
|
||||
const personTableData = persons.map((person) => ({
|
||||
id: person.id,
|
||||
userId: person.userId,
|
||||
email: person.attributes.email,
|
||||
createdAt: person.createdAt,
|
||||
attributes: person.attributes,
|
||||
personId: person.id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<PersonTable
|
||||
data={personTableData}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasMore={hasMore}
|
||||
isDataLoaded={isDataLoaded}
|
||||
deletePersons={deletePersons}
|
||||
environmentId={environment.id}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -2,17 +2,17 @@ import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { TProductConfigChannel } from "@formbricks/types/product";
|
||||
import { SecondaryNavigation } from "@formbricks/ui/SecondaryNavigation";
|
||||
|
||||
interface PeopleSegmentsTabsProps {
|
||||
interface PersonSecondaryNavigationProps {
|
||||
activeId: string;
|
||||
environmentId?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const PeopleSecondaryNavigation = async ({
|
||||
export const PersonSecondaryNavigation = async ({
|
||||
activeId,
|
||||
environmentId,
|
||||
loading,
|
||||
}: PeopleSegmentsTabsProps) => {
|
||||
}: PersonSecondaryNavigationProps) => {
|
||||
let currentProductChannel: TProductConfigChannel = null;
|
||||
|
||||
if (!loading && environmentId) {
|
||||