Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed383e7862 | |||
| 3db7c7946b | |||
| 2f63659739 | |||
| b3757ae7c1 | |||
| 942bf818a5 | |||
| 74b03a54e1 | |||
| 5282637772 | |||
| 9165daffe9 | |||
| de05d2abdf | |||
| a907179c7d | |||
| bc7d3e5da4 | |||
| 7042d73e99 | |||
| fe7ca5a923 | |||
| 960edfd3f0 | |||
| 2b0df8280d | |||
| 13ce552a39 | |||
| 4d6665ab3e | |||
| 483bdc0eff | |||
| 8238b502fe | |||
| 01ceaa13ec | |||
| 35ff935a27 | |||
| 901cd42f56 | |||
| 63e1ac11cf | |||
| 744f3410ae | |||
| 0e73d81999 | |||
| ba46782da4 | |||
| 25774f6f08 | |||
| 9a98772210 | |||
| 59a29dd3d6 | |||
| e4fceb2e5e | |||
| 350c895d8c | |||
| 0b553447e0 | |||
| 6b64367d99 | |||
| fe9746ba67 | |||
| e4009d5951 | |||
| b1ed61c247 | |||
| 10255aa102 | |||
| 774c6f19a5 | |||
| ebf35ea582 | |||
| f13efc954e | |||
| 9ee052a229 | |||
| 152fbede90 | |||
| 4ed1747ee2 | |||
| 88c492afd8 | |||
| c7e0b02595 | |||
| 15aa9b2731 | |||
| 6f7359abf6 | |||
| 0bf5e0fd4c | |||
| 887c5c0eef | |||
| af4ae38564 | |||
| 6b829744a1 | |||
| 7b0d4926e8 | |||
| 334166b4b1 | |||
| 1685e77a35 | |||
| 927f97e9ad | |||
| 76c437b16a | |||
| 0c2d425a45 | |||
| 7aa827d1e1 | |||
| d4961f1840 | |||
| 6772ea6be4 | |||
| 9bacc88063 | |||
| 005c777c9c | |||
| 560dce3bbf | |||
| ffd45a6f20 | |||
| 1ddd82c084 | |||
| 647539c617 | |||
| a5a3161f7c | |||
| 0535581f6d | |||
| 97b04f9e43 | |||
| 6ec0861c49 | |||
| 8542320a8e | |||
| 7d3c8d35e1 | |||
| 6abcf91a07 | |||
| 9a7887d9fd | |||
| 7dd8bb95bd | |||
| df40c0ef11 | |||
| 7e14b86a63 | |||
| a1ba3af439 | |||
| 0a8c5e384d | |||
| b20ce46a7b | |||
| 58636a9d51 | |||
| 328e3d0b9a | |||
| f1a2ecaa3a | |||
| d56f05fb19 | |||
| c32ced20f1 | |||
| 387590986a | |||
| 4a5c0b1409 | |||
| 275731e381 | |||
| 71c3ac0e4e | |||
| d876c495be | |||
| 1ba885e5dc | |||
| 245972234e | |||
| f743709908 | |||
| 79603293a0 | |||
| 29e0cf96d4 | |||
| 4eea6a11c8 |
@@ -51,6 +51,7 @@ jobs:
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env
|
||||
echo "E2E_TESTING=1" >> .env
|
||||
echo "NEXT_PUBLIC_E2E_TESTING=1" >> .env
|
||||
shell: bash
|
||||
|
||||
- name: Build App
|
||||
|
||||
@@ -79,28 +79,6 @@ Promise<{ id: string }, NetworkError | Error>
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
- Update Display
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Update Display">
|
||||
|
||||
```javascript {{ title: 'Update Display Method Call'}}
|
||||
await api.client.display.update(
|
||||
displayId: "<your-display-id>",
|
||||
{
|
||||
userId: "<your-user-id>", // optional
|
||||
responseId: "<your-response-id>", // optional
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
```javascript {{ title: 'Update Display Method Return Type' }}
|
||||
Promise<{ }, NetworkError | Error]>
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
## Responses
|
||||
|
||||
- Create Response
|
||||
@@ -173,29 +151,6 @@ Promise<{ }, NetworkError | Error]>
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
## Action
|
||||
|
||||
- Create Action:
|
||||
|
||||
<Note> An environment cannot have 2 actions with the same name. </Note>
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Create Action">
|
||||
|
||||
```javascript {{ title: 'Create Action Method Call'}}
|
||||
await api.client.action.create({
|
||||
name: "<your-action-name>", // required
|
||||
userId: "<your-user-id>", // required
|
||||
});
|
||||
```
|
||||
|
||||
```javascript {{ title: 'Create Action Method Return Type' }}
|
||||
Promise<{ }, NetworkError | Error]>
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
## Attribute
|
||||
|
||||
- Update Attribute
|
||||
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 41 KiB |
@@ -0,0 +1,171 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import ActionCalculateOperators from "./images/action-calculate-operators.webp";
|
||||
import ActionCalculateValue from "./images/action-calculate-value.webp";
|
||||
import ActionCalculateVariables from "./images/action-calculate-variables.webp";
|
||||
import ActionCalculate from "./images/action-calculate.webp";
|
||||
import ActionJump from "./images/action-jump.webp";
|
||||
import ActionOptions from "./images/action-options.webp";
|
||||
import ActionRequire from "./images/action-require.webp";
|
||||
import AddLogic from "./images/add-logic.webp";
|
||||
import ConditionChaining from "./images/condition-chaining.webp";
|
||||
import ConditionOperators from "./images/condition-operators.webp";
|
||||
import ConditionOptions from "./images/condition-options.webp";
|
||||
import ConditionValue from "./images/condition-value.webp";
|
||||
import Conditions from "./images/conditions.webp";
|
||||
import Editor from "./images/editor.webp";
|
||||
import QuestionLogic from "./images/question-logic.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Logic Editor",
|
||||
description:
|
||||
"Create complex survey logic with the Logic Editor. Use conditions, actions, and variables to create a personalized survey experience.",
|
||||
};
|
||||
|
||||
# Logic Editor
|
||||
|
||||
Create complex survey logic with the Logic Editor. Use conditions, actions, and variables to create a personalized survey experience.
|
||||
|
||||
<MdxImage src={Editor} alt="Logic Editor" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Condition**: A rule that determines when an action should be executed.
|
||||
- **Action**: A task that is executed when a condition is met.
|
||||
|
||||
## **Creating Logic**
|
||||
|
||||
1. **Add a Logic Block**: Click the `Add logic +` button to add a new logic block.
|
||||
|
||||
<MdxImage src={AddLogic} alt="Add Logic" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
|
||||
<Note>
|
||||
You can add multiple logic blocks to a survey. Logic blocks are executed in the order they are added. You
|
||||
can rearrange the order of logic blocks.
|
||||
</Note>
|
||||
|
||||
2. **Add Conditions**: Add conditions to the logic block. Conditions are rules that determine when an action should be executed.
|
||||
|
||||
<MdxImage
|
||||
src={Conditions}
|
||||
alt="Add Conditions"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
Conditons can be based on:
|
||||
|
||||
- **Question**: The answer to a question.
|
||||
- **Variable**: A variable value.
|
||||
- **Hidden Field**: The value of a hidden field.
|
||||
|
||||
2.a **Condition Options**: Choose from a list of available conditions.
|
||||
|
||||
<MdxImage
|
||||
src={ConditionOptions}
|
||||
alt="Condition Options"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
2.b **Condition Operators**: Choose an operator to compare the condition value.
|
||||
|
||||
<MdxImage
|
||||
src={ConditionOperators}
|
||||
alt="Condition Operators"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
2.c **Condition Value**: Enter a value to compare the condition against.
|
||||
Comparisons can be made against a fixed value or a dynamic value.
|
||||
Dynamic values can be based on a question, variable, or hidden field.
|
||||
|
||||
<MdxImage
|
||||
src={ConditionValue}
|
||||
alt="Condition Value"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
<Note>
|
||||
- Conditions can be grouped. - Conditions can be combined using AND or OR operators. You can add multiple
|
||||
conditions to a logic block. Conditions are evaluated in the order they are added.
|
||||
</Note>
|
||||
|
||||
<MdxImage
|
||||
src={ConditionChaining}
|
||||
alt="Condition Chaining"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
3. **Add Actions**: Add actions to the logic block. Actions are tasks that are executed when a condition is met.
|
||||
|
||||
<Note>You can add multiple actions to a logic block. Actions are executed in the order they are added.</Note>
|
||||
|
||||
- 3.a **Action Options**: Choose from a list of available actions.
|
||||
|
||||
<MdxImage
|
||||
src={ActionOptions}
|
||||
alt="Add Actions"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
Action is of the following types:
|
||||
|
||||
- **Calculate**: Perform a calculation. These variables are then available for use in other questions.
|
||||
|
||||
- Calculations can be performed on variables.
|
||||
- Calculations can be based on fixed values or dynamic values.
|
||||
<MdxImage
|
||||
src={ActionCalculateVariables}
|
||||
alt="Action Calculate Variables"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
<MdxImage
|
||||
src={ActionCalculateOperators}
|
||||
alt="Action Calculate Variables"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
<MdxImage
|
||||
src={ActionCalculateValue}
|
||||
alt="Action Calculate Variables"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
<MdxImage
|
||||
src={ActionCalculate}
|
||||
alt="Action Calculate"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
- **Require Answer**: Make a question required. Only the optional questions can be marked as required while filling the survey.
|
||||
<MdxImage
|
||||
src={ActionRequire}
|
||||
alt="Action Require"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
- **Jump to Question**: Skip to a specific question. The user will be redirected to the specified question based on the condition.
|
||||
<MdxImage
|
||||
src={ActionJump}
|
||||
alt="Action Jump"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
4. **Save Logic**: Click the `Save` button to save the logic block.
|
||||
|
||||
# Question Logic
|
||||
|
||||
This logic is executed when the user answers the question. Logic can be as simple as showing a follow-up question based on the answer or as complex as calculating a score based on multiple answers.
|
||||
|
||||
<MdxImage
|
||||
src={QuestionLogic}
|
||||
alt="Question Logic"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
Before Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -1,15 +1,15 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
|
||||
import EntraIDAppReg01 from "./images/entra_app_reg_01.png";
|
||||
import EntraIDAppReg02 from "./images/entra_app_reg_02.png";
|
||||
import EntraIDAppReg03 from "./images/entra_app_reg_03.png";
|
||||
import EntraIDAppReg04 from "./images/entra_app_reg_04.png";
|
||||
import EntraIDAppReg05 from "./images/entra_app_reg_05.png";
|
||||
import EntraIDAppReg06 from "./images/entra_app_reg_06.png";
|
||||
import EntraIDAppReg07 from "./images/entra_app_reg_07.png";
|
||||
import EntraIDAppReg08 from "./images/entra_app_reg_08.png";
|
||||
import EntraIDAppReg09 from "./images/entra_app_reg_09.png";
|
||||
import EntraIDAppReg10 from "./images/entra_app_reg_10.png";
|
||||
import EntraIDAppReg01 from "./images/entra_app_reg_01.webp";
|
||||
import EntraIDAppReg02 from "./images/entra_app_reg_02.webp";
|
||||
import EntraIDAppReg03 from "./images/entra_app_reg_03.webp";
|
||||
import EntraIDAppReg04 from "./images/entra_app_reg_04.webp";
|
||||
import EntraIDAppReg05 from "./images/entra_app_reg_05.webp";
|
||||
import EntraIDAppReg06 from "./images/entra_app_reg_06.webp";
|
||||
import EntraIDAppReg07 from "./images/entra_app_reg_07.webp";
|
||||
import EntraIDAppReg08 from "./images/entra_app_reg_08.webp";
|
||||
import EntraIDAppReg09 from "./images/entra_app_reg_09.webp";
|
||||
import EntraIDAppReg10 from "./images/entra_app_reg_10.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Configure Formbricks with External auth providers",
|
||||
|
||||
@@ -106,6 +106,7 @@ export const navigation: Array<NavGroup> = [
|
||||
links: [
|
||||
{ title: "Access Roles", href: "/global/access-roles" },
|
||||
{ title: "Styling Theme", href: "/global/styling-theme" },
|
||||
{ title: "Logic Editor", href: "/global/logic-editor" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||
import { getCustomHeadline } from "@/app/(app)/(onboarding)/lib/utils";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
@@ -26,12 +25,9 @@ const Page = async ({ params }: ConnectPageProps) => {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
const channel = product.config.channel;
|
||||
const industry = product.config.industry;
|
||||
const channel = product.config.channel || null;
|
||||
const industry = product.config.industry || null;
|
||||
|
||||
if (!channel || !industry) {
|
||||
return notFound();
|
||||
}
|
||||
const customHeadline = getCustomHeadline(channel, industry);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils";
|
||||
import { XMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
|
||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||
import { ActivityIcon, ShoppingCartIcon, SmileIcon, StarIcon, ThumbsUpIcon, UsersIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { createSurveyAction } from "@formbricks/ui/TemplateList/actions";
|
||||
|
||||
interface XMTemplateListProps {
|
||||
product: TProduct;
|
||||
user: TUser;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const XMTemplateList = ({ product, user, environmentId }: XMTemplateListProps) => {
|
||||
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const createSurvey = async (activeTemplate: TXMTemplate) => {
|
||||
const augmentedTemplate: TSurveyCreateInput = {
|
||||
...activeTemplate,
|
||||
type: "link",
|
||||
createdBy: user.id,
|
||||
};
|
||||
const createSurveyResponse = await createSurveyAction({
|
||||
environmentId: environmentId,
|
||||
surveyBody: augmentedTemplate,
|
||||
});
|
||||
|
||||
if (createSurveyResponse?.data) {
|
||||
router.push(`/environments/${environmentId}/surveys/${createSurveyResponse.data.id}/edit?mode=cx`);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createSurveyResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTemplateClick = (templateIdx) => {
|
||||
setActiveTemplateId(templateIdx);
|
||||
const template = XMTemplates[templateIdx];
|
||||
const newTemplate = replacePresetPlaceholders(template, product);
|
||||
createSurvey(newTemplate);
|
||||
};
|
||||
|
||||
const XMTemplateOptions = [
|
||||
{
|
||||
title: "NPS",
|
||||
description: "Implement proven best practices to understand WHY people buy.",
|
||||
icon: ShoppingCartIcon,
|
||||
onClick: () => handleTemplateClick(0),
|
||||
isLoading: activeTemplateId === 0,
|
||||
},
|
||||
{
|
||||
title: "5-Star Rating",
|
||||
description: "Universal feedback solution to gauge overall satisfaction.",
|
||||
icon: StarIcon,
|
||||
onClick: () => handleTemplateClick(1),
|
||||
isLoading: activeTemplateId === 1,
|
||||
},
|
||||
{
|
||||
title: "CSAT",
|
||||
description: "Implement best practices to measure customer satisfaction.",
|
||||
icon: ThumbsUpIcon,
|
||||
onClick: () => handleTemplateClick(2),
|
||||
isLoading: activeTemplateId === 2,
|
||||
},
|
||||
{
|
||||
title: "CES",
|
||||
description: "Leverage every touchpoint to understand ease of customer interaction.",
|
||||
icon: ActivityIcon,
|
||||
onClick: () => handleTemplateClick(3),
|
||||
isLoading: activeTemplateId === 3,
|
||||
},
|
||||
{
|
||||
title: "Smileys",
|
||||
description: "Use visual indicators to capture feedback across customer touchpoints.",
|
||||
icon: SmileIcon,
|
||||
onClick: () => handleTemplateClick(4),
|
||||
isLoading: activeTemplateId === 4,
|
||||
},
|
||||
{
|
||||
title: "eNPS",
|
||||
description: "Universal feedback to understand employee engagement and satisfaction.",
|
||||
icon: UsersIcon,
|
||||
onClick: () => handleTemplateClick(5),
|
||||
isLoading: activeTemplateId === 5,
|
||||
},
|
||||
];
|
||||
|
||||
return <OnboardingOptionsContainer options={XMTemplateOptions} />;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { replaceQuestionPresetPlaceholders } from "@formbricks/lib/utils/templates";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
|
||||
// replace all occurences of productName with the actual product name in the current template
|
||||
export const replacePresetPlaceholders = (template: TXMTemplate, product: TProduct) => {
|
||||
const survey = structuredClone(template);
|
||||
survey.name = survey.name.replace("{{productName}}", product.name);
|
||||
survey.questions = survey.questions.map((question) => {
|
||||
return replaceQuestionPresetPlaceholders(question, product);
|
||||
});
|
||||
return { ...template, ...survey };
|
||||
};
|
||||
@@ -0,0 +1,406 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { getDefaultEndingCard } from "@formbricks/lib/templates";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
|
||||
export const XMSurveyDefault: TXMTemplate = {
|
||||
name: "",
|
||||
endings: [getDefaultEndingCard([])],
|
||||
questions: [],
|
||||
styling: {
|
||||
overwriteThemeStyling: true,
|
||||
},
|
||||
};
|
||||
|
||||
const NPSSurvey = (): TXMTemplate => {
|
||||
return {
|
||||
...XMSurveyDefault,
|
||||
name: "NPS Survey",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: { default: "How likely are you to recommend {{productName}} to a friend or colleague?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Not at all likely" },
|
||||
upperLabel: { default: "Extremely likely" },
|
||||
isColorCodingEnabled: true,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "To help us improve, can you describe the reason(s) for your rating?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Any other comments, feedback, or concerns?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const StarRatingSurvey = (): TXMTemplate => {
|
||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
||||
|
||||
return {
|
||||
...XMSurveyDefault,
|
||||
name: "{{productName}}'s Rating Survey",
|
||||
questions: [
|
||||
{
|
||||
id: reusableQuestionIds[0],
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[0],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isLessThanOrEqual",
|
||||
rightOperand: {
|
||||
type: "static",
|
||||
value: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: reusableQuestionIds[2],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
range: 5,
|
||||
scale: "number",
|
||||
headline: { default: "How do you like {{productName}}?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Extremely dissatisfied" },
|
||||
upperLabel: { default: "Extremely satisfied" },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[1],
|
||||
html: { default: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>' },
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[1],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isClicked",
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: XMSurveyDefault.endings[0].id,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
headline: { default: "Happy to hear 🙏 Please write a review for us!" },
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonLabel: { default: "Write review" },
|
||||
buttonExternal: true,
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[2],
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Sorry to hear! What is ONE thing we can do better?" },
|
||||
required: true,
|
||||
subheader: { default: "Help us improve your experience." },
|
||||
buttonLabel: { default: "Send" },
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const CSATSurvey = (): TXMTemplate => {
|
||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
||||
|
||||
return {
|
||||
...XMSurveyDefault,
|
||||
name: "{{productName}} CSAT",
|
||||
questions: [
|
||||
{
|
||||
id: reusableQuestionIds[0],
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[0],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isLessThanOrEqual",
|
||||
rightOperand: {
|
||||
type: "static",
|
||||
value: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: reusableQuestionIds[2],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
headline: { default: "How satisfied are you with your {{productName}} experience?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Extremely dissatisfied" },
|
||||
upperLabel: { default: "Extremely satisfied" },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[1],
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[1],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isSubmitted",
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: XMSurveyDefault.endings[0].id,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
headline: { default: "Lovely! Is there anything we can do to improve your experience?" },
|
||||
required: false,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[2],
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Ugh, sorry! Is there anything we can do to improve your experience?" },
|
||||
required: false,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const CESSurvey = (): TXMTemplate => {
|
||||
return {
|
||||
...XMSurveyDefault,
|
||||
name: "CES Survey",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
range: 5,
|
||||
scale: "number",
|
||||
headline: { default: "{{productName}} makes it easy for me to [ADD GOAL]" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Disagree strongly" },
|
||||
upperLabel: { default: "Agree strongly" },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Thanks! How could we make it easier for you to [ADD GOAL]?" },
|
||||
required: true,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const SmileysRatingSurvey = (): TXMTemplate => {
|
||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
||||
|
||||
return {
|
||||
...XMSurveyDefault,
|
||||
name: "Smileys Survey",
|
||||
questions: [
|
||||
{
|
||||
id: reusableQuestionIds[0],
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[0],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isLessThanOrEqual",
|
||||
rightOperand: {
|
||||
type: "static",
|
||||
value: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: reusableQuestionIds[2],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
headline: { default: "How do you like {{productName}}?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Not good" },
|
||||
upperLabel: { default: "Very satisfied" },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[1],
|
||||
html: { default: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>' },
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: reusableQuestionIds[1],
|
||||
type: "question",
|
||||
},
|
||||
operator: "isClicked",
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: XMSurveyDefault.endings[0].id,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
headline: { default: "Happy to hear 🙏 Please write a review for us!" },
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonLabel: { default: "Write review" },
|
||||
buttonExternal: true,
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[2],
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Sorry to hear! What is ONE thing we can do better?" },
|
||||
required: true,
|
||||
subheader: { default: "Help us improve your experience." },
|
||||
buttonLabel: { default: "Send" },
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const eNPSSurvey = (): TXMTemplate => {
|
||||
return {
|
||||
...XMSurveyDefault,
|
||||
name: "eNPS Survey",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: {
|
||||
default: "How likely are you to recommend working at this company to a friend or colleague?",
|
||||
},
|
||||
required: false,
|
||||
lowerLabel: { default: "Not at all likely" },
|
||||
upperLabel: { default: "Extremely likely" },
|
||||
isColorCodingEnabled: true,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "To help us improve, can you describe the reason(s) for your rating?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Any other comments, feedback, or concerns?" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const XMTemplates: TXMTemplate[] = [
|
||||
NPSSurvey(),
|
||||
StarRatingSurvey(),
|
||||
CSATSurvey(),
|
||||
CESSurvey(),
|
||||
SmileysRatingSurvey(),
|
||||
eNPSSurvey(),
|
||||
];
|
||||
@@ -0,0 +1,60 @@
|
||||
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
|
||||
import { getProductByEnvironmentId, getProducts } from "@formbricks/lib/product/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Header } from "@formbricks/ui/Header";
|
||||
|
||||
interface XMTemplatePageProps {
|
||||
params: {
|
||||
environmentId: string;
|
||||
};
|
||||
}
|
||||
|
||||
const Page = async ({ params }: XMTemplatePageProps) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||
|
||||
const product = await getProductByEnvironmentId(environment.id);
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
const products = await getProducts(organizationId);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header title="What kind of feedback would you like to get?" />
|
||||
<XMTemplateList product={product} user={user} environmentId={environment.id} />
|
||||
{products.length >= 2 && (
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="minimal"
|
||||
href={`/environments/${environment.id}/surveys`}>
|
||||
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
|
||||
|
||||
export const getCustomHeadline = (channel: TProductConfigChannel, industry: TProductConfigIndustry) => {
|
||||
export const getCustomHeadline = (channel?: TProductConfigChannel, industry?: TProductConfigIndustry) => {
|
||||
const combinations = {
|
||||
"website+eCommerce": "web shop",
|
||||
"website+saas": "landing page",
|
||||
|
||||
@@ -17,14 +17,14 @@ const Page = async ({ params }: ChannelPageProps) => {
|
||||
description: "Run well-timed pop-up surveys.",
|
||||
icon: GlobeIcon,
|
||||
iconText: "Built for scale",
|
||||
href: `/organizations/${params.organizationId}/products/new/industry?channel=website`,
|
||||
href: `/organizations/${params.organizationId}/products/new/settings?channel=website`,
|
||||
},
|
||||
{
|
||||
title: "App with sign up",
|
||||
description: "Run highly-targeted micro-surveys.",
|
||||
icon: GlobeLockIcon,
|
||||
iconText: "Enrich user profiles",
|
||||
href: `/organizations/${params.organizationId}/products/new/industry?channel=app`,
|
||||
href: `/organizations/${params.organizationId}/products/new/settings?channel=app`,
|
||||
},
|
||||
{
|
||||
channel: "link",
|
||||
@@ -32,7 +32,7 @@ const Page = async ({ params }: ChannelPageProps) => {
|
||||
description: "Reach people anywhere online.",
|
||||
icon: LinkIcon,
|
||||
iconText: "Anywhere online",
|
||||
href: `/organizations/${params.organizationId}/products/new/industry?channel=link`,
|
||||
href: `/organizations/${params.organizationId}/products/new/settings?channel=link`,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||
import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
|
||||
import { getProducts } from "@formbricks/lib/product/service";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Header } from "@formbricks/ui/Header";
|
||||
|
||||
interface ModePageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
};
|
||||
}
|
||||
|
||||
const Page = async ({ params }: ModePageProps) => {
|
||||
const channelOptions = [
|
||||
{
|
||||
title: "Formbricks Surveys",
|
||||
description: "Multi-purpose survey platform for web, app and email surveys.",
|
||||
icon: ListTodoIcon,
|
||||
href: `/organizations/${params.organizationId}/products/new/channel`,
|
||||
},
|
||||
{
|
||||
title: "Formbricks CX",
|
||||
description: "Surveys and reports to understand what your customers need.",
|
||||
icon: HeartIcon,
|
||||
href: `/organizations/${params.organizationId}/products/new/settings?mode=cx`,
|
||||
},
|
||||
];
|
||||
|
||||
const products = await getProducts(params.organizationId);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header title="What are you here for?" />
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{products.length >= 1 && (
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="minimal"
|
||||
href={"/"}>
|
||||
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -13,6 +13,7 @@ import { PREVIEW_SURVEY } from "@formbricks/lib/styling/constants";
|
||||
import {
|
||||
TProductConfigChannel,
|
||||
TProductConfigIndustry,
|
||||
TProductMode,
|
||||
TProductUpdateInput,
|
||||
ZProductUpdateInput,
|
||||
} from "@formbricks/types/product";
|
||||
@@ -32,6 +33,7 @@ import { SurveyInline } from "@formbricks/ui/Survey";
|
||||
|
||||
interface ProductSettingsProps {
|
||||
organizationId: string;
|
||||
productMode: TProductMode;
|
||||
channel: TProductConfigChannel;
|
||||
industry: TProductConfigIndustry;
|
||||
defaultBrandColor: string;
|
||||
@@ -39,6 +41,7 @@ interface ProductSettingsProps {
|
||||
|
||||
export const ProductSettings = ({
|
||||
organizationId,
|
||||
productMode,
|
||||
channel,
|
||||
industry,
|
||||
defaultBrandColor,
|
||||
@@ -68,10 +71,12 @@ export const ProductSettings = ({
|
||||
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||
}
|
||||
}
|
||||
if (channel !== "link") {
|
||||
if (channel === "app" || channel === "website") {
|
||||
router.push(`/environments/${productionEnvironment?.id}/connect`);
|
||||
} else {
|
||||
} else if (channel === "link") {
|
||||
router.push(`/environments/${productionEnvironment?.id}/surveys`);
|
||||
} else if (productMode === "cx") {
|
||||
router.push(`/environments/${productionEnvironment?.id}/xm-templates`);
|
||||
}
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createProductResponse);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { getCustomHeadline } from "@/app/(app)/(onboarding)/lib/utils";
|
||||
import { ProductSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
|
||||
import { getProducts } from "@formbricks/lib/product/service";
|
||||
import { startsWithVowel } from "@formbricks/lib/utils/strings";
|
||||
import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
|
||||
import { TProductConfigChannel, TProductConfigIndustry, TProductMode } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Header } from "@formbricks/ui/Header";
|
||||
|
||||
@@ -16,19 +15,21 @@ interface ProductSettingsPageProps {
|
||||
searchParams: {
|
||||
channel?: TProductConfigChannel;
|
||||
industry?: TProductConfigIndustry;
|
||||
mode?: TProductMode;
|
||||
};
|
||||
}
|
||||
|
||||
const Page = async ({ params, searchParams }: ProductSettingsPageProps) => {
|
||||
const channel = searchParams.channel;
|
||||
const industry = searchParams.industry;
|
||||
if (!channel || !industry) return notFound();
|
||||
const channel = searchParams.channel || null;
|
||||
const industry = searchParams.industry || null;
|
||||
const mode = searchParams.mode || "surveys";
|
||||
|
||||
const customHeadline = getCustomHeadline(channel, industry);
|
||||
const products = await getProducts(params.organizationId);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
{channel === "link" ? (
|
||||
{channel === "link" || mode === "cx" ? (
|
||||
<Header
|
||||
title="Match your brand, get 2x more responses."
|
||||
subtitle="When people recognize your brand, they are much more likely to start and complete responses."
|
||||
@@ -41,6 +42,7 @@ const Page = async ({ params, searchParams }: ProductSettingsPageProps) => {
|
||||
)}
|
||||
<ProductSettings
|
||||
organizationId={params.organizationId}
|
||||
productMode={mode}
|
||||
channel={channel}
|
||||
industry={industry}
|
||||
defaultBrandColor={DEFAULT_BRAND_COLOR}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LucideProps } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ForwardRefExoticComponent, RefAttributes } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { OptionCard } from "@formbricks/ui/OptionCard";
|
||||
|
||||
interface OnboardingOptionsContainerProps {
|
||||
@@ -8,34 +9,51 @@ interface OnboardingOptionsContainerProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ForwardRefExoticComponent<Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>>;
|
||||
iconText: string;
|
||||
href: string;
|
||||
iconText?: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
isLoading?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => {
|
||||
const getOptionCard = (option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<OptionCard
|
||||
size="md"
|
||||
key={option.title}
|
||||
title={option.title}
|
||||
onSelect={option.onClick}
|
||||
description={option.description}
|
||||
loading={option.isLoading || false}>
|
||||
<div className="flex flex-col items-center">
|
||||
<Icon className="h-16 w-16 text-slate-600" strokeWidth={0.5} />
|
||||
{option.iconText && (
|
||||
<p className="mt-4 w-fit rounded-xl bg-slate-200 px-4 text-sm text-slate-700">
|
||||
{option.iconText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</OptionCard>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid w-5/6 grid-cols-3 gap-8 text-center lg:w-2/3">
|
||||
{options.map((option, index) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<Link href={option.href}>
|
||||
<OptionCard
|
||||
size="md"
|
||||
key={index}
|
||||
title={option.title}
|
||||
description={option.description}
|
||||
loading={false}>
|
||||
<div className="flex flex-col items-center">
|
||||
<Icon className="h-16 w-16 text-slate-600" strokeWidth={0.5} />
|
||||
<p className="mt-4 w-fit rounded-xl bg-slate-200 px-4 text-sm text-slate-700">
|
||||
{option.iconText}
|
||||
</p>
|
||||
</div>
|
||||
</OptionCard>
|
||||
<div
|
||||
className={cn({
|
||||
"grid w-5/6 grid-cols-3 gap-8 text-center lg:w-2/3": options.length >= 3,
|
||||
"flex justify-center gap-8": options.length < 3,
|
||||
})}>
|
||||
{options.map((option) =>
|
||||
option.href ? (
|
||||
<Link key={option.title} href={option.href}>
|
||||
{getOptionCard(option)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
) : (
|
||||
getOptionCard(option)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { getQuestionDefaults, questionTypes, universalQuestionPresets } from "@/app/lib/questions";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import {
|
||||
CXQuestionTypes,
|
||||
getQuestionDefaults,
|
||||
questionTypes,
|
||||
universalQuestionPresets,
|
||||
} from "@formbricks/lib/utils/questions";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
|
||||
interface AddQuestionButtonProps {
|
||||
addQuestion: (question: any) => void;
|
||||
product: TProduct;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const AddQuestionButton = ({ addQuestion, product }: AddQuestionButtonProps) => {
|
||||
export const AddQuestionButton = ({ addQuestion, product, isCxMode }: AddQuestionButtonProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const availableQuestionTypes = isCxMode ? CXQuestionTypes : questionTypes;
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
@@ -37,7 +45,7 @@ export const AddQuestionButton = ({ addQuestion, product }: AddQuestionButtonPro
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="justify-left flex flex-col">
|
||||
{/* <hr className="py-1 text-slate-600" /> */}
|
||||
{questionTypes.map((questionType) => (
|
||||
{availableQuestionTypes.map((questionType) => (
|
||||
<button
|
||||
type="button"
|
||||
key={questionType.id}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ConditionalLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConditionalLogic";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { LogicEditor } from "./LogicEditor";
|
||||
import { UpdateQuestionId } from "./UpdateQuestionId";
|
||||
|
||||
interface AdvancedSettingsProps {
|
||||
@@ -19,16 +19,14 @@ export const AdvancedSettings = ({
|
||||
attributeClasses,
|
||||
}: AdvancedSettingsProps) => {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<LogicEditor
|
||||
question={question}
|
||||
updateQuestion={updateQuestion}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<ConditionalLogic
|
||||
question={question}
|
||||
updateQuestion={updateQuestion}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
|
||||
<UpdateQuestionId
|
||||
question={question}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { LogicEditor } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor";
|
||||
import {
|
||||
getDefaultOperatorForQuestion,
|
||||
replaceEndingCardHeadlineRecall,
|
||||
} from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { debounce } from "lodash";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CopyIcon,
|
||||
EllipsisVerticalIcon,
|
||||
PlusIcon,
|
||||
SplitIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { duplicateLogicItem } from "@formbricks/lib/surveyLogic/utils";
|
||||
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
interface ConditionalLogicProps {
|
||||
localSurvey: TSurvey;
|
||||
questionIdx: number;
|
||||
question: TSurveyQuestion;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
attributeClasses: TAttributeClass[];
|
||||
}
|
||||
|
||||
export function ConditionalLogic({
|
||||
attributeClasses,
|
||||
localSurvey,
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
}: ConditionalLogicProps) {
|
||||
const [questionLogic, setQuestionLogic] = useState(question.logic);
|
||||
|
||||
const debouncedUpdateQuestion = useMemo(() => debounce(updateQuestion, 500), [updateQuestion]);
|
||||
|
||||
useEffect(() => {
|
||||
debouncedUpdateQuestion(questionIdx, {
|
||||
logic: questionLogic,
|
||||
});
|
||||
}, [questionLogic]);
|
||||
|
||||
const transformedSurvey = useMemo(() => {
|
||||
let modifiedSurvey = replaceHeadlineRecall(localSurvey, "default", attributeClasses);
|
||||
modifiedSurvey = replaceEndingCardHeadlineRecall(modifiedSurvey, "default", attributeClasses);
|
||||
|
||||
return modifiedSurvey;
|
||||
}, [localSurvey, attributeClasses]);
|
||||
|
||||
const updateQuestionLogic = (_questionIdx: number, updatedAttributes: any) => {
|
||||
setQuestionLogic(updatedAttributes.logic);
|
||||
};
|
||||
|
||||
const addLogic = () => {
|
||||
const operator = getDefaultOperatorForQuestion(question);
|
||||
|
||||
const initialCondition: TSurveyLogic = {
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: question.id,
|
||||
type: "question",
|
||||
},
|
||||
operator,
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
updateQuestionLogic(questionIdx, {
|
||||
logic: [...(questionLogic ?? []), initialCondition],
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveLogic = (logicItemIdx: number) => {
|
||||
const logicCopy = structuredClone(questionLogic ?? []);
|
||||
logicCopy.splice(logicItemIdx, 1);
|
||||
|
||||
updateQuestionLogic(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const moveLogic = (from: number, to: number) => {
|
||||
const logicCopy = structuredClone(questionLogic ?? []);
|
||||
const [movedItem] = logicCopy.splice(from, 1);
|
||||
logicCopy.splice(to, 0, movedItem);
|
||||
|
||||
updateQuestionLogic(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const duplicateLogic = (logicItemIdx: number) => {
|
||||
const logicCopy = structuredClone(questionLogic ?? []);
|
||||
const logicItem = logicCopy[logicItemIdx];
|
||||
const newLogicItem = duplicateLogicItem(logicItem);
|
||||
logicCopy.splice(logicItemIdx + 1, 0, newLogicItem);
|
||||
|
||||
updateQuestionLogic(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<Label className="flex gap-2">
|
||||
Conditional Logic
|
||||
<SplitIcon className="h-4 w-4 rotate-90" />
|
||||
</Label>
|
||||
|
||||
{questionLogic && questionLogic.length > 0 && (
|
||||
<div className="mt-2 flex flex-col gap-4">
|
||||
{questionLogic.map((logicItem, logicItemIdx) => (
|
||||
<div
|
||||
key={logicItem.id}
|
||||
className="flex w-full grow items-start gap-2 rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<LogicEditor
|
||||
localSurvey={transformedSurvey}
|
||||
logicItem={logicItem}
|
||||
updateQuestion={updateQuestionLogic}
|
||||
question={question}
|
||||
questionLogic={questionLogic}
|
||||
questionIdx={questionIdx}
|
||||
logicIdx={logicItemIdx}
|
||||
isLast={logicItemIdx === (questionLogic ?? []).length - 1}
|
||||
/>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<EllipsisVerticalIcon className="h-4 w-4 text-slate-700 hover:text-slate-950" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
duplicateLogic(logicItemIdx);
|
||||
}}>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
disabled={logicItemIdx === 0}
|
||||
onClick={() => {
|
||||
moveLogic(logicItemIdx, logicItemIdx - 1);
|
||||
}}>
|
||||
<ArrowUpIcon className="h-4 w-4" />
|
||||
Move up
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
disabled={logicItemIdx === (questionLogic ?? []).length - 1}
|
||||
onClick={() => {
|
||||
moveLogic(logicItemIdx, logicItemIdx + 1);
|
||||
}}>
|
||||
<ArrowDownIcon className="h-4 w-4" />
|
||||
Move down
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
handleRemoveLogic(logicItemIdx);
|
||||
}}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<Button
|
||||
id="logicJumps"
|
||||
className="bg-slate-100 hover:bg-slate-50"
|
||||
type="button"
|
||||
name="logicJumps"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
EndIcon={PlusIcon}
|
||||
onClick={addLogic}>
|
||||
Add logic
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { EditorCardMenu } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditorCardMenu";
|
||||
import { EndScreenForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EndScreenForm";
|
||||
import { RedirectUrlForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RedirectUrlForm";
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util";
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
@@ -32,11 +32,6 @@ interface EditEndingCardProps {
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
const endingCardTypes = [
|
||||
{ value: "endScreen", label: "Ending card" },
|
||||
{ value: "redirectToUrl", label: "Redirect to Url" },
|
||||
];
|
||||
|
||||
export const EditEndingCard = ({
|
||||
localSurvey,
|
||||
endingCardIndex,
|
||||
@@ -52,9 +47,16 @@ export const EditEndingCard = ({
|
||||
isFormbricksCloud,
|
||||
}: EditEndingCardProps) => {
|
||||
const endingCard = localSurvey.endings[endingCardIndex];
|
||||
|
||||
const isRedirectToUrlDisabled = isFormbricksCloud
|
||||
? plan === "free" && endingCard.type !== "redirectToUrl"
|
||||
: false;
|
||||
|
||||
const endingCardTypes = [
|
||||
{ value: "endScreen", label: "Ending card" },
|
||||
{ value: "redirectToUrl", label: "Redirect to Url", disabled: isRedirectToUrlDisabled },
|
||||
];
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: endingCard.id,
|
||||
});
|
||||
@@ -204,14 +206,16 @@ export const EditEndingCard = ({
|
||||
<OptionsSwitch
|
||||
options={endingCardTypes}
|
||||
currentOption={endingCard.type}
|
||||
handleOptionChange={() => {
|
||||
if (endingCard.type === "endScreen") {
|
||||
updateSurvey({ type: "redirectToUrl" });
|
||||
} else {
|
||||
updateSurvey({ type: "endScreen" });
|
||||
handleOptionChange={(newType) => {
|
||||
const selectedOption = endingCardTypes.find((option) => option.value === newType);
|
||||
if (!selectedOption?.disabled) {
|
||||
if (newType === "redirectToUrl") {
|
||||
updateSurvey({ type: "redirectToUrl" });
|
||||
} else {
|
||||
updateSurvey({ type: "endScreen" });
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={isRedirectToUrlDisabled}
|
||||
/>
|
||||
</TooltipRenderer>
|
||||
{endingCard.type === "endScreen" && (
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { QUESTIONS_ICON_MAP, QUESTIONS_NAME_MAP, getQuestionDefaults } from "@/app/lib/questions";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import {
|
||||
CX_QUESTIONS_NAME_MAP,
|
||||
QUESTIONS_ICON_MAP,
|
||||
QUESTIONS_NAME_MAP,
|
||||
getQuestionDefaults,
|
||||
} from "@formbricks/lib/utils/questions";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import {
|
||||
TSurvey,
|
||||
@@ -12,7 +17,6 @@ import {
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyRedirectUrlCard,
|
||||
ZSurveyQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { ConfirmationModal } from "@formbricks/ui/ConfirmationModal";
|
||||
import {
|
||||
@@ -37,6 +41,7 @@ interface EditorCardMenuProps {
|
||||
addCard: (question: any, index?: number) => void;
|
||||
cardType: "question" | "ending";
|
||||
product?: TProduct;
|
||||
isCxMode?: boolean;
|
||||
}
|
||||
|
||||
export const EditorCardMenu = ({
|
||||
@@ -51,76 +56,75 @@ export const EditorCardMenu = ({
|
||||
updateCard,
|
||||
addCard,
|
||||
cardType,
|
||||
isCxMode = false,
|
||||
}: EditorCardMenuProps) => {
|
||||
const [logicWarningModal, setLogicWarningModal] = useState(false);
|
||||
const [changeToType, setChangeToType] = useState(
|
||||
card.type !== "endScreen" && card.type !== "redirectToUrl" ? card.type : undefined
|
||||
);
|
||||
const [changeToType, setChangeToType] = useState(() => {
|
||||
if (card.type !== "endScreen" && card.type !== "redirectToUrl") {
|
||||
return card.type;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
const isDeleteDisabled =
|
||||
cardType === "question"
|
||||
? survey.questions.length === 1
|
||||
: survey.type === "link" && survey.endings.length === 1;
|
||||
|
||||
const availableQuestionTypes = isCxMode ? CX_QUESTIONS_NAME_MAP : QUESTIONS_NAME_MAP;
|
||||
|
||||
const changeQuestionType = (type?: TSurveyQuestionTypeEnum) => {
|
||||
const parseResult = ZSurveyQuestion.safeParse(card);
|
||||
if (parseResult.success && type) {
|
||||
const question = parseResult.data;
|
||||
const { headline, required, subheader, imageUrl, videoUrl, buttonLabel, backButtonLabel } = question;
|
||||
if (!type) return;
|
||||
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
const { headline, required, subheader, imageUrl, videoUrl, buttonLabel, backButtonLabel } =
|
||||
card as TSurveyQuestion;
|
||||
|
||||
// if going from single select to multi select or vice versa, we need to keep the choices as well
|
||||
|
||||
if (
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceSingle &&
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) ||
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle)
|
||||
) {
|
||||
updateCard(cardIdx, {
|
||||
choices: question.choices,
|
||||
type,
|
||||
logic: undefined,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
|
||||
if (
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceSingle &&
|
||||
card.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) ||
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
|
||||
card.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle)
|
||||
) {
|
||||
updateCard(cardIdx, {
|
||||
...questionDefaults,
|
||||
choices: card.choices,
|
||||
type,
|
||||
headline,
|
||||
subheader,
|
||||
required,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
logic: undefined,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
updateCard(cardIdx, {
|
||||
...questionDefaults,
|
||||
type,
|
||||
headline,
|
||||
subheader,
|
||||
required,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
logic: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const addQuestionCardBelow = (type: TSurveyQuestionTypeEnum) => {
|
||||
const parseResult = ZSurveyQuestion.safeParse(card);
|
||||
if (parseResult.success) {
|
||||
const question = parseResult.data;
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
|
||||
addCard(
|
||||
{
|
||||
...questionDefaults,
|
||||
type,
|
||||
id: createId(),
|
||||
required: true,
|
||||
},
|
||||
cardIdx + 1
|
||||
);
|
||||
addCard(
|
||||
{
|
||||
...questionDefaults,
|
||||
type,
|
||||
id: createId(),
|
||||
required: true,
|
||||
},
|
||||
cardIdx + 1
|
||||
);
|
||||
|
||||
// scroll to the new question
|
||||
const section = document.getElementById(`${question.id}`);
|
||||
section?.scrollIntoView({ behavior: "smooth", block: "end", inline: "end" });
|
||||
}
|
||||
const section = document.getElementById(`${card.id}`);
|
||||
section?.scrollIntoView({ behavior: "smooth", block: "end", inline: "end" });
|
||||
};
|
||||
|
||||
const addEndingCardBelow = () => {
|
||||
@@ -167,29 +171,25 @@ export const EditorCardMenu = ({
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent className="ml-2 border border-slate-200 text-slate-600 hover:text-slate-700">
|
||||
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
|
||||
const parsedResult = ZSurveyQuestion.safeParse(card);
|
||||
if (parsedResult.success) {
|
||||
const question = parsedResult.data;
|
||||
if (type === question.type) return null;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
className="min-h-8 cursor-pointer"
|
||||
onClick={() => {
|
||||
setChangeToType(type as TSurveyQuestionTypeEnum);
|
||||
if (question.logic) {
|
||||
setLogicWarningModal(true);
|
||||
return;
|
||||
}
|
||||
{Object.entries(availableQuestionTypes).map(([type, name]) => {
|
||||
if (type === card.type) return null;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
className="min-h-8 cursor-pointer"
|
||||
onClick={() => {
|
||||
setChangeToType(type as TSurveyQuestionTypeEnum);
|
||||
if ((card as TSurveyQuestion).logic) {
|
||||
setLogicWarningModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
changeQuestionType(type as TSurveyQuestionTypeEnum);
|
||||
}}>
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
|
||||
<span className="ml-2">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
changeQuestionType(type as TSurveyQuestionTypeEnum);
|
||||
}}>
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
|
||||
<span className="ml-2">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
@@ -212,7 +212,7 @@ export const EditorCardMenu = ({
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent className="ml-4 border border-slate-200">
|
||||
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
|
||||
{Object.entries(availableQuestionTypes).map(([type, name]) => {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { findHiddenFieldUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { EyeOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -65,6 +66,25 @@ export const HiddenFieldsCard = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteHiddenField = (fieldId: string) => {
|
||||
const quesIdx = findHiddenFieldUsedInLogic(localSurvey, fieldId);
|
||||
|
||||
if (quesIdx !== -1) {
|
||||
toast.error(
|
||||
`${fieldId} is used in logic of question ${quesIdx + 1}. Please remove it from logic first.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
updateSurvey(
|
||||
{
|
||||
enabled: true,
|
||||
fieldIds: localSurvey.hiddenFields?.fieldIds?.filter((q) => q !== fieldId),
|
||||
},
|
||||
fieldId
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(open ? "shadow-lg" : "shadow-md", "group z-10 flex flex-row rounded-lg bg-white")}>
|
||||
<div
|
||||
@@ -111,15 +131,7 @@ export const HiddenFieldsCard = ({
|
||||
return (
|
||||
<Tag
|
||||
key={fieldId}
|
||||
onDelete={() => {
|
||||
updateSurvey(
|
||||
{
|
||||
enabled: true,
|
||||
fieldIds: localSurvey.hiddenFields?.fieldIds?.filter((q) => q !== fieldId),
|
||||
},
|
||||
fieldId
|
||||
);
|
||||
}}
|
||||
onDelete={(fieldId) => handleDeleteHiddenField(fieldId)}
|
||||
tagId={fieldId}
|
||||
tagName={fieldId}
|
||||
/>
|
||||
|
||||
@@ -231,7 +231,7 @@ export const HowToSendCard = ({
|
||||
You can also use Formbricks to run {promotedFeaturesString} surveys.{" "}
|
||||
<Link
|
||||
target="_blank"
|
||||
href={`/organizations/${organizationId}/products/new/channel`}
|
||||
href={`/organizations/${organizationId}/products/new/mode`}
|
||||
className="font-medium underline decoration-slate-400 underline-offset-2">
|
||||
Create a new product
|
||||
</Link>{" "}
|
||||
|
||||
@@ -1,477 +1,53 @@
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ChevronDown,
|
||||
CornerDownRightIcon,
|
||||
HelpCircle,
|
||||
SplitIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicCondition,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
|
||||
import { LogicEditorActions } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditorActions";
|
||||
import { LogicEditorConditions } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditorConditions";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface LogicEditorProps {
|
||||
localSurvey: TSurvey;
|
||||
questionIdx: number;
|
||||
question: TSurveyQuestion;
|
||||
logicItem: TSurveyLogic;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
attributeClasses: TAttributeClass[];
|
||||
question: TSurveyQuestion;
|
||||
questionLogic: TSurveyLogic[];
|
||||
questionIdx: number;
|
||||
logicIdx: number;
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
type LogicConditions = {
|
||||
[K in TSurveyLogicCondition]: {
|
||||
label: string;
|
||||
values: string[] | null;
|
||||
unique?: boolean;
|
||||
multiSelect?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const conditions = {
|
||||
openText: ["submitted", "skipped"],
|
||||
multipleChoiceSingle: ["submitted", "skipped", "equals", "notEquals", "includesOne"],
|
||||
multipleChoiceMulti: ["submitted", "skipped", "includesAll", "includesOne", "equals"],
|
||||
nps: [
|
||||
"equals",
|
||||
"notEquals",
|
||||
"lessThan",
|
||||
"lessEqual",
|
||||
"greaterThan",
|
||||
"greaterEqual",
|
||||
"submitted",
|
||||
"skipped",
|
||||
],
|
||||
rating: [
|
||||
"equals",
|
||||
"notEquals",
|
||||
"lessThan",
|
||||
"lessEqual",
|
||||
"greaterThan",
|
||||
"greaterEqual",
|
||||
"submitted",
|
||||
"skipped",
|
||||
],
|
||||
cta: ["clicked", "skipped"],
|
||||
consent: ["skipped", "accepted"],
|
||||
pictureSelection: ["submitted", "skipped", "includesAll", "includesOne", "equals"],
|
||||
fileUpload: ["uploaded", "notUploaded"],
|
||||
cal: ["skipped", "booked"],
|
||||
matrix: ["isCompletelySubmitted", "isPartiallySubmitted", "skipped"],
|
||||
address: ["submitted", "skipped"],
|
||||
ranking: ["submitted", "skipped"],
|
||||
};
|
||||
|
||||
export const LogicEditor = ({
|
||||
export function LogicEditor({
|
||||
localSurvey,
|
||||
question,
|
||||
questionIdx,
|
||||
logicItem,
|
||||
updateQuestion,
|
||||
attributeClasses,
|
||||
}: LogicEditorProps) => {
|
||||
const [searchValue, setSearchValue] = useState<string>("");
|
||||
const showDropdownSearch = question.type !== "pictureSelection";
|
||||
const transformedSurvey = useMemo(() => {
|
||||
return replaceHeadlineRecall(localSurvey, "default", attributeClasses);
|
||||
}, [localSurvey, attributeClasses]);
|
||||
|
||||
const questionValues: string[] = useMemo(() => {
|
||||
if ("choices" in question) {
|
||||
if (question.type === "pictureSelection") {
|
||||
return question.choices.map((choice) => choice.id);
|
||||
} else {
|
||||
return question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
|
||||
}
|
||||
} else if ("range" in question) {
|
||||
return Array.from({ length: question.range ? question.range : 0 }, (_, i) => (i + 1).toString());
|
||||
} else if (question.type === TSurveyQuestionTypeEnum.NPS) {
|
||||
return Array.from({ length: 11 }, (_, i) => (i + 0).toString());
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [question]);
|
||||
|
||||
const logicConditions: LogicConditions = {
|
||||
submitted: {
|
||||
label: "is submitted",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
skipped: {
|
||||
label: "is skipped",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
accepted: {
|
||||
label: "is accepted",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
clicked: {
|
||||
label: "is clicked",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
equals: {
|
||||
label: "equals",
|
||||
values: questionValues,
|
||||
},
|
||||
notEquals: {
|
||||
label: "does not equal",
|
||||
values: questionValues,
|
||||
},
|
||||
lessThan: {
|
||||
label: "is less than",
|
||||
values: questionValues,
|
||||
},
|
||||
lessEqual: {
|
||||
label: "is less or equal to",
|
||||
values: questionValues,
|
||||
},
|
||||
greaterThan: {
|
||||
label: "is greater than",
|
||||
values: questionValues,
|
||||
},
|
||||
greaterEqual: {
|
||||
label: "is greater or equal to",
|
||||
values: questionValues,
|
||||
},
|
||||
includesAll: {
|
||||
label: "includes all of",
|
||||
values: questionValues,
|
||||
multiSelect: true,
|
||||
},
|
||||
includesOne: {
|
||||
label: "includes one of",
|
||||
values: questionValues,
|
||||
multiSelect: true,
|
||||
},
|
||||
uploaded: {
|
||||
label: "has uploaded file",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
notUploaded: {
|
||||
label: "has not uploaded file",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
booked: {
|
||||
label: "has a call booked",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
isCompletelySubmitted: {
|
||||
label: "is completely submitted",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
isPartiallySubmitted: {
|
||||
label: "is partially submitted",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
};
|
||||
|
||||
const addLogic = () => {
|
||||
if (question.logic && question.logic?.length >= 0) {
|
||||
const hasUndefinedLogic = question.logic.some(
|
||||
(logic) =>
|
||||
logic.condition === undefined && logic.value === undefined && logic.destination === undefined
|
||||
);
|
||||
if (hasUndefinedLogic) {
|
||||
toast("Please fill current logic jumps first.", {
|
||||
icon: "🤓",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newLogic: TSurveyLogic[] = !question.logic ? [] : question.logic;
|
||||
newLogic.push({
|
||||
condition: undefined,
|
||||
value: undefined,
|
||||
destination: undefined,
|
||||
});
|
||||
updateQuestion(questionIdx, { logic: newLogic });
|
||||
};
|
||||
|
||||
const updateLogic = (logicIdx: number, updatedAttributes: any) => {
|
||||
const currentLogic: any = question.logic ? question.logic[logicIdx] : undefined;
|
||||
if (!currentLogic) return;
|
||||
|
||||
// clean logic value if not needed or if condition changed between multiSelect and singleSelect conditions
|
||||
const updatedCondition = updatedAttributes?.condition;
|
||||
const currentCondition = currentLogic?.condition;
|
||||
const updatedLogicCondition = logicConditions[updatedCondition];
|
||||
const currentLogicCondition = logicConditions[currentCondition];
|
||||
if (updatedCondition) {
|
||||
if (updatedLogicCondition?.multiSelect && !currentLogicCondition?.multiSelect) {
|
||||
updatedAttributes.value = [];
|
||||
} else if (
|
||||
(!updatedLogicCondition?.multiSelect && currentLogicCondition?.multiSelect) ||
|
||||
updatedLogicCondition?.values === null
|
||||
) {
|
||||
updatedAttributes.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const newLogic = !question.logic
|
||||
? []
|
||||
: question.logic.map((logic, idx) => {
|
||||
if (idx === logicIdx) {
|
||||
return { ...logic, ...updatedAttributes };
|
||||
}
|
||||
return logic;
|
||||
});
|
||||
|
||||
updateQuestion(questionIdx, { logic: newLogic });
|
||||
};
|
||||
|
||||
const updateMultiSelectLogic = (logicIdx: number, checked: boolean, value: string) => {
|
||||
const newLogic = !question.logic
|
||||
? []
|
||||
: question.logic.map((logic, idx) => {
|
||||
if (idx === logicIdx) {
|
||||
const newValues = !logic.value ? [] : logic.value;
|
||||
if (checked) {
|
||||
newValues.push(value);
|
||||
} else {
|
||||
newValues.splice(newValues.indexOf(value), 1);
|
||||
}
|
||||
return { ...logic, value: Array.from(new Set(newValues)) };
|
||||
}
|
||||
return logic;
|
||||
});
|
||||
|
||||
updateQuestion(questionIdx, { logic: newLogic });
|
||||
};
|
||||
|
||||
const deleteLogic = (logicIdx: number) => {
|
||||
const updatedLogic = !question.logic ? [] : structuredClone(question.logic);
|
||||
updatedLogic.splice(logicIdx, 1);
|
||||
updateQuestion(questionIdx, { logic: updatedLogic });
|
||||
};
|
||||
|
||||
if (!(question.type in conditions)) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const getLogicDisplayValue = (value: string | string[]): string => {
|
||||
if (question.type === "pictureSelection") {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((val) => {
|
||||
const choiceIndex = question.choices.findIndex((choice) => choice.id === val);
|
||||
return `Picture ${choiceIndex + 1}`;
|
||||
})
|
||||
.join(", ");
|
||||
} else {
|
||||
const choiceIndex = question.choices.findIndex((choice) => choice.id === value);
|
||||
return `Picture ${choiceIndex + 1}`;
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
return value.join(", ");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const getOptionPreview = (value: string) => {
|
||||
if (question.type === "pictureSelection") {
|
||||
const choice = question.choices.find((choice) => choice.id === value);
|
||||
if (choice) {
|
||||
return <Image src={choice.imageUrl} alt={"Picture"} width={20} height={12} className="rounded-xs" />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
question,
|
||||
questionLogic,
|
||||
questionIdx,
|
||||
logicIdx,
|
||||
isLast,
|
||||
}: LogicEditorProps) {
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<Label>Logic Jumps</Label>
|
||||
|
||||
{question?.logic && question?.logic?.length !== 0 && (
|
||||
<div className="mt-2 space-y-3">
|
||||
{question?.logic?.map((logic, logicIdx) => (
|
||||
<div key={logicIdx} className="flex items-center space-x-2 space-y-1 text-xs xl:text-sm">
|
||||
<div>
|
||||
<CornerDownRightIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-slate-800">If this answer</p>
|
||||
|
||||
<Select value={logic.condition} onValueChange={(e) => updateLogic(logicIdx, { condition: e })}>
|
||||
<SelectTrigger className="min-w-fit flex-1">
|
||||
<SelectValue placeholder="Select condition" className="text-xs lg:text-sm" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{conditions[question.type].map(
|
||||
(condition) =>
|
||||
!(question.required && (condition === "skipped" || condition === "notUploaded")) && (
|
||||
<SelectItem
|
||||
key={condition}
|
||||
value={condition}
|
||||
title={logicConditions[condition].label}
|
||||
className="text-xs lg:text-sm">
|
||||
{logicConditions[condition].label}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{logic.condition && logicConditions[logic.condition].values != null && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
|
||||
<div className="flex h-10 w-full items-center justify-between overflow-hidden rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
{!logic.value || logic.value?.length === 0 ? (
|
||||
<p className="line-clamp-1 text-slate-400" title="Select match type">
|
||||
Select match type
|
||||
</p>
|
||||
) : (
|
||||
<p className="line-clamp-1" title={getLogicDisplayValue(logic.value)}>
|
||||
{getLogicDisplayValue(logic.value)}
|
||||
</p>
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-40 bg-slate-50 text-slate-700"
|
||||
align="start"
|
||||
side="bottom">
|
||||
{showDropdownSearch && (
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search options"
|
||||
className="mb-1 w-full bg-white"
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
value={searchValue}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<div className="max-h-72 overflow-y-auto overflow-x-hidden">
|
||||
{logicConditions[logic.condition].values
|
||||
?.filter((value) => value.includes(searchValue))
|
||||
?.map((value) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={value}
|
||||
title={value}
|
||||
checked={
|
||||
!logicConditions[logic.condition].multiSelect
|
||||
? logic.value === value
|
||||
: logic.value?.includes(value)
|
||||
}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onCheckedChange={(e) =>
|
||||
!logicConditions[logic.condition].multiSelect
|
||||
? updateLogic(logicIdx, { value })
|
||||
: updateMultiSelectLogic(logicIdx, e, value)
|
||||
}>
|
||||
<div className="flex space-x-2">
|
||||
{question.type === "pictureSelection" && getOptionPreview(value)}
|
||||
<p>{getLogicDisplayValue(value)}</p>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<p className="text-slate-800">jump to</p>
|
||||
|
||||
<Select
|
||||
value={logic.destination}
|
||||
onValueChange={(e) => updateLogic(logicIdx, { destination: e })}>
|
||||
<SelectTrigger className="w-fit overflow-hidden">
|
||||
<SelectValue placeholder="Select question" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{transformedSurvey.questions.map(
|
||||
(question, idx) =>
|
||||
idx !== questionIdx && (
|
||||
<SelectItem
|
||||
key={question.id}
|
||||
value={question.id}
|
||||
title={getLocalizedValue(question.headline, "default")}>
|
||||
<div className="w-96">
|
||||
<p className="truncate text-left">
|
||||
{idx + 1}
|
||||
{". "}
|
||||
{getLocalizedValue(question.headline, "default")}
|
||||
</p>
|
||||
</div>
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
{localSurvey.endings.map((ending) => {
|
||||
return (
|
||||
<SelectItem value={ending.id}>
|
||||
{ending.type === "endScreen"
|
||||
? getLocalizedValue(ending.headline, "default")
|
||||
: ending.label}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div>
|
||||
<TrashIcon
|
||||
className="h-4 w-4 cursor-pointer text-slate-400"
|
||||
onClick={() => deleteLogic(logicIdx)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap items-center space-x-2 py-1 text-sm">
|
||||
<ArrowDownIcon className="h-4 w-4" />
|
||||
<p className="text-slate-700">All other answers will continue to the next question</p>
|
||||
</div>
|
||||
<div className="flex w-full grow flex-col gap-4 overflow-x-auto pb-2 text-sm">
|
||||
<LogicEditorConditions
|
||||
conditions={logicItem.conditions}
|
||||
updateQuestion={updateQuestion}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
localSurvey={localSurvey}
|
||||
logicIdx={logicIdx}
|
||||
/>
|
||||
<LogicEditorActions
|
||||
logicItem={logicItem}
|
||||
logicIdx={logicIdx}
|
||||
questionLogic={questionLogic}
|
||||
updateQuestion={updateQuestion}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
/>
|
||||
{isLast ? (
|
||||
<div className="flex flex-wrap items-center space-x-2">
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
<p className="text-slate-700">All other answers will continue to the next question</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<Button
|
||||
id="logicJumps"
|
||||
type="button"
|
||||
name="logicJumps"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
StartIcon={SplitIcon}
|
||||
onClick={() => addLogic()}>
|
||||
Add logic
|
||||
</Button>
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="ml-2 inline h-4 w-4 cursor-default text-slate-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[300px]" side="top">
|
||||
With logic jumps you can skip questions based on the responses users give.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import {
|
||||
actionObjectiveOptions,
|
||||
getActionOperatorOptions,
|
||||
} from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import {
|
||||
CopyIcon,
|
||||
EllipsisVerticalIcon,
|
||||
EyeOffIcon,
|
||||
FileDigitIcon,
|
||||
FileType2Icon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { questionIconMapping } from "@formbricks/lib/utils/questions";
|
||||
import {
|
||||
TActionNumberVariableCalculateOperator,
|
||||
TActionObjective,
|
||||
TActionTextVariableCalculateOperator,
|
||||
TActionVariableValueType,
|
||||
TSurvey,
|
||||
TSurveyLogicAction,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
import { InputCombobox, TComboboxGroupedOption, TComboboxOption } from "@formbricks/ui/InputCombobox";
|
||||
|
||||
interface LogicEditorActionProps {
|
||||
action: TSurveyLogicAction;
|
||||
actionIdx: number;
|
||||
handleObjectiveChange: (actionIdx: number, val: TActionObjective) => void;
|
||||
handleValuesChange: (actionIdx: number, values: any) => void;
|
||||
handleActionsChange: (operation: "remove" | "addBelow" | "duplicate", actionIdx: number) => void;
|
||||
isRemoveDisabled: boolean;
|
||||
questions: TSurveyQuestion[];
|
||||
endings: TSurvey["endings"];
|
||||
variables: TSurvey["variables"];
|
||||
questionIdx: number;
|
||||
hiddenFields: {
|
||||
enabled: boolean;
|
||||
fieldIds?: string[] | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
const _LogicEditorAction = ({
|
||||
action,
|
||||
actionIdx,
|
||||
handleActionsChange,
|
||||
handleObjectiveChange,
|
||||
handleValuesChange,
|
||||
isRemoveDisabled,
|
||||
questions,
|
||||
endings,
|
||||
variables,
|
||||
questionIdx,
|
||||
hiddenFields,
|
||||
}: LogicEditorActionProps) => {
|
||||
const actionTargetOptions = useMemo((): TComboboxOption[] => {
|
||||
let filteredQuestions = questions.filter((_, idx) => idx !== questionIdx);
|
||||
|
||||
if (action.objective === "requireAnswer") {
|
||||
filteredQuestions = filteredQuestions.filter((question) => !question.required);
|
||||
}
|
||||
|
||||
const questionOptions = filteredQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
};
|
||||
});
|
||||
|
||||
if (action.objective === "requireAnswer") return questionOptions;
|
||||
|
||||
const endingCardOptions = endings.map((ending) => {
|
||||
return {
|
||||
label:
|
||||
ending.type === "endScreen"
|
||||
? getLocalizedValue(ending.headline, "default") || "End Screen"
|
||||
: ending.label || "Redirect Thank you card",
|
||||
value: ending.id,
|
||||
};
|
||||
});
|
||||
|
||||
return [...questionOptions, ...endingCardOptions];
|
||||
}, [action.objective, endings, questionIdx, questions]);
|
||||
|
||||
const actionVariableOptions = useMemo((): TComboboxOption[] => {
|
||||
return variables.map((variable) => {
|
||||
return {
|
||||
icon: variable.type === "number" ? FileDigitIcon : FileType2Icon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
variableType: variable.type,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [variables]);
|
||||
|
||||
const getActionValueOptions = useCallback(
|
||||
(variableId: string): TComboboxGroupedOption[] => {
|
||||
const hiddenFieldIds = hiddenFields?.fieldIds ?? [];
|
||||
|
||||
const hiddenFieldsOptions = hiddenFieldIds.map((field) => {
|
||||
return {
|
||||
icon: EyeOffIcon,
|
||||
label: field,
|
||||
value: field,
|
||||
meta: {
|
||||
type: "hiddenField",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const selectedVariable = variables.find((variable) => variable.id === variableId);
|
||||
const filteredVariables = variables.filter((variable) => variable.id !== variableId);
|
||||
|
||||
if (!selectedVariable) return [];
|
||||
|
||||
if (selectedVariable.type === "text") {
|
||||
const allowedQuestions = questions.filter((question) =>
|
||||
[
|
||||
TSurveyQuestionTypeEnum.OpenText,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyQuestionTypeEnum.Rating,
|
||||
TSurveyQuestionTypeEnum.NPS,
|
||||
TSurveyQuestionTypeEnum.Date,
|
||||
].includes(question.type)
|
||||
);
|
||||
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const stringVariables = filteredVariables.filter((variable) => variable.type === "text");
|
||||
const variableOptions = stringVariables.map((variable) => {
|
||||
return {
|
||||
icon: FileType2Icon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return groupedOptions;
|
||||
} else if (selectedVariable.type === "number") {
|
||||
const allowedQuestions = questions.filter((question) =>
|
||||
[TSurveyQuestionTypeEnum.Rating, TSurveyQuestionTypeEnum.NPS].includes(question.type)
|
||||
);
|
||||
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const numberVariables = filteredVariables.filter((variable) => variable.type === "number");
|
||||
const variableOptions = numberVariables.map((variable) => {
|
||||
return {
|
||||
icon: FileDigitIcon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return groupedOptions;
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
[hiddenFields?.fieldIds, questions, variables]
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={action.id} className="flex grow items-center justify-between gap-x-2">
|
||||
<div className="block w-9 shrink-0">{actionIdx === 0 ? "Then" : "and"}</div>
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<InputCombobox
|
||||
id={`action-${actionIdx}-objective`}
|
||||
key={`objective-${action.id}`}
|
||||
showSearch={false}
|
||||
options={actionObjectiveOptions}
|
||||
value={action.objective}
|
||||
onChangeValue={(val: TActionObjective) => {
|
||||
handleObjectiveChange(actionIdx, val);
|
||||
}}
|
||||
comboboxClasses="grow"
|
||||
/>
|
||||
{action.objective !== "calculate" && (
|
||||
<InputCombobox
|
||||
id={`action-${actionIdx}-target`}
|
||||
key={`target-${action.id}`}
|
||||
showSearch={false}
|
||||
options={actionTargetOptions}
|
||||
value={action.target}
|
||||
onChangeValue={(val: string) => {
|
||||
handleValuesChange(actionIdx, {
|
||||
target: val,
|
||||
});
|
||||
}}
|
||||
comboboxClasses="grow"
|
||||
/>
|
||||
)}
|
||||
{action.objective === "calculate" && (
|
||||
<>
|
||||
<InputCombobox
|
||||
id={`action-${actionIdx}-variableId`}
|
||||
key={`variableId-${action.id}`}
|
||||
showSearch={false}
|
||||
options={actionVariableOptions}
|
||||
value={action.variableId}
|
||||
onChangeValue={(val: string) => {
|
||||
handleValuesChange(actionIdx, {
|
||||
variableId: val,
|
||||
value: {
|
||||
type: "static",
|
||||
value: "",
|
||||
},
|
||||
});
|
||||
}}
|
||||
comboboxClasses="grow"
|
||||
emptyDropdownText="Add a variable to calculate"
|
||||
/>
|
||||
<InputCombobox
|
||||
id={`action-${actionIdx}-operator`}
|
||||
key={`operator-${action.id}`}
|
||||
showSearch={false}
|
||||
options={getActionOperatorOptions(variables.find((v) => v.id === action.variableId)?.type)}
|
||||
value={action.operator}
|
||||
onChangeValue={(
|
||||
val: TActionTextVariableCalculateOperator | TActionNumberVariableCalculateOperator
|
||||
) => {
|
||||
handleValuesChange(actionIdx, {
|
||||
operator: val,
|
||||
});
|
||||
}}
|
||||
comboboxClasses="grow"
|
||||
/>
|
||||
<InputCombobox
|
||||
id={`action-${actionIdx}-value`}
|
||||
key={`value-${action.id}`}
|
||||
withInput={true}
|
||||
clearable={true}
|
||||
value={action.value?.value ?? ""}
|
||||
inputProps={{
|
||||
placeholder: "Value",
|
||||
type: variables.find((v) => v.id === action.variableId)?.type || "text",
|
||||
}}
|
||||
groupedOptions={getActionValueOptions(action.variableId)}
|
||||
onChangeValue={(val, option, fromInput) => {
|
||||
const fieldType = option?.meta?.type as TActionVariableValueType;
|
||||
|
||||
if (!fromInput && fieldType !== "static") {
|
||||
handleValuesChange(actionIdx, {
|
||||
value: {
|
||||
type: fieldType,
|
||||
value: val as string,
|
||||
},
|
||||
});
|
||||
} else if (fromInput) {
|
||||
handleValuesChange(actionIdx, {
|
||||
value: {
|
||||
type: "static",
|
||||
value: val as string,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
comboboxClasses="grow shrink-0"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger id={`actions-${actionIdx}-dropdown`}>
|
||||
<EllipsisVerticalIcon className="h-4 w-4 text-slate-700 hover:text-slate-950" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
handleActionsChange("addBelow", actionIdx);
|
||||
}}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add action below
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
disabled={isRemoveDisabled}
|
||||
onClick={() => {
|
||||
handleActionsChange("remove", actionIdx);
|
||||
}}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
handleActionsChange("duplicate", actionIdx);
|
||||
}}>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LogicEditorAction = React.memo(_LogicEditorAction);
|
||||
@@ -0,0 +1,110 @@
|
||||
import { LogicEditorAction } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditorAction";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { CornerDownRightIcon } from "lucide-react";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { getUpdatedActionBody } from "@formbricks/lib/surveyLogic/utils";
|
||||
import { TActionObjective, TSurvey, TSurveyLogic, TSurveyLogicAction } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface LogicEditorActions {
|
||||
localSurvey: TSurvey;
|
||||
logicItem: TSurveyLogic;
|
||||
logicIdx: number;
|
||||
questionLogic: TSurveyLogic[];
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
questionIdx: number;
|
||||
}
|
||||
|
||||
export const LogicEditorActions = ({
|
||||
localSurvey,
|
||||
logicItem,
|
||||
logicIdx,
|
||||
questionLogic,
|
||||
updateQuestion,
|
||||
questionIdx,
|
||||
}: LogicEditorActions) => {
|
||||
const actions = logicItem.actions;
|
||||
|
||||
const handleActionsChange = useCallback(
|
||||
(
|
||||
operation: "remove" | "addBelow" | "duplicate" | "update",
|
||||
actionIdx: number,
|
||||
action?: TSurveyLogicAction
|
||||
) => {
|
||||
const currentLogicCopy = structuredClone(logicItem);
|
||||
const actionsClone = currentLogicCopy.actions;
|
||||
|
||||
switch (operation) {
|
||||
case "remove":
|
||||
actionsClone.splice(actionIdx, 1);
|
||||
break;
|
||||
case "addBelow":
|
||||
actionsClone.splice(actionIdx + 1, 0, {
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
target: "",
|
||||
});
|
||||
break;
|
||||
case "duplicate":
|
||||
actionsClone.splice(actionIdx + 1, 0, { ...actionsClone[actionIdx], id: createId() });
|
||||
break;
|
||||
case "update":
|
||||
if (!action) return;
|
||||
actionsClone[actionIdx] = action;
|
||||
break;
|
||||
}
|
||||
|
||||
const updatedLogic = questionLogic.map((item, idx) => (idx === logicIdx ? currentLogicCopy : item));
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: updatedLogic,
|
||||
});
|
||||
},
|
||||
[logicIdx, logicItem, questionIdx, questionLogic]
|
||||
);
|
||||
|
||||
const handleObjectiveChange = useCallback(
|
||||
(actionIdx: number, objective: TActionObjective) => {
|
||||
const action = actions[actionIdx];
|
||||
const actionBody = getUpdatedActionBody(action, objective);
|
||||
handleActionsChange("update", actionIdx, actionBody);
|
||||
},
|
||||
[actions]
|
||||
);
|
||||
|
||||
const handleValuesChange = useCallback(
|
||||
(actionIdx: number, values: Partial<TSurveyLogicAction>) => {
|
||||
const action = actions[actionIdx];
|
||||
const actionBody = { ...action, ...values } as TSurveyLogicAction;
|
||||
handleActionsChange("update", actionIdx, actionBody);
|
||||
},
|
||||
[actions]
|
||||
);
|
||||
|
||||
const questions = useMemo(() => localSurvey.questions, [localSurvey.questions]);
|
||||
const endings = useMemo(() => localSurvey.endings, [localSurvey.endings]);
|
||||
const variables = useMemo(() => localSurvey.variables, [localSurvey.variables]);
|
||||
const hiddenFields = useMemo(() => localSurvey.hiddenFields, [localSurvey.hiddenFields]);
|
||||
|
||||
return (
|
||||
<div className="flex grow gap-2">
|
||||
<CornerDownRightIcon className="mt-3 h-4 w-4 shrink-0" />
|
||||
<div className="flex grow flex-col gap-y-2">
|
||||
{actions?.map((action, idx) => (
|
||||
<LogicEditorAction
|
||||
action={action}
|
||||
actionIdx={idx}
|
||||
handleActionsChange={handleActionsChange}
|
||||
handleObjectiveChange={handleObjectiveChange}
|
||||
handleValuesChange={handleValuesChange}
|
||||
endings={endings}
|
||||
isRemoveDisabled={actions.length === 1}
|
||||
questions={questions}
|
||||
variables={variables}
|
||||
questionIdx={questionIdx}
|
||||
hiddenFields={hiddenFields}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,347 @@
|
||||
import {
|
||||
getConditionOperatorOptions,
|
||||
getConditionValueOptions,
|
||||
getDefaultOperatorForQuestion,
|
||||
getMatchValueProps,
|
||||
} from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { CopyIcon, EllipsisVerticalIcon, PlusIcon, TrashIcon, WorkflowIcon } from "lucide-react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import {
|
||||
addConditionBelow,
|
||||
createGroupFromResource,
|
||||
duplicateCondition,
|
||||
isConditionGroup,
|
||||
removeCondition,
|
||||
toggleGroupConnector,
|
||||
updateCondition,
|
||||
} from "@formbricks/lib/surveyLogic/utils";
|
||||
import {
|
||||
TConditionGroup,
|
||||
TDynamicLogicField,
|
||||
TRightOperand,
|
||||
TSingleCondition,
|
||||
TSurvey,
|
||||
TSurveyLogicConditionsOperator,
|
||||
TSurveyQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
import { InputCombobox, TComboboxOption } from "@formbricks/ui/InputCombobox";
|
||||
|
||||
interface LogicEditorConditionsProps {
|
||||
conditions: TConditionGroup;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
question: TSurveyQuestion;
|
||||
localSurvey: TSurvey;
|
||||
questionIdx: number;
|
||||
logicIdx: number;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export function LogicEditorConditions({
|
||||
conditions,
|
||||
logicIdx,
|
||||
question,
|
||||
localSurvey,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
depth = 0,
|
||||
}: LogicEditorConditionsProps) {
|
||||
const handleAddConditionBelow = (resourceId: string) => {
|
||||
const operator = getDefaultOperatorForQuestion(question);
|
||||
|
||||
const condition: TSingleCondition = {
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
value: question.id,
|
||||
type: "question",
|
||||
},
|
||||
operator,
|
||||
};
|
||||
|
||||
const logicCopy = structuredClone(question.logic) ?? [];
|
||||
const logicItem = logicCopy[logicIdx];
|
||||
addConditionBelow(logicItem.conditions, resourceId, condition);
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleConnectorChange = (groupId: string) => {
|
||||
const logicCopy = structuredClone(question.logic) ?? [];
|
||||
const logicItem = logicCopy[logicIdx];
|
||||
toggleGroupConnector(logicItem.conditions, groupId);
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveCondition = (resourceId: string) => {
|
||||
const logicCopy = structuredClone(question.logic) ?? [];
|
||||
const logicItem = logicCopy[logicIdx];
|
||||
removeCondition(logicItem.conditions, resourceId);
|
||||
|
||||
// Remove the logic item if there are no conditions left
|
||||
if (logicItem.conditions.conditions.length === 0) {
|
||||
logicCopy.splice(logicIdx, 1);
|
||||
}
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDuplicateCondition = (resourceId: string) => {
|
||||
const logicCopy = structuredClone(question.logic) ?? [];
|
||||
const logicItem = logicCopy[logicIdx];
|
||||
duplicateCondition(logicItem.conditions, resourceId);
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateGroup = (resourceId: string) => {
|
||||
const logicCopy = structuredClone(question.logic) ?? [];
|
||||
const logicItem = logicCopy[logicIdx];
|
||||
createGroupFromResource(logicItem.conditions, resourceId);
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateCondition = (resourceId: string, updateConditionBody: Partial<TSingleCondition>) => {
|
||||
const logicCopy = structuredClone(question.logic) ?? [];
|
||||
const logicItem = logicCopy[logicIdx];
|
||||
updateCondition(logicItem.conditions, resourceId, updateConditionBody);
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleQuestionChange = (condition: TSingleCondition, value: string, option?: TComboboxOption) => {
|
||||
handleUpdateCondition(condition.id, {
|
||||
leftOperand: {
|
||||
value,
|
||||
type: option?.meta?.type as TDynamicLogicField,
|
||||
},
|
||||
operator: "isSkipped",
|
||||
rightOperand: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOperatorChange = (condition: TSingleCondition, value: TSurveyLogicConditionsOperator) => {
|
||||
if (value !== condition.operator) {
|
||||
handleUpdateCondition(condition.id, {
|
||||
operator: value,
|
||||
rightOperand: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRightOperandChange = (
|
||||
condition: TSingleCondition,
|
||||
value: string | number | string[],
|
||||
option?: TComboboxOption
|
||||
) => {
|
||||
const type = (option?.meta?.type as TRightOperand["type"]) || "static";
|
||||
|
||||
switch (type) {
|
||||
case "question":
|
||||
case "hiddenField":
|
||||
case "variable":
|
||||
handleUpdateCondition(condition.id, {
|
||||
rightOperand: {
|
||||
value: value as string,
|
||||
type,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "static":
|
||||
handleUpdateCondition(condition.id, {
|
||||
rightOperand: {
|
||||
value,
|
||||
type,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const renderCondition = (
|
||||
condition: TSingleCondition | TConditionGroup,
|
||||
index: number,
|
||||
parentConditionGroup: TConditionGroup
|
||||
) => {
|
||||
const connector = parentConditionGroup.connector;
|
||||
if (isConditionGroup(condition)) {
|
||||
return (
|
||||
<div key={condition.id} className="flex items-start justify-between gap-4">
|
||||
{index === 0 ? (
|
||||
<div>When</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn("w-14", index === 1 && "cursor-pointer underline")}
|
||||
onClick={() => {
|
||||
if (index !== 1) return;
|
||||
handleConnectorChange(parentConditionGroup.id);
|
||||
}}>
|
||||
{connector}
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-lg border border-slate-400 p-3">
|
||||
<LogicEditorConditions
|
||||
conditions={condition}
|
||||
updateQuestion={updateQuestion}
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
logicIdx={logicIdx}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<EllipsisVerticalIcon className="h-4 w-4 text-slate-700 hover:text-slate-950" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
handleAddConditionBelow(condition.id);
|
||||
}}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add condition below
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
disabled={depth === 0 && conditions.conditions.length === 1}
|
||||
onClick={() => handleRemoveCondition(condition.id)}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const conditionValueOptions = getConditionValueOptions(localSurvey, questionIdx);
|
||||
const conditionOperatorOptions = getConditionOperatorOptions(condition, localSurvey);
|
||||
const { show, options, showInput = false, inputType } = getMatchValueProps(condition, localSurvey);
|
||||
|
||||
const allowMultiSelect = ["equalsOneOf", "includesAllOf", "includesOneOf"].includes(condition.operator);
|
||||
return (
|
||||
<div key={condition.id} className="flex items-center gap-x-2">
|
||||
<div className="w-10 shrink-0">
|
||||
{index === 0 ? (
|
||||
"When"
|
||||
) : (
|
||||
<div
|
||||
className={cn("w-14", index === 1 && "cursor-pointer underline")}
|
||||
onClick={() => {
|
||||
if (index !== 1) return;
|
||||
handleConnectorChange(parentConditionGroup.id);
|
||||
}}>
|
||||
{connector}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<InputCombobox
|
||||
id={`condition-${depth}-${index}-conditionValue`}
|
||||
key="conditionValue"
|
||||
showSearch={false}
|
||||
groupedOptions={conditionValueOptions}
|
||||
value={condition.leftOperand.value}
|
||||
onChangeValue={(val: string, option) => {
|
||||
handleQuestionChange(condition, val, option);
|
||||
}}
|
||||
comboboxClasses="grow"
|
||||
/>
|
||||
<InputCombobox
|
||||
id={`condition-${depth}-${index}-conditionOperator`}
|
||||
key="conditionOperator"
|
||||
showSearch={false}
|
||||
options={conditionOperatorOptions}
|
||||
value={condition.operator}
|
||||
onChangeValue={(val: TSurveyLogicConditionsOperator) => {
|
||||
handleOperatorChange(condition, val);
|
||||
}}
|
||||
comboboxClasses="grow min-w-[150px]"
|
||||
/>
|
||||
{show && (
|
||||
<InputCombobox
|
||||
id={`condition-${depth}-${index}-conditionMatchValue`}
|
||||
withInput={showInput}
|
||||
inputProps={{
|
||||
type: inputType,
|
||||
placeholder: "Value",
|
||||
}}
|
||||
key="conditionMatchValue"
|
||||
showSearch={false}
|
||||
groupedOptions={options}
|
||||
allowMultiSelect={allowMultiSelect}
|
||||
showCheckIcon={allowMultiSelect}
|
||||
comboboxClasses="grow min-w-[180px] max-w-[300px]"
|
||||
value={condition.rightOperand?.value}
|
||||
clearable={true}
|
||||
onChangeValue={(val, option) => {
|
||||
handleRightOperandChange(condition, val, option);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger id={`condition-${depth}-${index}-dropdown`}>
|
||||
<EllipsisVerticalIcon className="h-4 w-4 text-slate-700 hover:text-slate-950" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
handleAddConditionBelow(condition.id);
|
||||
}}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add condition below
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
disabled={depth === 0 && conditions.conditions.length === 1}
|
||||
onClick={() => handleRemoveCondition(condition.id)}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => handleDuplicateCondition(condition.id)}>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => handleCreateGroup(condition.id)}>
|
||||
<WorkflowIcon className="h-4 w-4" />
|
||||
Create group
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{conditions?.conditions.map((condition, index) => renderCondition(condition, index, conditions))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { findOptionUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { DndContext } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createI18nString, extractLanguageCodes, getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import toast from "react-hot-toast";
|
||||
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import {
|
||||
TI18nString,
|
||||
@@ -68,8 +70,6 @@ export const MultipleChoiceQuestionForm = ({
|
||||
};
|
||||
|
||||
const updateChoice = (choiceIdx: number, updatedAttributes: { label: TI18nString }) => {
|
||||
const newLabel = updatedAttributes.label.en;
|
||||
const oldLabel = question.choices[choiceIdx].label;
|
||||
let newChoices: any[] = [];
|
||||
if (question.choices) {
|
||||
newChoices = question.choices.map((choice, idx) => {
|
||||
@@ -78,19 +78,9 @@ export const MultipleChoiceQuestionForm = ({
|
||||
});
|
||||
}
|
||||
|
||||
let newLogic: any[] = [];
|
||||
question.logic?.forEach((logic) => {
|
||||
let newL: string | string[] | undefined = logic.value;
|
||||
if (Array.isArray(logic.value)) {
|
||||
newL = logic.value.map((value) =>
|
||||
value === getLocalizedValue(oldLabel, selectedLanguageCode) ? newLabel : value
|
||||
);
|
||||
} else {
|
||||
newL = logic.value === getLocalizedValue(oldLabel, selectedLanguageCode) ? newLabel : logic.value;
|
||||
}
|
||||
newLogic.push({ ...logic, value: newL });
|
||||
updateQuestion(questionIdx, {
|
||||
choices: newChoices,
|
||||
});
|
||||
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
|
||||
};
|
||||
|
||||
const addChoice = (choiceIdx?: number) => {
|
||||
@@ -132,23 +122,27 @@ export const MultipleChoiceQuestionForm = ({
|
||||
};
|
||||
|
||||
const deleteChoice = (choiceIdx: number) => {
|
||||
const choiceToDelete = question.choices[choiceIdx].id;
|
||||
|
||||
if (choiceToDelete !== "other") {
|
||||
const questionIdx = findOptionUsedInLogic(localSurvey, question.id, choiceToDelete);
|
||||
if (questionIdx !== -1) {
|
||||
toast.error(
|
||||
`This option is used in logic for question ${questionIdx + 1}. Please fix the logic first before deleting.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newChoices = !question.choices ? [] : question.choices.filter((_, idx) => idx !== choiceIdx);
|
||||
const choiceValue = question.choices[choiceIdx].label[selectedLanguageCode];
|
||||
if (isInvalidValue === choiceValue) {
|
||||
setisInvalidValue(null);
|
||||
}
|
||||
let newLogic: any[] = [];
|
||||
question.logic?.forEach((logic) => {
|
||||
let newL: string | string[] | undefined = logic.value;
|
||||
if (Array.isArray(logic.value)) {
|
||||
newL = logic.value.filter((value) => value !== choiceValue);
|
||||
} else {
|
||||
newL = logic.value !== choiceValue ? logic.value : undefined;
|
||||
}
|
||||
newLogic.push({ ...logic, value: newL });
|
||||
});
|
||||
|
||||
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
|
||||
updateQuestion(questionIdx, {
|
||||
choices: newChoices,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -219,6 +213,7 @@ export const MultipleChoiceQuestionForm = ({
|
||||
<Label htmlFor="choices">Options*</Label>
|
||||
<div className="mt-2" id="choices">
|
||||
<DndContext
|
||||
id="multi-choice-choices"
|
||||
onDragEnd={(event) => {
|
||||
const { active, over } = event;
|
||||
|
||||
|
||||
@@ -39,22 +39,10 @@ export const PictureSelectionForm = ({
|
||||
// Filter out the deleted choice from the choices array
|
||||
const newChoices = question.choices?.filter((choice) => choice.id !== choiceValue) || [];
|
||||
|
||||
// Update the logic, removing the deleted choice value
|
||||
const newLogic =
|
||||
question.logic?.map((logic) => {
|
||||
let updatedValue = logic.value;
|
||||
|
||||
if (Array.isArray(logic.value)) {
|
||||
updatedValue = logic.value.filter((value) => value !== choiceValue);
|
||||
} else if (logic.value === choiceValue) {
|
||||
updatedValue = undefined;
|
||||
}
|
||||
|
||||
return { ...logic, value: updatedValue };
|
||||
}) || [];
|
||||
|
||||
// Update the question with new choices and logic
|
||||
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
|
||||
updateQuestion(questionIdx, {
|
||||
choices: newChoices,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileInputChanges = (urls: string[]) => {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { RankingQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm";
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util";
|
||||
import { QUESTIONS_ICON_MAP, getTSurveyQuestionTypeEnumName } from "@/app/lib/questions";
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { QUESTIONS_ICON_MAP, getTSurveyQuestionTypeEnumName } from "@formbricks/lib/utils/questions";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
@@ -54,6 +54,7 @@ interface QuestionCardProps {
|
||||
attributeClasses: TAttributeClass[];
|
||||
addQuestion: (question: any, index?: number) => void;
|
||||
isFormbricksCloud: boolean;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const QuestionCard = ({
|
||||
@@ -74,6 +75,7 @@ export const QuestionCard = ({
|
||||
attributeClasses,
|
||||
addQuestion,
|
||||
isFormbricksCloud,
|
||||
isCxMode,
|
||||
}: QuestionCardProps) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: question.id,
|
||||
@@ -206,6 +208,7 @@ export const QuestionCard = ({
|
||||
updateCard={updateQuestion}
|
||||
addCard={addQuestion}
|
||||
cardType="question"
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ interface QuestionsDraggableProps {
|
||||
attributeClasses: TAttributeClass[];
|
||||
addQuestion: (question: any, index?: number) => void;
|
||||
isFormbricksCloud: boolean;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const QuestionsDroppable = ({
|
||||
@@ -38,6 +39,7 @@ export const QuestionsDroppable = ({
|
||||
attributeClasses,
|
||||
addQuestion,
|
||||
isFormbricksCloud,
|
||||
isCxMode,
|
||||
}: QuestionsDraggableProps) => {
|
||||
return (
|
||||
<div className="group mb-5 flex w-full flex-col gap-5">
|
||||
@@ -62,6 +64,7 @@ export const QuestionsDroppable = ({
|
||||
attributeClasses={attributeClasses}
|
||||
addQuestion={addQuestion}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
@@ -31,12 +31,14 @@ interface QuestionsAudienceTabsProps {
|
||||
activeId: TSurveyEditorTabs;
|
||||
setActiveId: React.Dispatch<React.SetStateAction<TSurveyEditorTabs>>;
|
||||
isStylingTabVisible?: boolean;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const QuestionsAudienceTabs = ({
|
||||
activeId,
|
||||
setActiveId,
|
||||
isStylingTabVisible,
|
||||
isCxMode,
|
||||
}: QuestionsAudienceTabsProps) => {
|
||||
const tabsComputed = useMemo(() => {
|
||||
if (isStylingTabVisible) {
|
||||
@@ -45,10 +47,13 @@ export const QuestionsAudienceTabs = ({
|
||||
return tabs.filter((tab) => tab.id !== "styling");
|
||||
}, [isStylingTabVisible]);
|
||||
|
||||
// Hide settings tab in CX mode
|
||||
let tabsToDisplay = isCxMode ? tabsComputed.filter((tab) => tab.id !== "settings") : tabsComputed;
|
||||
|
||||
return (
|
||||
<div className="fixed z-30 flex h-12 w-full items-center justify-center border-b bg-white md:w-1/2">
|
||||
<nav className="flex h-full items-center space-x-4" aria-label="Tabs">
|
||||
{tabsComputed.map((tab) => (
|
||||
{tabsToDisplay.map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.id}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { AddEndingCardButton } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddEndingCardButton";
|
||||
import { SurveyVariablesCard } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCard";
|
||||
import { findQuestionUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
@@ -16,11 +18,18 @@ import toast from "react-hot-toast";
|
||||
import { MultiLanguageCard } from "@formbricks/ee/multi-language/components/multi-language-card";
|
||||
import { addMultiLanguageLabels, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { isConditionGroup } from "@formbricks/lib/surveyLogic/utils";
|
||||
import { getDefaultEndingCard } from "@formbricks/lib/templates";
|
||||
import { checkForEmptyFallBackValue, extractRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import {
|
||||
TConditionGroup,
|
||||
TSingleCondition,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicAction,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { findQuestionsWithCyclicLogic } from "@formbricks/types/surveys/validation";
|
||||
import {
|
||||
@@ -49,6 +58,7 @@ interface QuestionsViewProps {
|
||||
isFormbricksCloud: boolean;
|
||||
attributeClasses: TAttributeClass[];
|
||||
plan: TOrganizationBillingPlan;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const QuestionsView = ({
|
||||
@@ -65,6 +75,7 @@ export const QuestionsView = ({
|
||||
isFormbricksCloud,
|
||||
attributeClasses,
|
||||
plan,
|
||||
isCxMode,
|
||||
}: QuestionsViewProps) => {
|
||||
const internalQuestionIdMap = useMemo(() => {
|
||||
return localSurvey.questions.reduce((acc, question) => {
|
||||
@@ -75,22 +86,75 @@ export const QuestionsView = ({
|
||||
|
||||
const surveyLanguages = localSurvey.languages;
|
||||
const [backButtonLabel, setbackButtonLabel] = useState(null);
|
||||
|
||||
const handleQuestionLogicChange = (survey: TSurvey, compareId: string, updatedId: string): TSurvey => {
|
||||
survey.questions.forEach((question) => {
|
||||
if (question.headline[selectedLanguageCode].includes(`recall:${compareId}`)) {
|
||||
question.headline[selectedLanguageCode] = question.headline[selectedLanguageCode].replaceAll(
|
||||
`recall:${compareId}`,
|
||||
`recall:${updatedId}`
|
||||
);
|
||||
const updateConditions = (conditions: TConditionGroup): TConditionGroup => {
|
||||
return {
|
||||
...conditions,
|
||||
conditions: conditions?.conditions.map((condition) => {
|
||||
if (isConditionGroup(condition)) {
|
||||
return updateConditions(condition);
|
||||
} else {
|
||||
return updateSingleCondition(condition);
|
||||
}
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const updateSingleCondition = (condition: TSingleCondition): TSingleCondition => {
|
||||
let updatedCondition = { ...condition };
|
||||
|
||||
if (condition.leftOperand.value === compareId) {
|
||||
updatedCondition.leftOperand = { ...condition.leftOperand, value: updatedId };
|
||||
}
|
||||
if (!question.logic) return;
|
||||
question.logic.forEach((rule) => {
|
||||
if (rule.destination === compareId) {
|
||||
rule.destination = updatedId;
|
||||
|
||||
if (condition.rightOperand?.type === "question" && condition.rightOperand?.value === compareId) {
|
||||
updatedCondition.rightOperand = { ...condition.rightOperand, value: updatedId };
|
||||
}
|
||||
|
||||
return updatedCondition;
|
||||
};
|
||||
|
||||
const updateActions = (actions: TSurveyLogicAction[]): TSurveyLogicAction[] => {
|
||||
return actions.map((action) => {
|
||||
let updatedAction = { ...action };
|
||||
|
||||
if (updatedAction.objective === "jumpToQuestion" && updatedAction.target === compareId) {
|
||||
updatedAction.target = updatedId;
|
||||
}
|
||||
|
||||
if (updatedAction.objective === "requireAnswer" && updatedAction.target === compareId) {
|
||||
updatedAction.target = updatedId;
|
||||
}
|
||||
|
||||
return updatedAction;
|
||||
});
|
||||
});
|
||||
return survey;
|
||||
};
|
||||
|
||||
return {
|
||||
...survey,
|
||||
questions: survey.questions.map((question) => {
|
||||
let updatedQuestion = { ...question };
|
||||
|
||||
if (question.headline[selectedLanguageCode].includes(`recall:${compareId}`)) {
|
||||
question.headline[selectedLanguageCode] = question.headline[selectedLanguageCode].replaceAll(
|
||||
`recall:${compareId}`,
|
||||
`recall:${updatedId}`
|
||||
);
|
||||
}
|
||||
|
||||
// Update advanced logic
|
||||
if (question.logic) {
|
||||
updatedQuestion.logic = question.logic.map((logicRule: TSurveyLogic) => ({
|
||||
...logicRule,
|
||||
conditions: updateConditions(logicRule.conditions),
|
||||
actions: updateActions(logicRule.actions),
|
||||
}));
|
||||
}
|
||||
|
||||
return updatedQuestion;
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -200,6 +264,7 @@ export const QuestionsView = ({
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setLocalSurvey(updatedSurvey);
|
||||
validateSurveyQuestion(updatedSurvey.questions[questionIdx]);
|
||||
};
|
||||
@@ -209,6 +274,14 @@ export const QuestionsView = ({
|
||||
const activeQuestionIdTemp = activeQuestionId ?? localSurvey.questions[0].id;
|
||||
let updatedSurvey: TSurvey = { ...localSurvey };
|
||||
|
||||
// checking if this question is used in logic of any other question
|
||||
const quesIdx = findQuestionUsedInLogic(localSurvey, questionId);
|
||||
|
||||
if (quesIdx !== -1) {
|
||||
toast.error(`This question is used in logic of question ${quesIdx + 1}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// check if we are recalling from this question for every language
|
||||
updatedSurvey.questions.forEach((question) => {
|
||||
for (const [languageCode, headline] of Object.entries(question.headline)) {
|
||||
@@ -221,7 +294,7 @@ export const QuestionsView = ({
|
||||
}
|
||||
});
|
||||
updatedSurvey.questions.splice(questionIdx, 1);
|
||||
updatedSurvey = handleQuestionLogicChange(updatedSurvey, questionId, "");
|
||||
|
||||
const firstEndingCard = localSurvey.endings[0];
|
||||
setLocalSurvey(updatedSurvey);
|
||||
delete internalQuestionIdMap[questionId];
|
||||
@@ -359,20 +432,26 @@ export const QuestionsView = ({
|
||||
|
||||
return (
|
||||
<div className="mt-12 w-full px-5 py-4">
|
||||
<div className="mb-5 flex w-full flex-col gap-5">
|
||||
<EditWelcomeCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes("start") : false}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
</div>
|
||||
{!isCxMode && (
|
||||
<div className="mb-5 flex w-full flex-col gap-5">
|
||||
<EditWelcomeCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes("start") : false}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DndContext sensors={sensors} onDragEnd={onQuestionCardDragEnd} collisionDetection={closestCorners}>
|
||||
<DndContext
|
||||
id="questions"
|
||||
sensors={sensors}
|
||||
onDragEnd={onQuestionCardDragEnd}
|
||||
collisionDetection={closestCorners}>
|
||||
<QuestionsDroppable
|
||||
localSurvey={localSurvey}
|
||||
product={product}
|
||||
@@ -389,13 +468,18 @@ export const QuestionsView = ({
|
||||
attributeClasses={attributeClasses}
|
||||
addQuestion={addQuestion}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
</DndContext>
|
||||
|
||||
<AddQuestionButton addQuestion={addQuestion} product={product} />
|
||||
<AddQuestionButton addQuestion={addQuestion} product={product} isCxMode={isCxMode} />
|
||||
<div className="mt-5 flex flex-col gap-5">
|
||||
<hr className="border-t border-dashed" />
|
||||
<DndContext sensors={sensors} onDragEnd={onEndingCardDragEnd} collisionDetection={closestCorners}>
|
||||
<DndContext
|
||||
id="endings"
|
||||
sensors={sensors}
|
||||
onDragEnd={onEndingCardDragEnd}
|
||||
collisionDetection={closestCorners}>
|
||||
<SortableContext items={localSurvey.endings} strategy={verticalListSortingStrategy}>
|
||||
{localSurvey.endings.map((ending, index) => {
|
||||
return (
|
||||
@@ -419,37 +503,41 @@ export const QuestionsView = ({
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<AddEndingCardButton
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
addEndingCard={addEndingCard}
|
||||
/>
|
||||
<hr />
|
||||
{!isCxMode && (
|
||||
<>
|
||||
<AddEndingCardButton
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
addEndingCard={addEndingCard}
|
||||
/>
|
||||
<hr />
|
||||
|
||||
<HiddenFieldsCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
/>
|
||||
<HiddenFieldsCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
/>
|
||||
|
||||
{/* <SurveyVariablesCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
/> */}
|
||||
<SurveyVariablesCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
/>
|
||||
|
||||
<MultiLanguageCard
|
||||
localSurvey={localSurvey}
|
||||
product={product}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
isMultiLanguageAllowed={isMultiLanguageAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
/>
|
||||
<MultiLanguageCard
|
||||
localSurvey={localSurvey}
|
||||
product={product}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
isMultiLanguageAllowed={isMultiLanguageAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -167,6 +167,7 @@ export const RankingQuestionForm = ({
|
||||
<Label htmlFor="choices">Options*</Label>
|
||||
<div className="mt-2" id="choices">
|
||||
<DndContext
|
||||
id="ranking-choices"
|
||||
onDragEnd={(event) => {
|
||||
const { active, over } = event;
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ type StylingViewProps = {
|
||||
localStylingChanges: TSurveyStyling | null;
|
||||
setLocalStylingChanges: React.Dispatch<React.SetStateAction<TSurveyStyling | null>>;
|
||||
isUnsplashConfigured: boolean;
|
||||
isCxMode: boolean;
|
||||
};
|
||||
|
||||
export const StylingView = ({
|
||||
@@ -47,6 +48,7 @@ export const StylingView = ({
|
||||
localStylingChanges,
|
||||
setLocalStylingChanges,
|
||||
isUnsplashConfigured,
|
||||
isCxMode,
|
||||
}: StylingViewProps) => {
|
||||
const stylingDefaults: TBaseStyling = useMemo(() => {
|
||||
let stylingDefaults: TBaseStyling;
|
||||
@@ -197,28 +199,30 @@ export const StylingView = ({
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<div className="mt-12 space-y-3 p-5">
|
||||
<div className="flex items-center gap-4 py-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="overwriteThemeStyling"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch checked={!!field.value} onCheckedChange={handleOverwriteToggle} />
|
||||
</FormControl>
|
||||
{!isCxMode && (
|
||||
<div className="flex items-center gap-4 py-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="overwriteThemeStyling"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch checked={!!field.value} onCheckedChange={handleOverwriteToggle} />
|
||||
</FormControl>
|
||||
|
||||
<div>
|
||||
<FormLabel className="text-base font-semibold text-slate-900">
|
||||
Add custom styles
|
||||
</FormLabel>
|
||||
<FormDescription className="text-sm text-slate-800">
|
||||
Override the theme with individual styles for this survey.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel className="text-base font-semibold text-slate-900">
|
||||
Add custom styles
|
||||
</FormLabel>
|
||||
<FormDescription className="text-sm text-slate-800">
|
||||
Override the theme with individual styles for this survey.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormStylingSettings
|
||||
open={formStylingOpen}
|
||||
@@ -248,31 +252,32 @@ export const StylingView = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex h-8 items-center justify-between">
|
||||
<div>
|
||||
{overwriteThemeStyling && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setConfirmResetStylingModalOpen(true)}>
|
||||
Reset to theme styles
|
||||
<RotateCcwIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{!isCxMode && (
|
||||
<div className="mt-4 flex h-8 items-center justify-between">
|
||||
<div>
|
||||
{overwriteThemeStyling && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setConfirmResetStylingModalOpen(true)}>
|
||||
Reset to theme styles
|
||||
<RotateCcwIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">
|
||||
Adjust the theme in the{" "}
|
||||
<Link
|
||||
href={`/environments/${environment.id}/product/look`}
|
||||
target="_blank"
|
||||
className="font-semibold underline">
|
||||
Look & Feel
|
||||
</Link>{" "}
|
||||
settings
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">
|
||||
Adjust the theme in the{" "}
|
||||
<Link
|
||||
href={`/environments/${environment.id}/product/look`}
|
||||
target="_blank"
|
||||
className="font-semibold underline">
|
||||
Look & Feel
|
||||
</Link>{" "}
|
||||
settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
)}
|
||||
<AlertDialog
|
||||
open={confirmResetStylingModalOpen}
|
||||
setOpen={setConfirmResetStylingModalOpen}
|
||||
|
||||
@@ -37,6 +37,7 @@ interface SurveyEditorProps {
|
||||
isFormbricksCloud: boolean;
|
||||
isUnsplashConfigured: boolean;
|
||||
plan: TOrganizationBillingPlan;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const SurveyEditor = ({
|
||||
@@ -55,6 +56,7 @@ export const SurveyEditor = ({
|
||||
isFormbricksCloud,
|
||||
isUnsplashConfigured,
|
||||
plan,
|
||||
isCxMode = false,
|
||||
}: SurveyEditorProps) => {
|
||||
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("questions");
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
|
||||
@@ -144,6 +146,7 @@ export const SurveyEditor = ({
|
||||
responseCount={responseCount}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
<div className="relative z-0 flex flex-1 overflow-hidden">
|
||||
<main
|
||||
@@ -152,6 +155,7 @@ export const SurveyEditor = ({
|
||||
<QuestionsAudienceTabs
|
||||
activeId={activeView}
|
||||
setActiveId={setActiveView}
|
||||
isCxMode={isCxMode}
|
||||
isStylingTabVisible={!!product.styling.allowStyleOverwrite}
|
||||
/>
|
||||
|
||||
@@ -170,6 +174,7 @@ export const SurveyEditor = ({
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
attributeClasses={attributeClasses}
|
||||
plan={plan}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -185,6 +190,7 @@ export const SurveyEditor = ({
|
||||
localStylingChanges={localStylingChanges}
|
||||
setLocalStylingChanges={setLocalStylingChanges}
|
||||
isUnsplashConfigured={isUnsplashConfigured}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ interface SurveyMenuBarProps {
|
||||
responseCount: number;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (selectedLanguage: string) => void;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const SurveyMenuBar = ({
|
||||
@@ -52,6 +53,7 @@ export const SurveyMenuBar = ({
|
||||
product,
|
||||
responseCount,
|
||||
selectedLanguageCode,
|
||||
isCxMode,
|
||||
}: SurveyMenuBarProps) => {
|
||||
const router = useRouter();
|
||||
const [audiencePrompt, setAudiencePrompt] = useState(true);
|
||||
@@ -86,6 +88,13 @@ export const SurveyMenuBar = ({
|
||||
};
|
||||
}, [localSurvey, survey]);
|
||||
|
||||
const clearSurveyLocalStorage = () => {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.removeItem(`${localSurvey.id}-columnOrder`);
|
||||
localStorage.removeItem(`${localSurvey.id}-columnVisibility`);
|
||||
}
|
||||
};
|
||||
|
||||
const containsEmptyTriggers = useMemo(() => {
|
||||
if (localSurvey.type === "link") return false;
|
||||
|
||||
@@ -183,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;
|
||||
@@ -233,6 +244,7 @@ export const SurveyMenuBar = ({
|
||||
}
|
||||
|
||||
const segment = await handleSegmentUpdate();
|
||||
clearSurveyLocalStorage();
|
||||
const updatedSurveyResponse = await updateSurveyAction({ ...localSurvey, segment });
|
||||
|
||||
setIsSurveySaving(false);
|
||||
@@ -278,6 +290,7 @@ export const SurveyMenuBar = ({
|
||||
}
|
||||
const status = localSurvey.runOnDate ? "scheduled" : "inProgress";
|
||||
const segment = await handleSegmentUpdate();
|
||||
clearSurveyLocalStorage();
|
||||
|
||||
await updateSurveyAction({
|
||||
...localSurvey,
|
||||
@@ -296,16 +309,18 @@ export const SurveyMenuBar = ({
|
||||
<>
|
||||
<div className="border-b border-slate-200 bg-white px-5 py-2.5 sm:flex sm:items-center sm:justify-between">
|
||||
<div className="flex h-full items-center space-x-2 whitespace-nowrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-full"
|
||||
StartIcon={ArrowLeftIcon}
|
||||
onClick={() => {
|
||||
handleBack();
|
||||
}}>
|
||||
Back
|
||||
</Button>
|
||||
{!isCxMode && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-full"
|
||||
StartIcon={ArrowLeftIcon}
|
||||
onClick={() => {
|
||||
handleBack();
|
||||
}}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<p className="hidden pl-4 font-semibold md:block">{product.name} / </p>
|
||||
<Input
|
||||
defaultValue={localSurvey.name}
|
||||
@@ -341,16 +356,19 @@ export const SurveyMenuBar = ({
|
||||
updateLocalSurveyStatus={updateLocalSurveyStatus}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={disableSave}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mr-3"
|
||||
loading={isSurveySaving}
|
||||
onClick={() => handleSurveySave()}
|
||||
type="submit">
|
||||
Save
|
||||
</Button>
|
||||
{!isCxMode && (
|
||||
<Button
|
||||
disabled={disableSave}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mr-3"
|
||||
loading={isSurveySaving}
|
||||
onClick={() => handleSurveySave()}
|
||||
type="submit">
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{localSurvey.status !== "draft" && (
|
||||
<Button
|
||||
disabled={disableSave}
|
||||
@@ -379,7 +397,7 @@ export const SurveyMenuBar = ({
|
||||
disabled={isSurveySaving || containsEmptyTriggers}
|
||||
loading={isSurveyPublishing}
|
||||
onClick={handleSurveyPublish}>
|
||||
Publish
|
||||
{isCxMode ? "Save & Close" : "Publish"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { FileDigitIcon } from "lucide-react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { SurveyVariablesCardItem } from "./SurveyVariablesCardItem";
|
||||
@@ -37,7 +38,9 @@ export const SurveyVariablesCard = ({
|
||||
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
|
||||
)}>
|
||||
<p>🪣</p>
|
||||
<div className="flex w-full justify-center">
|
||||
<FileDigitIcon className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { findVariableUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { extractRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
import { TSurvey, TSurveyVariable } from "@formbricks/types/surveys/types";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
@@ -72,11 +74,19 @@ export const SurveyVariablesCardItem = ({
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, mode, editSurveyVariable]);
|
||||
|
||||
const onVaribleDelete = (variable: TSurveyVariable) => {
|
||||
const onVariableDelete = (variable: TSurveyVariable) => {
|
||||
const questions = [...localSurvey.questions];
|
||||
|
||||
// find if this variable is used in any question's recall and remove it for every language
|
||||
const quesIdx = findVariableUsedInLogic(localSurvey, variable.id);
|
||||
|
||||
if (quesIdx !== -1) {
|
||||
toast.error(
|
||||
`${variable.name} is used in logic of question ${quesIdx + 1}. Please remove it from logic first.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// find if this variable is used in any question's recall and remove it for every language
|
||||
questions.forEach((question) => {
|
||||
for (const [languageCode, headline] of Object.entries(question.headline)) {
|
||||
if (headline.includes(`recall:${variable.id}`)) {
|
||||
@@ -210,7 +220,7 @@ export const SurveyVariablesCardItem = ({
|
||||
type="button"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => onVaribleDelete(variable)}>
|
||||
onClick={() => onVariableDelete(variable)}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
import { TSurveyQuestionTypeEnum, ZSurveyLogicConditionsOperator } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const logicRules = {
|
||||
question: {
|
||||
[`${TSurveyQuestionTypeEnum.OpenText}.text`]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "contains",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.contains,
|
||||
},
|
||||
{
|
||||
label: "does not contain",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotContain,
|
||||
},
|
||||
{
|
||||
label: "starts with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.startsWith,
|
||||
},
|
||||
{
|
||||
label: "does not start with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotStartWith,
|
||||
},
|
||||
{
|
||||
label: "ends with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.endsWith,
|
||||
},
|
||||
{
|
||||
label: "does not end with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEndWith,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[`${TSurveyQuestionTypeEnum.OpenText}.number`]: {
|
||||
options: [
|
||||
{
|
||||
label: "=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "!=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: ">",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThan,
|
||||
},
|
||||
{
|
||||
label: "<",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThan,
|
||||
},
|
||||
{
|
||||
label: ">=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "<=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "equals one of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equalsOneOf,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "includes all of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.includesAllOf,
|
||||
},
|
||||
{
|
||||
label: "includes one of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.includesOneOf,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.PictureSelection]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "includes all of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.includesAllOf,
|
||||
},
|
||||
{
|
||||
label: "includes one of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.includesOneOf,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Rating]: {
|
||||
options: [
|
||||
{
|
||||
label: "=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "!=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: ">",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThan,
|
||||
},
|
||||
{
|
||||
label: "<",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThan,
|
||||
},
|
||||
{
|
||||
label: ">=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "<=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.NPS]: {
|
||||
options: [
|
||||
{
|
||||
label: "=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "!=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: ">",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThan,
|
||||
},
|
||||
{
|
||||
label: "<",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThan,
|
||||
},
|
||||
{
|
||||
label: ">=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "<=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.CTA]: {
|
||||
options: [
|
||||
{
|
||||
label: "is clicked",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isClicked,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Consent]: {
|
||||
options: [
|
||||
{
|
||||
label: "is accepted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isAccepted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Date]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "is before",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isBefore,
|
||||
},
|
||||
{
|
||||
label: "is after",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isAfter,
|
||||
},
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.FileUpload]: {
|
||||
options: [
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Ranking]: {
|
||||
options: [
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Cal]: {
|
||||
options: [
|
||||
{
|
||||
label: "is booked",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isBooked,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Matrix]: {
|
||||
options: [
|
||||
{
|
||||
label: "is partially submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isPartiallySubmitted,
|
||||
},
|
||||
{
|
||||
label: "is completely submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isCompletelySubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
[TSurveyQuestionTypeEnum.Address]: {
|
||||
options: [
|
||||
{
|
||||
label: "is submitted",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
|
||||
},
|
||||
{
|
||||
label: "is skipped",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
["variable.text"]: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "contains",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.contains,
|
||||
},
|
||||
{
|
||||
label: "does not contain",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotContain,
|
||||
},
|
||||
{
|
||||
label: "starts with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.startsWith,
|
||||
},
|
||||
{
|
||||
label: "does not start with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotStartWith,
|
||||
},
|
||||
{
|
||||
label: "ends with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.endsWith,
|
||||
},
|
||||
{
|
||||
label: "does not end with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEndWith,
|
||||
},
|
||||
],
|
||||
},
|
||||
["variable.number"]: {
|
||||
options: [
|
||||
{
|
||||
label: "=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "!=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: ">",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThan,
|
||||
},
|
||||
{
|
||||
label: "<",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThan,
|
||||
},
|
||||
{
|
||||
label: ">=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isGreaterThanOrEqual,
|
||||
},
|
||||
{
|
||||
label: "<=",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isLessThanOrEqual,
|
||||
},
|
||||
],
|
||||
},
|
||||
hiddenField: {
|
||||
options: [
|
||||
{
|
||||
label: "equals",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.equals,
|
||||
},
|
||||
{
|
||||
label: "does not equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "contains",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.contains,
|
||||
},
|
||||
{
|
||||
label: "does not contain",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotContain,
|
||||
},
|
||||
{
|
||||
label: "starts with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.startsWith,
|
||||
},
|
||||
{
|
||||
label: "does not start with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotStartWith,
|
||||
},
|
||||
{
|
||||
label: "ends with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.endsWith,
|
||||
},
|
||||
{
|
||||
label: "does not end with",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEndWith,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export type TLogicRuleOption = (typeof logicRules.question)[keyof typeof logicRules.question]["options"];
|
||||
@@ -1,18 +0,0 @@
|
||||
// formats the text to highlight specific parts of the text with slashes
|
||||
export const formatTextWithSlashes = (text: string) => {
|
||||
const regex = /\/(.*?)\\/g;
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
// Check if the part was inside slashes
|
||||
if (index % 2 !== 0) {
|
||||
return (
|
||||
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-xs">
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return part;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,930 @@
|
||||
import { EyeOffIcon, FileDigitIcon, FileType2Icon } from "lucide-react";
|
||||
import { HTMLInputTypeAttribute, useMemo } from "react";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { isConditionGroup } from "@formbricks/lib/surveyLogic/utils";
|
||||
import { questionIconMapping, questionTypes } from "@formbricks/lib/utils/questions";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import {
|
||||
TConditionGroup,
|
||||
TLeftOperand,
|
||||
TRightOperand,
|
||||
TSingleCondition,
|
||||
TSurvey,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicAction,
|
||||
TSurveyLogicConditionsOperator,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyVariable,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TComboboxGroupedOption, TComboboxOption } from "@formbricks/ui/InputCombobox";
|
||||
import { TLogicRuleOption, logicRules } from "./logicRuleEngine";
|
||||
|
||||
// formats the text to highlight specific parts of the text with slashes
|
||||
export const formatTextWithSlashes = (text: string) => {
|
||||
const regex = /\/(.*?)\\/g;
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
// Check if the part was inside slashes
|
||||
if (index % 2 !== 0) {
|
||||
return (
|
||||
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-xs">
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return part;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const getConditionValueOptions = (
|
||||
localSurvey: TSurvey,
|
||||
currQuestionIdx: number
|
||||
): TComboboxGroupedOption[] => {
|
||||
const hiddenFields = localSurvey.hiddenFields?.fieldIds ?? [];
|
||||
const variables = localSurvey.variables ?? [];
|
||||
const questions = localSurvey.questions;
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
const questionOptions = questions
|
||||
.filter((_, idx) => idx <= currQuestionIdx)
|
||||
.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const variableOptions = variables.map((variable) => {
|
||||
return {
|
||||
icon: variable.type === "number" ? FileDigitIcon : FileType2Icon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hiddenFieldsOptions = hiddenFields.map((field) => {
|
||||
return {
|
||||
icon: EyeOffIcon,
|
||||
label: field,
|
||||
value: field,
|
||||
meta: {
|
||||
type: "hiddenField",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return groupedOptions;
|
||||
};
|
||||
|
||||
export const replaceEndingCardHeadlineRecall = (
|
||||
survey: TSurvey,
|
||||
language: string,
|
||||
attributeClasses: TAttributeClass[]
|
||||
) => {
|
||||
const modifiedSurvey = structuredClone(survey);
|
||||
modifiedSurvey.endings.forEach((ending) => {
|
||||
if (ending.type === "endScreen") {
|
||||
ending.headline = recallToHeadline(
|
||||
ending.headline || {},
|
||||
modifiedSurvey,
|
||||
false,
|
||||
language,
|
||||
attributeClasses
|
||||
);
|
||||
}
|
||||
});
|
||||
return modifiedSurvey;
|
||||
};
|
||||
|
||||
export const actionObjectiveOptions: TComboboxOption[] = [
|
||||
{ label: "Calculate", value: "calculate" },
|
||||
{ label: "Require Answer", value: "requireAnswer" },
|
||||
{ label: "Jump to question", value: "jumpToQuestion" },
|
||||
];
|
||||
|
||||
const getQuestionOperatorOptions = (question: TSurveyQuestion): TComboboxOption[] => {
|
||||
let options: TLogicRuleOption;
|
||||
|
||||
if (question.type === "openText") {
|
||||
const inputType = question.inputType === "number" ? "number" : "text";
|
||||
options = logicRules.question[`openText.${inputType}`].options;
|
||||
} else {
|
||||
options = logicRules.question[question.type].options;
|
||||
}
|
||||
|
||||
if (question.required) {
|
||||
options = options.filter((option) => option.value !== "isSkipped") as TLogicRuleOption;
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
export const getDefaultOperatorForQuestion = (question: TSurveyQuestion): TSurveyLogicConditionsOperator => {
|
||||
const options = getQuestionOperatorOptions(question);
|
||||
|
||||
return options[0].value.toString() as TSurveyLogicConditionsOperator;
|
||||
};
|
||||
|
||||
export const getConditionOperatorOptions = (
|
||||
condition: TSingleCondition,
|
||||
localSurvey: TSurvey
|
||||
): TComboboxOption[] => {
|
||||
if (condition.leftOperand.type === "variable") {
|
||||
const variables = localSurvey.variables ?? [];
|
||||
const variableType =
|
||||
variables.find((variable) => variable.id === condition.leftOperand.value)?.type || "text";
|
||||
return logicRules[`variable.${variableType}`].options;
|
||||
} else if (condition.leftOperand.type === "hiddenField") {
|
||||
return logicRules.hiddenField.options;
|
||||
} else if (condition.leftOperand.type === "question") {
|
||||
const questions = localSurvey.questions ?? [];
|
||||
const question = questions.find((question) => question.id === condition.leftOperand.value);
|
||||
|
||||
if (!question) return [];
|
||||
|
||||
return getQuestionOperatorOptions(question);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const getMatchValueProps = (
|
||||
condition: TSingleCondition,
|
||||
localSurvey: TSurvey
|
||||
): {
|
||||
show?: boolean;
|
||||
showInput?: boolean;
|
||||
inputType?: HTMLInputTypeAttribute;
|
||||
options: TComboboxGroupedOption[];
|
||||
} => {
|
||||
if (
|
||||
[
|
||||
"isAccepted",
|
||||
"isBooked",
|
||||
"isClicked",
|
||||
"isCompletelySubmitted",
|
||||
"isPartiallySubmitted",
|
||||
"isSkipped",
|
||||
"isSubmitted",
|
||||
].includes(condition.operator)
|
||||
) {
|
||||
return { show: false, options: [] };
|
||||
}
|
||||
|
||||
let questions = localSurvey.questions ?? [];
|
||||
let variables = localSurvey.variables ?? [];
|
||||
let hiddenFields = localSurvey.hiddenFields?.fieldIds ?? [];
|
||||
|
||||
const selectedQuestion = questions.find((question) => question.id === condition.leftOperand.value);
|
||||
const selectedVariable = variables.find((variable) => variable.id === condition.leftOperand.value);
|
||||
|
||||
if (condition.leftOperand.type === "question") {
|
||||
questions = questions.filter((question) => question.id !== condition.leftOperand.value);
|
||||
} else if (condition.leftOperand.type === "variable") {
|
||||
variables = variables.filter((variable) => variable.id !== condition.leftOperand.value);
|
||||
} else if (condition.leftOperand.type === "hiddenField") {
|
||||
hiddenFields = hiddenFields.filter((field) => field !== condition.leftOperand.value);
|
||||
}
|
||||
|
||||
if (condition.leftOperand.type === "question") {
|
||||
if (selectedQuestion?.type === TSurveyQuestionTypeEnum.OpenText) {
|
||||
const allowedQuestionTypes = [TSurveyQuestionTypeEnum.OpenText];
|
||||
|
||||
if (selectedQuestion.inputType === "number") {
|
||||
allowedQuestionTypes.push(TSurveyQuestionTypeEnum.Rating, TSurveyQuestionTypeEnum.NPS);
|
||||
}
|
||||
|
||||
if (["equals", "doesNotEqual"].includes(condition.operator)) {
|
||||
if (selectedQuestion.inputType !== "number") {
|
||||
allowedQuestionTypes.push(
|
||||
TSurveyQuestionTypeEnum.Date,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceMulti
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allowedQuestions = questions.filter((question) => allowedQuestionTypes.includes(question.type));
|
||||
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const variableOptions = variables
|
||||
.filter((variable) =>
|
||||
selectedQuestion.inputType !== "number" ? variable.type === "text" : variable.type === "number"
|
||||
)
|
||||
.map((variable) => {
|
||||
return {
|
||||
icon: variable.type === "number" ? FileDigitIcon : FileType2Icon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hiddenFieldsOptions = hiddenFields.map((field) => {
|
||||
return {
|
||||
icon: EyeOffIcon,
|
||||
label: field,
|
||||
value: field,
|
||||
meta: {
|
||||
type: "hiddenField",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
return {
|
||||
show: true,
|
||||
showInput: true,
|
||||
inputType: selectedQuestion.inputType === "number" ? "number" : "text",
|
||||
options: groupedOptions,
|
||||
};
|
||||
} else if (
|
||||
selectedQuestion?.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||
selectedQuestion?.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
|
||||
) {
|
||||
const choices = selectedQuestion.choices.map((choice) => {
|
||||
return {
|
||||
label: getLocalizedValue(choice.label, "default"),
|
||||
value: choice.id,
|
||||
meta: {
|
||||
type: "static",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: false,
|
||||
options: [{ label: "Choices", value: "choices", options: choices }],
|
||||
};
|
||||
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.PictureSelection) {
|
||||
const choices = selectedQuestion.choices.map((choice, idx) => {
|
||||
return {
|
||||
imgSrc: choice.imageUrl,
|
||||
label: `Picture ${idx + 1}`,
|
||||
value: choice.id,
|
||||
meta: {
|
||||
type: "static",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: false,
|
||||
options: [{ label: "Choices", value: "choices", options: choices }],
|
||||
};
|
||||
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.Rating) {
|
||||
const choices = Array.from({ length: selectedQuestion.range }, (_, idx) => {
|
||||
return {
|
||||
label: `${idx + 1}`,
|
||||
value: idx + 1,
|
||||
meta: {
|
||||
type: "static",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const numberVariables = variables.filter((variable) => variable.type === "number");
|
||||
|
||||
const variableOptions = numberVariables.map((variable) => {
|
||||
return {
|
||||
icon: FileDigitIcon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (choices.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Choices",
|
||||
value: "choices",
|
||||
options: choices,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: false,
|
||||
options: groupedOptions,
|
||||
};
|
||||
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.NPS) {
|
||||
const choices = Array.from({ length: 11 }, (_, idx) => {
|
||||
return {
|
||||
label: `${idx}`,
|
||||
value: idx,
|
||||
meta: {
|
||||
type: "static",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const numberVariables = variables.filter((variable) => variable.type === "number");
|
||||
|
||||
const variableOptions = numberVariables.map((variable) => {
|
||||
return {
|
||||
icon: FileDigitIcon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (choices.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Choices",
|
||||
value: "choices",
|
||||
options: choices,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: false,
|
||||
options: groupedOptions,
|
||||
};
|
||||
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.Date) {
|
||||
const openTextQuestions = questions.filter((question) =>
|
||||
[TSurveyQuestionTypeEnum.OpenText, TSurveyQuestionTypeEnum.Date].includes(question.type)
|
||||
);
|
||||
|
||||
const questionOptions = openTextQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const stringVariables = variables.filter((variable) => variable.type === "text");
|
||||
|
||||
const variableOptions = stringVariables.map((variable) => {
|
||||
return {
|
||||
icon: FileType2Icon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hiddenFieldsOptions = hiddenFields.map((field) => {
|
||||
return {
|
||||
icon: EyeOffIcon,
|
||||
label: field,
|
||||
value: field,
|
||||
meta: {
|
||||
type: "hiddenField",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: true,
|
||||
inputType: "date",
|
||||
options: groupedOptions,
|
||||
};
|
||||
}
|
||||
} else if (condition.leftOperand.type === "variable") {
|
||||
if (selectedVariable?.type === "text") {
|
||||
const allowedQuestionTypes = [
|
||||
TSurveyQuestionTypeEnum.OpenText,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
];
|
||||
|
||||
if (["equals", "doesNotEqual"].includes(condition.operator)) {
|
||||
allowedQuestionTypes.push(TSurveyQuestionTypeEnum.MultipleChoiceMulti, TSurveyQuestionTypeEnum.Date);
|
||||
}
|
||||
|
||||
const allowedQuestions = questions.filter((question) => allowedQuestionTypes.includes(question.type));
|
||||
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const stringVariables = variables.filter((variable) => variable.type === "text");
|
||||
|
||||
const variableOptions = stringVariables.map((variable) => {
|
||||
return {
|
||||
icon: FileType2Icon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hiddenFieldsOptions = hiddenFields.map((field) => {
|
||||
return {
|
||||
icon: EyeOffIcon,
|
||||
label: field,
|
||||
value: field,
|
||||
meta: {
|
||||
type: "hiddenField",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: true,
|
||||
inputType: "text",
|
||||
options: groupedOptions,
|
||||
};
|
||||
} else if (selectedVariable?.type === "number") {
|
||||
const allowedQuestions = questions.filter((question) =>
|
||||
[TSurveyQuestionTypeEnum.Rating, TSurveyQuestionTypeEnum.NPS].includes(question.type)
|
||||
);
|
||||
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const numberVariables = variables.filter((variable) => variable.type === "number");
|
||||
|
||||
const variableOptions = numberVariables.map((variable) => {
|
||||
return {
|
||||
icon: FileDigitIcon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hiddenFieldsOptions = hiddenFields.map((field) => {
|
||||
return {
|
||||
icon: EyeOffIcon,
|
||||
label: field,
|
||||
value: field,
|
||||
meta: {
|
||||
type: "hiddenField",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: true,
|
||||
inputType: "number",
|
||||
options: groupedOptions,
|
||||
};
|
||||
}
|
||||
} else if (condition.leftOperand.type === "hiddenField") {
|
||||
const allowedQuestionTypes = [
|
||||
TSurveyQuestionTypeEnum.OpenText,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
];
|
||||
|
||||
if (["equals", "doesNotEqual"].includes(condition.operator)) {
|
||||
allowedQuestionTypes.push(TSurveyQuestionTypeEnum.MultipleChoiceMulti, TSurveyQuestionTypeEnum.Date);
|
||||
}
|
||||
|
||||
const allowedQuestions = questions.filter((question) => allowedQuestionTypes.includes(question.type));
|
||||
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: questionIconMapping[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const variableOptions = variables
|
||||
.filter((variable) => variable.type === "text")
|
||||
.map((variable) => {
|
||||
return {
|
||||
icon: FileType2Icon,
|
||||
label: variable.name,
|
||||
value: variable.id,
|
||||
meta: {
|
||||
type: "variable",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hiddenFieldsOptions = hiddenFields.map((field) => {
|
||||
return {
|
||||
icon: EyeOffIcon,
|
||||
label: field,
|
||||
value: field,
|
||||
meta: {
|
||||
type: "hiddenField",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
|
||||
if (questionOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Questions",
|
||||
value: "questions",
|
||||
options: questionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (variableOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Variables",
|
||||
value: "variables",
|
||||
options: variableOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenFieldsOptions.length > 0) {
|
||||
groupedOptions.push({
|
||||
label: "Hidden Fields",
|
||||
value: "hiddenFields",
|
||||
options: hiddenFieldsOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showInput: true,
|
||||
inputType: "text",
|
||||
options: groupedOptions,
|
||||
};
|
||||
}
|
||||
|
||||
return { show: false, options: [] };
|
||||
};
|
||||
|
||||
export const getActionOperatorOptions = (variableType?: TSurveyVariable["type"]): TComboboxOption[] => {
|
||||
if (variableType === "number") {
|
||||
return [
|
||||
{
|
||||
label: "Add +",
|
||||
value: "add",
|
||||
},
|
||||
{
|
||||
label: "Subtract -",
|
||||
value: "subtract",
|
||||
},
|
||||
{
|
||||
label: "Multiply *",
|
||||
value: "multiply",
|
||||
},
|
||||
{
|
||||
label: "Divide /",
|
||||
value: "divide",
|
||||
},
|
||||
{
|
||||
label: "Assign =",
|
||||
value: "assign",
|
||||
},
|
||||
];
|
||||
} else if (variableType === "text") {
|
||||
return [
|
||||
{
|
||||
label: "Assign =",
|
||||
value: "assign",
|
||||
},
|
||||
{
|
||||
label: "Concat +",
|
||||
value: "concat",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const isUsedInLeftOperand = (
|
||||
leftOperand: TLeftOperand,
|
||||
type: "question" | "hiddenField" | "variable",
|
||||
id: string
|
||||
): boolean => {
|
||||
switch (type) {
|
||||
case "question":
|
||||
return leftOperand.type === "question" && leftOperand.value === id;
|
||||
case "hiddenField":
|
||||
return leftOperand.type === "hiddenField" && leftOperand.value === id;
|
||||
case "variable":
|
||||
return leftOperand.type === "variable" && leftOperand.value === id;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isUsedInRightOperand = (
|
||||
rightOperand: TRightOperand,
|
||||
type: "question" | "hiddenField" | "variable",
|
||||
id: string
|
||||
): boolean => {
|
||||
switch (type) {
|
||||
case "question":
|
||||
return rightOperand.type === "question" && rightOperand.value === id;
|
||||
case "hiddenField":
|
||||
return rightOperand.type === "hiddenField" && rightOperand.value === id;
|
||||
case "variable":
|
||||
return rightOperand.type === "variable" && rightOperand.value === id;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const findQuestionUsedInLogic = (survey: TSurvey, questionId: string): number => {
|
||||
const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => {
|
||||
if (isConditionGroup(condition)) {
|
||||
// It's a TConditionGroup
|
||||
return condition.conditions.some(isUsedInCondition);
|
||||
} else {
|
||||
// It's a TSingleCondition
|
||||
return (
|
||||
(condition.rightOperand && isUsedInRightOperand(condition.rightOperand, "question", questionId)) ||
|
||||
isUsedInLeftOperand(condition.leftOperand, "question", questionId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isUsedInAction = (action: TSurveyLogicAction): boolean => {
|
||||
return (
|
||||
(action.objective === "jumpToQuestion" && action.target === questionId) ||
|
||||
(action.objective === "requireAnswer" && action.target === questionId)
|
||||
);
|
||||
};
|
||||
|
||||
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
|
||||
return isUsedInCondition(logicRule.conditions) || logicRule.actions.some(isUsedInAction);
|
||||
};
|
||||
|
||||
return survey.questions.findIndex(
|
||||
(question) => question.logic && question.id !== questionId && question.logic.some(isUsedInLogicRule)
|
||||
);
|
||||
};
|
||||
|
||||
export const findOptionUsedInLogic = (survey: TSurvey, questionId: string, optionId: string): number => {
|
||||
const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => {
|
||||
if (isConditionGroup(condition)) {
|
||||
// It's a TConditionGroup
|
||||
return condition.conditions.some(isUsedInCondition);
|
||||
} else {
|
||||
// It's a TSingleCondition
|
||||
return isUsedInOperand(condition);
|
||||
}
|
||||
};
|
||||
|
||||
const isUsedInOperand = (condition: TSingleCondition): boolean => {
|
||||
if (condition.leftOperand.type === "question" && condition.leftOperand.value === questionId) {
|
||||
if (condition.rightOperand && condition.rightOperand.type === "static") {
|
||||
if (Array.isArray(condition.rightOperand.value)) {
|
||||
return condition.rightOperand.value.includes(optionId);
|
||||
} else {
|
||||
return condition.rightOperand.value === optionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
|
||||
return isUsedInCondition(logicRule.conditions);
|
||||
};
|
||||
|
||||
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
|
||||
};
|
||||
|
||||
export const findVariableUsedInLogic = (survey: TSurvey, variableId: string): number => {
|
||||
const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => {
|
||||
if (isConditionGroup(condition)) {
|
||||
// It's a TConditionGroup
|
||||
return condition.conditions.some(isUsedInCondition);
|
||||
} else {
|
||||
// It's a TSingleCondition
|
||||
return (
|
||||
(condition.rightOperand && isUsedInRightOperand(condition.rightOperand, "variable", variableId)) ||
|
||||
isUsedInLeftOperand(condition.leftOperand, "variable", variableId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isUsedInAction = (action: TSurveyLogicAction): boolean => {
|
||||
return action.objective === "calculate" && action.variableId === variableId;
|
||||
};
|
||||
|
||||
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
|
||||
return isUsedInCondition(logicRule.conditions) || logicRule.actions.some(isUsedInAction);
|
||||
};
|
||||
|
||||
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
|
||||
};
|
||||
|
||||
export const findHiddenFieldUsedInLogic = (survey: TSurvey, hiddenFieldId: string): number => {
|
||||
const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => {
|
||||
if (isConditionGroup(condition)) {
|
||||
// It's a TConditionGroup
|
||||
return condition.conditions.some(isUsedInCondition);
|
||||
} else {
|
||||
// It's a TSingleCondition
|
||||
return (
|
||||
(condition.rightOperand &&
|
||||
isUsedInRightOperand(condition.rightOperand, "hiddenField", hiddenFieldId)) ||
|
||||
isUsedInLeftOperand(condition.leftOperand, "hiddenField", hiddenFieldId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
|
||||
return isUsedInCondition(logicRule.conditions);
|
||||
};
|
||||
|
||||
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
|
||||
};
|
||||
@@ -22,7 +22,7 @@ export const generateMetadata = async ({ params }) => {
|
||||
};
|
||||
};
|
||||
|
||||
const Page = async ({ params }) => {
|
||||
const Page = async ({ params, searchParams }) => {
|
||||
const [
|
||||
survey,
|
||||
product,
|
||||
@@ -70,6 +70,8 @@ const Page = async ({ params }) => {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
const isCxMode = searchParams.mode === "cx";
|
||||
|
||||
return (
|
||||
<SurveyEditor
|
||||
survey={survey}
|
||||
@@ -87,6 +89,7 @@ const Page = async ({ params }) => {
|
||||
plan={organization.billing.plan}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isUnsplashConfigured={UNSPLASH_ACCESS_KEY ? true : false}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { TagIcon } from "lucide-react";
|
||||
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||
@@ -8,7 +8,7 @@ const Loading = () => {
|
||||
<>
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="People">
|
||||
<PeopleSecondaryNavigation activeId="attributes" loading />
|
||||
<PersonSecondaryNavigation activeId="attributes" loading />
|
||||
</PageHeader>
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-5 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { CircleHelpIcon } from "lucide-react";
|
||||
import { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
@@ -42,7 +42,7 @@ const Page = async ({ params }) => {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="People" cta={HowToAddAttributesButton}>
|
||||
<PeopleSecondaryNavigation activeId="attributes" environmentId={params.environmentId} />
|
||||
<PersonSecondaryNavigation activeId="attributes" environmentId={params.environmentId} />
|
||||
</PageHeader>
|
||||
<AttributeClassesTable attributeClasses={attributeClasses} />
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { deletePersonAction } from "@/app/(app)/environments/[environmentId]/(people)/people/[personId]/actions";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { deletePersonAction } from "@formbricks/ui/DataTable/actions";
|
||||
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
|
||||
|
||||
interface DeletePersonButtonProps {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { getAttributes } from "@formbricks/lib/attribute/service";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
|
||||
import { getPeople } from "@formbricks/lib/person/service";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
const ZGetPersonsAction = z.object({
|
||||
environmentId: ZId,
|
||||
page: z.number(),
|
||||
});
|
||||
|
||||
export const getPersonsAction = authenticatedActionClient
|
||||
.schema(ZGetPersonsAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
rules: ["environment", "read"],
|
||||
});
|
||||
|
||||
return getPeople(parsedInput.environmentId, parsedInput.page);
|
||||
});
|
||||
|
||||
const ZGetPersonAttributesAction = z.object({
|
||||
environmentId: ZId,
|
||||
personId: ZId,
|
||||
});
|
||||
|
||||
export const getPersonAttributesAction = authenticatedActionClient
|
||||
.schema(ZGetPersonAttributesAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
rules: ["environment", "read"],
|
||||
});
|
||||
|
||||
return getAttributes(parsedInput.personId);
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { getAttributes } from "@formbricks/lib/attribute/service";
|
||||
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
|
||||
import { TPerson } from "@formbricks/types/people";
|
||||
import { PersonAvatar } from "@formbricks/ui/Avatars";
|
||||
|
||||
export const PersonCard = async ({ person }: { person: TPerson }) => {
|
||||
const attributes = await getAttributes(person.id);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/environments/${person.environmentId}/people/${person.id}`}
|
||||
key={person.id}
|
||||
className="w-full">
|
||||
<div className="m-2 grid h-16 grid-cols-7 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
|
||||
<div className="col-span-3 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="ph-no-capture h-10 w-10 flex-shrink-0">
|
||||
<PersonAvatar personId={person.id} />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="ph-no-capture font-medium text-slate-900">
|
||||
<span>{getPersonIdentifier({ id: person.id, userId: person.userId }, attributes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{person.userId}</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{attributes.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { getPersonsAction } from "@/app/(app)/environments/[environmentId]/(people)/people/actions";
|
||||
import { PersonTable } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonTable";
|
||||
import { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TPersonWithAttributes } from "@formbricks/types/people";
|
||||
|
||||
interface PersonDataViewProps {
|
||||
environment: TEnvironment;
|
||||
personCount: number;
|
||||
itemsPerPage: number;
|
||||
}
|
||||
|
||||
export const PersonDataView = ({ environment, personCount, itemsPerPage }: PersonDataViewProps) => {
|
||||
const [persons, setPersons] = useState<TPersonWithAttributes[]>([]);
|
||||
const [pageNumber, setPageNumber] = useState<number>(1);
|
||||
const [totalPersons, setTotalPersons] = useState<number>(0);
|
||||
const [isDataLoaded, setIsDataLoaded] = useState<boolean>(false);
|
||||
const [hasMore, setHasMore] = useState<boolean>(false);
|
||||
const [loadingNextPage, setLoadingNextPage] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTotalPersons(personCount);
|
||||
setHasMore(pageNumber < Math.ceil(personCount / itemsPerPage));
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const getPersonActionData = await getPersonsAction({
|
||||
environmentId: environment.id,
|
||||
page: pageNumber,
|
||||
});
|
||||
if (getPersonActionData?.data) {
|
||||
setPersons(getPersonActionData.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching people data:", error);
|
||||
} finally {
|
||||
setIsDataLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [pageNumber, personCount, itemsPerPage, environment.id]);
|
||||
|
||||
const fetchNextPage = async () => {
|
||||
if (hasMore && !loadingNextPage) {
|
||||
setLoadingNextPage(true);
|
||||
const getPersonsActionData = await getPersonsAction({
|
||||
environmentId: environment.id,
|
||||
page: pageNumber,
|
||||
});
|
||||
if (getPersonsActionData?.data) {
|
||||
const newData = getPersonsActionData.data;
|
||||
setPersons((prevPersonsData) => [...prevPersonsData, ...newData]);
|
||||
}
|
||||
setPageNumber((prevPage) => prevPage + 1);
|
||||
setHasMore(pageNumber + 1 < Math.ceil(totalPersons / itemsPerPage));
|
||||
setLoadingNextPage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deletePersons = (personIds: string[]) => {
|
||||
setPersons((prevPersons) => prevPersons.filter((p) => !personIds.includes(p.id)));
|
||||
};
|
||||
|
||||
const personTableData = persons.map((person) => ({
|
||||
id: person.id,
|
||||
userId: person.userId,
|
||||
email: person.attributes.email,
|
||||
createdAt: person.createdAt,
|
||||
attributes: person.attributes,
|
||||
personId: person.id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<PersonTable
|
||||
data={personTableData}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasMore={hasMore}
|
||||
isDataLoaded={isDataLoaded}
|
||||
deletePersons={deletePersons}
|
||||
environmentId={environment.id}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -2,17 +2,17 @@ import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { TProductConfigChannel } from "@formbricks/types/product";
|
||||
import { SecondaryNavigation } from "@formbricks/ui/SecondaryNavigation";
|
||||
|
||||
interface PeopleSegmentsTabsProps {
|
||||
interface PersonSecondaryNavigationProps {
|
||||
activeId: string;
|
||||
environmentId?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const PeopleSecondaryNavigation = async ({
|
||||
export const PersonSecondaryNavigation = async ({
|
||||
activeId,
|
||||
environmentId,
|
||||
loading,
|
||||
}: PeopleSegmentsTabsProps) => {
|
||||
}: PersonSecondaryNavigationProps) => {
|
||||
let currentProductChannel: TProductConfigChannel = null;
|
||||
|
||||
if (!loading && environmentId) {
|
||||
@@ -0,0 +1,240 @@
|
||||
import { generatePersonTableColumns } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonTableColumn";
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
|
||||
import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { VisibilityState, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TPersonTableData } from "@formbricks/types/people";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { DataTableHeader, DataTableSettingsModal, DataTableToolbar } from "@formbricks/ui/DataTable";
|
||||
import { getCommonPinningStyles } from "@formbricks/ui/DataTable/lib/utils";
|
||||
import { Skeleton } from "@formbricks/ui/Skeleton";
|
||||
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@formbricks/ui/Table";
|
||||
|
||||
interface PersonTableProps {
|
||||
data: TPersonTableData[];
|
||||
fetchNextPage: () => void;
|
||||
hasMore: boolean;
|
||||
deletePersons: (personIds: string[]) => void;
|
||||
isDataLoaded: boolean;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const PersonTable = ({
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasMore,
|
||||
deletePersons,
|
||||
isDataLoaded,
|
||||
environmentId,
|
||||
}: PersonTableProps) => {
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||
const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const router = useRouter();
|
||||
// Generate columns
|
||||
const columns = useMemo(() => generatePersonTableColumns(isExpanded ?? false), [isExpanded]);
|
||||
|
||||
// Load saved settings from localStorage
|
||||
useEffect(() => {
|
||||
const savedColumnOrder = localStorage.getItem(`${environmentId}-columnOrder`);
|
||||
const savedColumnVisibility = localStorage.getItem(`${environmentId}-columnVisibility`);
|
||||
const savedExpandedSettings = localStorage.getItem(`${environmentId}-rowExpand`);
|
||||
if (savedColumnOrder && JSON.parse(savedColumnOrder).length > 0) {
|
||||
setColumnOrder(JSON.parse(savedColumnOrder));
|
||||
} else {
|
||||
setColumnOrder(table.getAllLeafColumns().map((d) => d.id));
|
||||
}
|
||||
|
||||
if (savedColumnVisibility) {
|
||||
setColumnVisibility(JSON.parse(savedColumnVisibility));
|
||||
}
|
||||
if (savedExpandedSettings !== null) {
|
||||
setIsExpanded(JSON.parse(savedExpandedSettings));
|
||||
}
|
||||
}, [environmentId]);
|
||||
|
||||
// Save settings to localStorage when they change
|
||||
useEffect(() => {
|
||||
if (columnOrder.length > 0) {
|
||||
localStorage.setItem(`${environmentId}-columnOrder`, JSON.stringify(columnOrder));
|
||||
}
|
||||
if (Object.keys(columnVisibility).length > 0) {
|
||||
localStorage.setItem(`${environmentId}-columnVisibility`, JSON.stringify(columnVisibility));
|
||||
}
|
||||
|
||||
if (isExpanded !== null) {
|
||||
localStorage.setItem(`${environmentId}-rowExpand`, JSON.stringify(isExpanded));
|
||||
}
|
||||
}, [columnOrder, columnVisibility, isExpanded, environmentId]);
|
||||
|
||||
// Initialize DnD sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
);
|
||||
|
||||
// Memoize table data and columns
|
||||
const tableData: TPersonTableData[] = useMemo(
|
||||
() => (!isDataLoaded ? Array(10).fill({}) : data),
|
||||
[data, isDataLoaded]
|
||||
);
|
||||
const tableColumns = useMemo(
|
||||
() =>
|
||||
!isDataLoaded
|
||||
? columns.map((column) => ({
|
||||
...column,
|
||||
cell: () => (
|
||||
<Skeleton className="w-full">
|
||||
<div className="h-6"></div>
|
||||
</Skeleton>
|
||||
),
|
||||
}))
|
||||
: columns,
|
||||
[columns, data]
|
||||
);
|
||||
|
||||
// React Table instance
|
||||
const table = useReactTable({
|
||||
data: tableData,
|
||||
columns: tableColumns,
|
||||
getRowId: (originalRow) => originalRow.personId,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
columnResizeMode: "onChange",
|
||||
columnResizeDirection: "ltr",
|
||||
manualPagination: true,
|
||||
defaultColumn: { size: 300 },
|
||||
state: {
|
||||
columnOrder,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnPinning: {
|
||||
left: ["select", "createdAt"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Handle column drag end
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (active && over && active.id !== over.id) {
|
||||
setColumnOrder((prevOrder) => {
|
||||
const oldIndex = prevOrder.indexOf(active.id as string);
|
||||
const newIndex = prevOrder.indexOf(over.id as string);
|
||||
return arrayMove(prevOrder, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToHorizontalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
sensors={sensors}>
|
||||
<DataTableToolbar
|
||||
setIsExpanded={setIsExpanded}
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
isExpanded={isExpanded ?? false}
|
||||
table={table}
|
||||
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>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
{table.getRowModel().rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data && hasMore && data.length > 0 && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button onClick={fetchNextPage} className="bg-blue-500 text-white">
|
||||
Load More
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTableSettingsModal
|
||||
open={isTableSettingsModalOpen}
|
||||
setOpen={setIsTableSettingsModalOpen}
|
||||
table={table}
|
||||
columnOrder={columnOrder}
|
||||
handleDragEnd={handleDragEnd}
|
||||
/>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TPersonTableData } from "@formbricks/types/people";
|
||||
import { getSelectionColumn } from "@formbricks/ui/DataTable";
|
||||
|
||||
export const generatePersonTableColumns = (isExpanded: boolean): ColumnDef<TPersonTableData>[] => {
|
||||
const dateColumn: ColumnDef<TPersonTableData> = {
|
||||
accessorKey: "createdAt",
|
||||
header: () => "Date",
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const isoDateString = row.original.createdAt;
|
||||
const date = new Date(isoDateString);
|
||||
|
||||
const formattedDate = date.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const formattedTime = date.toLocaleString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="truncate text-slate-900">{formattedDate}</p>
|
||||
<p className="truncate text-slate-900">{formattedTime}</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const userColumn: ColumnDef<TPersonTableData> = {
|
||||
accessorKey: "user",
|
||||
header: "User",
|
||||
cell: ({ row }) => {
|
||||
const personId = row.original.personId;
|
||||
return <p className="truncate text-slate-900">{personId}</p>;
|
||||
},
|
||||
};
|
||||
|
||||
const userIdColumn: ColumnDef<TPersonTableData> = {
|
||||
accessorKey: "userId",
|
||||
header: "User ID",
|
||||
cell: ({ row }) => {
|
||||
const userId = row.original.userId;
|
||||
return <p className="truncate text-slate-900">{userId}</p>;
|
||||
},
|
||||
};
|
||||
|
||||
const emailColumn: ColumnDef<TPersonTableData> = {
|
||||
accessorKey: "email",
|
||||
header: "Email",
|
||||
};
|
||||
|
||||
const attributesColumn: ColumnDef<TPersonTableData> = {
|
||||
accessorKey: "attributes",
|
||||
header: "Attributes",
|
||||
cell: ({ row }) => {
|
||||
const attributes = row.original.attributes;
|
||||
|
||||
// Handle cases where attributes are missing or empty
|
||||
if (!attributes || Object.keys(attributes).length === 0) return null;
|
||||
|
||||
return (
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return [getSelectionColumn(), dateColumn, userColumn, userIdColumn, emailColumn, attributesColumn];
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
export const Pagination = ({ environmentId, currentPage, totalItems, itemsPerPage }) => {
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
|
||||
const previousPageLink =
|
||||
currentPage === 1 ? "#" : `/environments/${environmentId}/people?page=${currentPage - 1}`;
|
||||
const nextPageLink =
|
||||
currentPage === totalPages ? "#" : `/environments/${environmentId}/people?page=${currentPage + 1}`;
|
||||
|
||||
return (
|
||||
<nav aria-label="Page navigation" className="flex justify-center">
|
||||
<ul className="mt-4 inline-flex -space-x-px text-sm">
|
||||
<li>
|
||||
<a
|
||||
href={previousPageLink}
|
||||
className={`ml-0 flex h-8 items-center justify-center rounded-l-lg border border-slate-300 bg-white px-3 text-slate-500 ${
|
||||
currentPage === 1
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: "hover:bg-slate-100 hover:text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-white"
|
||||
}`}>
|
||||
Previous
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{Array.from({ length: totalPages }).map((_, idx) => {
|
||||
const pageNum = idx + 1;
|
||||
const pageLink = `/environments/${environmentId}/people?page=${pageNum}`;
|
||||
|
||||
return (
|
||||
<li key={pageNum} className="hidden sm:block">
|
||||
<a
|
||||
href={pageNum === currentPage ? "#" : pageLink}
|
||||
className={`flex h-8 items-center justify-center px-3 ${
|
||||
pageNum === currentPage ? "bg-blue-50 text-green-500" : "bg-white text-slate-500"
|
||||
} border border-slate-300 hover:bg-slate-100 hover:text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-white`}>
|
||||
{pageNum}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
<li>
|
||||
<a
|
||||
href={nextPageLink}
|
||||
className={`ml-0 flex h-8 items-center justify-center rounded-r-lg border border-slate-300 bg-white px-3 text-slate-500 ${
|
||||
currentPage === totalPages
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: "hover:bg-slate-100 hover:text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-white"
|
||||
}`}>
|
||||
Next
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||
|
||||
@@ -7,7 +7,7 @@ const Loading = () => {
|
||||
<>
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="People">
|
||||
<PeopleSecondaryNavigation activeId="people" loading />
|
||||
<PersonSecondaryNavigation activeId="people" loading />
|
||||
</PageHeader>
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
|
||||
@@ -1,42 +1,20 @@
|
||||
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||
import { PersonDataView } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonDataView";
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { CircleHelpIcon } from "lucide-react";
|
||||
import { ITEMS_PER_PAGE } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getPeople, getPeopleCount } from "@formbricks/lib/person/service";
|
||||
import { TPerson } from "@formbricks/types/people";
|
||||
import { getPersonCount } from "@formbricks/lib/person/service";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { EmptySpaceFiller } from "@formbricks/ui/EmptySpaceFiller";
|
||||
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||
import { Pagination } from "@formbricks/ui/Pagination";
|
||||
import { PersonCard } from "./components/PersonCard";
|
||||
|
||||
const Page = async ({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { environmentId: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
}) => {
|
||||
const pageNumber = searchParams.page ? parseInt(searchParams.page as string) : 1;
|
||||
const [environment, totalPeople] = await Promise.all([
|
||||
getEnvironment(params.environmentId),
|
||||
getPeopleCount(params.environmentId),
|
||||
]);
|
||||
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");
|
||||
}
|
||||
const maxPageNumber = Math.ceil(totalPeople / ITEMS_PER_PAGE);
|
||||
let hidePagination = false;
|
||||
|
||||
let people: TPerson[] = [];
|
||||
|
||||
if (pageNumber < 1 || pageNumber > maxPageNumber) {
|
||||
people = [];
|
||||
hidePagination = true;
|
||||
} else {
|
||||
people = await getPeople(params.environmentId, pageNumber);
|
||||
}
|
||||
|
||||
const HowToAddPeopleButton = (
|
||||
<Button
|
||||
@@ -52,35 +30,9 @@ const Page = async ({
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="People" cta={HowToAddPeopleButton}>
|
||||
<PeopleSecondaryNavigation activeId="people" environmentId={params.environmentId} />
|
||||
<PersonSecondaryNavigation activeId="people" environmentId={params.environmentId} />
|
||||
</PageHeader>
|
||||
{people.length === 0 ? (
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
emptyMessage="Your users will appear here as soon as they use your app ⏲️"
|
||||
noWidgetRequired={true}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-7 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-3 pl-6">User</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">User ID</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">Email</div>
|
||||
</div>
|
||||
{people.map((person) => (
|
||||
<PersonCard person={person} key={person.id} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hidePagination ? null : (
|
||||
<Pagination
|
||||
baseUrl={`/environments/${params.environmentId}/people`}
|
||||
currentPage={pageNumber}
|
||||
totalItems={totalPeople}
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
/>
|
||||
)}
|
||||
<PersonDataView environment={environment} personCount={personCount} itemsPerPage={ITEMS_PER_PAGE} />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { UsersIcon } from "lucide-react";
|
||||
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||
@@ -8,7 +8,7 @@ const Loading = () => {
|
||||
<>
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="People">
|
||||
<PeopleSecondaryNavigation activeId="segments" loading />
|
||||
<PersonSecondaryNavigation activeId="segments" loading />
|
||||
</PageHeader>
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { BasicCreateSegmentModal } from "@/app/(app)/environments/[environmentId]/(people)/segments/components/BasicCreateSegmentModal";
|
||||
import { SegmentTable } from "@/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTable";
|
||||
import { CreateSegmentModal } from "@formbricks/ee/advanced-targeting/components/create-segment-modal";
|
||||
@@ -56,7 +56,7 @@ const Page = async ({ params }) => {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="People" cta={renderCreateSegmentButton()}>
|
||||
<PeopleSecondaryNavigation activeId="segments" environmentId={params.environmentId} />
|
||||
<PersonSecondaryNavigation activeId="segments" environmentId={params.environmentId} />
|
||||
</PageHeader>
|
||||
<SegmentTable
|
||||
segments={filteredSegments}
|
||||
|
||||
@@ -169,7 +169,7 @@ export const MainNavigation = ({
|
||||
};
|
||||
|
||||
const handleAddProduct = (organizationId: string) => {
|
||||
router.push(`/organizations/${organizationId}/products/new/channel`);
|
||||
router.push(`/organizations/${organizationId}/products/new/mode`);
|
||||
};
|
||||
|
||||
const mainNavigation = useMemo(
|
||||
|
||||
@@ -15,7 +15,7 @@ export const WidgetStatusIndicator = ({ environment, size, type }: WidgetStatusI
|
||||
notImplemented: {
|
||||
icon: AlertTriangleIcon,
|
||||
title: `Your ${type} is not yet connected.`,
|
||||
subtitle: `Connect your ${type} with Formbricks to get started. To run ${type === "app" ? "in-app" : "website"} surveys follow the setup guide.`,
|
||||
subtitle: ``,
|
||||
shortText: `Connect your ${type} with Formbricks`,
|
||||
},
|
||||
running: {
|
||||
|
||||
@@ -47,6 +47,7 @@ export type IntegrationModalInputs = {
|
||||
table: string;
|
||||
survey: string;
|
||||
questions: string[];
|
||||
includeVariables: boolean;
|
||||
includeHiddenFields: boolean;
|
||||
includeMetadata: boolean;
|
||||
};
|
||||
@@ -91,8 +92,9 @@ export const AddIntegrationModal = ({
|
||||
const { index: _index, ...rest } = defaultData;
|
||||
reset(rest);
|
||||
fetchTable(defaultData.base);
|
||||
setIncludeHiddenFields(defaultData.includeHiddenFields);
|
||||
setIncludeMetadata(defaultData.includeMetadata);
|
||||
setIncludeVariables(!!defaultData.includeVariables);
|
||||
setIncludeHiddenFields(!!defaultData.includeHiddenFields);
|
||||
setIncludeMetadata(!!defaultData.includeMetadata);
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
@@ -100,6 +102,12 @@ export const AddIntegrationModal = ({
|
||||
}, [isEditMode]);
|
||||
|
||||
const survey = watch("survey");
|
||||
const includeVariables = watch("includeVariables");
|
||||
|
||||
const setIncludeVariables = (includeVariables: boolean) => {
|
||||
setValue("includeVariables", includeVariables);
|
||||
};
|
||||
|
||||
const selectedSurvey = surveys.find((item) => item.id === survey);
|
||||
const submitHandler = async (data: IntegrationModalInputs) => {
|
||||
try {
|
||||
@@ -130,6 +138,7 @@ export const AddIntegrationModal = ({
|
||||
baseId: data.base,
|
||||
tableId: data.table,
|
||||
tableName: currentTable?.name ?? "",
|
||||
includeVariables: data.includeVariables,
|
||||
includeHiddenFields,
|
||||
includeMetadata,
|
||||
};
|
||||
@@ -329,6 +338,8 @@ export const AddIntegrationModal = ({
|
||||
</div>
|
||||
</div>
|
||||
<AdditionalIntegrationSettings
|
||||
includeVariables={includeVariables}
|
||||
setIncludeVariables={setIncludeVariables}
|
||||
includeHiddenFields={includeHiddenFields}
|
||||
includeMetadata={includeMetadata}
|
||||
setIncludeHiddenFields={setIncludeHiddenFields}
|
||||
|
||||
@@ -108,6 +108,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
questions: data.questionIds,
|
||||
survey: data.surveyId,
|
||||
table: data.tableId,
|
||||
includeVariables: !!data.includeVariables,
|
||||
includeHiddenFields: !!data.includeHiddenFields,
|
||||
includeMetadata: !!data.includeMetadata,
|
||||
index,
|
||||
|
||||
@@ -62,6 +62,7 @@ export const AddIntegrationModal = ({
|
||||
const [spreadsheetUrl, setSpreadsheetUrl] = useState("");
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
const existingIntegrationData = googleSheetIntegration?.config?.data;
|
||||
const [includeVariables, setIncludeVariables] = useState(false);
|
||||
const [includeHiddenFields, setIncludeHiddenFields] = useState(false);
|
||||
const [includeMetadata, setIncludeMetadata] = useState(false);
|
||||
const googleSheetIntegrationData: TIntegrationGoogleSheetsInput = {
|
||||
@@ -89,6 +90,7 @@ export const AddIntegrationModal = ({
|
||||
})!
|
||||
);
|
||||
setSelectedQuestions(selectedIntegration.questionIds);
|
||||
setIncludeVariables(!!selectedIntegration.includeVariables);
|
||||
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
|
||||
setIncludeMetadata(!!selectedIntegration.includeMetadata);
|
||||
return;
|
||||
@@ -127,6 +129,7 @@ export const AddIntegrationModal = ({
|
||||
? "All questions"
|
||||
: "Selected questions";
|
||||
integrationData.createdAt = new Date();
|
||||
integrationData.includeVariables = includeVariables;
|
||||
integrationData.includeHiddenFields = includeHiddenFields;
|
||||
integrationData.includeMetadata = includeMetadata;
|
||||
if (selectedIntegration) {
|
||||
@@ -256,6 +259,8 @@ export const AddIntegrationModal = ({
|
||||
</div>
|
||||
</div>
|
||||
<AdditionalIntegrationSettings
|
||||
includeVariables={includeVariables}
|
||||
setIncludeVariables={setIncludeVariables}
|
||||
includeHiddenFields={includeHiddenFields}
|
||||
includeMetadata={includeMetadata}
|
||||
setIncludeHiddenFields={setIncludeHiddenFields}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
TYPE_MAPPING,
|
||||
UNSUPPORTED_TYPES_BY_NOTION,
|
||||
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
|
||||
import { questionTypes } from "@/app/lib/questions";
|
||||
import NotionLogo from "@/images/notion.png";
|
||||
import { PlusIcon, XIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
@@ -13,6 +12,7 @@ import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { questionTypes } from "@formbricks/lib/utils/questions";
|
||||
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TIntegrationInput } from "@formbricks/types/integration";
|
||||
@@ -118,6 +118,13 @@ export const AddIntegrationModal = ({
|
||||
}))
|
||||
: [];
|
||||
|
||||
const variables =
|
||||
selectedSurvey?.variables.map((variable) => ({
|
||||
id: variable.id,
|
||||
name: variable.name,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
})) || [];
|
||||
|
||||
const hiddenFields = selectedSurvey?.hiddenFields.enabled
|
||||
? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
|
||||
id: fId,
|
||||
@@ -133,7 +140,7 @@ export const AddIntegrationModal = ({
|
||||
},
|
||||
];
|
||||
|
||||
return [...questions, ...hiddenFields, ...Metadata];
|
||||
return [...questions, ...variables, ...hiddenFields, ...Metadata];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedSurvey?.id]);
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ export const AddChannelMappingModal = ({
|
||||
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
|
||||
const [selectedChannel, setSelectedChannel] = useState<TIntegrationItem | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
const [includeVariables, setIncludeVariables] = useState(false);
|
||||
const [includeHiddenFields, setIncludeHiddenFields] = useState(false);
|
||||
const [includeMetadata, setIncludeMetadata] = useState(false);
|
||||
const existingIntegrationData = slackIntegration?.config?.data;
|
||||
@@ -81,6 +82,7 @@ export const AddChannelMappingModal = ({
|
||||
})!
|
||||
);
|
||||
setSelectedQuestions(selectedIntegration.questionIds);
|
||||
setIncludeVariables(!!selectedIntegration.includeVariables);
|
||||
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
|
||||
setIncludeMetadata(!!selectedIntegration.includeMetadata);
|
||||
return;
|
||||
@@ -112,6 +114,7 @@ export const AddChannelMappingModal = ({
|
||||
? "All questions"
|
||||
: "Selected questions",
|
||||
createdAt: new Date(),
|
||||
includeVariables,
|
||||
includeHiddenFields,
|
||||
includeMetadata,
|
||||
};
|
||||
@@ -258,6 +261,8 @@ export const AddChannelMappingModal = ({
|
||||
</div>
|
||||
</div>
|
||||
<AdditionalIntegrationSettings
|
||||
includeVariables={includeVariables}
|
||||
setIncludeVariables={setIncludeVariables}
|
||||
includeHiddenFields={includeHiddenFields}
|
||||
includeMetadata={includeMetadata}
|
||||
setIncludeHiddenFields={setIncludeHiddenFields}
|
||||
|
||||