Merge branch 'main' into feature/upgrade-deps-09
@@ -1,7 +1,7 @@
|
||||
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
|
||||
title: "[🕹️]"
|
||||
labels: 🕹️ oss.gg, player submission, hacktoberfest
|
||||
assignees: []
|
||||
body:
|
||||
- type: textarea
|
||||
|
||||
14
.github/workflows/semantic-pull-requests.yml
vendored
@@ -20,6 +20,20 @@ jobs:
|
||||
id: lint_pr_title
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
types: |
|
||||
fix
|
||||
feat
|
||||
chore
|
||||
docs
|
||||
style
|
||||
refactor
|
||||
perf
|
||||
test
|
||||
build
|
||||
ci
|
||||
revert
|
||||
ossgg
|
||||
|
||||
- uses: marocchino/sticky-pull-request-comment@v2
|
||||
# When the previous steps fails, the workflow would stop. By adding this
|
||||
|
||||
|
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"
|
||||
/>
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
75
apps/docs/app/global/shareable-dashboards/page.mdx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
|
||||
import StepOne from "./images/1-publish-to-web.webp";
|
||||
import StepTwo from "./images/2-warning-publish.webp";
|
||||
import StepThree from "./images/3-share-link.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Shareable Dashboards",
|
||||
description:
|
||||
"Create shareable links to dashboards of specific surveys.",
|
||||
};
|
||||
|
||||
# Shareable Dashboards
|
||||
|
||||
Formbricks allows you to create public, shareable versions of your survey results dashboards. This feature enables you to easily share survey results with stakeholders, team members, or the public without granting access to your Formbricks account.
|
||||
|
||||
## How To Publish Survey Results
|
||||
|
||||
1. **Go to survey summary**: Choose the survey for which you want to create a shareable dashboard and go to its summary page.
|
||||
|
||||
2. **Share results**: Click the "Share results" and then "Publish to web".
|
||||
|
||||
<MdxImage
|
||||
src={StepOne}
|
||||
alt="Go to survey summary"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
3. **Confirm**: Click "Publish to public web" (it's public).
|
||||
|
||||
<MdxImage
|
||||
src={StepTwo}
|
||||
alt="Go to survey summary"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
4. **Share link**: Formbricks has generated a unique URL for your public dashboard. Share it around.
|
||||
|
||||
<MdxImage
|
||||
src={StepThree}
|
||||
alt="Go to survey summary"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
<Note>Whoever has access to the link can access the survey results.</Note>
|
||||
|
||||
## How To Unpublish Survey Results
|
||||
|
||||
Unpublish is very simple: Go to "Share results" -> "Unpublish from web" -> "Unpublish".
|
||||
|
||||
<MdxImage
|
||||
src={StepThree}
|
||||
alt="Go to survey summary"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Read-only access**: Viewers can see survey results but cannot modify data or settings.
|
||||
- **Real-time updates**: The shared dashboard reflects current survey data in real-time.
|
||||
- **Filters included**: Visitors can access all filters to dissect the data.
|
||||
- **Revocable access**: You can disable the shared link at any time to restrict access.
|
||||
|
||||
## Use Cases
|
||||
|
||||
- Share results with clients or stakeholders
|
||||
- Publish survey findings to your website or blog
|
||||
- Collaborate with team members without sharing account credentials
|
||||
- Create transparency by making certain survey results public
|
||||
|
||||
Shareable dashboards provide a simple yet powerful way to disseminate survey insights while maintaining control over your Formbricks account and data.
|
||||
|
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",
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
// ResponsiveVideo.js
|
||||
// ResponsiveVideo.tsx
|
||||
export const ResponsiveVideo = ({ src, title }) => {
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden pt-[56.25%]">
|
||||
<iframe
|
||||
src={src}
|
||||
title={title}
|
||||
frameBorder="0"
|
||||
className="absolute left-0 top-0 h-full w-full"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen></iframe>
|
||||
<div className="max-w-[1280px]">
|
||||
<div className="relative w-full overflow-hidden pt-[56.25%]">
|
||||
<iframe
|
||||
src={src}
|
||||
title={title}
|
||||
frameBorder="0"
|
||||
className="absolute left-0 top-0 h-full w-full"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen></iframe>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -44,6 +44,7 @@ export const navigation: Array<NavGroup> = [
|
||||
{ title: "Limit submissions", href: "/global/limit-submissions" }, // global
|
||||
{ title: "Recall Functionality", href: "/global/recall" }, // global
|
||||
{ title: "Partial Submissions", href: "/global/partial-submissions" }, // global
|
||||
{ title: "Shareable Dashboards", href: "/global/shareable-dashboards" },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -68,6 +69,7 @@ export const navigation: Array<NavGroup> = [
|
||||
{ title: "Limit submissions", href: "/global/limit-submissions" }, // global
|
||||
{ title: "Recall Functionality", href: "/global/recall" }, // global
|
||||
{ title: "Partial Submissions", href: "/global/partial-submissions" }, // global
|
||||
{ title: "Shareable Dashboards", href: "/global/shareable-dashboards" },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -91,6 +93,7 @@ export const navigation: Array<NavGroup> = [
|
||||
{ title: "User Metadata", href: "/global/metadata" },
|
||||
{ title: "Custom Styling", href: "/global/overwrite-styling" }, // global
|
||||
{ title: "Conditional Logic", href: "/global/conditional-logic" },
|
||||
{ title: "Shareable Dashboards", href: "/global/shareable-dashboards" },
|
||||
{ title: "Start & End Dates", href: "/global/custom-start-end-conditions" },
|
||||
{ title: "Limit submissions", href: "/global/limit-submissions" }, // global
|
||||
{ title: "Recall Functionality", href: "/global/recall" },
|
||||
@@ -106,6 +109,7 @@ export const navigation: Array<NavGroup> = [
|
||||
links: [
|
||||
{ title: "Access Roles", href: "/global/access-roles" },
|
||||
{ title: "Styling Theme", href: "/global/styling-theme" },
|
||||
{ title: "Logic Editor", href: "/global/logic-editor" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -12,215 +12,395 @@ export const XMSurveyDefault: TXMTemplate = {
|
||||
},
|
||||
};
|
||||
|
||||
const NPSSurvey: TXMTemplate = {
|
||||
...XMSurveyDefault,
|
||||
name: "NPS Survey",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: { default: "How likely are you to recommend {{productName}} to a friend or colleague?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Not at all likely" },
|
||||
upperLabel: { default: "Extremely likely" },
|
||||
isColorCodingEnabled: true,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "To help us improve, can you describe the reason(s) for your rating?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Any other comments, feedback, or concerns?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const StarRatingSurvey: TXMTemplate = {
|
||||
...XMSurveyDefault,
|
||||
name: "{{productName}}'s Rating Survey",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [{ value: 3, condition: "lessEqual", destination: "tk9wpw2gxgb8fa6pbpp3qq5l" }],
|
||||
range: 5,
|
||||
scale: "number",
|
||||
headline: { default: "How do you like {{productName}}?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Extremely dissatisfied" },
|
||||
upperLabel: { default: "Extremely satisfied" },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
html: { default: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>' },
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
logic: [{ condition: "clicked", destination: XMSurveyDefault.endings[0].id }],
|
||||
headline: { default: "Happy to hear 🙏 Please write a review for us!" },
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonLabel: { default: "Write review" },
|
||||
buttonExternal: true,
|
||||
},
|
||||
{
|
||||
id: "tk9wpw2gxgb8fa6pbpp3qq5l",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Sorry to hear! What is ONE thing we can do better?" },
|
||||
required: true,
|
||||
subheader: { default: "Help us improve your experience." },
|
||||
buttonLabel: { default: "Send" },
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const CSATSurvey: TXMTemplate = {
|
||||
...XMSurveyDefault,
|
||||
name: "{{productName}} CSAT",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [{ value: 3, condition: "lessEqual", destination: "vyo4mkw4ln95ts4ya7qp2tth" }],
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
headline: { default: "How satisfied are you with your {{productName}} experience?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Extremely dissatisfied" },
|
||||
upperLabel: { default: "Extremely satisfied" },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [{ condition: "submitted", destination: XMSurveyDefault.endings[0].id }],
|
||||
headline: { default: "Lovely! Is there anything we can do to improve your experience?" },
|
||||
required: false,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: "vyo4mkw4ln95ts4ya7qp2tth",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Ugh, sorry! Is there anything we can do to improve your experience?" },
|
||||
required: false,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const CESSurvey: TXMTemplate = {
|
||||
...XMSurveyDefault,
|
||||
name: "CES Survey",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
range: 5,
|
||||
scale: "number",
|
||||
headline: { default: "{{productName}} makes it easy for me to [ADD GOAL]" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Disagree strongly" },
|
||||
upperLabel: { default: "Agree strongly" },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Thanks! How could we make it easier for you to [ADD GOAL]?" },
|
||||
required: true,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const SmileysRatingSurvey: TXMTemplate = {
|
||||
...XMSurveyDefault,
|
||||
name: "Smileys Survey",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [{ value: 3, condition: "lessEqual", destination: "tk9wpw2gxgb8fa6pbpp3qq5l" }],
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
headline: { default: "How do you like {{productName}}?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Not good" },
|
||||
upperLabel: { default: "Very satisfied" },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
html: { default: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>' },
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
logic: [{ condition: "clicked", destination: XMSurveyDefault.endings[0].id }],
|
||||
headline: { default: "Happy to hear 🙏 Please write a review for us!" },
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonLabel: { default: "Write review" },
|
||||
buttonExternal: true,
|
||||
},
|
||||
{
|
||||
id: "tk9wpw2gxgb8fa6pbpp3qq5l",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Sorry to hear! What is ONE thing we can do better?" },
|
||||
required: true,
|
||||
subheader: { default: "Help us improve your experience." },
|
||||
buttonLabel: { default: "Send" },
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eNPSSurvey: TXMTemplate = {
|
||||
...XMSurveyDefault,
|
||||
name: "eNPS Survey",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: {
|
||||
default: "How likely are you to recommend working at this company to a friend or colleague?",
|
||||
const NPSSurvey = (): TXMTemplate => {
|
||||
return {
|
||||
...XMSurveyDefault,
|
||||
name: "NPS Survey",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: { default: "How likely are you to recommend {{productName}} to a friend or colleague?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Not at all likely" },
|
||||
upperLabel: { default: "Extremely likely" },
|
||||
isColorCodingEnabled: true,
|
||||
},
|
||||
required: false,
|
||||
lowerLabel: { default: "Not at all likely" },
|
||||
upperLabel: { default: "Extremely likely" },
|
||||
isColorCodingEnabled: true,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "To help us improve, can you describe the reason(s) for your rating?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Any other comments, feedback, or concerns?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "To help us improve, can you describe the reason(s) for your rating?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Any other comments, feedback, or concerns?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const StarRatingSurvey = (): TXMTemplate => {
|
||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
||||
|
||||
return {
|
||||
...XMSurveyDefault,
|
||||
name: "{{productName}}'s Rating Survey",
|
||||
questions: [
|
||||
{
|
||||
id: reusableQuestionIds[0],
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[0],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isLessThanOrEqual",
|
||||
rightOperand: {
|
||||
type: "static",
|
||||
value: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: reusableQuestionIds[2],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
range: 5,
|
||||
scale: "number",
|
||||
headline: { default: "How do you like {{productName}}?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Extremely dissatisfied" },
|
||||
upperLabel: { default: "Extremely satisfied" },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[1],
|
||||
html: { default: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>' },
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[1],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isClicked",
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: XMSurveyDefault.endings[0].id,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
headline: { default: "Happy to hear 🙏 Please write a review for us!" },
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonLabel: { default: "Write review" },
|
||||
buttonExternal: true,
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[2],
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Sorry to hear! What is ONE thing we can do better?" },
|
||||
required: true,
|
||||
subheader: { default: "Help us improve your experience." },
|
||||
buttonLabel: { default: "Send" },
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const CSATSurvey = (): TXMTemplate => {
|
||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
||||
|
||||
return {
|
||||
...XMSurveyDefault,
|
||||
name: "{{productName}} CSAT",
|
||||
questions: [
|
||||
{
|
||||
id: reusableQuestionIds[0],
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[0],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isLessThanOrEqual",
|
||||
rightOperand: {
|
||||
type: "static",
|
||||
value: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: reusableQuestionIds[2],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
headline: { default: "How satisfied are you with your {{productName}} experience?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Extremely dissatisfied" },
|
||||
upperLabel: { default: "Extremely satisfied" },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[1],
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[1],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isSubmitted",
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: XMSurveyDefault.endings[0].id,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
headline: { default: "Lovely! Is there anything we can do to improve your experience?" },
|
||||
required: false,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[2],
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Ugh, sorry! Is there anything we can do to improve your experience?" },
|
||||
required: false,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const CESSurvey = (): TXMTemplate => {
|
||||
return {
|
||||
...XMSurveyDefault,
|
||||
name: "CES Survey",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
range: 5,
|
||||
scale: "number",
|
||||
headline: { default: "{{productName}} makes it easy for me to [ADD GOAL]" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Disagree strongly" },
|
||||
upperLabel: { default: "Agree strongly" },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Thanks! How could we make it easier for you to [ADD GOAL]?" },
|
||||
required: true,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const SmileysRatingSurvey = (): TXMTemplate => {
|
||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
||||
|
||||
return {
|
||||
...XMSurveyDefault,
|
||||
name: "Smileys Survey",
|
||||
questions: [
|
||||
{
|
||||
id: reusableQuestionIds[0],
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[0],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isLessThanOrEqual",
|
||||
rightOperand: {
|
||||
type: "static",
|
||||
value: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: reusableQuestionIds[2],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
headline: { default: "How do you like {{productName}}?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Not good" },
|
||||
upperLabel: { default: "Very satisfied" },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[1],
|
||||
html: { default: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>' },
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[1],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isClicked",
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: XMSurveyDefault.endings[0].id,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
headline: { default: "Happy to hear 🙏 Please write a review for us!" },
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonLabel: { default: "Write review" },
|
||||
buttonExternal: true,
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[2],
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Sorry to hear! What is ONE thing we can do better?" },
|
||||
required: true,
|
||||
subheader: { default: "Help us improve your experience." },
|
||||
buttonLabel: { default: "Send" },
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const eNPSSurvey = (): TXMTemplate => {
|
||||
return {
|
||||
...XMSurveyDefault,
|
||||
name: "eNPS Survey",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: {
|
||||
default: "How likely are you to recommend working at this company to a friend or colleague?",
|
||||
},
|
||||
required: false,
|
||||
lowerLabel: { default: "Not at all likely" },
|
||||
upperLabel: { default: "Extremely likely" },
|
||||
isColorCodingEnabled: true,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "To help us improve, can you describe the reason(s) for your rating?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Any other comments, feedback, or concerns?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const XMTemplates: TXMTemplate[] = [
|
||||
NPSSurvey,
|
||||
StarRatingSurvey,
|
||||
CSATSurvey,
|
||||
CESSurvey,
|
||||
SmileysRatingSurvey,
|
||||
eNPSSurvey,
|
||||
NPSSurvey(),
|
||||
StarRatingSurvey(),
|
||||
CSATSurvey(),
|
||||
CESSurvey(),
|
||||
SmileysRatingSurvey(),
|
||||
eNPSSurvey(),
|
||||
];
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { FORMBRICKS_PRODUCT_ID_LS, FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@formbricks/lib/localStorage";
|
||||
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@formbricks/lib/localStorage";
|
||||
import { PREVIEW_SURVEY } from "@formbricks/lib/styling/constants";
|
||||
import {
|
||||
TProductConfigChannel,
|
||||
@@ -64,8 +64,6 @@ export const ProductSettings = ({
|
||||
);
|
||||
if (productionEnvironment) {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(FORMBRICKS_PRODUCT_ID_LS, productionEnvironment.productId);
|
||||
|
||||
// Rmove filters when creating a new product
|
||||
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||
}
|
||||
|
||||
@@ -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,199 @@
|
||||
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 {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CopyIcon,
|
||||
EllipsisVerticalIcon,
|
||||
PlusIcon,
|
||||
SplitIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo } 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/components/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/components/DropdownMenu";
|
||||
import { Label } from "@formbricks/ui/components/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 transformedSurvey = useMemo(() => {
|
||||
let modifiedSurvey = replaceHeadlineRecall(localSurvey, "default", attributeClasses);
|
||||
modifiedSurvey = replaceEndingCardHeadlineRecall(modifiedSurvey, "default", attributeClasses);
|
||||
|
||||
return modifiedSurvey;
|
||||
}, [localSurvey, attributeClasses]);
|
||||
|
||||
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: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: [...(question?.logic ?? []), initialCondition],
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveLogic = (logicItemIdx: number) => {
|
||||
const logicCopy = structuredClone(question.logic ?? []);
|
||||
logicCopy.splice(logicItemIdx, 1);
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const moveLogic = (from: number, to: number) => {
|
||||
const logicCopy = structuredClone(question.logic ?? []);
|
||||
const [movedItem] = logicCopy.splice(from, 1);
|
||||
logicCopy.splice(to, 0, movedItem);
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const duplicateLogic = (logicItemIdx: number) => {
|
||||
const logicCopy = structuredClone(question.logic ?? []);
|
||||
const logicItem = logicCopy[logicItemIdx];
|
||||
const newLogicItem = duplicateLogicItem(logicItem);
|
||||
logicCopy.splice(logicItemIdx + 1, 0, newLogicItem);
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<Label className="flex gap-2">
|
||||
Conditional Logic
|
||||
<SplitIcon className="h-4 w-4 rotate-90" />
|
||||
</Label>
|
||||
|
||||
{question.logic && question.logic.length > 0 && (
|
||||
<div className="mt-2 flex flex-col gap-4">
|
||||
{question.logic.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={updateQuestion}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
logicIdx={logicItemIdx}
|
||||
isLast={logicItemIdx === (question.logic ?? []).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 === (question.logic ?? []).length - 1}
|
||||
onClick={() => {
|
||||
moveLogic(logicItemIdx, logicItemIdx + 1);
|
||||
}}>
|
||||
<ArrowDownIcon className="h-4 w-4" />
|
||||
Move down
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
handleRemoveLogic(logicItemIdx);
|
||||
}}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<Button
|
||||
id="logicJumps"
|
||||
className="bg-slate-100 hover:bg-slate-50"
|
||||
type="button"
|
||||
name="logicJumps"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
EndIcon={PlusIcon}
|
||||
onClick={addLogic}>
|
||||
Add logic
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { EditorCardMenu } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditorCardMenu";
|
||||
import { EndScreenForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EndScreenForm";
|
||||
import { RedirectUrlForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RedirectUrlForm";
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util";
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyRedirectUrlCard,
|
||||
ZSurveyQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { ConfirmationModal } from "@formbricks/ui/components/ConfirmationModal";
|
||||
import {
|
||||
@@ -60,9 +59,13 @@ export const EditorCardMenu = ({
|
||||
isCxMode = false,
|
||||
}: EditorCardMenuProps) => {
|
||||
const [logicWarningModal, setLogicWarningModal] = useState(false);
|
||||
const [changeToType, setChangeToType] = useState(
|
||||
card.type !== "endScreen" && card.type !== "redirectToUrl" ? card.type : undefined
|
||||
);
|
||||
const [changeToType, setChangeToType] = useState(() => {
|
||||
if (card.type !== "endScreen" && card.type !== "redirectToUrl") {
|
||||
return card.type;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
const isDeleteDisabled =
|
||||
cardType === "question"
|
||||
? survey.questions.length === 1
|
||||
@@ -71,65 +74,57 @@ export const EditorCardMenu = ({
|
||||
const availableQuestionTypes = isCxMode ? CX_QUESTIONS_NAME_MAP : QUESTIONS_NAME_MAP;
|
||||
|
||||
const changeQuestionType = (type?: TSurveyQuestionTypeEnum) => {
|
||||
const parseResult = ZSurveyQuestion.safeParse(card);
|
||||
if (parseResult.success && type) {
|
||||
const question = parseResult.data;
|
||||
const { headline, required, subheader, imageUrl, videoUrl, buttonLabel, backButtonLabel } = question;
|
||||
if (!type) return;
|
||||
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
const { headline, required, subheader, imageUrl, videoUrl, buttonLabel, backButtonLabel } =
|
||||
card as TSurveyQuestion;
|
||||
|
||||
// if going from single select to multi select or vice versa, we need to keep the choices as well
|
||||
|
||||
if (
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceSingle &&
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) ||
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle)
|
||||
) {
|
||||
updateCard(cardIdx, {
|
||||
choices: question.choices,
|
||||
type,
|
||||
logic: undefined,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
|
||||
if (
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceSingle &&
|
||||
card.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) ||
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
|
||||
card.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle)
|
||||
) {
|
||||
updateCard(cardIdx, {
|
||||
...questionDefaults,
|
||||
choices: card.choices,
|
||||
type,
|
||||
headline,
|
||||
subheader,
|
||||
required,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
logic: undefined,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
updateCard(cardIdx, {
|
||||
...questionDefaults,
|
||||
type,
|
||||
headline,
|
||||
subheader,
|
||||
required,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
logic: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const addQuestionCardBelow = (type: TSurveyQuestionTypeEnum) => {
|
||||
const parseResult = ZSurveyQuestion.safeParse(card);
|
||||
if (parseResult.success) {
|
||||
const question = parseResult.data;
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
|
||||
addCard(
|
||||
{
|
||||
...questionDefaults,
|
||||
type,
|
||||
id: createId(),
|
||||
required: true,
|
||||
},
|
||||
cardIdx + 1
|
||||
);
|
||||
addCard(
|
||||
{
|
||||
...questionDefaults,
|
||||
type,
|
||||
id: createId(),
|
||||
required: true,
|
||||
},
|
||||
cardIdx + 1
|
||||
);
|
||||
|
||||
// scroll to the new question
|
||||
const section = document.getElementById(`${question.id}`);
|
||||
section?.scrollIntoView({ behavior: "smooth", block: "end", inline: "end" });
|
||||
}
|
||||
const section = document.getElementById(`${card.id}`);
|
||||
section?.scrollIntoView({ behavior: "smooth", block: "end", inline: "end" });
|
||||
};
|
||||
|
||||
const addEndingCardBelow = () => {
|
||||
@@ -177,28 +172,24 @@ export const EditorCardMenu = ({
|
||||
|
||||
<DropdownMenuSubContent className="ml-2 border border-slate-200 text-slate-600 hover:text-slate-700">
|
||||
{Object.entries(availableQuestionTypes).map(([type, name]) => {
|
||||
const parsedResult = ZSurveyQuestion.safeParse(card);
|
||||
if (parsedResult.success) {
|
||||
const question = parsedResult.data;
|
||||
if (type === question.type) return null;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
className="min-h-8 cursor-pointer"
|
||||
onClick={() => {
|
||||
setChangeToType(type as TSurveyQuestionTypeEnum);
|
||||
if (question.logic) {
|
||||
setLogicWarningModal(true);
|
||||
return;
|
||||
}
|
||||
if (type === card.type) return null;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
className="min-h-8 cursor-pointer"
|
||||
onClick={() => {
|
||||
setChangeToType(type as TSurveyQuestionTypeEnum);
|
||||
if ((card as TSurveyQuestion).logic) {
|
||||
setLogicWarningModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
changeQuestionType(type as TSurveyQuestionTypeEnum);
|
||||
}}>
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
|
||||
<span className="ml-2">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
changeQuestionType(type as TSurveyQuestionTypeEnum);
|
||||
}}>
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
|
||||
<span className="ml-2">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { findHiddenFieldUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { EyeOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -65,6 +66,25 @@ export const HiddenFieldsCard = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteHiddenField = (fieldId: string) => {
|
||||
const quesIdx = findHiddenFieldUsedInLogic(localSurvey, fieldId);
|
||||
|
||||
if (quesIdx !== -1) {
|
||||
toast.error(
|
||||
`${fieldId} is used in logic of question ${quesIdx + 1}. Please remove it from logic first.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
updateSurvey(
|
||||
{
|
||||
enabled: true,
|
||||
fieldIds: localSurvey.hiddenFields?.fieldIds?.filter((q) => q !== fieldId),
|
||||
},
|
||||
fieldId
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(open ? "shadow-lg" : "shadow-md", "group z-10 flex flex-row rounded-lg bg-white")}>
|
||||
<div
|
||||
@@ -111,15 +131,7 @@ export const HiddenFieldsCard = ({
|
||||
return (
|
||||
<Tag
|
||||
key={fieldId}
|
||||
onDelete={() => {
|
||||
updateSurvey(
|
||||
{
|
||||
enabled: true,
|
||||
fieldIds: localSurvey.hiddenFields?.fieldIds?.filter((q) => q !== fieldId),
|
||||
},
|
||||
fieldId
|
||||
);
|
||||
}}
|
||||
onDelete={(fieldId) => handleDeleteHiddenField(fieldId)}
|
||||
tagId={fieldId}
|
||||
tagName={fieldId}
|
||||
/>
|
||||
|
||||
@@ -1,483 +1,51 @@
|
||||
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/components/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/components/DropdownMenu";
|
||||
import { Input } from "@formbricks/ui/components/Input";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@formbricks/ui/components/Select";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/components/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;
|
||||
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,
|
||||
logicItem,
|
||||
updateQuestion,
|
||||
question,
|
||||
questionIdx,
|
||||
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" />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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}
|
||||
question={question}
|
||||
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,236 @@
|
||||
import {
|
||||
actionObjectiveOptions,
|
||||
getActionOperatorOptions,
|
||||
getActionTargetOptions,
|
||||
getActionValueOptions,
|
||||
getActionVariableOptions,
|
||||
} from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { CopyIcon, CornerDownRightIcon, EllipsisVerticalIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { getUpdatedActionBody } from "@formbricks/lib/surveyLogic/utils";
|
||||
import {
|
||||
TActionNumberVariableCalculateOperator,
|
||||
TActionObjective,
|
||||
TActionTextVariableCalculateOperator,
|
||||
TActionVariableValueType,
|
||||
TSurvey,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicAction,
|
||||
TSurveyQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/components/DropdownMenu";
|
||||
import { InputCombobox } from "@formbricks/ui/components/InputCombobox";
|
||||
|
||||
interface LogicEditorActions {
|
||||
localSurvey: TSurvey;
|
||||
logicItem: TSurveyLogic;
|
||||
logicIdx: number;
|
||||
question: TSurveyQuestion;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
questionIdx: number;
|
||||
}
|
||||
|
||||
export function LogicEditorActions({
|
||||
localSurvey,
|
||||
logicItem,
|
||||
logicIdx,
|
||||
question,
|
||||
updateQuestion,
|
||||
questionIdx,
|
||||
}: LogicEditorActions) {
|
||||
const actions = logicItem.actions;
|
||||
|
||||
const handleActionsChange = (
|
||||
operation: "remove" | "addBelow" | "duplicate" | "update",
|
||||
actionIdx: number,
|
||||
action?: TSurveyLogicAction
|
||||
) => {
|
||||
const logicCopy = structuredClone(question.logic) ?? [];
|
||||
const currentLogicItem = logicCopy[logicIdx];
|
||||
const actionsClone = currentLogicItem.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;
|
||||
}
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleObjectiveChange = (actionIdx: number, objective: TActionObjective) => {
|
||||
const action = actions[actionIdx];
|
||||
const actionBody = getUpdatedActionBody(action, objective);
|
||||
handleActionsChange("update", actionIdx, actionBody);
|
||||
};
|
||||
|
||||
const handleValuesChange = (actionIdx: number, values: Partial<TSurveyLogicAction>) => {
|
||||
const action = actions[actionIdx];
|
||||
const actionBody = { ...action, ...values } as TSurveyLogicAction;
|
||||
handleActionsChange("update", actionIdx, actionBody);
|
||||
};
|
||||
|
||||
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) => (
|
||||
<div key={action.id} className="flex grow items-center justify-between gap-x-2">
|
||||
<div className="block w-9 shrink-0">{idx === 0 ? "Then" : "and"}</div>
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<InputCombobox
|
||||
id={`action-${idx}-objective`}
|
||||
key={`objective-${action.id}`}
|
||||
showSearch={false}
|
||||
options={actionObjectiveOptions}
|
||||
value={action.objective}
|
||||
onChangeValue={(val: TActionObjective) => {
|
||||
handleObjectiveChange(idx, val);
|
||||
}}
|
||||
comboboxClasses="grow"
|
||||
/>
|
||||
{action.objective !== "calculate" && (
|
||||
<InputCombobox
|
||||
id={`action-${idx}-target`}
|
||||
key={`target-${action.id}`}
|
||||
showSearch={false}
|
||||
options={getActionTargetOptions(action, localSurvey, questionIdx)}
|
||||
value={action.target}
|
||||
onChangeValue={(val: string) => {
|
||||
handleValuesChange(idx, {
|
||||
target: val,
|
||||
});
|
||||
}}
|
||||
comboboxClasses="grow"
|
||||
/>
|
||||
)}
|
||||
{action.objective === "calculate" && (
|
||||
<>
|
||||
<InputCombobox
|
||||
id={`action-${idx}-variableId`}
|
||||
key={`variableId-${action.id}`}
|
||||
showSearch={false}
|
||||
options={getActionVariableOptions(localSurvey)}
|
||||
value={action.variableId}
|
||||
onChangeValue={(val: string) => {
|
||||
handleValuesChange(idx, {
|
||||
variableId: val,
|
||||
value: {
|
||||
type: "static",
|
||||
value: "",
|
||||
},
|
||||
});
|
||||
}}
|
||||
comboboxClasses="grow"
|
||||
emptyDropdownText="Add a variable to calculate"
|
||||
/>
|
||||
<InputCombobox
|
||||
id={`action-${idx}-operator`}
|
||||
key={`operator-${action.id}`}
|
||||
showSearch={false}
|
||||
options={getActionOperatorOptions(
|
||||
localSurvey.variables.find((v) => v.id === action.variableId)?.type
|
||||
)}
|
||||
value={action.operator}
|
||||
onChangeValue={(
|
||||
val: TActionTextVariableCalculateOperator | TActionNumberVariableCalculateOperator
|
||||
) => {
|
||||
handleValuesChange(idx, {
|
||||
operator: val,
|
||||
});
|
||||
}}
|
||||
comboboxClasses="grow"
|
||||
/>
|
||||
<InputCombobox
|
||||
id={`action-${idx}-value`}
|
||||
key={`value-${action.id}`}
|
||||
withInput={true}
|
||||
clearable={true}
|
||||
value={action.value?.value ?? ""}
|
||||
inputProps={{
|
||||
placeholder: "Value",
|
||||
type: localSurvey.variables.find((v) => v.id === action.variableId)?.type || "text",
|
||||
}}
|
||||
groupedOptions={getActionValueOptions(action.variableId, localSurvey)}
|
||||
onChangeValue={(val, option, fromInput) => {
|
||||
const fieldType = option?.meta?.type as TActionVariableValueType;
|
||||
|
||||
if (!fromInput && fieldType !== "static") {
|
||||
handleValuesChange(idx, {
|
||||
value: {
|
||||
type: fieldType,
|
||||
value: val as string,
|
||||
},
|
||||
});
|
||||
} else if (fromInput) {
|
||||
handleValuesChange(idx, {
|
||||
value: {
|
||||
type: "static",
|
||||
value: val as string,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
comboboxClasses="grow shrink-0"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger id={`actions-${idx}-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", idx);
|
||||
}}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add action below
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
disabled={actions.length === 1}
|
||||
onClick={() => {
|
||||
handleActionsChange("remove", idx);
|
||||
}}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
handleActionsChange("duplicate", idx);
|
||||
}}>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</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/components/DropdownMenu";
|
||||
import { InputCombobox, TComboboxOption } from "@formbricks/ui/components/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 zero 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,
|
||||
@@ -74,8 +76,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) => {
|
||||
@@ -84,19 +84,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) => {
|
||||
@@ -138,23 +128,27 @@ export const MultipleChoiceQuestionForm = ({
|
||||
};
|
||||
|
||||
const deleteChoice = (choiceIdx: number) => {
|
||||
const choiceToDelete = question.choices[choiceIdx].id;
|
||||
|
||||
if (choiceToDelete !== "other") {
|
||||
const questionIdx = findOptionUsedInLogic(localSurvey, question.id, choiceToDelete);
|
||||
if (questionIdx !== -1) {
|
||||
toast.error(
|
||||
`This option is used in logic for question ${questionIdx + 1}. Please fix the logic first before deleting.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newChoices = !question.choices ? [] : question.choices.filter((_, idx) => idx !== choiceIdx);
|
||||
const choiceValue = question.choices[choiceIdx].label[selectedLanguageCode];
|
||||
if (isInvalidValue === choiceValue) {
|
||||
setisInvalidValue(null);
|
||||
}
|
||||
let newLogic: any[] = [];
|
||||
question.logic?.forEach((logic) => {
|
||||
let newL: string | string[] | undefined = logic.value;
|
||||
if (Array.isArray(logic.value)) {
|
||||
newL = logic.value.filter((value) => value !== choiceValue);
|
||||
} else {
|
||||
newL = logic.value !== choiceValue ? logic.value : undefined;
|
||||
}
|
||||
newLogic.push({ ...logic, value: newL });
|
||||
});
|
||||
|
||||
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
|
||||
updateQuestion(questionIdx, {
|
||||
choices: newChoices,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -39,22 +39,10 @@ export const PictureSelectionForm = ({
|
||||
// Filter out the deleted choice from the choices array
|
||||
const newChoices = question.choices?.filter((choice) => choice.id !== choiceValue) || [];
|
||||
|
||||
// Update the logic, removing the deleted choice value
|
||||
const newLogic =
|
||||
question.logic?.map((logic) => {
|
||||
let updatedValue = logic.value;
|
||||
|
||||
if (Array.isArray(logic.value)) {
|
||||
updatedValue = logic.value.filter((value) => value !== choiceValue);
|
||||
} else if (logic.value === choiceValue) {
|
||||
updatedValue = undefined;
|
||||
}
|
||||
|
||||
return { ...logic, value: updatedValue };
|
||||
}) || [];
|
||||
|
||||
// Update the question with new choices and logic
|
||||
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
|
||||
updateQuestion(questionIdx, {
|
||||
choices: newChoices,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileInputChanges = (urls: string[]) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { RankingQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm";
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util";
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { AddEndingCardButton } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddEndingCardButton";
|
||||
import { SurveyVariablesCard } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCard";
|
||||
import { findQuestionUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
@@ -16,11 +18,18 @@ import toast from "react-hot-toast";
|
||||
import { MultiLanguageCard } from "@formbricks/ee/multi-language/components/multi-language-card";
|
||||
import { addMultiLanguageLabels, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { isConditionGroup } from "@formbricks/lib/surveyLogic/utils";
|
||||
import { getDefaultEndingCard } from "@formbricks/lib/templates";
|
||||
import { checkForEmptyFallBackValue, extractRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import {
|
||||
TConditionGroup,
|
||||
TSingleCondition,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicAction,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { findQuestionsWithCyclicLogic } from "@formbricks/types/surveys/validation";
|
||||
import {
|
||||
@@ -77,22 +86,75 @@ export const QuestionsView = ({
|
||||
|
||||
const surveyLanguages = localSurvey.languages;
|
||||
const [backButtonLabel, setbackButtonLabel] = useState(null);
|
||||
|
||||
const handleQuestionLogicChange = (survey: TSurvey, compareId: string, updatedId: string): TSurvey => {
|
||||
survey.questions.forEach((question) => {
|
||||
if (question.headline[selectedLanguageCode].includes(`recall:${compareId}`)) {
|
||||
question.headline[selectedLanguageCode] = question.headline[selectedLanguageCode].replaceAll(
|
||||
`recall:${compareId}`,
|
||||
`recall:${updatedId}`
|
||||
);
|
||||
const updateConditions = (conditions: TConditionGroup): TConditionGroup => {
|
||||
return {
|
||||
...conditions,
|
||||
conditions: conditions?.conditions.map((condition) => {
|
||||
if (isConditionGroup(condition)) {
|
||||
return updateConditions(condition);
|
||||
} else {
|
||||
return updateSingleCondition(condition);
|
||||
}
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const updateSingleCondition = (condition: TSingleCondition): TSingleCondition => {
|
||||
let updatedCondition = { ...condition };
|
||||
|
||||
if (condition.leftOperand.value === compareId) {
|
||||
updatedCondition.leftOperand = { ...condition.leftOperand, value: updatedId };
|
||||
}
|
||||
if (!question.logic) return;
|
||||
question.logic.forEach((rule) => {
|
||||
if (rule.destination === compareId) {
|
||||
rule.destination = updatedId;
|
||||
|
||||
if (condition.rightOperand?.type === "question" && condition.rightOperand?.value === compareId) {
|
||||
updatedCondition.rightOperand = { ...condition.rightOperand, value: updatedId };
|
||||
}
|
||||
|
||||
return updatedCondition;
|
||||
};
|
||||
|
||||
const updateActions = (actions: TSurveyLogicAction[]): TSurveyLogicAction[] => {
|
||||
return actions.map((action) => {
|
||||
let updatedAction = { ...action };
|
||||
|
||||
if (updatedAction.objective === "jumpToQuestion" && updatedAction.target === compareId) {
|
||||
updatedAction.target = updatedId;
|
||||
}
|
||||
|
||||
if (updatedAction.objective === "requireAnswer" && updatedAction.target === compareId) {
|
||||
updatedAction.target = updatedId;
|
||||
}
|
||||
|
||||
return updatedAction;
|
||||
});
|
||||
});
|
||||
return survey;
|
||||
};
|
||||
|
||||
return {
|
||||
...survey,
|
||||
questions: survey.questions.map((question) => {
|
||||
let updatedQuestion = { ...question };
|
||||
|
||||
if (question.headline[selectedLanguageCode].includes(`recall:${compareId}`)) {
|
||||
question.headline[selectedLanguageCode] = question.headline[selectedLanguageCode].replaceAll(
|
||||
`recall:${compareId}`,
|
||||
`recall:${updatedId}`
|
||||
);
|
||||
}
|
||||
|
||||
// Update advanced logic
|
||||
if (question.logic) {
|
||||
updatedQuestion.logic = question.logic.map((logicRule: TSurveyLogic) => ({
|
||||
...logicRule,
|
||||
conditions: updateConditions(logicRule.conditions),
|
||||
actions: updateActions(logicRule.actions),
|
||||
}));
|
||||
}
|
||||
|
||||
return updatedQuestion;
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -211,6 +273,14 @@ export const QuestionsView = ({
|
||||
const activeQuestionIdTemp = activeQuestionId ?? localSurvey.questions[0].id;
|
||||
let updatedSurvey: TSurvey = { ...localSurvey };
|
||||
|
||||
// checking if this question is used in logic of any other question
|
||||
const quesIdx = findQuestionUsedInLogic(localSurvey, questionId);
|
||||
|
||||
if (quesIdx !== -1) {
|
||||
toast.error(`This question is used in logic of question ${quesIdx + 1}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// check if we are recalling from this question for every language
|
||||
updatedSurvey.questions.forEach((question) => {
|
||||
for (const [languageCode, headline] of Object.entries(question.headline)) {
|
||||
@@ -223,7 +293,7 @@ export const QuestionsView = ({
|
||||
}
|
||||
});
|
||||
updatedSurvey.questions.splice(questionIdx, 1);
|
||||
updatedSurvey = handleQuestionLogicChange(updatedSurvey, questionId, "");
|
||||
|
||||
const firstEndingCard = localSurvey.endings[0];
|
||||
setLocalSurvey(updatedSurvey);
|
||||
delete internalQuestionIdMap[questionId];
|
||||
@@ -448,12 +518,12 @@ export const QuestionsView = ({
|
||||
activeQuestionId={activeQuestionId}
|
||||
/>
|
||||
|
||||
{/* <SurveyVariablesCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
/> */}
|
||||
<SurveyVariablesCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
/>
|
||||
|
||||
<MultiLanguageCard
|
||||
localSurvey={localSurvey}
|
||||
|
||||
@@ -192,7 +192,9 @@ export const SurveyMenuBar = ({
|
||||
|
||||
toast.error(`${messageSplit} ${invalidLanguageLabels.join(", ")}`);
|
||||
} else {
|
||||
toast.error(currentError.message);
|
||||
toast.error(currentError.message, {
|
||||
className: "w-fit !max-w-md",
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { FileDigitIcon } from "lucide-react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { SurveyVariablesCardItem } from "./SurveyVariablesCardItem";
|
||||
@@ -37,7 +38,9 @@ export const SurveyVariablesCard = ({
|
||||
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
|
||||
)}>
|
||||
<p>🪣</p>
|
||||
<div className="flex w-full justify-center">
|
||||
<FileDigitIcon className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { findVariableUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { extractRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
import { TSurvey, TSurveyVariable } from "@formbricks/types/surveys/types";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
@@ -78,11 +80,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}`)) {
|
||||
@@ -216,7 +226,7 @@ export const SurveyVariablesCardItem = ({
|
||||
type="button"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => onVaribleDelete(variable)}>
|
||||
onClick={() => onVariableDelete(variable)}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
import { TSurveyQuestionTypeEnum, ZSurveyLogicConditionsOperator } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const logicRules = {
|
||||
question: {
|
||||
[`${TSurveyQuestionTypeEnum.OpenText}.text`]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "contains",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.contains,
|
||||
},
|
||||
{
|
||||
label: "does not contain",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotContain,
|
||||
},
|
||||
{
|
||||
label: "starts with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.startsWith,
|
||||
},
|
||||
{
|
||||
label: "does not start with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotStartWith,
|
||||
},
|
||||
{
|
||||
label: "ends with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.endsWith,
|
||||
},
|
||||
{
|
||||
label: "does not end with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEndWith,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[`${TSurveyQuestionTypeEnum.OpenText}.number`]: {
|
||||
options: [
|
||||
{
|
||||
label: "=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "!=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: ">",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThan,
|
||||
},
|
||||
{
|
||||
label: "<",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThan,
|
||||
},
|
||||
{
|
||||
label: ">=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "<=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "equals one of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equalsOneOf,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "includes all of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.includesAllOf,
|
||||
},
|
||||
{
|
||||
label: "includes one of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.includesOneOf,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.PictureSelection]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "includes all of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.includesAllOf,
|
||||
},
|
||||
{
|
||||
label: "includes one of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.includesOneOf,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Rating]: {
|
||||
options: [
|
||||
{
|
||||
label: "=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "!=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: ">",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThan,
|
||||
},
|
||||
{
|
||||
label: "<",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThan,
|
||||
},
|
||||
{
|
||||
label: ">=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "<=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.NPS]: {
|
||||
options: [
|
||||
{
|
||||
label: "=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "!=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: ">",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThan,
|
||||
},
|
||||
{
|
||||
label: "<",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThan,
|
||||
},
|
||||
{
|
||||
label: ">=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "<=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.CTA]: {
|
||||
options: [
|
||||
{
|
||||
label: "is clicked",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isClicked,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Consent]: {
|
||||
options: [
|
||||
{
|
||||
label: "is accepted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isAccepted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Date]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "is before",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isBefore,
|
||||
},
|
||||
{
|
||||
label: "is after",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isAfter,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.FileUpload]: {
|
||||
options: [
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Ranking]: {
|
||||
options: [
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Cal]: {
|
||||
options: [
|
||||
{
|
||||
label: "is booked",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isBooked,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Matrix]: {
|
||||
options: [
|
||||
{
|
||||
label: "is partially submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isPartiallySubmitted,
|
||||
},
|
||||
{
|
||||
label: "is completely submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isCompletelySubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Address]: {
|
||||
options: [
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
["variable.text"]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "contains",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.contains,
|
||||
},
|
||||
{
|
||||
label: "does not contain",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotContain,
|
||||
},
|
||||
{
|
||||
label: "starts with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.startsWith,
|
||||
},
|
||||
{
|
||||
label: "does not start with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotStartWith,
|
||||
},
|
||||
{
|
||||
label: "ends with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.endsWith,
|
||||
},
|
||||
{
|
||||
label: "does not end with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEndWith,
|
||||
},
|
||||
],
|
||||
},
|
||||
["variable.number"]: {
|
||||
options: [
|
||||
{
|
||||
label: "=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "!=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: ">",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThan,
|
||||
},
|
||||
{
|
||||
label: "<",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThan,
|
||||
},
|
||||
{
|
||||
label: ">=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "<=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThanOrEqual,
|
||||
},
|
||||
],
|
||||
},
|
||||
hiddenField: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "contains",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.contains,
|
||||
},
|
||||
{
|
||||
label: "does not contain",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotContain,
|
||||
},
|
||||
{
|
||||
label: "starts with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.startsWith,
|
||||
},
|
||||
{
|
||||
label: "does not start with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotStartWith,
|
||||
},
|
||||
{
|
||||
label: "ends with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.endsWith,
|
||||
},
|
||||
{
|
||||
label: "does not end with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEndWith,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export type TLogicRuleOption = (typeof logicRules.question)[keyof typeof logicRules.question]["options"];
|
||||
@@ -1,18 +0,0 @@
|
||||
// formats the text to highlight specific parts of the text with slashes
|
||||
export const formatTextWithSlashes = (text: string) => {
|
||||
const regex = /\/(.*?)\\/g;
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
// Check if the part was inside slashes
|
||||
if (index % 2 !== 0) {
|
||||
return (
|
||||
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-xs">
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return part;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -8,7 +8,7 @@ import type { TProduct, TProductConfigChannel, TProductConfigIndustry } from "@f
|
||||
import type { TTemplate, TTemplateRole } from "@formbricks/types/templates";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { PreviewSurvey } from "@formbricks/ui/components/PreviewSurvey";
|
||||
import { SearchBox } from "@formbricks/ui/components/SearchBox";
|
||||
import { SearchBar } from "@formbricks/ui/components/SearchBar";
|
||||
import { TemplateList } from "@formbricks/ui/components/TemplateList";
|
||||
import { minimalSurvey } from "../../lib/minimalSurvey";
|
||||
|
||||
@@ -39,14 +39,11 @@ export const TemplateContainerWithPreview = ({
|
||||
<div className="mb-3 ml-6 mt-6 flex flex-col items-center justify-between md:flex-row md:items-end">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Create a new survey</h1>
|
||||
<div className="px-6">
|
||||
<SearchBox
|
||||
autoFocus
|
||||
<SearchBar
|
||||
value={templateSearch ?? ""}
|
||||
onChange={(e) => setTemplateSearch(e.target.value)}
|
||||
onChange={setTemplateSearch}
|
||||
placeholder={"Search..."}
|
||||
className="block rounded-md border border-slate-100 bg-white shadow-sm focus:border-slate-500 focus:outline-none focus:ring-0 sm:text-sm md:w-auto"
|
||||
type="search"
|
||||
name="search"
|
||||
className="border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,8 @@ import { ZId } from "@formbricks/types/common";
|
||||
|
||||
const ZGetPersonsAction = z.object({
|
||||
environmentId: ZId,
|
||||
page: z.number(),
|
||||
offset: z.number().int().nonnegative(),
|
||||
searchValue: z.string().optional(),
|
||||
});
|
||||
|
||||
export const getPersonsAction = authenticatedActionClient
|
||||
@@ -22,7 +23,7 @@ export const getPersonsAction = authenticatedActionClient
|
||||
rules: ["environment", "read"],
|
||||
});
|
||||
|
||||
return getPeople(parsedInput.environmentId, parsedInput.page);
|
||||
return getPeople(parsedInput.environmentId, parsedInput.offset, parsedInput.searchValue);
|
||||
});
|
||||
|
||||
const ZGetPersonAttributesAction = z.object({
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { getPersonsAction } from "@/app/(app)/environments/[environmentId]/(people)/people/actions";
|
||||
import { PersonTable } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonTable";
|
||||
import { debounce } from "lodash";
|
||||
import { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -9,31 +10,33 @@ import { TPersonWithAttributes } from "@formbricks/types/people";
|
||||
|
||||
interface PersonDataViewProps {
|
||||
environment: TEnvironment;
|
||||
personCount: number;
|
||||
itemsPerPage: number;
|
||||
}
|
||||
|
||||
export const PersonDataView = ({ environment, personCount, itemsPerPage }: PersonDataViewProps) => {
|
||||
export const PersonDataView = ({ environment, 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);
|
||||
const [searchValue, setSearchValue] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
setTotalPersons(personCount);
|
||||
setHasMore(pageNumber < Math.ceil(personCount / itemsPerPage));
|
||||
|
||||
const fetchData = async () => {
|
||||
setIsDataLoaded(false);
|
||||
try {
|
||||
setHasMore(true);
|
||||
const getPersonActionData = await getPersonsAction({
|
||||
environmentId: environment.id,
|
||||
page: pageNumber,
|
||||
offset: 0,
|
||||
searchValue,
|
||||
});
|
||||
const personData = getPersonActionData?.data;
|
||||
if (getPersonActionData?.data) {
|
||||
setPersons(getPersonActionData.data);
|
||||
}
|
||||
if (personData && personData.length < itemsPerPage) {
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching people data:", error);
|
||||
} finally {
|
||||
@@ -41,23 +44,35 @@ export const PersonDataView = ({ environment, personCount, itemsPerPage }: Perso
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [pageNumber, personCount, itemsPerPage, environment.id]);
|
||||
const debouncedFetchData = debounce(fetchData, 300);
|
||||
debouncedFetchData();
|
||||
|
||||
return () => {
|
||||
debouncedFetchData.cancel();
|
||||
};
|
||||
}, [searchValue]);
|
||||
|
||||
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]);
|
||||
try {
|
||||
const getPersonsActionData = await getPersonsAction({
|
||||
environmentId: environment.id,
|
||||
offset: persons.length,
|
||||
searchValue,
|
||||
});
|
||||
const personData = getPersonsActionData?.data;
|
||||
if (personData) {
|
||||
setPersons((prevPersonsData) => [...prevPersonsData, ...personData]);
|
||||
if (personData.length === 0 || personData.length < itemsPerPage) {
|
||||
setHasMore(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching next page of people data:", error);
|
||||
} finally {
|
||||
setLoadingNextPage(false);
|
||||
}
|
||||
setPageNumber((prevPage) => prevPage + 1);
|
||||
setHasMore(pageNumber + 1 < Math.ceil(totalPersons / itemsPerPage));
|
||||
setLoadingNextPage(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,6 +97,8 @@ export const PersonDataView = ({ environment, personCount, itemsPerPage }: Perso
|
||||
isDataLoaded={isDataLoaded}
|
||||
deletePersons={deletePersons}
|
||||
environmentId={environment.id}
|
||||
searchValue={searchValue}
|
||||
setSearchValue={setSearchValue}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
DataTableToolbar,
|
||||
} from "@formbricks/ui/components/DataTable";
|
||||
import { getCommonPinningStyles } from "@formbricks/ui/components/DataTable/lib/utils";
|
||||
import { SearchBar } from "@formbricks/ui/components/SearchBar";
|
||||
import { Skeleton } from "@formbricks/ui/components/Skeleton";
|
||||
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@formbricks/ui/components/Table";
|
||||
|
||||
@@ -33,6 +34,8 @@ interface PersonTableProps {
|
||||
deletePersons: (personIds: string[]) => void;
|
||||
isDataLoaded: boolean;
|
||||
environmentId: string;
|
||||
searchValue: string;
|
||||
setSearchValue: (value: string) => void;
|
||||
}
|
||||
|
||||
export const PersonTable = ({
|
||||
@@ -42,6 +45,8 @@ export const PersonTable = ({
|
||||
deletePersons,
|
||||
isDataLoaded,
|
||||
environmentId,
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
}: PersonTableProps) => {
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||
@@ -50,7 +55,10 @@ export const PersonTable = ({
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const router = useRouter();
|
||||
// Generate columns
|
||||
const columns = useMemo(() => generatePersonTableColumns(isExpanded ?? false), [isExpanded]);
|
||||
const columns = useMemo(
|
||||
() => generatePersonTableColumns(isExpanded ?? false, searchValue),
|
||||
[isExpanded, searchValue]
|
||||
);
|
||||
|
||||
// Load saved settings from localStorage
|
||||
useEffect(() => {
|
||||
@@ -148,7 +156,8 @@ export const PersonTable = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full">
|
||||
<SearchBar value={searchValue} onChange={setSearchValue} placeholder="Search person" />
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToHorizontalAxis]}
|
||||
@@ -162,65 +171,63 @@ export const PersonTable = ({
|
||||
deleteRows={deletePersons}
|
||||
type="person"
|
||||
/>
|
||||
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-200">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Table style={{ width: table.getCenterTotalSize(), tableLayout: "fixed" }}>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DataTableHeader
|
||||
key={header.id}
|
||||
header={header}
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={"group cursor-pointer"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
onClick={() => {
|
||||
if (cell.column.id === "select") return;
|
||||
router.push(`/environments/${environmentId}/people/${row.id}`);
|
||||
}}
|
||||
style={cell.column.id === "select" ? getCommonPinningStyles(cell.column) : {}}
|
||||
className={cn(
|
||||
"border-slate-200 bg-white shadow-none group-hover:bg-slate-100",
|
||||
row.getIsSelected() && "bg-slate-100",
|
||||
{
|
||||
"border-r": !cell.column.getIsLastColumn(),
|
||||
"border-l": !cell.column.getIsFirstColumn(),
|
||||
}
|
||||
)}>
|
||||
<div
|
||||
className={cn("flex flex-1 items-center truncate", isExpanded ? "h-full" : "h-10")}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
</TableCell>
|
||||
<div className="w-full overflow-x-auto rounded-xl border border-slate-200">
|
||||
<Table className="w-full" style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DataTableHeader
|
||||
key={header.id}
|
||||
header={header}
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
/>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
{table.getRowModel().rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</SortableContext>
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={"group cursor-pointer"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
onClick={() => {
|
||||
if (cell.column.id === "select") return;
|
||||
router.push(`/environments/${environmentId}/people/${row.id}`);
|
||||
}}
|
||||
style={cell.column.id === "select" ? getCommonPinningStyles(cell.column) : {}}
|
||||
className={cn(
|
||||
"border-slate-200 bg-white shadow-none group-hover:bg-slate-100",
|
||||
row.getIsSelected() && "bg-slate-100",
|
||||
{
|
||||
"border-r": !cell.column.getIsLastColumn(),
|
||||
"border-l": !cell.column.getIsFirstColumn(),
|
||||
}
|
||||
)}>
|
||||
<div
|
||||
className={cn("flex flex-1 items-center truncate", isExpanded ? "h-full" : "h-10")}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
{table.getRowModel().rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{data && hasMore && data.length > 0 && (
|
||||
|
||||
@@ -4,12 +4,15 @@ import { ColumnDef } from "@tanstack/react-table";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TPersonTableData } from "@formbricks/types/people";
|
||||
import { getSelectionColumn } from "@formbricks/ui/components/DataTable";
|
||||
import { HighlightedText } from "@formbricks/ui/components/HighlightedText";
|
||||
|
||||
export const generatePersonTableColumns = (isExpanded: boolean): ColumnDef<TPersonTableData>[] => {
|
||||
export const generatePersonTableColumns = (
|
||||
isExpanded: boolean,
|
||||
searchValue: string
|
||||
): ColumnDef<TPersonTableData>[] => {
|
||||
const dateColumn: ColumnDef<TPersonTableData> = {
|
||||
accessorKey: "createdAt",
|
||||
header: () => "Date",
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const isoDateString = row.original.createdAt;
|
||||
const date = new Date(isoDateString);
|
||||
@@ -40,7 +43,7 @@ export const generatePersonTableColumns = (isExpanded: boolean): ColumnDef<TPers
|
||||
header: "User",
|
||||
cell: ({ row }) => {
|
||||
const personId = row.original.personId;
|
||||
return <p className="truncate text-slate-900">{personId}</p>;
|
||||
return <HighlightedText value={personId} searchValue={searchValue} />;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -49,13 +52,19 @@ export const generatePersonTableColumns = (isExpanded: boolean): ColumnDef<TPers
|
||||
header: "User ID",
|
||||
cell: ({ row }) => {
|
||||
const userId = row.original.userId;
|
||||
return <p className="truncate text-slate-900">{userId}</p>;
|
||||
return <HighlightedText value={userId} searchValue={searchValue} />;
|
||||
},
|
||||
};
|
||||
|
||||
const emailColumn: ColumnDef<TPersonTableData> = {
|
||||
accessorKey: "email",
|
||||
header: "Email",
|
||||
cell: ({ row }) => {
|
||||
const email = row.original.attributes.email;
|
||||
if (email) {
|
||||
return <HighlightedText value={email} searchValue={searchValue} />;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const attributesColumn: ColumnDef<TPersonTableData> = {
|
||||
@@ -71,7 +80,8 @@ export const generatePersonTableColumns = (isExpanded: boolean): ColumnDef<TPers
|
||||
<div className={cn(!isExpanded && "flex space-x-2")}>
|
||||
{Object.entries(attributes).map(([key, value]) => (
|
||||
<div key={key} className="flex space-x-2">
|
||||
<div className="font-semibold">{key}</div> : <div>{value}</div>
|
||||
<div className="font-semibold">{key}</div> :{" "}
|
||||
<HighlightedText value={value} searchValue={searchValue} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,14 +3,12 @@ import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environment
|
||||
import { CircleHelpIcon } from "lucide-react";
|
||||
import { ITEMS_PER_PAGE } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getPersonCount } from "@formbricks/lib/person/service";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/components/PageHeader";
|
||||
|
||||
const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
const personCount = await getPersonCount(params.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
@@ -32,7 +30,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
<PageHeader pageTitle="People" cta={HowToAddPeopleButton}>
|
||||
<PersonSecondaryNavigation activeId="people" environmentId={params.environmentId} />
|
||||
</PageHeader>
|
||||
<PersonDataView environment={environment} personCount={personCount} itemsPerPage={ITEMS_PER_PAGE} />
|
||||
<PersonDataView environment={environment} itemsPerPage={ITEMS_PER_PAGE} />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@formbricks/lib/localStorage";
|
||||
|
||||
interface EnvironmentStorageHandlerProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
const EnvironmentStorageHandler = ({ environmentId }: EnvironmentStorageHandlerProps) => {
|
||||
useEffect(() => {
|
||||
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, environmentId);
|
||||
}, [environmentId]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default EnvironmentStorageHandler;
|
||||
@@ -32,7 +32,6 @@ import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { FORMBRICKS_PRODUCT_ID_LS, FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@formbricks/lib/localStorage";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -56,7 +55,7 @@ import {
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/components/DropdownMenu";
|
||||
import { version } from "../../../../../package.json";
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
interface NavigationProps {
|
||||
environment: TEnvironment;
|
||||
@@ -119,16 +118,6 @@ export const MainNavigation = ({
|
||||
}
|
||||
}, [organization]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const productId = localStorage.getItem(FORMBRICKS_PRODUCT_ID_LS);
|
||||
const targetProduct = products.find((product) => product.id === productId);
|
||||
if (targetProduct && productId && product && product.id !== targetProduct.id) {
|
||||
router.push(`/products/${targetProduct.id}/`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sortedOrganizations = useMemo(() => {
|
||||
return [...organizations].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [organizations]);
|
||||
@@ -155,12 +144,6 @@ export const MainNavigation = ({
|
||||
}, [products]);
|
||||
|
||||
const handleEnvironmentChangeByProduct = (productId: string) => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(FORMBRICKS_PRODUCT_ID_LS, productId);
|
||||
|
||||
// Remove filters when switching products
|
||||
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||
}
|
||||
router.push(`/products/${productId}/`);
|
||||
};
|
||||
|
||||
@@ -257,7 +240,7 @@ export const MainNavigation = ({
|
||||
const res = await getLatestStableFbReleaseAction();
|
||||
if (res?.data) {
|
||||
const latestVersionTag = res.data;
|
||||
const currentVersionTag = `v${version}`;
|
||||
const currentVersionTag = `v${packageJson.version}`;
|
||||
|
||||
if (currentVersionTag !== latestVersionTag) {
|
||||
setLatestVersion(latestVersionTag);
|
||||
|
||||
@@ -53,6 +53,7 @@ export type IntegrationModalInputs = {
|
||||
table: string;
|
||||
survey: string;
|
||||
questions: string[];
|
||||
includeVariables: boolean;
|
||||
includeHiddenFields: boolean;
|
||||
includeMetadata: boolean;
|
||||
};
|
||||
@@ -97,8 +98,9 @@ export const AddIntegrationModal = ({
|
||||
const { index: _index, ...rest } = defaultData;
|
||||
reset(rest);
|
||||
fetchTable(defaultData.base);
|
||||
setIncludeHiddenFields(defaultData.includeHiddenFields);
|
||||
setIncludeMetadata(defaultData.includeMetadata);
|
||||
setIncludeVariables(!!defaultData.includeVariables);
|
||||
setIncludeHiddenFields(!!defaultData.includeHiddenFields);
|
||||
setIncludeMetadata(!!defaultData.includeMetadata);
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
@@ -106,6 +108,12 @@ export const AddIntegrationModal = ({
|
||||
}, [isEditMode]);
|
||||
|
||||
const survey = watch("survey");
|
||||
const includeVariables = watch("includeVariables");
|
||||
|
||||
const setIncludeVariables = (includeVariables: boolean) => {
|
||||
setValue("includeVariables", includeVariables);
|
||||
};
|
||||
|
||||
const selectedSurvey = surveys.find((item) => item.id === survey);
|
||||
const submitHandler = async (data: IntegrationModalInputs) => {
|
||||
try {
|
||||
@@ -136,6 +144,7 @@ export const AddIntegrationModal = ({
|
||||
baseId: data.base,
|
||||
tableId: data.table,
|
||||
tableName: currentTable?.name ?? "",
|
||||
includeVariables: data.includeVariables,
|
||||
includeHiddenFields,
|
||||
includeMetadata,
|
||||
};
|
||||
@@ -335,6 +344,8 @@ export const AddIntegrationModal = ({
|
||||
</div>
|
||||
</div>
|
||||
<AdditionalIntegrationSettings
|
||||
includeVariables={includeVariables}
|
||||
setIncludeVariables={setIncludeVariables}
|
||||
includeHiddenFields={includeHiddenFields}
|
||||
includeMetadata={includeMetadata}
|
||||
setIncludeHiddenFields={setIncludeHiddenFields}
|
||||
|
||||
@@ -108,6 +108,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
questions: data.questionIds,
|
||||
survey: data.surveyId,
|
||||
table: data.tableId,
|
||||
includeVariables: !!data.includeVariables,
|
||||
includeHiddenFields: !!data.includeHiddenFields,
|
||||
includeMetadata: !!data.includeMetadata,
|
||||
index,
|
||||
|
||||
@@ -62,6 +62,7 @@ export const AddIntegrationModal = ({
|
||||
const [spreadsheetUrl, setSpreadsheetUrl] = useState("");
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
const existingIntegrationData = googleSheetIntegration?.config?.data;
|
||||
const [includeVariables, setIncludeVariables] = useState(false);
|
||||
const [includeHiddenFields, setIncludeHiddenFields] = useState(false);
|
||||
const [includeMetadata, setIncludeMetadata] = useState(false);
|
||||
const googleSheetIntegrationData: TIntegrationGoogleSheetsInput = {
|
||||
@@ -89,6 +90,7 @@ export const AddIntegrationModal = ({
|
||||
})!
|
||||
);
|
||||
setSelectedQuestions(selectedIntegration.questionIds);
|
||||
setIncludeVariables(!!selectedIntegration.includeVariables);
|
||||
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
|
||||
setIncludeMetadata(!!selectedIntegration.includeMetadata);
|
||||
return;
|
||||
@@ -127,6 +129,7 @@ export const AddIntegrationModal = ({
|
||||
? "All questions"
|
||||
: "Selected questions";
|
||||
integrationData.createdAt = new Date();
|
||||
integrationData.includeVariables = includeVariables;
|
||||
integrationData.includeHiddenFields = includeHiddenFields;
|
||||
integrationData.includeMetadata = includeMetadata;
|
||||
if (selectedIntegration) {
|
||||
@@ -256,6 +259,8 @@ export const AddIntegrationModal = ({
|
||||
</div>
|
||||
</div>
|
||||
<AdditionalIntegrationSettings
|
||||
includeVariables={includeVariables}
|
||||
setIncludeVariables={setIncludeVariables}
|
||||
includeHiddenFields={includeHiddenFields}
|
||||
includeMetadata={includeMetadata}
|
||||
setIncludeHiddenFields={setIncludeHiddenFields}
|
||||
|
||||
@@ -118,6 +118,13 @@ export const AddIntegrationModal = ({
|
||||
}))
|
||||
: [];
|
||||
|
||||
const variables =
|
||||
selectedSurvey?.variables.map((variable) => ({
|
||||
id: variable.id,
|
||||
name: variable.name,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
})) || [];
|
||||
|
||||
const hiddenFields = selectedSurvey?.hiddenFields.enabled
|
||||
? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
|
||||
id: fId,
|
||||
@@ -133,7 +140,7 @@ export const AddIntegrationModal = ({
|
||||
},
|
||||
];
|
||||
|
||||
return [...questions, ...hiddenFields, ...Metadata];
|
||||
return [...questions, ...variables, ...hiddenFields, ...Metadata];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedSurvey?.id]);
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ export const AddChannelMappingModal = ({
|
||||
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
|
||||
const [selectedChannel, setSelectedChannel] = useState<TIntegrationItem | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
const [includeVariables, setIncludeVariables] = useState(false);
|
||||
const [includeHiddenFields, setIncludeHiddenFields] = useState(false);
|
||||
const [includeMetadata, setIncludeMetadata] = useState(false);
|
||||
const existingIntegrationData = slackIntegration?.config?.data;
|
||||
@@ -81,6 +82,7 @@ export const AddChannelMappingModal = ({
|
||||
})!
|
||||
);
|
||||
setSelectedQuestions(selectedIntegration.questionIds);
|
||||
setIncludeVariables(!!selectedIntegration.includeVariables);
|
||||
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
|
||||
setIncludeMetadata(!!selectedIntegration.includeMetadata);
|
||||
return;
|
||||
@@ -112,6 +114,7 @@ export const AddChannelMappingModal = ({
|
||||
? "All questions"
|
||||
: "Selected questions",
|
||||
createdAt: new Date(),
|
||||
includeVariables,
|
||||
includeHiddenFields,
|
||||
includeMetadata,
|
||||
};
|
||||
@@ -258,6 +261,8 @@ export const AddChannelMappingModal = ({
|
||||
</div>
|
||||
</div>
|
||||
<AdditionalIntegrationSettings
|
||||
includeVariables={includeVariables}
|
||||
setIncludeVariables={setIncludeVariables}
|
||||
includeHiddenFields={includeHiddenFields}
|
||||
includeMetadata={includeMetadata}
|
||||
setIncludeHiddenFields={setIncludeHiddenFields}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getUser } from "@formbricks/lib/user/service";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { ToasterClient } from "@formbricks/ui/components/ToasterClient";
|
||||
import { FormbricksClient } from "../../components/FormbricksClient";
|
||||
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
|
||||
import { PosthogIdentify } from "./components/PosthogIdentify";
|
||||
|
||||
const EnvLayout = async ({ children, params }) => {
|
||||
@@ -54,6 +55,7 @@ const EnvLayout = async ({ children, params }) => {
|
||||
/>
|
||||
<FormbricksClient session={session} userEmail={user.email} />
|
||||
<ToasterClient />
|
||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||
<EnvironmentLayout environmentId={params.environmentId} session={session}>
|
||||
{children}
|
||||
</EnvironmentLayout>
|
||||
|
||||
@@ -64,6 +64,12 @@ const mapResponsesToTableData = (responses: TResponse[], survey: TSurvey): TResp
|
||||
responseId: response.id,
|
||||
tags: response.tags,
|
||||
notes: response.notes,
|
||||
variables: survey.variables.reduce(
|
||||
(acc, curr) => {
|
||||
return Object.assign(acc, { [curr.id]: response.variables[curr.id] });
|
||||
},
|
||||
{} as Record<string, string | number>
|
||||
),
|
||||
verifiedEmail: typeof response.data["verifiedEmail"] === "string" ? response.data["verifiedEmail"] : "",
|
||||
language: response.language,
|
||||
person: response.person,
|
||||
|
||||
@@ -182,11 +182,7 @@ export const ResponseTable = ({
|
||||
/>
|
||||
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-200">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Table
|
||||
style={{
|
||||
width: table.getCenterTotalSize(),
|
||||
tableLayout: "fixed",
|
||||
}}>
|
||||
<Table className="w-full" style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
|
||||
@@ -6,7 +6,7 @@ import Link from "next/link";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
|
||||
import { processResponseData } from "@formbricks/lib/responses";
|
||||
import { QUESTIONS_ICON_MAP } from "@formbricks/lib/utils/questions";
|
||||
import { QUESTIONS_ICON_MAP, VARIABLES_ICON_MAP } from "@formbricks/lib/utils/questions";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
@@ -240,6 +240,24 @@ export const generateResponseTableColumns = (
|
||||
},
|
||||
};
|
||||
|
||||
const variableColumns: ColumnDef<TResponseTableData>[] = survey.variables.map((variable) => {
|
||||
return {
|
||||
accessorKey: variable.id,
|
||||
header: () => (
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">{VARIABLES_ICON_MAP[variable.type]}</span>
|
||||
<span className="truncate">{variable.name}</span>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const variableResponse = row.original.variables[variable.id];
|
||||
if (typeof variableResponse === "string" || typeof variableResponse === "number") {
|
||||
return <div className="text-slate-900">{variableResponse}</div>;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hiddenFieldColumns: ColumnDef<TResponseTableData>[] = survey.hiddenFields.fieldIds
|
||||
? survey.hiddenFields.fieldIds.map((hiddenFieldId) => {
|
||||
return {
|
||||
@@ -282,6 +300,7 @@ export const generateResponseTableColumns = (
|
||||
statusColumn,
|
||||
...(survey.isVerifyEmailEnabled ? [verifiedEmailColumn] : []),
|
||||
...questionColumns,
|
||||
...variableColumns,
|
||||
...hiddenFieldColumns,
|
||||
tagsColumn,
|
||||
notesColumn,
|
||||
|
||||
28
apps/web/app/ClientEnvironmentRedirect.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@formbricks/lib/localStorage";
|
||||
|
||||
interface ClientEnvironmentRedirectProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
const ClientEnvironmentRedirect = ({ environmentId }: ClientEnvironmentRedirectProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const lastEnvironmentId = localStorage.getItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
|
||||
if (lastEnvironmentId) {
|
||||
// Redirect to the last environment the user was in
|
||||
router.push(`/environments/${lastEnvironmentId}`);
|
||||
} else {
|
||||
router.push(`/environments/${environmentId}`);
|
||||
}
|
||||
}, [environmentId, router]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ClientEnvironmentRedirect;
|
||||
@@ -31,6 +31,7 @@ const convertMetaObjectToString = (metadata: TResponseMeta): string => {
|
||||
const processDataForIntegration = async (
|
||||
data: TPipelineInput,
|
||||
survey: TSurvey,
|
||||
includeVariables: boolean,
|
||||
includeMetadata: boolean,
|
||||
includeHiddenFields: boolean,
|
||||
questionIds: string[]
|
||||
@@ -44,6 +45,16 @@ const processDataForIntegration = async (
|
||||
values[0].push(convertMetaObjectToString(data.response.meta));
|
||||
values[1].push("Metadata");
|
||||
}
|
||||
if (includeVariables) {
|
||||
survey.variables.forEach((variable) => {
|
||||
const value = data.response.variables[variable.id];
|
||||
if (value !== undefined) {
|
||||
values[0].push(String(data.response.variables[variable.id]));
|
||||
values[1].push(variable.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return values;
|
||||
};
|
||||
|
||||
@@ -102,6 +113,7 @@ const handleAirtableIntegration = async (
|
||||
const values = await processDataForIntegration(
|
||||
data,
|
||||
survey,
|
||||
!!element.includeVariables,
|
||||
!!element.includeMetadata,
|
||||
!!element.includeHiddenFields,
|
||||
element.questionIds
|
||||
@@ -135,6 +147,7 @@ const handleGoogleSheetsIntegration = async (
|
||||
const values = await processDataForIntegration(
|
||||
data,
|
||||
survey,
|
||||
!!element.includeVariables,
|
||||
!!element.includeMetadata,
|
||||
!!element.includeHiddenFields,
|
||||
element.questionIds
|
||||
@@ -173,6 +186,7 @@ const handleSlackIntegration = async (
|
||||
const values = await processDataForIntegration(
|
||||
data,
|
||||
survey,
|
||||
!!element.includeVariables,
|
||||
!!element.includeMetadata,
|
||||
!!element.includeHiddenFields,
|
||||
element.questionIds
|
||||
|
||||
@@ -1,54 +1,8 @@
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
import formbricks from "@formbricks/js/app";
|
||||
import { env } from "@formbricks/lib/env";
|
||||
|
||||
export const formbricksEnabled =
|
||||
typeof env.NEXT_PUBLIC_FORMBRICKS_API_HOST && env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID;
|
||||
const ttc = { onboarding: 0 };
|
||||
|
||||
const getFormbricksApi = () => {
|
||||
const environmentId = env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID;
|
||||
const apiHost = env.NEXT_PUBLIC_FORMBRICKS_API_HOST;
|
||||
|
||||
if (typeof environmentId !== "string" || typeof apiHost !== "string") {
|
||||
throw new Error("Formbricks environment ID or API host is not defined");
|
||||
}
|
||||
|
||||
return new FormbricksAPI({
|
||||
environmentId,
|
||||
apiHost,
|
||||
});
|
||||
};
|
||||
|
||||
export const createResponse = async (
|
||||
surveyId: string,
|
||||
userId: string,
|
||||
data: { [questionId: string]: any },
|
||||
finished: boolean = false
|
||||
): Promise<any> => {
|
||||
const api = getFormbricksApi();
|
||||
return await api.client.response.create({
|
||||
surveyId,
|
||||
userId,
|
||||
finished,
|
||||
data,
|
||||
ttc,
|
||||
});
|
||||
};
|
||||
|
||||
export const updateResponse = async (
|
||||
responseId: string,
|
||||
data: { [questionId: string]: any },
|
||||
finished: boolean = false
|
||||
): Promise<any> => {
|
||||
const api = getFormbricksApi();
|
||||
return await api.client.response.update({
|
||||
responseId,
|
||||
finished,
|
||||
data,
|
||||
ttc,
|
||||
});
|
||||
};
|
||||
|
||||
export const formbricksLogout = async () => {
|
||||
localStorage.clear();
|
||||
|
||||
19
apps/web/app/middleware/responseHeaders.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export const getCSPHeaderValues = () => {
|
||||
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
||||
const cspHeader = `
|
||||
default-src 'self';
|
||||
script-src 'self';
|
||||
style-src 'self';
|
||||
img-src 'self' blob: data:;
|
||||
font-src 'self';
|
||||
object-src 'none';
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
frame-ancestors 'none';
|
||||
upgrade-insecure-requests;
|
||||
`
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
|
||||
return { nonce, cspHeader };
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClientEnvironmentRedirect from "@/app/ClientEnvironmentRedirect";
|
||||
import type { Session } from "next-auth";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -42,7 +43,7 @@ const Page = async () => {
|
||||
return redirect(`/organizations/${userOrganizations[0].id}/products/new/mode`);
|
||||
}
|
||||
|
||||
return redirect(`/environments/${environment.id}`);
|
||||
return <ClientEnvironmentRedirect environmentId={environment.id} />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -278,6 +278,7 @@ export const LinkSurvey = ({
|
||||
url: window.location.href,
|
||||
source: sourceParam || "",
|
||||
},
|
||||
variables: responseUpdate.variables,
|
||||
displayId: surveyState.displayId,
|
||||
...(Object.keys(hiddenFieldsRecord).length > 0 && { hiddenFields: hiddenFieldsRecord }),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { surveys } from "@/playwright/utils/mock";
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./lib/fixtures";
|
||||
import { createSurvey } from "./utils/helper";
|
||||
import { createSurvey, createSurveyWithLogic } from "./utils/helper";
|
||||
|
||||
test.describe("Survey Create & Submit Response", async () => {
|
||||
test.use({
|
||||
launchOptions: {
|
||||
slowMo: 110,
|
||||
},
|
||||
});
|
||||
|
||||
test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
let url: string | null;
|
||||
|
||||
test.slow();
|
||||
|
||||
test("Create survey and submit response", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.login();
|
||||
@@ -453,3 +461,256 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
expect(germanSurveyUrl).toContain("lang=de");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Testing Survey with advanced logic", async () => {
|
||||
test.setTimeout(180000);
|
||||
let url: string | null;
|
||||
|
||||
test("Create survey and submit response", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.login();
|
||||
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
|
||||
await test.step("Create Survey", async () => {
|
||||
await createSurveyWithLogic(page, surveys.createWithLogicAndSubmit);
|
||||
|
||||
// Save & Publish Survey
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
|
||||
await page.locator("#howToSendCardTrigger").click();
|
||||
await expect(page.locator("#howToSendCardOption-link")).toBeVisible();
|
||||
await page.locator("#howToSendCardOption-link").click();
|
||||
|
||||
await page.getByRole("button", { name: "Publish" }).click();
|
||||
|
||||
// Get URL
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/);
|
||||
await page.getByLabel("Copy survey link to clipboard").click();
|
||||
url = await page.evaluate("navigator.clipboard.readText()");
|
||||
});
|
||||
|
||||
await test.step("Submit Survey Response", async () => {
|
||||
await page.goto(url!);
|
||||
await page.waitForURL(/\/s\/[A-Za-z0-9]+$/);
|
||||
|
||||
// Welcome Card
|
||||
await expect(page.getByText(surveys.createWithLogicAndSubmit.welcomeCard.headline)).toBeVisible();
|
||||
await expect(page.getByText(surveys.createWithLogicAndSubmit.welcomeCard.description)).toBeVisible();
|
||||
await page.locator("#questionCard--1").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Open Text Question
|
||||
await expect(page.getByText(surveys.createWithLogicAndSubmit.openTextQuestion.question)).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(surveys.createWithLogicAndSubmit.openTextQuestion.description)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByPlaceholder(surveys.createWithLogicAndSubmit.openTextQuestion.placeholder)
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByPlaceholder(surveys.createWithLogicAndSubmit.openTextQuestion.placeholder)
|
||||
.fill("This is my Open Text answer");
|
||||
await page.locator("#questionCard-0").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Single Select Question
|
||||
await expect(
|
||||
page.getByText(surveys.createWithLogicAndSubmit.singleSelectQuestion.question)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(surveys.createWithLogicAndSubmit.singleSelectQuestion.description)
|
||||
).toBeVisible();
|
||||
for (let i = 0; i < surveys.createWithLogicAndSubmit.singleSelectQuestion.options.length; i++) {
|
||||
await expect(
|
||||
page
|
||||
.locator("#questionCard-1 label")
|
||||
.filter({ hasText: surveys.createWithLogicAndSubmit.singleSelectQuestion.options[i] })
|
||||
).toBeVisible();
|
||||
}
|
||||
await expect(page.getByText("Other")).toBeVisible();
|
||||
await expect(page.locator("#questionCard-1").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-1").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page
|
||||
.locator("#questionCard-1 label")
|
||||
.filter({ hasText: surveys.createWithLogicAndSubmit.singleSelectQuestion.options[0] })
|
||||
.click();
|
||||
await page.locator("#questionCard-1").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Multi Select Question
|
||||
await expect(
|
||||
page.getByText(surveys.createWithLogicAndSubmit.multiSelectQuestion.question)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(surveys.createWithLogicAndSubmit.multiSelectQuestion.description)
|
||||
).toBeVisible();
|
||||
for (let i = 0; i < surveys.createWithLogicAndSubmit.singleSelectQuestion.options.length; i++) {
|
||||
await expect(
|
||||
page
|
||||
.locator("#questionCard-2 label")
|
||||
.filter({ hasText: surveys.createWithLogicAndSubmit.multiSelectQuestion.options[i] })
|
||||
).toBeVisible();
|
||||
}
|
||||
await expect(page.locator("#questionCard-2").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-2").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
for (let i = 0; i < surveys.createWithLogicAndSubmit.multiSelectQuestion.options.length; i++) {
|
||||
await page
|
||||
.locator("#questionCard-2 label")
|
||||
.filter({ hasText: surveys.createWithLogicAndSubmit.multiSelectQuestion.options[i] })
|
||||
.click();
|
||||
}
|
||||
await page.locator("#questionCard-2").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Picture Select Question
|
||||
await expect(
|
||||
page.getByText(surveys.createWithLogicAndSubmit.pictureSelectQuestion.question)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(surveys.createWithLogicAndSubmit.pictureSelectQuestion.description)
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await expect(page.getByRole("img", { name: "puppy-1-small.jpg" })).toBeVisible();
|
||||
await expect(page.getByRole("img", { name: "puppy-2-small.jpg" })).toBeVisible();
|
||||
await page.getByRole("img", { name: "puppy-1-small.jpg" }).click();
|
||||
await page.locator("#questionCard-3").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Rating Question
|
||||
await expect(page.getByText(surveys.createWithLogicAndSubmit.ratingQuestion.question)).toBeVisible();
|
||||
await expect(page.getByText(surveys.createWithLogicAndSubmit.ratingQuestion.description)).toBeVisible();
|
||||
await expect(
|
||||
page.locator("#questionCard-4").getByText(surveys.createWithLogicAndSubmit.ratingQuestion.lowLabel)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator("#questionCard-4").getByText(surveys.createWithLogicAndSubmit.ratingQuestion.highLabel)
|
||||
).toBeVisible();
|
||||
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5);
|
||||
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).not.toBeVisible();
|
||||
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.getByRole("group", { name: "Choices" }).locator("path").nth(3).click();
|
||||
|
||||
// NPS Question
|
||||
await expect(page.getByText(surveys.createWithLogicAndSubmit.npsQuestion.question)).toBeVisible();
|
||||
await expect(
|
||||
page.locator("#questionCard-5").getByText(surveys.createWithLogicAndSubmit.npsQuestion.lowLabel)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator("#questionCard-5").getByText(surveys.createWithLogicAndSubmit.npsQuestion.highLabel)
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#questionCard-5").getByRole("button", { name: "Next" })).not.toBeVisible();
|
||||
await expect(page.locator("#questionCard-5").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
|
||||
for (let i = 0; i < 11; i++) {
|
||||
await expect(page.locator("#questionCard-5").getByText(`${i}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
await page.locator("#questionCard-5").getByText("5", { exact: true }).click();
|
||||
|
||||
// Ranking Question
|
||||
await expect(page.getByText(surveys.createWithLogicAndSubmit.ranking.question)).toBeVisible();
|
||||
await page.locator("#questionCard-6").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Matrix Question
|
||||
await expect(page.getByText(surveys.createWithLogicAndSubmit.matrix.question)).toBeVisible();
|
||||
await expect(page.getByText(surveys.createWithLogicAndSubmit.matrix.description)).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.rows[0] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.rows[1] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.rows[2] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[0] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[1] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[2] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[3] })
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#questionCard-7").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-7").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.getByRole("row", { name: "Roses" }).getByRole("cell").nth(1).click();
|
||||
await page.getByRole("row", { name: "Trees" }).getByRole("cell").nth(1).click();
|
||||
await page.getByRole("row", { name: "Ocean" }).getByRole("cell").nth(1).click();
|
||||
await page.locator("#questionCard-7").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// CTA Question
|
||||
await expect(page.getByText(surveys.createWithLogicAndSubmit.ctaQuestion.question)).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: surveys.createWithLogicAndSubmit.ctaQuestion.buttonLabel })
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole("button", { name: surveys.createWithLogicAndSubmit.ctaQuestion.buttonLabel })
|
||||
.click();
|
||||
|
||||
// Consent Question
|
||||
await expect(page.getByText(surveys.createWithLogicAndSubmit.consentQuestion.question)).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(surveys.createWithLogicAndSubmit.consentQuestion.checkboxLabel)
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.getByText(surveys.createWithLogicAndSubmit.consentQuestion.checkboxLabel).check();
|
||||
await page.locator("#questionCard-9").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// File Upload Question
|
||||
await expect(
|
||||
page.getByText(surveys.createWithLogicAndSubmit.fileUploadQuestion.question)
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#questionCard-10").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-10").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await expect(
|
||||
page.locator("label").filter({ hasText: "Click or drag to upload files." }).locator("div").nth(0)
|
||||
).toBeVisible();
|
||||
await page.locator("input[type=file]").setInputFiles({
|
||||
name: "file.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("this is test"),
|
||||
});
|
||||
await page.getByText("Uploading...").waitFor({ state: "hidden" });
|
||||
await page.locator("#questionCard-10").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Date Question
|
||||
await expect(page.getByText(surveys.createWithLogicAndSubmit.date.question)).toBeVisible();
|
||||
await page.getByText("Select a date").click();
|
||||
const date = new Date().getDate();
|
||||
const month = new Date().toLocaleString("default", { month: "long" });
|
||||
await page.getByRole("button", { name: `${month} ${date},` }).click();
|
||||
await page.locator("#questionCard-11").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Cal Question
|
||||
await expect(page.getByText(surveys.createWithLogicAndSubmit.cal.question)).toBeVisible();
|
||||
await page.locator("#questionCard-12").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Address Question
|
||||
await expect(page.getByText(surveys.createWithLogicAndSubmit.address.question)).toBeVisible();
|
||||
await expect(page.getByPlaceholder(surveys.createWithLogicAndSubmit.address.placeholder)).toBeVisible();
|
||||
await page
|
||||
.getByPlaceholder(surveys.createWithLogicAndSubmit.address.placeholder)
|
||||
.fill("This is my Address");
|
||||
await page.getByRole("button", { name: "Finish" }).click();
|
||||
|
||||
// loading spinner -> wait for it to disappear
|
||||
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
|
||||
|
||||
// Thank You Card
|
||||
await expect(page.getByText(surveys.createWithLogicAndSubmit.thankYouCard.headline)).toBeVisible();
|
||||
await expect(page.getByText(surveys.createWithLogicAndSubmit.thankYouCard.description)).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Verify Survey Response", async () => {
|
||||
await page.goBack();
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/);
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await page.getByRole("link").filter({ hasText: "Responses" }).click();
|
||||
await expect(page.getByRole("table")).toBeVisible();
|
||||
await expect(page.getByRole("cell", { name: "score" })).toBeVisible();
|
||||
await page.waitForTimeout(5000);
|
||||
await expect(page.getByRole("cell", { name: "32", exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CreateSurveyParams } from "@/playwright/utils/mock";
|
||||
import { CreateSurveyParams, CreateSurveyWithLogicParams } from "@/playwright/utils/mock";
|
||||
import { expect } from "@playwright/test";
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { Page } from "playwright";
|
||||
@@ -304,3 +304,619 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
await page.getByLabel("Note*").fill(params.thankYouCard.headline);
|
||||
await page.getByLabel("Description").fill(params.thankYouCard.description);
|
||||
};
|
||||
|
||||
export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWithLogicParams) => {
|
||||
const addQuestion = "Add QuestionAdd a new question to your survey";
|
||||
|
||||
await page.getByRole("button", { name: "Start from scratch Create a" }).click();
|
||||
await page.getByRole("button", { name: "Create survey", exact: true }).click();
|
||||
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/edit$/);
|
||||
|
||||
// Add variables
|
||||
await page.getByText("Variables").click();
|
||||
await page.getByPlaceholder("Field name e.g, score, price").click();
|
||||
await page.getByPlaceholder("Field name e.g, score, price").fill("score");
|
||||
await page.getByRole("button", { name: "Add variable" }).click();
|
||||
await page
|
||||
.locator("form")
|
||||
.filter({ hasText: "Add variable" })
|
||||
.getByPlaceholder("Field name e.g, score, price")
|
||||
.fill("secret");
|
||||
await page.locator("form").filter({ hasText: "Add variable" }).getByRole("combobox").click();
|
||||
await page.getByLabel("Text", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "Add variable" }).click();
|
||||
|
||||
// Welcome Card
|
||||
await expect(page.locator("#welcome-toggle")).toBeVisible();
|
||||
await page.getByText("Welcome Card").click();
|
||||
await page.locator("#welcome-toggle").check();
|
||||
await page.getByLabel("Note*").fill(params.welcomeCard.headline);
|
||||
await page.locator("form").getByText("Thanks for providing your").fill(params.welcomeCard.description);
|
||||
await page.getByText("Welcome CardOn").click();
|
||||
|
||||
// Open Text Question
|
||||
await page.getByRole("main").getByText("What would you like to know?").click();
|
||||
|
||||
await page.getByLabel("Question*").fill(params.openTextQuestion.question);
|
||||
await page.getByRole("button", { name: "Add Description", exact: true }).click();
|
||||
await page.getByLabel("Description").fill(params.openTextQuestion.description);
|
||||
await page.getByLabel("Placeholder").fill(params.openTextQuestion.placeholder);
|
||||
|
||||
await page.locator("p").filter({ hasText: params.openTextQuestion.question }).click();
|
||||
|
||||
// Single Select Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Single-Select" }).click();
|
||||
await page.getByLabel("Question*").fill(params.singleSelectQuestion.question);
|
||||
await page.getByRole("button", { name: "Add Description", exact: true }).click();
|
||||
await page.getByLabel("Description").fill(params.singleSelectQuestion.description);
|
||||
await page.getByPlaceholder("Option 1").fill(params.singleSelectQuestion.options[0]);
|
||||
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
|
||||
await page.getByRole("button", { name: 'Add "Other"', exact: true }).click();
|
||||
await page.getByLabel("Required").click();
|
||||
|
||||
// Multi Select Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Multi-Select" }).click();
|
||||
await page.getByLabel("Question*").fill(params.multiSelectQuestion.question);
|
||||
await page.getByRole("button", { name: "Add Description", exact: true }).click();
|
||||
await page.getByLabel("Description").fill(params.multiSelectQuestion.description);
|
||||
await page.getByPlaceholder("Option 1").fill(params.multiSelectQuestion.options[0]);
|
||||
await page.getByPlaceholder("Option 2").fill(params.multiSelectQuestion.options[1]);
|
||||
await page.getByPlaceholder("Option 3").fill(params.multiSelectQuestion.options[2]);
|
||||
|
||||
// Picture Select Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Picture Selection" }).click();
|
||||
await page.getByLabel("Question*").fill(params.pictureSelectQuestion.question);
|
||||
await page.getByRole("button", { name: "Add Description", exact: true }).click();
|
||||
await page.getByLabel("Description").fill(params.pictureSelectQuestion.description);
|
||||
await page.getByLabel("Required").click();
|
||||
|
||||
// Rating Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Rating" }).click();
|
||||
await page.getByLabel("Question*").fill(params.ratingQuestion.question);
|
||||
await page.getByRole("button", { name: "Add Description", exact: true }).click();
|
||||
await page.getByLabel("Description").fill(params.ratingQuestion.description);
|
||||
await page.getByPlaceholder("Not good").fill(params.ratingQuestion.lowLabel);
|
||||
await page.getByPlaceholder("Very satisfied").fill(params.ratingQuestion.highLabel);
|
||||
|
||||
// NPS Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Net Promoter Score (NPS)" }).click();
|
||||
await page.getByLabel("Question*").fill(params.npsQuestion.question);
|
||||
await page.getByLabel("Lower label").fill(params.npsQuestion.lowLabel);
|
||||
await page.getByLabel("Upper label").fill(params.npsQuestion.highLabel);
|
||||
|
||||
// Fill Ranking question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Ranking" }).click();
|
||||
await page.getByLabel("Question*").fill(params.ranking.question);
|
||||
await page.getByLabel("Required").click();
|
||||
|
||||
// Matrix Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Matrix" }).click();
|
||||
await page.getByLabel("Question*").fill(params.matrix.question);
|
||||
await page.getByRole("button", { name: "Add Description", exact: true }).click();
|
||||
await page.getByLabel("Description").fill(params.matrix.description);
|
||||
await page.locator("#row-0").click();
|
||||
await page.locator("#row-0").fill(params.matrix.rows[0]);
|
||||
await page.locator("#row-1").click();
|
||||
await page.locator("#row-1").fill(params.matrix.rows[1]);
|
||||
await page.locator("#row-2").click();
|
||||
await page.locator("#row-2").fill(params.matrix.rows[2]);
|
||||
await page.locator("#column-0").click();
|
||||
await page.locator("#column-0").fill(params.matrix.columns[0]);
|
||||
await page.locator("#column-1").click();
|
||||
await page.locator("#column-1").fill(params.matrix.columns[1]);
|
||||
await page.locator("#column-2").click();
|
||||
await page.locator("#column-2").fill(params.matrix.columns[2]);
|
||||
await page.locator("#column-3").click();
|
||||
await page.locator("#column-3").fill(params.matrix.columns[3]);
|
||||
|
||||
// CTA Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Statement (Call to Action)" }).click();
|
||||
await page.getByPlaceholder("Your question here. Recall").fill(params.ctaQuestion.question);
|
||||
await page.getByPlaceholder("Finish").fill(params.ctaQuestion.buttonLabel);
|
||||
await page.getByLabel("Required").click();
|
||||
|
||||
// Consent Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Consent" }).click();
|
||||
await page.getByLabel("Question*").fill(params.consentQuestion.question);
|
||||
await page.getByPlaceholder("I agree to the terms and").fill(params.consentQuestion.checkboxLabel);
|
||||
|
||||
// File Upload Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "File Upload" }).click();
|
||||
await page.getByLabel("Question*").fill(params.fileUploadQuestion.question);
|
||||
|
||||
// Date Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Date" }).click();
|
||||
await page.getByLabel("Question*").fill(params.date.question);
|
||||
|
||||
// Cal Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Schedule a meeting" }).click();
|
||||
await page.getByLabel("Question*").fill(params.cal.question);
|
||||
await page.getByLabel("Required").click();
|
||||
|
||||
// Fill Address Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Address" }).click();
|
||||
await page.getByLabel("Question*").fill(params.address.question);
|
||||
|
||||
// Adding logic
|
||||
// Open Text Question
|
||||
await page.locator("p", { hasText: params.openTextQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#condition-0-0-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "is submitted" }).click();
|
||||
await page.locator("#action-0-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-0-variableId").click();
|
||||
await page.getByRole("option", { name: "score" }).click();
|
||||
await page.locator("#action-0-operator").click();
|
||||
await page.getByRole("option", { name: "Assign =" }).click();
|
||||
await page.locator("#action-0-value-input").click();
|
||||
await page.locator("#action-0-value-input").fill("1");
|
||||
await page.locator("#actions-0-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add action below" }).click();
|
||||
await page.locator("#action-1-objective").click();
|
||||
await page.getByRole("option", { name: "Require Answer" }).click();
|
||||
await page.locator("#action-1-target").click();
|
||||
await page.getByRole("option", { name: params.singleSelectQuestion.question }).click();
|
||||
await page.locator("#actions-1-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add action below" }).click();
|
||||
await page.locator("#action-2-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-2-variableId").click();
|
||||
await page.getByRole("option", { name: "secret" }).click();
|
||||
await page.locator("#action-2-operator").click();
|
||||
await page.getByRole("option", { name: "Assign =" }).click();
|
||||
await page.getByRole("textbox", { name: "Value" }).click();
|
||||
await page.getByRole("textbox", { name: "Value" }).fill("This ");
|
||||
|
||||
// Single Select Question
|
||||
await page.locator("p", { hasText: params.singleSelectQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#condition-0-0-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "equals one of" }).click();
|
||||
await page.locator("#condition-0-0-conditionMatchValue").click();
|
||||
await page.getByRole("option", { name: params.singleSelectQuestion.options[0] }).click();
|
||||
await page.getByRole("option", { name: params.singleSelectQuestion.options[1] }).click();
|
||||
await page.locator("#action-0-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-0-variableId").click();
|
||||
await page.getByRole("option", { name: "score" }).click();
|
||||
await page.locator("#action-0-operator").click();
|
||||
await page.getByRole("option", { name: "Add +" }).click();
|
||||
await page.getByPlaceholder("Value").click();
|
||||
await page.getByPlaceholder("Value").fill("1");
|
||||
await page.locator("#actions-0-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add action below" }).click();
|
||||
await page.locator("#action-1-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-1-variableId").click();
|
||||
await page.getByRole("option", { name: "secret" }).click();
|
||||
await page.locator("#action-1-operator").click();
|
||||
await page.getByRole("option", { name: "Concat +" }).click();
|
||||
await page.getByRole("textbox", { name: "Value" }).click();
|
||||
await page.getByRole("textbox", { name: "Value" }).fill("is ");
|
||||
|
||||
// Multi Select Question
|
||||
await page.locator("p", { hasText: params.multiSelectQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#condition-0-0-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "includes all of" }).click();
|
||||
await page.locator("#condition-0-0-conditionMatchValue").click();
|
||||
await page.getByRole("option", { name: params.multiSelectQuestion.options[0] }).click();
|
||||
await page.getByRole("option", { name: params.multiSelectQuestion.options[1] }).click();
|
||||
await page.getByRole("option", { name: params.multiSelectQuestion.options[2] }).click();
|
||||
await page.locator("#condition-0-0-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add condition below" }).click();
|
||||
await page.locator("#condition-0-1-conditionValue").click();
|
||||
await page.getByRole("option", { name: params.singleSelectQuestion.question }).click();
|
||||
await page.locator("#condition-0-1-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "is submitted" }).click();
|
||||
await page.locator("#action-0-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-0-variableId").click();
|
||||
await page.getByRole("option", { name: "score" }).click();
|
||||
await page.locator("#action-0-operator").click();
|
||||
await page.getByRole("option", { name: "Add +" }).click();
|
||||
await page.getByPlaceholder("Value").click();
|
||||
await page.getByPlaceholder("Value").fill("1");
|
||||
await page.locator("#actions-0-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add action below" }).click();
|
||||
await page.locator("#action-1-objective").click();
|
||||
await page.getByRole("option", { name: "Require Answer" }).click();
|
||||
await page.locator("#action-1-target").click();
|
||||
await page.getByRole("option", { name: params.pictureSelectQuestion.question }).click();
|
||||
await page.locator("#actions-1-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add action below" }).click();
|
||||
await page.locator("#action-2-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-2-variableId").click();
|
||||
await page.getByRole("option", { name: "secret" }).click();
|
||||
await page.locator("#action-2-operator").click();
|
||||
await page.getByRole("option", { name: "Concat +" }).click();
|
||||
await page.getByRole("textbox", { name: "Value" }).click();
|
||||
await page.getByRole("textbox", { name: "Value" }).fill("a ");
|
||||
|
||||
// Picture Select Question
|
||||
await page.locator("p", { hasText: params.pictureSelectQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#condition-0-0-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "is submitted" }).click();
|
||||
await page.locator("#action-0-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-0-variableId").click();
|
||||
await page.getByRole("option", { name: "score" }).click();
|
||||
await page.locator("#action-0-operator").click();
|
||||
await page.getByRole("option", { name: "Add +" }).click();
|
||||
await page.getByPlaceholder("Value").click();
|
||||
await page.getByPlaceholder("Value").fill("1");
|
||||
await page.locator("#actions-0-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add action below" }).click();
|
||||
await page.locator("#action-1-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-1-variableId").click();
|
||||
await page.getByRole("option", { name: "secret" }).click();
|
||||
await page.locator("#action-1-operator").click();
|
||||
await page.getByRole("option", { name: "Concat +" }).click();
|
||||
await page.getByRole("textbox", { name: "Value" }).click();
|
||||
await page.getByRole("textbox", { name: "Value" }).fill("secret ");
|
||||
|
||||
// Rating Question
|
||||
await page.locator("p", { hasText: params.ratingQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#condition-0-0-conditionOperator").click();
|
||||
await page.getByRole("option", { name: ">=" }).click();
|
||||
await page.locator("#condition-0-0-conditionMatchValue").click();
|
||||
await page.getByRole("option", { name: "3" }).click();
|
||||
await page.locator("#action-0-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-0-variableId").click();
|
||||
await page.getByRole("option", { name: "score" }).click();
|
||||
await page.locator("#action-0-operator").click();
|
||||
await page.getByRole("option", { name: "Add +" }).click();
|
||||
await page.getByPlaceholder("Value").click();
|
||||
await page.getByPlaceholder("Value").fill("1");
|
||||
await page.locator("#actions-0-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add action below" }).click();
|
||||
await page.locator("#action-1-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-1-variableId").click();
|
||||
await page.getByRole("option", { name: "secret" }).click();
|
||||
await page.locator("#action-1-operator").click();
|
||||
await page.getByRole("option", { name: "Concat +" }).click();
|
||||
await page.getByRole("textbox", { name: "Value" }).click();
|
||||
await page.getByRole("textbox", { name: "Value" }).fill("message ");
|
||||
|
||||
// NPS Question
|
||||
await page.locator("p", { hasText: params.npsQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#condition-0-0-conditionOperator").click();
|
||||
await page.getByRole("option", { name: ">", exact: true }).click();
|
||||
await page.locator("#condition-0-0-conditionMatchValue").click();
|
||||
await page.getByRole("option", { name: "2" }).click();
|
||||
await page.locator("#condition-0-0-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add condition below" }).click();
|
||||
await page.locator("#condition-0-1-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "<", exact: true }).click();
|
||||
await page.locator("#condition-0-1-conditionMatchValue").click();
|
||||
await page.getByRole("option", { name: "8" }).click();
|
||||
await page.locator("#condition-0-1-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add condition below" }).click();
|
||||
await page.locator("#condition-0-2-conditionValue").click();
|
||||
await page.getByRole("option", { name: params.ratingQuestion.question }).click();
|
||||
await page.locator("#condition-0-2-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "=", exact: true }).click();
|
||||
await page.locator("#condition-0-2-conditionMatchValue").click();
|
||||
await page.getByRole("option", { name: "4" }).click();
|
||||
await page.locator("#condition-0-2-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add condition below" }).click();
|
||||
await page.locator("#condition-0-3-conditionValue").click();
|
||||
await page.getByRole("option", { name: params.ratingQuestion.question }).click();
|
||||
await page.locator("#condition-0-3-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "<=" }).click();
|
||||
await page.locator("#condition-0-3-conditionMatchValue").click();
|
||||
await page.getByRole("option", { name: "1" }).click();
|
||||
await page.locator("#condition-0-3-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Create group" }).click();
|
||||
await page.locator("#condition-1-0-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add condition below" }).click();
|
||||
await page.getByText("and").nth(4).click();
|
||||
await page.locator("#condition-1-1-conditionValue").click();
|
||||
await page
|
||||
.getByRole("option")
|
||||
.filter({ hasText: new RegExp(`^${params.pictureSelectQuestion.question}$`) })
|
||||
.click();
|
||||
await page.locator("#condition-1-1-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "is submitted" }).click();
|
||||
await page.locator("#action-0-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-0-variableId").click();
|
||||
await page.getByRole("option", { name: "score" }).click();
|
||||
await page.locator("#action-0-operator").click();
|
||||
await page.getByRole("option", { name: "Add +" }).click();
|
||||
await page.getByPlaceholder("Value").click();
|
||||
await page.getByPlaceholder("Value").fill("1");
|
||||
await page.locator("#actions-0-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add action below" }).click();
|
||||
await page.locator("#action-1-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-1-variableId").click();
|
||||
await page.getByRole("option", { name: "secret" }).click();
|
||||
await page.locator("#action-1-operator").click();
|
||||
await page.getByRole("option", { name: "Concat +" }).click();
|
||||
await page.getByRole("textbox", { name: "Value" }).click();
|
||||
await page.getByRole("textbox", { name: "Value" }).fill("for ");
|
||||
|
||||
// Ranking Question
|
||||
await page.locator("p", { hasText: params.ranking.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#condition-0-0-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "is skipped" }).click();
|
||||
await page.locator("#action-0-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-0-variableId").click();
|
||||
await page.getByRole("option", { name: "score" }).click();
|
||||
await page.locator("#action-0-operator").click();
|
||||
await page.getByRole("option", { name: "Add +" }).click();
|
||||
await page.getByPlaceholder("Value").click();
|
||||
await page.getByPlaceholder("Value").fill("1");
|
||||
await page.locator("#actions-0-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add action below" }).click();
|
||||
await page.locator("#action-1-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-1-variableId").click();
|
||||
await page.getByRole("option", { name: "secret" }).click();
|
||||
await page.locator("#action-1-operator").click();
|
||||
await page.getByRole("option", { name: "Concat +" }).click();
|
||||
await page.getByRole("textbox", { name: "Value" }).click();
|
||||
await page.getByRole("textbox", { name: "Value" }).fill("e2e ");
|
||||
|
||||
// Matrix Question
|
||||
await page.locator("p", { hasText: params.matrix.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#condition-0-0-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "is completely submitted" }).click();
|
||||
await page.locator("#action-0-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-0-variableId").click();
|
||||
await page.getByRole("option", { name: "score" }).click();
|
||||
await page.locator("#action-0-operator").click();
|
||||
await page.getByRole("option", { name: "Add +" }).click();
|
||||
await page.getByPlaceholder("Value").click();
|
||||
await page.getByPlaceholder("Value").fill("1");
|
||||
await page.locator("#actions-0-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add action below" }).click();
|
||||
await page.locator("#action-1-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-1-variableId").click();
|
||||
await page.getByRole("option", { name: "score" }).click();
|
||||
await page.locator("#action-1-variableId").click();
|
||||
await page.getByRole("option", { name: "secret" }).click();
|
||||
await page.locator("#action-1-operator").click();
|
||||
await page.getByRole("option", { name: "Concat +" }).click();
|
||||
await page.getByRole("textbox", { name: "Value" }).click();
|
||||
await page.getByRole("textbox", { name: "Value" }).fill("tests");
|
||||
await page.locator("#actions-1-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add action below" }).click();
|
||||
await page.locator("#action-2-objective").click();
|
||||
await page.getByRole("option", { name: "Require Answer" }).click();
|
||||
await page.locator("#action-2-target").click();
|
||||
await page.getByRole("option", { name: params.ctaQuestion.question }).click();
|
||||
|
||||
// CTA Question
|
||||
await page.locator("p", { hasText: params.ctaQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#condition-0-0-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "is skipped" }).click();
|
||||
await page.locator("#condition-0-0-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add condition below" }).click();
|
||||
await page.getByText("and", { exact: true }).click();
|
||||
await page.locator("#condition-0-1-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Create group" }).click();
|
||||
await page.locator("#condition-1-0-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add condition below" }).click();
|
||||
await page.locator("#condition-1-1-conditionValue").click();
|
||||
await page.getByRole("option", { name: "secret" }).click();
|
||||
await page.locator("#condition-1-1-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "contains" }).click();
|
||||
await page.getByPlaceholder("Value").click();
|
||||
await page.getByPlaceholder("Value").fill("test");
|
||||
await page.locator("#action-0-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-0-variableId").click();
|
||||
await page.getByRole("option", { name: "score" }).click();
|
||||
await page.locator("#action-0-operator").click();
|
||||
await page.getByRole("option", { name: "Add +" }).click();
|
||||
await page.locator("#action-0-value-input").click();
|
||||
await page.locator("#action-0-value-input").fill("1");
|
||||
|
||||
// Consent Question
|
||||
await page.locator("p", { hasText: params.consentQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#action-0-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-0-variableId").click();
|
||||
await page.getByRole("option", { name: "score" }).click();
|
||||
await page.locator("#action-0-operator").click();
|
||||
await page.getByRole("option", { name: "Add +" }).click();
|
||||
await page.locator("#action-0-value-input").click();
|
||||
await page.locator("#action-0-value-input").fill("2");
|
||||
|
||||
// File Upload Question
|
||||
await page.locator("p", { hasText: params.fileUploadQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#action-0-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-0-variableId").click();
|
||||
await page.getByRole("option", { name: "score" }).click();
|
||||
await page.locator("#action-0-operator").click();
|
||||
await page.getByRole("option", { name: "Add +" }).click();
|
||||
await page.locator("#action-0-value-input").click();
|
||||
await page.locator("#action-0-value-input").fill("1");
|
||||
|
||||
// Date Question
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const yesterday = new Date(new Date().setDate(new Date().getDate() - 1)).toISOString().split("T")[0];
|
||||
const tomorrow = new Date(new Date().setDate(new Date().getDate() + 1)).toISOString().split("T")[0];
|
||||
|
||||
await page.getByRole("main").getByText(params.date.question).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
|
||||
await page.getByPlaceholder("Value").fill(today);
|
||||
await page.locator("#condition-0-0-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add condition below" }).click();
|
||||
await page.locator("#condition-0-1-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "does not equal" }).click();
|
||||
await page.locator("#condition-0-1-conditionMatchValue-input").fill(yesterday);
|
||||
await page.locator("#condition-0-1-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add condition below" }).click();
|
||||
await page.locator("#condition-0-2-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "is before" }).click();
|
||||
await page.locator("#condition-0-2-conditionMatchValue-input").fill(tomorrow);
|
||||
await page.locator("#condition-0-2-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add condition below" }).click();
|
||||
await page.locator("#condition-0-3-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "is after" }).click();
|
||||
await page.locator("#condition-0-3-conditionMatchValue-input").fill(yesterday);
|
||||
await page.locator("#action-0-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-0-variableId").click();
|
||||
await page.getByRole("option", { name: "score" }).click();
|
||||
await page.locator("#action-0-operator").click();
|
||||
await page.getByRole("option", { name: "Add +" }).click();
|
||||
await page.locator("#action-0-value-input").click();
|
||||
await page.locator("#action-0-value-input").fill("1");
|
||||
|
||||
// Cal Question
|
||||
await page.locator("p", { hasText: params.cal.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#condition-0-0-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "is skipped" }).click();
|
||||
await page.locator("#action-0-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-0-variableId").click();
|
||||
await page.getByRole("option", { name: "score" }).click();
|
||||
await page.locator("#action-0-operator").click();
|
||||
await page.getByRole("option", { name: "Add +" }).click();
|
||||
await page.locator("#action-0-value-input").click();
|
||||
await page.locator("#action-0-value-input").fill("1");
|
||||
|
||||
// Address Question
|
||||
await page.locator("p", { hasText: params.address.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#action-0-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-0-variableId").click();
|
||||
await page.getByRole("option", { name: "score" }).click();
|
||||
await page.locator("#action-0-operator").click();
|
||||
await page.getByRole("option", { name: "Add +" }).click();
|
||||
await page.locator("#action-0-value-input").click();
|
||||
await page.locator("#action-0-value-input").fill("1");
|
||||
await page.locator("#actions-0-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add action below" }).click();
|
||||
await page.locator("#action-1-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-1-variableId").click();
|
||||
await page.getByRole("option", { name: "score" }).click();
|
||||
await page.locator("#action-1-operator").click();
|
||||
await page.getByRole("option", { name: "Add +" }).click();
|
||||
await page.locator("#action-1-value-input").click();
|
||||
await page.locator("#action-1-value-input").fill("1");
|
||||
await page.locator("#actions-1-dropdown").click();
|
||||
await page.getByRole("menuitem", { name: "Add action below" }).click();
|
||||
await page.locator("#action-2-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
await page.locator("#action-2-variableId").click();
|
||||
await page.getByRole("option", { name: "score" }).click();
|
||||
await page.locator("#action-2-operator").click();
|
||||
await page.getByRole("option", { name: "Multiply *" }).click();
|
||||
await page.locator("#action-2-value-input").click();
|
||||
await page.locator("#action-2-value-input").fill("2");
|
||||
|
||||
// Thank You Card
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: /^Thank you!Ending card$/ })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByLabel("Note*").fill(params.thankYouCard.headline);
|
||||
await page.getByLabel("Description").fill(params.thankYouCard.description);
|
||||
};
|
||||
|
||||
@@ -166,6 +166,77 @@ export const surveys = {
|
||||
description: "This is my Thank you Card Description!",
|
||||
},
|
||||
},
|
||||
createWithLogicAndSubmit: {
|
||||
welcomeCard: {
|
||||
headline: "Welcome to My Testing Survey Welcome Card!",
|
||||
description: "This is the description of my Welcome Card!",
|
||||
},
|
||||
openTextQuestion: {
|
||||
question: "This is my Open Text Question",
|
||||
description: "This is my Open Text Description",
|
||||
placeholder: "This is my Placeholder",
|
||||
},
|
||||
singleSelectQuestion: {
|
||||
question: "This is my Single Select Question",
|
||||
description: "This is my Single Select Description",
|
||||
options: ["Option 1", "Option 2"],
|
||||
},
|
||||
multiSelectQuestion: {
|
||||
question: "This is my Multi Select Question",
|
||||
description: "This is Multi Select Description",
|
||||
options: ["Option 1", "Option 2", "Option 3"],
|
||||
},
|
||||
ratingQuestion: {
|
||||
question: "This is my Rating Question",
|
||||
description: "This is Rating Description",
|
||||
lowLabel: "My Lower Label",
|
||||
highLabel: "My Upper Label",
|
||||
},
|
||||
npsQuestion: {
|
||||
question: "This is my NPS Question",
|
||||
lowLabel: "My Lower Label",
|
||||
highLabel: "My Upper Label",
|
||||
},
|
||||
ctaQuestion: {
|
||||
question: "This is my CTA Question",
|
||||
buttonLabel: "My Button Label",
|
||||
},
|
||||
consentQuestion: {
|
||||
question: "This is my Consent Question",
|
||||
checkboxLabel: "My Checkbox Label",
|
||||
},
|
||||
pictureSelectQuestion: {
|
||||
question: "This is my Picture Select Question",
|
||||
description: "This is Picture Select Description",
|
||||
},
|
||||
fileUploadQuestion: {
|
||||
question: "This is my File Upload Question",
|
||||
},
|
||||
date: {
|
||||
question: "This is my Date Question",
|
||||
},
|
||||
cal: {
|
||||
question: "This is my cal Question",
|
||||
},
|
||||
matrix: {
|
||||
question: "This is my Matrix Question",
|
||||
description: "0: Not at all, 3: Love it",
|
||||
rows: ["Roses", "Trees", "Ocean"],
|
||||
columns: ["0", "1", "2", "3"],
|
||||
},
|
||||
address: {
|
||||
question: "Where do you live?",
|
||||
placeholder: "Address Line 1",
|
||||
},
|
||||
ranking: {
|
||||
question: "This is my Ranking Question",
|
||||
choices: ["Work", "Money", "Travel", "Family", "Friends"],
|
||||
},
|
||||
thankYouCard: {
|
||||
headline: "This is my Thank You Card Headline!",
|
||||
description: "This is my Thank you Card Description!",
|
||||
},
|
||||
},
|
||||
germanCreate: {
|
||||
welcomeCard: {
|
||||
headline: "Willkommen zu meiner Testumfrage Willkommenskarte!", // German translation
|
||||
@@ -240,6 +311,7 @@ export const surveys = {
|
||||
};
|
||||
|
||||
export type CreateSurveyParams = typeof surveys.createAndSubmit;
|
||||
export type CreateSurveyWithLogicParams = typeof surveys.createWithLogicAndSubmit;
|
||||
|
||||
export const actions = {
|
||||
create: {
|
||||
|
||||
23
oss-gg/formbricks-quests/1-meme-magic.md
Normal file
@@ -0,0 +1,23 @@
|
||||
**Side Quest**: Meme Magic - Craft a meme where a brick plays a role. Tweet it, and tag us @formbricks to submit.
|
||||
**Points**: 150 Points
|
||||
**Proof**: Add a screenshot of meme to the PR description. Add a link to your tweet in the list below.
|
||||
|
||||
Please follow the following schema:
|
||||
|
||||
---
|
||||
|
||||
» 05-April-2024 by YOUR NAME
|
||||
» Link to Tweet: https://x.com/...
|
||||
|
||||
---
|
||||
|
||||
////////////////////////////
|
||||
|
||||
Your turn 👇
|
||||
|
||||
////////////////////////////
|
||||
|
||||
» 01-October-2024 by YOUR NAME
|
||||
» Link to Tweet: https://x.com/...
|
||||
|
||||
---
|
||||
23
oss-gg/formbricks-quests/2-gif-magic.md
Normal file
@@ -0,0 +1,23 @@
|
||||
**Side Quest**: GIF Magic - Craft a GIF where a brick plays a role. Upload it to GIPHY with tags 'open source', 'foss', 'formbricks'.
|
||||
**Points**: 150 Points
|
||||
**Proof**: Add a screenshot of GIF on Giphy to the PR description. Add a link to your GIPHY in the list below.
|
||||
|
||||
Please follow the following schema:
|
||||
|
||||
---
|
||||
|
||||
» 05-April-2024 by YOUR NAME
|
||||
» Link to Tweet: https://giphy.com/...
|
||||
|
||||
---
|
||||
|
||||
////////////////////////////
|
||||
|
||||
Your turn 👇
|
||||
|
||||
////////////////////////////
|
||||
|
||||
» 01-October-2024 by YOUR NAME
|
||||
» Link to Tweet: https://x.com/...
|
||||
|
||||
---
|
||||
21
oss-gg/formbricks-quests/3-follow-linkedin.md
Normal file
@@ -0,0 +1,21 @@
|
||||
**Side Quest**: Follow us on LinkedIn
|
||||
**Points**: 50 Points
|
||||
**Proof**: Add a screenshot of you following our LinkedIn profile to the PR description. Add your name to the list below.
|
||||
|
||||
Please follow the following schema:
|
||||
|
||||
---
|
||||
|
||||
» 05-April-2024 by YOUR NAME
|
||||
|
||||
---
|
||||
|
||||
////////////////////////////
|
||||
|
||||
Your turn 👇
|
||||
|
||||
////////////////////////////
|
||||
|
||||
» 01-October-2024 by YOUR NAME
|
||||
|
||||
---
|
||||
21
oss-gg/formbricks-quests/4-starry-eyed-supporter.md
Normal file
@@ -0,0 +1,21 @@
|
||||
**Side Quest**: Starry-eyed Supporter - Get five friends to star our repository.
|
||||
**Points**: 150 Points
|
||||
**Proof**: Screenshots to prove that you asked them and they confirmed + their GitHub names
|
||||
|
||||
Please follow the following schema:
|
||||
|
||||
---
|
||||
|
||||
» 05-April-2024 by YOUR GITHUB HANDLE
|
||||
|
||||
---
|
||||
|
||||
////////////////////////////
|
||||
|
||||
Your turn 👇
|
||||
|
||||
////////////////////////////
|
||||
|
||||
» 01-October-2024 by X
|
||||
|
||||
---
|
||||
23
oss-gg/formbricks-quests/5-brickfy-someone-famous.md
Normal file
@@ -0,0 +1,23 @@
|
||||
**Side Quest**: Brickify someone famous with AI
|
||||
**Points**: 150 Points
|
||||
**Proof**: Add link to your tweet with the brickified image to the list below.
|
||||
|
||||
Please follow the following schema:
|
||||
|
||||
---
|
||||
|
||||
» 05-April-2024 by YOUR GITHUB HANDLE
|
||||
» Link to Tweet: https://x.com/...
|
||||
|
||||
---
|
||||
|
||||
////////////////////////////
|
||||
|
||||
Your turn 👇
|
||||
|
||||
////////////////////////////
|
||||
|
||||
» 01-October-2024 by X
|
||||
» Link to Tweet: https://x.com/...
|
||||
|
||||
---
|
||||
19
oss-gg/formbricks-quests/6-secret-side-quests.md
Normal file
@@ -0,0 +1,19 @@
|
||||
**Side Quest**: Completed secret side quests
|
||||
**Points**: 150-300 Points
|
||||
**Proof**: Add your name to the list
|
||||
|
||||
Please follow the following schema:
|
||||
|
||||
---
|
||||
|
||||
## » 05-April-2024 by YOUR NAME
|
||||
|
||||
////////////////////////////
|
||||
|
||||
Your turn 👇
|
||||
|
||||
////////////////////////////
|
||||
|
||||
» 01-October-2024 by X
|
||||
|
||||
---
|
||||
19
oss-gg/formbricks-quests/7-quest-wizard.md
Normal file
@@ -0,0 +1,19 @@
|
||||
**Side Quest**: Complete all Formbricks side quests
|
||||
**Points**: 300 Points
|
||||
**Proof**: Add screenshots for each side quest to the PR description. Add your name to the list below.
|
||||
|
||||
Please follow the following schema:
|
||||
|
||||
---
|
||||
|
||||
## » 05-April-2024 by YOUR NAME
|
||||
|
||||
////////////////////////////
|
||||
|
||||
Your turn 👇
|
||||
|
||||
////////////////////////////
|
||||
|
||||
» 01-October-2024 by X
|
||||
|
||||
---
|
||||
21
oss-gg/oss-gg-quests/1-retweet-launch-tweet.md
Normal file
@@ -0,0 +1,21 @@
|
||||
**Side Quest**: Like & Re-Tweet oss.gg Launch Tweet
|
||||
**Points**: 50 Points
|
||||
**Proof**: Add a screenshot of your retweet and liking the tweet to the PR description. Add your handle to the list below.
|
||||
|
||||
Please follow the following schema:
|
||||
|
||||
---
|
||||
|
||||
» 05-April-2024 by YOUR TWITTER HANDLE
|
||||
|
||||
---
|
||||
|
||||
////////////////////////////
|
||||
|
||||
Your turn 👇
|
||||
|
||||
////////////////////////////
|
||||
|
||||
» 01-October-2024 by X
|
||||
|
||||
---
|
||||
21
oss-gg/oss-gg-quests/2-engage-on-reddit.md
Normal file
@@ -0,0 +1,21 @@
|
||||
**Side Quest**: Upvote and comment on Reddit launch thread
|
||||
**Points**: 50 Points
|
||||
**Proof**: Add a screenshot of your upvote and comment in the PR description. Add your handle to the list below.
|
||||
|
||||
Please follow the following schema:
|
||||
|
||||
---
|
||||
|
||||
» 05-April-2024 by YOUR REDDIT HANDLE
|
||||
|
||||
---
|
||||
|
||||
////////////////////////////
|
||||
|
||||
Your turn 👇
|
||||
|
||||
////////////////////////////
|
||||
|
||||
» 01-October-2024 by X
|
||||
|
||||
---
|
||||