Merge branch 'main' into feature/upgrade-deps-09

This commit is contained in:
Matti Nannt
2024-09-30 15:04:19 +02:00
committed by GitHub
153 changed files with 11445 additions and 2143 deletions

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View 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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

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

View File

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

View File

@@ -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" },
],
},
{

View File

@@ -12,215 +12,395 @@ export const XMSurveyDefault: TXMTemplate = {
},
};
const NPSSurvey: TXMTemplate = {
...XMSurveyDefault,
name: "NPS Survey",
questions: [
{
id: createId(),
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "How likely are you to recommend {{productName}} to a friend or colleague?" },
required: true,
lowerLabel: { default: "Not at all likely" },
upperLabel: { default: "Extremely likely" },
isColorCodingEnabled: true,
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "To help us improve, can you describe the reason(s) for your rating?" },
required: false,
inputType: "text",
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Any other comments, feedback, or concerns?" },
required: false,
inputType: "text",
},
],
};
const StarRatingSurvey: TXMTemplate = {
...XMSurveyDefault,
name: "{{productName}}'s Rating Survey",
questions: [
{
id: createId(),
type: TSurveyQuestionTypeEnum.Rating,
logic: [{ value: 3, condition: "lessEqual", destination: "tk9wpw2gxgb8fa6pbpp3qq5l" }],
range: 5,
scale: "number",
headline: { default: "How do you like {{productName}}?" },
required: true,
lowerLabel: { default: "Extremely dissatisfied" },
upperLabel: { default: "Extremely satisfied" },
isColorCodingEnabled: false,
},
{
id: createId(),
html: { default: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>' },
type: TSurveyQuestionTypeEnum.CTA,
logic: [{ condition: "clicked", destination: XMSurveyDefault.endings[0].id }],
headline: { default: "Happy to hear 🙏 Please write a review for us!" },
required: true,
buttonUrl: "https://formbricks.com/github",
buttonLabel: { default: "Write review" },
buttonExternal: true,
},
{
id: "tk9wpw2gxgb8fa6pbpp3qq5l",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Sorry to hear! What is ONE thing we can do better?" },
required: true,
subheader: { default: "Help us improve your experience." },
buttonLabel: { default: "Send" },
placeholder: { default: "Type your answer here..." },
inputType: "text",
},
],
};
const CSATSurvey: TXMTemplate = {
...XMSurveyDefault,
name: "{{productName}} CSAT",
questions: [
{
id: createId(),
type: TSurveyQuestionTypeEnum.Rating,
logic: [{ value: 3, condition: "lessEqual", destination: "vyo4mkw4ln95ts4ya7qp2tth" }],
range: 5,
scale: "smiley",
headline: { default: "How satisfied are you with your {{productName}} experience?" },
required: true,
lowerLabel: { default: "Extremely dissatisfied" },
upperLabel: { default: "Extremely satisfied" },
isColorCodingEnabled: false,
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
logic: [{ condition: "submitted", destination: XMSurveyDefault.endings[0].id }],
headline: { default: "Lovely! Is there anything we can do to improve your experience?" },
required: false,
placeholder: { default: "Type your answer here..." },
inputType: "text",
},
{
id: "vyo4mkw4ln95ts4ya7qp2tth",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Ugh, sorry! Is there anything we can do to improve your experience?" },
required: false,
placeholder: { default: "Type your answer here..." },
inputType: "text",
},
],
};
const CESSurvey: TXMTemplate = {
...XMSurveyDefault,
name: "CES Survey",
questions: [
{
id: createId(),
type: TSurveyQuestionTypeEnum.Rating,
range: 5,
scale: "number",
headline: { default: "{{productName}} makes it easy for me to [ADD GOAL]" },
required: true,
lowerLabel: { default: "Disagree strongly" },
upperLabel: { default: "Agree strongly" },
isColorCodingEnabled: false,
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Thanks! How could we make it easier for you to [ADD GOAL]?" },
required: true,
placeholder: { default: "Type your answer here..." },
inputType: "text",
},
],
};
const SmileysRatingSurvey: TXMTemplate = {
...XMSurveyDefault,
name: "Smileys Survey",
questions: [
{
id: createId(),
type: TSurveyQuestionTypeEnum.Rating,
logic: [{ value: 3, condition: "lessEqual", destination: "tk9wpw2gxgb8fa6pbpp3qq5l" }],
range: 5,
scale: "smiley",
headline: { default: "How do you like {{productName}}?" },
required: true,
lowerLabel: { default: "Not good" },
upperLabel: { default: "Very satisfied" },
isColorCodingEnabled: false,
},
{
id: createId(),
html: { default: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>' },
type: TSurveyQuestionTypeEnum.CTA,
logic: [{ condition: "clicked", destination: XMSurveyDefault.endings[0].id }],
headline: { default: "Happy to hear 🙏 Please write a review for us!" },
required: true,
buttonUrl: "https://formbricks.com/github",
buttonLabel: { default: "Write review" },
buttonExternal: true,
},
{
id: "tk9wpw2gxgb8fa6pbpp3qq5l",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Sorry to hear! What is ONE thing we can do better?" },
required: true,
subheader: { default: "Help us improve your experience." },
buttonLabel: { default: "Send" },
placeholder: { default: "Type your answer here..." },
inputType: "text",
},
],
};
const eNPSSurvey: TXMTemplate = {
...XMSurveyDefault,
name: "eNPS Survey",
questions: [
{
id: createId(),
type: TSurveyQuestionTypeEnum.NPS,
headline: {
default: "How likely are you to recommend working at this company to a friend or colleague?",
const NPSSurvey = (): TXMTemplate => {
return {
...XMSurveyDefault,
name: "NPS Survey",
questions: [
{
id: createId(),
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "How likely are you to recommend {{productName}} to a friend or colleague?" },
required: true,
lowerLabel: { default: "Not at all likely" },
upperLabel: { default: "Extremely likely" },
isColorCodingEnabled: true,
},
required: false,
lowerLabel: { default: "Not at all likely" },
upperLabel: { default: "Extremely likely" },
isColorCodingEnabled: true,
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "To help us improve, can you describe the reason(s) for your rating?" },
required: false,
inputType: "text",
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Any other comments, feedback, or concerns?" },
required: false,
inputType: "text",
},
],
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "To help us improve, can you describe the reason(s) for your rating?" },
required: false,
inputType: "text",
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Any other comments, feedback, or concerns?" },
required: false,
inputType: "text",
},
],
};
};
const StarRatingSurvey = (): TXMTemplate => {
const reusableQuestionIds = [createId(), createId(), createId()];
return {
...XMSurveyDefault,
name: "{{productName}}'s Rating Survey",
questions: [
{
id: reusableQuestionIds[0],
type: TSurveyQuestionTypeEnum.Rating,
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[0],
type: "question",
},
operator: "isLessThanOrEqual",
rightOperand: {
type: "static",
value: 3,
},
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: reusableQuestionIds[2],
},
],
},
],
range: 5,
scale: "number",
headline: { default: "How do you like {{productName}}?" },
required: true,
lowerLabel: { default: "Extremely dissatisfied" },
upperLabel: { default: "Extremely satisfied" },
isColorCodingEnabled: false,
},
{
id: reusableQuestionIds[1],
html: { default: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>' },
type: TSurveyQuestionTypeEnum.CTA,
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[1],
type: "question",
},
operator: "isClicked",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: XMSurveyDefault.endings[0].id,
},
],
},
],
headline: { default: "Happy to hear 🙏 Please write a review for us!" },
required: true,
buttonUrl: "https://formbricks.com/github",
buttonLabel: { default: "Write review" },
buttonExternal: true,
},
{
id: reusableQuestionIds[2],
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Sorry to hear! What is ONE thing we can do better?" },
required: true,
subheader: { default: "Help us improve your experience." },
buttonLabel: { default: "Send" },
placeholder: { default: "Type your answer here..." },
inputType: "text",
},
],
};
};
const CSATSurvey = (): TXMTemplate => {
const reusableQuestionIds = [createId(), createId(), createId()];
return {
...XMSurveyDefault,
name: "{{productName}} CSAT",
questions: [
{
id: reusableQuestionIds[0],
type: TSurveyQuestionTypeEnum.Rating,
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[0],
type: "question",
},
operator: "isLessThanOrEqual",
rightOperand: {
type: "static",
value: 3,
},
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: reusableQuestionIds[2],
},
],
},
],
range: 5,
scale: "smiley",
headline: { default: "How satisfied are you with your {{productName}} experience?" },
required: true,
lowerLabel: { default: "Extremely dissatisfied" },
upperLabel: { default: "Extremely satisfied" },
isColorCodingEnabled: false,
},
{
id: reusableQuestionIds[1],
type: TSurveyQuestionTypeEnum.OpenText,
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[1],
type: "question",
},
operator: "isSubmitted",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: XMSurveyDefault.endings[0].id,
},
],
},
],
headline: { default: "Lovely! Is there anything we can do to improve your experience?" },
required: false,
placeholder: { default: "Type your answer here..." },
inputType: "text",
},
{
id: reusableQuestionIds[2],
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Ugh, sorry! Is there anything we can do to improve your experience?" },
required: false,
placeholder: { default: "Type your answer here..." },
inputType: "text",
},
],
};
};
const CESSurvey = (): TXMTemplate => {
return {
...XMSurveyDefault,
name: "CES Survey",
questions: [
{
id: createId(),
type: TSurveyQuestionTypeEnum.Rating,
range: 5,
scale: "number",
headline: { default: "{{productName}} makes it easy for me to [ADD GOAL]" },
required: true,
lowerLabel: { default: "Disagree strongly" },
upperLabel: { default: "Agree strongly" },
isColorCodingEnabled: false,
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Thanks! How could we make it easier for you to [ADD GOAL]?" },
required: true,
placeholder: { default: "Type your answer here..." },
inputType: "text",
},
],
};
};
const SmileysRatingSurvey = (): TXMTemplate => {
const reusableQuestionIds = [createId(), createId(), createId()];
return {
...XMSurveyDefault,
name: "Smileys Survey",
questions: [
{
id: reusableQuestionIds[0],
type: TSurveyQuestionTypeEnum.Rating,
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[0],
type: "question",
},
operator: "isLessThanOrEqual",
rightOperand: {
type: "static",
value: 3,
},
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: reusableQuestionIds[2],
},
],
},
],
range: 5,
scale: "smiley",
headline: { default: "How do you like {{productName}}?" },
required: true,
lowerLabel: { default: "Not good" },
upperLabel: { default: "Very satisfied" },
isColorCodingEnabled: false,
},
{
id: reusableQuestionIds[1],
html: { default: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>' },
type: TSurveyQuestionTypeEnum.CTA,
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[1],
type: "question",
},
operator: "isClicked",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: XMSurveyDefault.endings[0].id,
},
],
},
],
headline: { default: "Happy to hear 🙏 Please write a review for us!" },
required: true,
buttonUrl: "https://formbricks.com/github",
buttonLabel: { default: "Write review" },
buttonExternal: true,
},
{
id: reusableQuestionIds[2],
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Sorry to hear! What is ONE thing we can do better?" },
required: true,
subheader: { default: "Help us improve your experience." },
buttonLabel: { default: "Send" },
placeholder: { default: "Type your answer here..." },
inputType: "text",
},
],
};
};
const eNPSSurvey = (): TXMTemplate => {
return {
...XMSurveyDefault,
name: "eNPS Survey",
questions: [
{
id: createId(),
type: TSurveyQuestionTypeEnum.NPS,
headline: {
default: "How likely are you to recommend working at this company to a friend or colleague?",
},
required: false,
lowerLabel: { default: "Not at all likely" },
upperLabel: { default: "Extremely likely" },
isColorCodingEnabled: true,
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "To help us improve, can you describe the reason(s) for your rating?" },
required: false,
inputType: "text",
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Any other comments, feedback, or concerns?" },
required: false,
inputType: "text",
},
],
};
};
export const XMTemplates: TXMTemplate[] = [
NPSSurvey,
StarRatingSurvey,
CSATSurvey,
CESSurvey,
SmileysRatingSurvey,
eNPSSurvey,
NPSSurvey(),
StarRatingSurvey(),
CSATSurvey(),
CESSurvey(),
SmileysRatingSurvey(),
eNPSSurvey(),
];

View File

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

View File

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

View File

@@ -0,0 +1,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>
);
}

View File

@@ -3,7 +3,7 @@
import { EditorCardMenu } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditorCardMenu";
import { EndScreenForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EndScreenForm";
import { RedirectUrlForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RedirectUrlForm";
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util";
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { createId } from "@paralleldrive/cuid2";

View File

@@ -17,7 +17,6 @@ import {
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveyRedirectUrlCard,
ZSurveyQuestion,
} from "@formbricks/types/surveys/types";
import { ConfirmationModal } from "@formbricks/ui/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>

View File

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

View File

@@ -1,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>
);
};
}

View File

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

View File

@@ -0,0 +1,347 @@
import {
getConditionOperatorOptions,
getConditionValueOptions,
getDefaultOperatorForQuestion,
getMatchValueProps,
} from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
import { createId } from "@paralleldrive/cuid2";
import { CopyIcon, EllipsisVerticalIcon, PlusIcon, TrashIcon, WorkflowIcon } from "lucide-react";
import { cn } from "@formbricks/lib/cn";
import {
addConditionBelow,
createGroupFromResource,
duplicateCondition,
isConditionGroup,
removeCondition,
toggleGroupConnector,
updateCondition,
} from "@formbricks/lib/surveyLogic/utils";
import {
TConditionGroup,
TDynamicLogicField,
TRightOperand,
TSingleCondition,
TSurvey,
TSurveyLogicConditionsOperator,
TSurveyQuestion,
} from "@formbricks/types/surveys/types";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@formbricks/ui/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>
);
}

View File

@@ -1,11 +1,13 @@
"use client";
import { findOptionUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
import { DndContext } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { createId } from "@paralleldrive/cuid2";
import { PlusIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { createI18nString, extractLanguageCodes, getLocalizedValue } from "@formbricks/lib/i18n/utils";
import toast from "react-hot-toast";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import {
TI18nString,
@@ -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(() => {

View File

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

View File

@@ -1,7 +1,7 @@
"use client";
import { RankingQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm";
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util";
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import * as Collapsible from "@radix-ui/react-collapsible";

View File

@@ -1,6 +1,8 @@
"use client";
import { AddEndingCardButton } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddEndingCardButton";
import { SurveyVariablesCard } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCard";
import { findQuestionUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
import {
DndContext,
DragEndEvent,
@@ -16,11 +18,18 @@ import toast from "react-hot-toast";
import { MultiLanguageCard } from "@formbricks/ee/multi-language/components/multi-language-card";
import { addMultiLanguageLabels, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { isConditionGroup } from "@formbricks/lib/surveyLogic/utils";
import { getDefaultEndingCard } from "@formbricks/lib/templates";
import { checkForEmptyFallBackValue, extractRecallInfo } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
import { TProduct } from "@formbricks/types/product";
import {
TConditionGroup,
TSingleCondition,
TSurveyLogic,
TSurveyLogicAction,
} from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { findQuestionsWithCyclicLogic } from "@formbricks/types/surveys/validation";
import {
@@ -77,22 +86,75 @@ export const QuestionsView = ({
const surveyLanguages = localSurvey.languages;
const [backButtonLabel, setbackButtonLabel] = useState(null);
const handleQuestionLogicChange = (survey: TSurvey, compareId: string, updatedId: string): TSurvey => {
survey.questions.forEach((question) => {
if (question.headline[selectedLanguageCode].includes(`recall:${compareId}`)) {
question.headline[selectedLanguageCode] = question.headline[selectedLanguageCode].replaceAll(
`recall:${compareId}`,
`recall:${updatedId}`
);
const updateConditions = (conditions: TConditionGroup): TConditionGroup => {
return {
...conditions,
conditions: conditions?.conditions.map((condition) => {
if (isConditionGroup(condition)) {
return updateConditions(condition);
} else {
return updateSingleCondition(condition);
}
}),
};
};
const updateSingleCondition = (condition: TSingleCondition): TSingleCondition => {
let updatedCondition = { ...condition };
if (condition.leftOperand.value === compareId) {
updatedCondition.leftOperand = { ...condition.leftOperand, value: updatedId };
}
if (!question.logic) return;
question.logic.forEach((rule) => {
if (rule.destination === compareId) {
rule.destination = updatedId;
if (condition.rightOperand?.type === "question" && condition.rightOperand?.value === compareId) {
updatedCondition.rightOperand = { ...condition.rightOperand, value: updatedId };
}
return updatedCondition;
};
const updateActions = (actions: TSurveyLogicAction[]): TSurveyLogicAction[] => {
return actions.map((action) => {
let updatedAction = { ...action };
if (updatedAction.objective === "jumpToQuestion" && updatedAction.target === compareId) {
updatedAction.target = updatedId;
}
if (updatedAction.objective === "requireAnswer" && updatedAction.target === compareId) {
updatedAction.target = updatedId;
}
return updatedAction;
});
});
return survey;
};
return {
...survey,
questions: survey.questions.map((question) => {
let updatedQuestion = { ...question };
if (question.headline[selectedLanguageCode].includes(`recall:${compareId}`)) {
question.headline[selectedLanguageCode] = question.headline[selectedLanguageCode].replaceAll(
`recall:${compareId}`,
`recall:${updatedId}`
);
}
// Update advanced logic
if (question.logic) {
updatedQuestion.logic = question.logic.map((logicRule: TSurveyLogic) => ({
...logicRule,
conditions: updateConditions(logicRule.conditions),
actions: updateActions(logicRule.actions),
}));
}
return updatedQuestion;
}),
};
};
useEffect(() => {
@@ -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}

View File

@@ -192,7 +192,9 @@ export const SurveyMenuBar = ({
toast.error(`${messageSplit} ${invalidLanguageLabels.join(", ")}`);
} else {
toast.error(currentError.message);
toast.error(currentError.message, {
className: "w-fit !max-w-md",
});
}
return false;

View File

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

View File

@@ -1,9 +1,11 @@
"use client";
import { findVariableUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
import { createId } from "@paralleldrive/cuid2";
import { TrashIcon } from "lucide-react";
import React, { useCallback, useEffect } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { extractRecallInfo } from "@formbricks/lib/utils/recall";
import { TSurvey, TSurveyVariable } from "@formbricks/types/surveys/types";
import { Button } from "@formbricks/ui/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>
)}

View File

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

View File

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

View File

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

View File

@@ -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({

View File

@@ -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}
/>
);
};

View File

@@ -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 && (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -108,6 +108,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
questions: data.questionIds,
survey: data.surveyId,
table: data.tableId,
includeVariables: !!data.includeVariables,
includeHiddenFields: !!data.includeHiddenFields,
includeMetadata: !!data.includeMetadata,
index,

View File

@@ -62,6 +62,7 @@ export const AddIntegrationModal = ({
const [spreadsheetUrl, setSpreadsheetUrl] = useState("");
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const existingIntegrationData = googleSheetIntegration?.config?.data;
const [includeVariables, setIncludeVariables] = useState(false);
const [includeHiddenFields, setIncludeHiddenFields] = useState(false);
const [includeMetadata, setIncludeMetadata] = useState(false);
const googleSheetIntegrationData: TIntegrationGoogleSheetsInput = {
@@ -89,6 +90,7 @@ export const AddIntegrationModal = ({
})!
);
setSelectedQuestions(selectedIntegration.questionIds);
setIncludeVariables(!!selectedIntegration.includeVariables);
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
setIncludeMetadata(!!selectedIntegration.includeMetadata);
return;
@@ -127,6 +129,7 @@ export const AddIntegrationModal = ({
? "All questions"
: "Selected questions";
integrationData.createdAt = new Date();
integrationData.includeVariables = includeVariables;
integrationData.includeHiddenFields = includeHiddenFields;
integrationData.includeMetadata = includeMetadata;
if (selectedIntegration) {
@@ -256,6 +259,8 @@ export const AddIntegrationModal = ({
</div>
</div>
<AdditionalIntegrationSettings
includeVariables={includeVariables}
setIncludeVariables={setIncludeVariables}
includeHiddenFields={includeHiddenFields}
includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}

View File

@@ -118,6 +118,13 @@ export const AddIntegrationModal = ({
}))
: [];
const variables =
selectedSurvey?.variables.map((variable) => ({
id: variable.id,
name: variable.name,
type: TSurveyQuestionTypeEnum.OpenText,
})) || [];
const hiddenFields = selectedSurvey?.hiddenFields.enabled
? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
id: fId,
@@ -133,7 +140,7 @@ export const AddIntegrationModal = ({
},
];
return [...questions, ...hiddenFields, ...Metadata];
return [...questions, ...variables, ...hiddenFields, ...Metadata];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSurvey?.id]);

View File

@@ -49,6 +49,7 @@ export const AddChannelMappingModal = ({
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
const [selectedChannel, setSelectedChannel] = useState<TIntegrationItem | null>(null);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const [includeVariables, setIncludeVariables] = useState(false);
const [includeHiddenFields, setIncludeHiddenFields] = useState(false);
const [includeMetadata, setIncludeMetadata] = useState(false);
const existingIntegrationData = slackIntegration?.config?.data;
@@ -81,6 +82,7 @@ export const AddChannelMappingModal = ({
})!
);
setSelectedQuestions(selectedIntegration.questionIds);
setIncludeVariables(!!selectedIntegration.includeVariables);
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
setIncludeMetadata(!!selectedIntegration.includeMetadata);
return;
@@ -112,6 +114,7 @@ export const AddChannelMappingModal = ({
? "All questions"
: "Selected questions",
createdAt: new Date(),
includeVariables,
includeHiddenFields,
includeMetadata,
};
@@ -258,6 +261,8 @@ export const AddChannelMappingModal = ({
</div>
</div>
<AdditionalIntegrationSettings
includeVariables={includeVariables}
setIncludeVariables={setIncludeVariables}
includeHiddenFields={includeHiddenFields}
includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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();
});
});
});

View File

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

View File

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

View File

@@ -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/...
---

View 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/...
---

View 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
---

View 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
---

View 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/...
---

View 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
---

View 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
---

View 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
---

View 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
---

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