diff --git a/apps/admin/package.json b/apps/admin/package.json index c1d24e61563..0f5715831b6 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -20,6 +20,7 @@ "@webiny/serverless-cms-aws": "0.0.0", "core-js": "3.39.0", "cross-fetch": "^3.1.5", + "funnel-builder": "1.0.0", "prop-types": "^15.8.1", "react": "18.2.0", "react-dom": "18.2.0", diff --git a/apps/admin/src/Extensions.tsx b/apps/admin/src/Extensions.tsx index cf289007f7f..fa69c8c559a 100644 --- a/apps/admin/src/Extensions.tsx +++ b/apps/admin/src/Extensions.tsx @@ -1,7 +1,12 @@ // This file is automatically updated via scaffolding utilities. // Learn more about extensions: https://webiny.link/extensions import React from "react"; +import { Extension as FunnelBuilderExtension } from "funnel-builder/src/admin"; export const Extensions = () => { - return <>; + return ( + <> + + + ); }; diff --git a/apps/api/graphql/package.json b/apps/api/graphql/package.json index fafa0b465c9..8e71c16d5d8 100644 --- a/apps/api/graphql/package.json +++ b/apps/api/graphql/package.json @@ -49,7 +49,8 @@ "@webiny/handler-graphql": "0.0.0", "@webiny/handler-logs": "0.0.0", "@webiny/project-utils": "0.0.0", - "@webiny/tasks": "0.0.0" + "@webiny/tasks": "0.0.0", + "funnel-builder": "1.0.0" }, "devDependencies": { "@webiny/cli-plugin-deploy-pulumi": "0.0.0", diff --git a/apps/api/graphql/src/extensions.ts b/apps/api/graphql/src/extensions.ts index 8fe26e9ecfd..017abd7131d 100644 --- a/apps/api/graphql/src/extensions.ts +++ b/apps/api/graphql/src/extensions.ts @@ -1,5 +1,7 @@ // This file is automatically updated via scaffolding utilities. +import { createExtension as FunnelBuilderExtension } from "funnel-builder/src/api"; + // Learn more about extensions: https://webiny.link/extensions export const extensions = () => { - return []; + return [FunnelBuilderExtension()]; }; diff --git a/apps/website/package.json b/apps/website/package.json index 8a33a09e912..40f72e6bf02 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -17,6 +17,7 @@ "apollo-link": "^1.2.14", "core-js": "3.39.0", "cross-fetch": "^3.1.5", + "funnel-builder": "1.0.0", "graphql": "^15.9.0", "react": "18.2.0", "react-dom": "18.2.0", diff --git a/apps/website/src/Extensions.tsx b/apps/website/src/Extensions.tsx index cf289007f7f..2c82bf777bc 100644 --- a/apps/website/src/Extensions.tsx +++ b/apps/website/src/Extensions.tsx @@ -1,7 +1,12 @@ // This file is automatically updated via scaffolding utilities. // Learn more about extensions: https://webiny.link/extensions import React from "react"; +import { Extension as FunnelBuilderExtension } from "funnel-builder/src/website"; export const Extensions = () => { - return <>; + return ( + <> + + + ); }; diff --git a/extensions/funnelBuilder/.babelrc.js b/extensions/funnelBuilder/.babelrc.js new file mode 100644 index 00000000000..9da7674cb52 --- /dev/null +++ b/extensions/funnelBuilder/.babelrc.js @@ -0,0 +1 @@ +module.exports = require("@webiny/project-utils").createBabelConfigForNode({ path: __dirname }); diff --git a/extensions/funnelBuilder/README.md b/extensions/funnelBuilder/README.md new file mode 100644 index 00000000000..a8e9cb6fc0d --- /dev/null +++ b/extensions/funnelBuilder/README.md @@ -0,0 +1,147 @@ +# Funnel Builder Extension + +The Funnel Builder is a Page Builder-based Webiny extension that lets you create multi-step funnels. It provides a set of page elements, admin interfaces, and API extensions to handle funnel creation, form submissions, and conditional logic. + +## Structure + +The extension is organized into three main folders: + +### Backend + +Located in `src/backend`, this folder contains API extensions for the Funnel Builder: + +- **api**: Contains GraphQL resolvers, content models, and theme settings handlers for the funnel builder functionality. + +### Frontend + +Located in `src/frontend`, this folder contains admin and website extensions: + +- **admin**: Contains React components, hooks, and plugins for the admin interface, including: + - Theme settings management + - Condition rules dialog for setting up conditional logic + - Field settings dialog for configuring form fields + - Various plugins for the admin panel + +- **pageElements**: Contains page builder elements for creating funnels, including: + - Button elements + - Container elements for organizing content + - Form field elements + - Step elements for multi-step funnels + - Controls and decorators for the page builder interface + +### Shared + +Located in `src/shared`, this folder contains shared code used by both backend and frontend: + +- **models**: Contains the business logic for the funnel builder, including: + - Funnel model + - Step model + - Field definition models + - Submission models + - Condition models for handling conditional logic + - **Validators** for form validation: + - Required, MinLength, MaxLength, Pattern + - Gte (Greater than or equal), Lte (Less than or equal) + - **Condition Operators** for conditional logic: + - Eq (Equals), Neq (Not equals) + - Gt (Greater than), Gte (Greater than or equal) + - Lt (Less than), Lte (Less than or equal) + - Includes, NotIncludes + - Empty, NotEmpty + - **Condition Actions** that execute when conditions are met: + - HideField, DisableField + - OnSubmitActivateStep, OnSubmitEndFunnel + - **Field Values** for handling different data types: + - String, StringArray + - Number, NumberArray + - Boolean, BooleanArray + - **Field Types** for form elements: + - TextField, TextareaField + - NumberField, CheckboxGroupField + +- Utility functions for creating elements, generating IDs, and other common tasks + +## Installation + +To install the Funnel Builder extension in your Webiny project, run the following command: + +``` +yarn webiny extension sandbox/ext-fub +``` + +This will download the extension and link it with your admin and website applications. + +Additionally, you need to add the following configuration within the compilerOptions section of your project's root `tsconfig.build.json` file: + +```json +"compilerOptions": { + // ... other options + "noPropertyAccessFromIndexSignature": false + // ... other options +} +``` + +This configuration is required for the Funnel Builder extension to work properly in a Webiny project. + +## API Integration + +To integrate the Funnel Builder API with your Webiny project, you need to manually update the API extensions file. + +In `apps/api/graphql/src/extensions.ts`, add the following: + +```typescript +// This file is automatically updated via scaffolding utilities. +import { createExtension as FunnelBuilderExtension } from "funnel-builder/src/api"; + +// Learn more about extensions: https://webiny.link/extensions +export const extensions = () => { + return [FunnelBuilderExtension()]; +}; +``` + +## Theme Configuration + +For optimal funnel rendering, you should simplify the Static layout in your theme. Modify the `theme/src/layouts/pages/Static.tsx` file to remove any extra HTML and just render the page content: + +```tsx +import React from "react"; + +interface FunnelEmbedProps { + children: React.ReactNode; +} + +const Static = ({ children }: FunnelEmbedProps) => { + return <>{children}; +}; + +export default Static; +``` + +This ensures that your funnel pages render without any additional HTML structure that might interfere with the funnel layout. + +## Usage + +To use the Funnel Builder: + +1. Go to the Page Builder and create a new page +2. In the page editor, you'll immediately have access to the initial funnel container +3. Start adding funnel fields, buttons (using the controls element) +4. Configure validators and conditional rules for your fields +5. Preview your funnel to test the user experience +6. Publish your page when you're satisfied with the funnel + +The Funnel Builder integrates seamlessly with the Page Builder interface, allowing you to create multi-step funnels with conditional logic, various field types, and custom actions. + +## Developer Guide + +If you're a developer looking to extend the Funnel Builder with new field types or other functionality, here's how to get started: + +### Adding New Field Types + +To add a new field type to the Funnel Builder: + +1. First, go to `src/shared/models/fields` folder and add a new class that extends the base field class +2. Then, add an admin plugin where you can reference the class and create the actual renderer +3. For a beginner-friendly example, try implementing a radio buttons renderer or something similarly straightforward + +Take time to familiarize yourself with the codebase structure. Examine how the existing field types are implemented, from their model definitions to their renderers. This will give you a good understanding of the patterns used throughout the extension. diff --git a/extensions/funnelBuilder/jest.config.js b/extensions/funnelBuilder/jest.config.js new file mode 100644 index 00000000000..cc5ac2bb64f --- /dev/null +++ b/extensions/funnelBuilder/jest.config.js @@ -0,0 +1,5 @@ +const base = require("../../jest.config.base"); + +module.exports = { + ...base({ path: __dirname }) +}; diff --git a/extensions/funnelBuilder/package.json b/extensions/funnelBuilder/package.json new file mode 100644 index 00000000000..908599c919e --- /dev/null +++ b/extensions/funnelBuilder/package.json @@ -0,0 +1,38 @@ +{ + "name": "funnel-builder", + "version": "1.0.0", + "keywords": [ + "webiny-extension", + "webiny-extension-type:pbElement" + ], + "dependencies": { + "@apollo/react-hooks": "^3.1.5", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@emotion/react": "11.10.8", + "@emotion/styled": "11.10.6", + "@material-design-icons/svg": "^0.14.3", + "@webiny/api-serverless-cms": "0.0.0", + "@webiny/app": "0.0.0", + "@webiny/app-admin": "0.0.0", + "@webiny/app-page-builder": "0.0.0", + "@webiny/app-page-builder-elements": "0.0.0", + "@webiny/app-website": "0.0.0", + "@webiny/form": "0.0.0", + "@webiny/handler-graphql": "0.0.0", + "@webiny/plugins": "0.0.0", + "@webiny/ui": "0.0.0", + "@webiny/validation": "0.0.0", + "graphql-request": "^6.0.0", + "graphql-tag": "^2.12.6", + "lodash": "^4.17.21", + "object-hash": "3.0.0", + "react": "18.2.0" + }, + "devDependencies": { + "@types/lodash": "^4.17.13", + "@types/object-hash": "^2.2.1" + } +} diff --git a/extensions/funnelBuilder/src/admin.tsx b/extensions/funnelBuilder/src/admin.tsx new file mode 100644 index 00000000000..919db2fa704 --- /dev/null +++ b/extensions/funnelBuilder/src/admin.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { ContainerAdminPlugins } from "./frontend/pageElements/container/admin/ContainerAdminPlugins"; +import { StepAdminPlugins } from "./frontend/pageElements/step/admin/StepAdminPlugins"; +import { TextFieldAdminPlugins } from "./frontend/pageElements/fields/text/TextFieldAdminPlugins"; +import { TextareaFieldAdminPlugins } from "./frontend/pageElements/fields/textarea/TextareaFieldAdminPlugins"; +import { CheckboxGroupFieldAdminPlugins } from "./frontend/pageElements/fields/checkboxGroup/CheckboxGroupFieldAdminPlugins"; +import { FunnelBuilderPageElementGroup } from "./frontend/pageElements/FunnelBuilderPageElementGroup"; +import { DecoratedElementControls } from "./frontend/pageElements/ElementControlsDecorator"; +import { FieldValidatorPlugins } from "./frontend/admin/plugins/fieldValidators"; +import { ConditionOperatorPlugins } from "./frontend/admin/plugins/conditionOperators"; +import { ConditionActionPlugins } from "./frontend/admin/plugins/conditionActions"; +import { PbEditorOverrideEventActionPlugins } from "./frontend/admin/plugins/editorActionOverrides"; +import { ControlsAdminPlugins } from "./frontend/pageElements/controls/ControlsAdminPlugins"; +import { ThemeSettingsProvider } from "./frontend/admin/ThemeSettingsProvider"; + +export const Extension = () => ( + <> + {/* Fields. */} + + + + + + {/* Container Page Element */} + + + + {/* Other */} + + + + + + + + +); diff --git a/extensions/funnelBuilder/src/api.ts b/extensions/funnelBuilder/src/api.ts new file mode 100644 index 00000000000..6db19cca3a7 --- /dev/null +++ b/extensions/funnelBuilder/src/api.ts @@ -0,0 +1,19 @@ +import { createContextPlugin } from "@webiny/api-serverless-cms"; +import { createInitialPageContent } from "./backend/api/createInitialPageContent"; +import { createModelGroup } from "./backend/api/contentModelGroup"; +import { createThemeSettingsModel } from "./backend/api/createThemeSettingsModel"; +import { getThemeSettings } from "./backend/api/getThemeSettings"; + +export const createExtension = () => { + return [ + createModelGroup(), + createThemeSettingsModel(), + getThemeSettings(), + createContextPlugin(ctx => { + ctx.pageBuilder.onPageBeforeCreate.subscribe(async ({ page }) => { + // Ensure funnel builder is immediately added to the page content. + page.content = createInitialPageContent(); + }); + }) + ]; +}; diff --git a/extensions/funnelBuilder/src/backend/api/contentModelGroup.ts b/extensions/funnelBuilder/src/backend/api/contentModelGroup.ts new file mode 100644 index 00000000000..b6279c14a09 --- /dev/null +++ b/extensions/funnelBuilder/src/backend/api/contentModelGroup.ts @@ -0,0 +1,15 @@ +import { createModelGroupPlugin } from "@webiny/api-serverless-cms"; + +type ModelGroup = Parameters[0]; + +export const group: ModelGroup = { + id: "settings", + name: "Settings", + description: "Settings", + icon: "fa/building", + slug: "settings" +}; + +export const createModelGroup = () => { + return createModelGroupPlugin(group); +}; diff --git a/extensions/funnelBuilder/src/backend/api/createInitialPageContent.ts b/extensions/funnelBuilder/src/backend/api/createInitialPageContent.ts new file mode 100644 index 00000000000..dbea012c7b7 --- /dev/null +++ b/extensions/funnelBuilder/src/backend/api/createInitialPageContent.ts @@ -0,0 +1,46 @@ +import { getRandomId } from "../../shared/getRandomId"; +import { createContainerElement } from "../../shared/createContainerElement"; + +export const createInitialPageContent = () => { + return { + id: getRandomId(), + type: "document", + data: {}, + elements: [ + { + id: getRandomId(), + type: "block", + data: { + settings: { + width: { + desktop: { + value: "100%" + } + }, + margin: { + desktop: { + top: "0px", + right: "0px", + bottom: "0px", + left: "0px", + advanced: true + } + }, + padding: { + desktop: { + all: "10px" + } + }, + horizontalAlignFlex: { + desktop: "center" + }, + verticalAlign: { + desktop: "flex-start" + } + } + }, + elements: [createContainerElement()] + } + ] + }; +}; diff --git a/extensions/funnelBuilder/src/backend/api/createThemeSettingsModel.ts b/extensions/funnelBuilder/src/backend/api/createThemeSettingsModel.ts new file mode 100644 index 00000000000..8a0755e0a01 --- /dev/null +++ b/extensions/funnelBuilder/src/backend/api/createThemeSettingsModel.ts @@ -0,0 +1,90 @@ +import { createSingleEntryModelPlugin } from "@webiny/api-serverless-cms"; +import { group } from "./contentModelGroup"; + +export const createThemeSettingsModel = () => { + return createSingleEntryModelPlugin({ + name: "Theme settings", + group: { + id: group.id, + name: group.name + }, + icon: "fas/building", + description: "Adjust the theme settings.", + modelId: "themeSettings", + singularApiName: "ThemeSettings", + pluralApiName: "ThemeSettings", + titleFieldId: "name", + imageFieldId: null, + lockedFields: [], + layout: [["1de2937p"]], + tags: ["type:model"], + fields: [ + { + id: "1de2937p", + fieldId: "theme", + storageId: "object@1de2937p", + type: "object", + label: "Theme", + tags: [], + placeholderText: null, + helpText: "Customize the theme settings.", + predefinedValues: { + enabled: false, + values: [] + }, + multipleValues: false, + renderer: { + name: "object", + settings: { + open: false + } + }, + validation: [], + listValidation: [], + settings: { + fields: [ + { + type: "text", + validation: [], + renderer: { + name: "text-input" + }, + label: "Primary Color", + fieldId: "primaryColor", + helpText: "Enter a color code (e.g., #000000)", + id: "6szn25bu", + storageId: "text@6szn25bu" + }, + { + type: "text", + validation: [], + renderer: { + name: "text-input" + }, + label: "Secondary Color", + fieldId: "secondaryColor", + helpText: "Enter a color code (e.g., #000000)", + id: "msev3l7j", + storageId: "text@msev3l7j" + }, + { + type: "file", + validation: [], + renderer: { + name: "file-input" + }, + label: "Logo", + fieldId: "logo", + settings: { + imagesOnly: true + }, + id: "4grhbpth", + storageId: "file@4grhbpth" + } + ], + layout: [["6szn25bu", "msev3l7j"], ["4grhbpth"]] + } + } + ] + }); +}; diff --git a/extensions/funnelBuilder/src/backend/api/getThemeSettings.ts b/extensions/funnelBuilder/src/backend/api/getThemeSettings.ts new file mode 100644 index 00000000000..3a2c09b1b89 --- /dev/null +++ b/extensions/funnelBuilder/src/backend/api/getThemeSettings.ts @@ -0,0 +1,73 @@ +import { createGraphQLSchemaPlugin } from "@webiny/api-serverless-cms"; +import { Response, ErrorResponse } from "@webiny/handler-graphql"; + +const fallbackResult = { + id: "fallback", + theme: { + primaryColor: "#fa5723", + secondaryColor: "#00ccb0", + logo: "" + } +}; + +export const getThemeSettings = () => { + return createGraphQLSchemaPlugin({ + typeDefs: /* GraphQL */ ` + type ThemeSettingsError { + code: String + message: String + data: JSON + stack: String + } + + type ThemeSettingsTheme { + primaryColor: String! + secondaryColor: String! + logo: String + } + + type ThemeSettings { + id: ID + theme: ThemeSettingsTheme + } + + type ThemeSettingsResponse { + data: ThemeSettings + error: ThemeSettingsError + } + extend type Query { + themeSettings: ThemeSettingsResponse + } + `, + resolvers: { + Query: { + themeSettings: async (_, __, context) => { + try { + const themeSettings = await context.security.withoutAuthorization( + async () => { + const themeSettingsModel = await context.cms.getModel( + "themeSettings" + ); + const themeSettingsEntry = + await context.cms.getSingletonEntryManager(themeSettingsModel); + const settings = await themeSettingsEntry.get(); + + return { + ...fallbackResult, + theme: { + ...fallbackResult.theme, + ...(settings.values.theme || {}) + } + }; + } + ); + + return new Response(themeSettings); + } catch (e) { + return new ErrorResponse(e); + } + } + } + } + }); +}; diff --git a/extensions/funnelBuilder/src/backend/api/types.ts b/extensions/funnelBuilder/src/backend/api/types.ts new file mode 100644 index 00000000000..4be288e11d7 --- /dev/null +++ b/extensions/funnelBuilder/src/backend/api/types.ts @@ -0,0 +1,3 @@ +import { Context as BaseContext } from "@webiny/api-serverless-cms"; + +export type Context = BaseContext; diff --git a/extensions/funnelBuilder/src/frontend/admin/ApplyTheme.tsx b/extensions/funnelBuilder/src/frontend/admin/ApplyTheme.tsx new file mode 100644 index 00000000000..c6bf0a1025e --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/ApplyTheme.tsx @@ -0,0 +1,44 @@ +import React, { useEffect } from "react"; +import styled from "@emotion/styled"; +import { AddLogo } from "@webiny/app-admin"; +import { useThemeSettings } from "./ThemeSettingsProvider"; + +const Logo = styled.img` + width: 100%; + height: auto; + max-width: 120px; +`; + +export const ApplyTheme = () => { + const { themeSettings } = useThemeSettings(); + + useEffect(() => { + if (!themeSettings?.theme) { + return; + } + + const headTag = document.getElementsByTagName("head")[0]; + const styleTag = document.createElement("style"); + + styleTag.innerHTML = ` + body { + --mdc-theme-primary: ${themeSettings.theme.primaryColor}; + --mdc-theme-secondary: ${themeSettings.theme.secondaryColor}; + } + .mdc-top-app-bar.primary { + background-color: ${themeSettings.theme.primaryColor}; + } + + .mdc-dialog .mdc-dialog__surface .mdc-dialog__title { + background-color: ${themeSettings.theme.secondaryColor}; + } + `; + headTag.appendChild(styleTag); + }, [themeSettings]); + + if (themeSettings?.theme?.logo) { + return } />; + } + + return null; +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog.tsx b/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog.tsx new file mode 100644 index 00000000000..e6790a8f8ec --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { Form, FormOnSubmit } from "@webiny/form"; +import { Dialog, DialogActions, DialogButton, DialogContent, DialogTitle } from "@webiny/ui/Dialog"; +import { ClassNames } from "@emotion/react"; +import { FunnelModelDto } from "../../shared/models/FunnelModel"; +import { ConditionRulesForm } from "./ConditionRulesDialog/ConditionRulesForm"; + +const dialogContentCss = { + width: 875, + minHeight: 600, + maxHeight: "800px !important", + height: "auto" +}; + +interface ConditionRulesDialogProps { + open: boolean; + data: FunnelModelDto; + onClose: () => void; + onSubmit: FormOnSubmit; +} + +export const ConditionRulesDialog = ({ + data, + open, + onClose, + onSubmit +}: ConditionRulesDialogProps) => { + return ( + + Conditional Rules + {data && ( + data={data} onSubmit={onSubmit}> + {({ submit }) => ( + <> + + {({ css }) => ( + + + + )} + + +
+ {"Cancel"} + {"Save"} +
+
+ + )} + + )} +
+ ); +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog/ConditionRulesForm.tsx b/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog/ConditionRulesForm.tsx new file mode 100644 index 00000000000..64f2b0e7b32 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog/ConditionRulesForm.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { useConditionRulesForm } from "./useConditionRulesForm"; +import { RulesListItem } from "./ConditionRulesForm/RulesListItem"; +import { RulesList } from "./ConditionRulesForm/RulesList"; + +export const ConditionRulesForm = () => { + const { rules } = useConditionRulesForm(); + + return ( + + {rules.map(rule => ( + + ))} + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog/ConditionRulesForm/RulesList.tsx b/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog/ConditionRulesForm/RulesList.tsx new file mode 100644 index 00000000000..86d469f0faa --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog/ConditionRulesForm/RulesList.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { Accordion, AccordionProps } from "@webiny/ui/Accordion"; +import { useConditionRulesForm } from "../useConditionRulesForm"; +import styled from "@emotion/styled"; +import { ReactComponent as BasePlusIcon } from "@material-design-icons/svg/outlined/add.svg"; +import EmptyView from "@webiny/app-admin/components/EmptyView"; +import { ButtonDefault, ButtonIcon } from "@webiny/ui/Button"; + +const PlusIcon = styled(BasePlusIcon)` + fill: white; + width: 16px; + height: 16px; + margin-right: 2px; +`; + +export interface ConditionRulesFormProps { + children: AccordionProps["children"]; +} + +export const RulesList = ({ children }: ConditionRulesFormProps) => { + const { rules, addRule } = useConditionRulesForm(); + + return ( + <> + {rules.length > 0 && {children}} + + {rules.length === 0 ? ( +
+ + } /> Add rule + + } + /> +
+ ) : ( +
+ + } /> Add rule + +
+ )} + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog/ConditionRulesForm/RulesListItem.tsx b/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog/ConditionRulesForm/RulesListItem.tsx new file mode 100644 index 00000000000..429fcab1a16 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog/ConditionRulesForm/RulesListItem.tsx @@ -0,0 +1,46 @@ +import React, { useMemo } from "react"; +import { IconButton } from "@webiny/ui/Button"; +import { AccordionItem } from "@webiny/ui/Accordion"; +import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete.svg"; +import { useConditionRulesForm } from "../useConditionRulesForm"; +import { FunnelConditionRuleModelDto } from "../../../../shared/models/FunnelConditionRuleModel"; +import { RuleConditionGroup } from "./RulesListItem/RuleConditionGroup"; +import { RuleActions } from "./RulesListItem/RuleActions"; + +interface RulesListItemProps { + rule: FunnelConditionRuleModelDto; +} + +export const RulesListItem = ({ rule }: RulesListItemProps) => { + const { getRuleIndex, removeRule, getConditionsCount, getActionsCount } = + useConditionRulesForm(); + const ruleIndex = getRuleIndex(rule); + + const conditionsCount = getConditionsCount(rule.id); + const actionsCount = getActionsCount(rule.id); + const description = useMemo(() => { + return [ + conditionsCount || "No", + conditionsCount === 1 ? "condition," : "conditions,", + actionsCount || "no", + actionsCount === 1 ? "action" : "actions" + ].join(" "); + }, [conditionsCount, actionsCount]); + + return ( + removeRule(rule.id)} icon={} />} + > +
+ {/* Root condition group is passed here. The component can + then also render nested condition groups. */} + + +
+
+ ); +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog/ConditionRulesForm/RulesListItem/RuleActions.tsx b/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog/ConditionRulesForm/RulesListItem/RuleActions.tsx new file mode 100644 index 00000000000..0e6ba6453b2 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog/ConditionRulesForm/RulesListItem/RuleActions.tsx @@ -0,0 +1,142 @@ +import React, { useMemo } from "react"; +import { ButtonDefault, ButtonIcon, IconButton } from "@webiny/ui/Button"; +import { Select } from "@webiny/ui/Select"; +import { Typography } from "@webiny/ui/Typography"; +import styled from "@emotion/styled"; +import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete.svg"; +import { ReactComponent as PlusIcon } from "@material-design-icons/svg/outlined/add.svg"; +import { plugins } from "@webiny/plugins"; +import { Form } from "@webiny/form"; +import { useConditionRulesForm } from "../../useConditionRulesForm"; +import { FunnelConditionRuleModelDto } from "../../../../../shared/models/FunnelConditionRuleModel"; +import { listConditionActions } from "../../../../../shared/models/conditionActions/conditionActionFactory"; +import { ConditionOperatorParams } from "../../../../../shared/models/FunnelConditionOperatorModel"; +import { ConditionActionParamsComponent } from "../../../plugins/PbEditorFunnelConditionActionPlugin"; +import { PbEditorFunnelConditionActionPluginProps } from "../../../plugins/PbEditorFunnelConditionActionPlugin"; + +const Fieldset = styled.div` + display: flex; + align-items: center; + column-gap: 10px; + position: relative; + width: 100%; + + & webiny-form-container { + flex: 1; + } +`; + +const Header = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + border-bottom: 1px solid #ebeaeb; + padding: 5px 0; +`; + +const NoActionsMessage = styled.div` + padding: 10px; +`; + +export interface RuleActionsProps { + rule: FunnelConditionRuleModelDto; +} + +export const RuleActions = ({ rule }: RuleActionsProps) => { + const { funnel, addAction, removeAction, updateAction } = useConditionRulesForm(); + + const conditionActionPlugins = useMemo(() => { + return plugins.byType( + "pb-editor-funnel-condition-action" + ) as unknown as PbEditorFunnelConditionActionPluginProps[]; + }, []); + + const availableConditionActions = listConditionActions(); + + return ( +
+
+ Actions + addAction(rule.id)}> + } /> Add action + +
+ + {rule.actions.length === 0 ? ( + + + No actions added yet. + + + ) : ( + rule.actions.map(action => { + const conditionActionPlugin = conditionActionPlugins.find( + p => p.actionClass.type === action.type + ); + + let ConditionActionParamsComponent: ConditionActionParamsComponent | undefined; + if (conditionActionPlugin) { + ConditionActionParamsComponent = conditionActionPlugin.settingsRenderer; + } + + return ( +
+ + + {ConditionActionParamsComponent && ( + + data={action.params} + onChange={params => { + return updateAction(rule.id, { + ...action, + params + }); + }} + > + {() => { + return ( + <> + {ConditionActionParamsComponent ? ( + + ) : null} + + ); + }} + + )} + } + onClick={() => removeAction(rule.id, action.id!)} + /> +
+ ); + }) + )} +
+ ); +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog/ConditionRulesForm/RulesListItem/RuleCondition.tsx b/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog/ConditionRulesForm/RulesListItem/RuleCondition.tsx new file mode 100644 index 00000000000..92174725645 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog/ConditionRulesForm/RulesListItem/RuleCondition.tsx @@ -0,0 +1,129 @@ +import React, { useMemo } from "react"; +import { IconButton } from "@webiny/ui/Button"; +import { Select } from "@webiny/ui/Select"; +import styled from "@emotion/styled"; +import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete.svg"; +import { Form } from "@webiny/form"; +import { plugins } from "@webiny/plugins"; +import { FunnelConditionGroupModelDto } from "../../../../../shared/models/FunnelConditionGroupModel"; +import { useConditionRulesForm } from "../../useConditionRulesForm"; +import { getConditionOperatorsByValueType } from "../../../../../shared/models/conditionOperators/conditionOperatorFactory"; +import { ConditionOperatorParams } from "../../../../../shared/models/FunnelConditionOperatorModel"; +import { + ConditionOperatorParamsComponent, + PbEditorFunnelConditionOperatorPluginProps +} from "../../../plugins/PbEditorFunnelConditionOperatorPlugin"; +import { FunnelConditionModelDto } from "../../../../../shared/models/FunnelConditionModel"; + +const Fieldset = styled.div` + display: flex; + align-items: center; + column-gap: 10px; + position: relative; + width: 100%; +`; + +interface RuleConditionGroupProps { + conditionGroup: FunnelConditionGroupModelDto; + condition: FunnelConditionModelDto; +} + +export const RuleCondition = ({ conditionGroup, condition }: RuleConditionGroupProps) => { + const { funnel, removeCondition, updateCondition } = useConditionRulesForm(); + + const conditionOperatorPlugins = useMemo(() => { + return plugins.byType( + "pb-editor-funnel-condition-operator" + ) as unknown as PbEditorFunnelConditionOperatorPluginProps[]; + }, []); + + const fieldDefinition = funnel.fields.find(f => f.id === condition.sourceFieldId); + + const availableConditionOperators = getConditionOperatorsByValueType( + fieldDefinition?.value?.type || "" + ); + + const conditionOperatorPlugin = conditionOperatorPlugins.find( + p => p.operatorClass.type === condition.operator.type + ); + + let ConditionRuleParamsComponent: ConditionOperatorParamsComponent | undefined; + if (conditionOperatorPlugin) { + ConditionRuleParamsComponent = conditionOperatorPlugin.settingsRenderer; + } + + console.log(funnel.conditionRules); + + return ( +
+ + + + + {ConditionRuleParamsComponent && ( + + data={condition.operator.params} + onChange={params => { + return updateCondition(conditionGroup.id, { + ...condition, + operator: { + ...condition.operator, + params + } + }); + }} + > + {() => { + return ( + <> + {ConditionRuleParamsComponent ? ( + + ) : null} + + ); + }} + + )} + + } + onClick={() => removeCondition(conditionGroup.id, condition.id)} + /> +
+ ); +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog/ConditionRulesForm/RulesListItem/RuleConditionGroup.tsx b/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog/ConditionRulesForm/RulesListItem/RuleConditionGroup.tsx new file mode 100644 index 00000000000..9a64f8772a1 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog/ConditionRulesForm/RulesListItem/RuleConditionGroup.tsx @@ -0,0 +1,125 @@ +import React from "react"; +import { ButtonDefault, ButtonIcon, IconButton } from "@webiny/ui/Button"; +import { Select } from "@webiny/ui/Select"; +import { Typography } from "@webiny/ui/Typography"; +import styled from "@emotion/styled"; +import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete.svg"; +import { ReactComponent as BasePlusIcon } from "@material-design-icons/svg/outlined/add.svg"; +import { Tooltip } from "@webiny/ui/Tooltip"; +import { FunnelConditionGroupModelDto } from "../../../../../shared/models/FunnelConditionGroupModel"; +import { useConditionRulesForm } from "../../useConditionRulesForm"; +import { RuleCondition } from "./RuleCondition"; + +const Header = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + border-bottom: 1px solid #ebeaeb; + padding: 5px 0; +`; + +const NoConditionsMessage = styled.div` + padding: 10px; +`; + +interface RuleConditionGroupProps { + conditionGroup: FunnelConditionGroupModelDto; + depth?: number; +} + +export const RuleConditionGroup = ({ conditionGroup, depth = 1 }: RuleConditionGroupProps) => { + const { addCondition, updateConditionGroupOperator, addConditionGroup, removeConditionGroup } = + useConditionRulesForm(); + + return ( +
+
+ Conditions +
+ Operator: + + + + + + + + + + + + + + + {additionalSettings} + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/FieldSettingsDialog/ValidatorsTab.tsx b/extensions/funnelBuilder/src/frontend/admin/FieldSettingsDialog/ValidatorsTab.tsx new file mode 100644 index 00000000000..9bc1c05f3b5 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/FieldSettingsDialog/ValidatorsTab.tsx @@ -0,0 +1,130 @@ +import React, { useCallback, useMemo } from "react"; +import { plugins } from "@webiny/plugins"; +import { Switch } from "@webiny/ui/Switch"; +import { + SimpleForm, + SimpleFormContent, + SimpleFormHeader +} from "@webiny/app-admin/components/SimpleForm"; +import { BindComponentRenderProp, Form, useBind } from "@webiny/form"; +import { Cell, Grid } from "@webiny/ui/Grid"; +import { Input } from "@webiny/ui/Input"; +import { validation } from "@webiny/validation"; +import { FunnelFieldDefinitionModel } from "../../../shared/models/FunnelFieldDefinitionModel"; +import { PbEditorFunnelFieldValidatorPluginProps } from "../plugins/PbEditorFunnelFieldValidatorPlugin"; +import { FieldValidatorDto } from "../../../shared/models/validators/AbstractValidator"; +import { validatorFromDto } from "../../../shared/models/validators/validatorFactory"; + +interface ValidatorsTabProps { + field: FunnelFieldDefinitionModel; +} + +export const ValidatorsTab = ({ field }: ValidatorsTabProps) => { + const supportedValidators = useMemo(() => { + const fieldSupportedValidators = field.supportedValidatorTypes; + if (!fieldSupportedValidators) { + return []; + } + + const validatorPlugins = plugins.byType( + "pb-editor-funnel-field-validator" + ) as unknown as PbEditorFunnelFieldValidatorPluginProps[]; + + return validatorPlugins.filter(plugin => { + return fieldSupportedValidators.includes(plugin.validatorType); + }); + }, [field.supportedValidatorTypes]); + + const { value: validatorsValue, onChange: updateValidatorsValue } = useBind({ + name: "validators" + }) as BindComponentRenderProp; + + const toggleValidator = useCallback( + (validatorType: string) => { + const alreadyEnabled = validatorsValue.some(item => item.type === validatorType); + + if (alreadyEnabled) { + updateValidatorsValue([ + ...validatorsValue.filter(item => item.type !== validatorType) + ]); + } else { + // We're immediately transforming the validator type to a DTO because we need + // to use DTOs as form data. Form data cannot be a class (model) instance. + const newValidator = validatorFromDto({ type: validatorType, params: {} }).toDto(); + updateValidatorsValue([...validatorsValue, newValidator]); + } + }, + [validatorsValue] + ); + + return ( + <> + {supportedValidators.map(validatorPlugin => { + const validator = validatorsValue.find( + item => item.type === validatorPlugin.validatorType + ); + + const validatorIndex = validatorsValue.findIndex( + item => item.type === validatorPlugin.validatorType + ); + + return ( + + + toggleValidator(validatorPlugin.validatorType)} + /> + + {validator && ( + + data={validator} + onChange={data => { + updateValidatorsValue([ + ...validatorsValue.slice(0, validatorIndex), + data, + ...validatorsValue.slice(validatorIndex + 1) + ]); + }} + > + {({ Bind, setValue }) => { + const { settingsRenderer: SettingsRendererComponent } = + validatorPlugin; + return ( + + + + + + + + + + {SettingsRendererComponent && ( + + setValue("params.errorMessage", message) + } + /> + )} + + ); + }} + + )} + + ); + })} + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/ThemeSettingsProvider.tsx b/extensions/funnelBuilder/src/frontend/admin/ThemeSettingsProvider.tsx new file mode 100644 index 00000000000..42f835382b0 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/ThemeSettingsProvider.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { createProviderPlugin } from "@webiny/app-admin"; +import { CircularProgress } from "@webiny/ui/Progress"; +import { useThemeSettingsQuery } from "./useThemeSettingsQuery"; +import { ApplyTheme } from "./ApplyTheme"; +import { ThemeSettings } from "../../shared/types"; + +export interface ThemeSettingsContextValue { + themeSettings: ThemeSettings; +} + +const ThemeSettingsContext = React.createContext(undefined); + +interface ThemeSettingsProps { + children: React.ReactNode; +} + +const ThemeSettingsLoader = ({ children }: ThemeSettingsProps) => { + const { loading, themeSettings, error } = useThemeSettingsQuery(); + + if (loading) { + return ; + } + + if (error) { + return ; + } + + if (!themeSettings) { + return ; + } + + return ( + + {children} + + ); +}; + +export const ThemeSettingsProvider = createProviderPlugin(Component => { + return function ThemeSettingsProvider({ children }) { + return ( + + + + {children} + + + ); + }; +}); + +export function useThemeSettings() { + const context = React.useContext(ThemeSettingsContext); + + if (!context) { + throw Error(`Missing ThemeSettingsProvider in the component hierarchy!`); + } + + return context; +} diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorEventActionPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorEventActionPlugin.tsx new file mode 100644 index 00000000000..b1bf3273aad --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorEventActionPlugin.tsx @@ -0,0 +1,12 @@ +import { legacyPluginToReactComponent } from "@webiny/app/utils"; +import { PbEditorEventActionPlugin as BasePbEditorEventActionPlugin } from "@webiny/app-page-builder/types"; + +type PbEditorEventActionPlugin = Pick< + BasePbEditorEventActionPlugin, + "onEditorMount" | "onEditorUnmount" +>; + +export const PbEditorEventActionPlugin = legacyPluginToReactComponent({ + pluginType: "pb-editor-event-action-plugin", + componentDisplayName: "PbEditorEventActionPlugin" +}); diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorFunnelConditionActionPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorFunnelConditionActionPlugin.tsx new file mode 100644 index 00000000000..e5988f9ecf5 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorFunnelConditionActionPlugin.tsx @@ -0,0 +1,17 @@ +import { legacyPluginToReactComponent } from "@webiny/app/utils"; +import * as React from "react"; +import { FunnelConditionActionModel } from "../../../shared/models/FunnelConditionActionModel"; +import { FunnelModelDto } from "../../../shared/models/FunnelModel"; + +export type ConditionActionParamsComponent = React.ComponentType<{ funnel: FunnelModelDto }>; + +export interface PbEditorFunnelConditionActionPluginProps { + actionClass: typeof FunnelConditionActionModel; + settingsRenderer?: ConditionActionParamsComponent; +} + +export const PbEditorFunnelConditionActionPlugin = + legacyPluginToReactComponent({ + pluginType: "pb-editor-funnel-condition-action", + componentDisplayName: "PbEditorFunnelConditionActionPlugin" + }); diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorFunnelConditionOperatorPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorFunnelConditionOperatorPlugin.tsx new file mode 100644 index 00000000000..22dd824b1ac --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorFunnelConditionOperatorPlugin.tsx @@ -0,0 +1,21 @@ +import { legacyPluginToReactComponent } from "@webiny/app/utils"; +import * as React from "react"; +import { FunnelFieldDefinitionModelDto } from "../../../shared/models/FunnelFieldDefinitionModel"; +import { FunnelConditionOperatorModel } from "../../../shared/models/FunnelConditionOperatorModel"; +import { FunnelModelDto } from "../../../shared/models/FunnelModel"; + +export type ConditionOperatorParamsComponent = React.ComponentType<{ + funnel: FunnelModelDto; + field?: FunnelFieldDefinitionModelDto; +}>; + +export interface PbEditorFunnelConditionOperatorPluginProps { + operatorClass: typeof FunnelConditionOperatorModel; + settingsRenderer?: ConditionOperatorParamsComponent; +} + +export const PbEditorFunnelConditionOperatorPlugin = + legacyPluginToReactComponent({ + pluginType: "pb-editor-funnel-condition-operator", + componentDisplayName: "PbEditorFunnelConditionOperatorPlugin" + }); diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorFunnelFieldPageElementPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorFunnelFieldPageElementPlugin.tsx new file mode 100644 index 00000000000..606e279e513 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorFunnelFieldPageElementPlugin.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { PbEditorPageElementPlugin } from "@webiny/app-page-builder"; +import { OnCreateActions } from "@webiny/app-page-builder/types"; +import type { Renderer } from "@webiny/app-page-builder-elements/types"; +import { createFieldElementType } from "../../../shared/constants"; +import { createInitialFieldData, FUB_PAGE_ELEMENT_GROUP } from "../../pageElements/fields/utils"; +import { ElementToolbarPreview } from "../../pageElements/ElementToolbarPreview"; + +export interface PbEditorFunnelFieldPageElementPluginProps { + fieldType: string; + renderer: Renderer; + name: string; + description: string; + icon: React.ReactNode; +} + +export const PbEditorFunnelFieldPageElementPlugin = ( + props: PbEditorFunnelFieldPageElementPluginProps +) => { + const fieldType = props.fieldType; + const pbElementType = createFieldElementType(fieldType); + + return ( + + ); + } + }} + // Defines which types of element settings are available to the user. + settings={[ + "pb-editor-page-element-settings-delete", + "pb-editor-page-element-settings-visibility", + "pb-editor-page-element-style-settings-padding", + "pb-editor-page-element-style-settings-margin", + "pb-editor-page-element-style-settings-width", + "pb-editor-page-element-style-settings-height", + "pb-editor-page-element-style-settings-background" + ]} + target={["cell", "block"]} + onCreate={OnCreateActions.OPEN_SETTINGS} + // `create` function creates the initial data for the page element. + create={options => { + return { + type: pbElementType, + elements: [], + data: createInitialFieldData(fieldType), + ...options + }; + }} + /> + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorFunnelFieldSettingsPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorFunnelFieldSettingsPlugin.tsx new file mode 100644 index 00000000000..199149f4f0e --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorFunnelFieldSettingsPlugin.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { legacyPluginToReactComponent } from "@webiny/app/utils"; + +export type RenderSettingsComponent = React.ComponentType<{ + afterChangeLabel: (value: string) => void; + uniqueFieldIdValidator: (fieldId: string) => void; +}>; + +export interface PbEditorFunnelFieldSettingsPluginProps { + fieldType: string; + renderer: RenderSettingsComponent; +} + +export const PbEditorFunnelFieldSettingsPlugin = + legacyPluginToReactComponent({ + pluginType: "pb-editor-funnel-field-settings", + componentDisplayName: "PbEditorFunnelFieldSettingsPlugin" + }); diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorFunnelFieldValidatorPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorFunnelFieldValidatorPlugin.tsx new file mode 100644 index 00000000000..55712916038 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorFunnelFieldValidatorPlugin.tsx @@ -0,0 +1,21 @@ +import { legacyPluginToReactComponent } from "@webiny/app/utils"; +import * as React from "react"; +import { FunnelFieldDefinitionModel } from "../../../shared/models/FunnelFieldDefinitionModel"; + +export type RenderSettings = React.ComponentType<{ + setMessage: (message: string) => void; + field?: FunnelFieldDefinitionModel; +}>; + +export interface PbEditorFunnelFieldValidatorPluginProps { + validatorType: string; + label: string; + description: string; + settingsRenderer?: RenderSettings; +} + +export const PbEditorFunnelFieldValidatorPlugin = + legacyPluginToReactComponent({ + pluginType: "pb-editor-funnel-field-validator", + componentDisplayName: "PbEditorFunnelFieldValidatorPlugin" + }); diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorOverrideEventActionPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorOverrideEventActionPlugin.tsx new file mode 100644 index 00000000000..6789afa4472 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorOverrideEventActionPlugin.tsx @@ -0,0 +1,35 @@ +import { useMemo } from "react"; +import { plugins } from "@webiny/plugins"; +import { PbEditorEventActionPlugin } from "@webiny/app-page-builder/types"; + +interface PbEditorOverrideActionPluginProps + extends Pick { + action: string; +} + +export const PbEditorOverrideActionHandlerPlugin = (props: PbEditorOverrideActionPluginProps) => { + useMemo(() => { + const originalByType = plugins.byType.bind(plugins); + + plugins.byType = type => { + if (type !== "pb-editor-event-action-plugin") { + return originalByType(type); + } + + const originalResult = originalByType(type); + const pluginNameToOverride = `pb-editor-event-action-${props.action}`; + return originalResult.map(plugin => { + if (plugin.name !== pluginNameToOverride) { + return plugin; + } + + return { + ...plugin, + ...props + } as PbEditorEventActionPlugin; + }); + }; + }, [props.action]); + + return null; +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorPageElementGroupPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorPageElementGroupPlugin.tsx new file mode 100644 index 00000000000..f00e9f71c1e --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/PbEditorPageElementGroupPlugin.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { legacyPluginToReactComponent } from "@webiny/app/utils"; + +interface PbEditorPageElementGroupPlugin { + name: string; + title: string; + icon: React.ReactNode; + emptyView?: React.ReactNode; +} + +export const PbEditorPageElementGroupPlugin = + legacyPluginToReactComponent({ + pluginType: "pb-editor-page-element-group", + componentDisplayName: "PbEditorPageElementGroupPlugin", + mapProps: props => { + const { title, icon, emptyView } = props; + return { + ...props, + group: { + title, + icon, + emptyView + } + }; + } + }); diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/conditionActions/DisableFieldConditionActionPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/conditionActions/DisableFieldConditionActionPlugin.tsx new file mode 100644 index 00000000000..6c3bb9d2442 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/conditionActions/DisableFieldConditionActionPlugin.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { + ConditionActionParamsComponent, + PbEditorFunnelConditionActionPlugin +} from "../PbEditorFunnelConditionActionPlugin"; +import { DisableFieldConditionAction } from "../../../../shared/models/conditionActions/DisableFieldConditionAction"; +import { Bind } from "@webiny/form"; +import { Select } from "@webiny/ui/Select"; + +const ActionSettings: ConditionActionParamsComponent = ({ funnel }) => { + return ( + + + + ); +}; + +export const DisableFieldConditionActionPlugin = () => { + return ( + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/conditionActions/HideFieldConditionActionPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/conditionActions/HideFieldConditionActionPlugin.tsx new file mode 100644 index 00000000000..8985c5d964c --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/conditionActions/HideFieldConditionActionPlugin.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { + ConditionActionParamsComponent, + PbEditorFunnelConditionActionPlugin +} from "../PbEditorFunnelConditionActionPlugin"; +import { HideFieldConditionAction } from "../../../../shared/models/conditionActions/HideFieldConditionAction"; +import { Bind } from "@webiny/form"; +import { Select } from "@webiny/ui/Select"; + +const ActionSettings: ConditionActionParamsComponent = ({ funnel }) => { + return ( + + + + ); +}; + +export const HideFieldConditionActionPlugin = () => { + return ( + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/conditionActions/OnSubmitActivateStepConditionActionPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/conditionActions/OnSubmitActivateStepConditionActionPlugin.tsx new file mode 100644 index 00000000000..d675cc18555 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/conditionActions/OnSubmitActivateStepConditionActionPlugin.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { + ConditionActionParamsComponent, + PbEditorFunnelConditionActionPlugin +} from "../PbEditorFunnelConditionActionPlugin"; +import { OnSubmitActivateStepConditionAction } from "../../../../shared/models/conditionActions/OnSubmitActivateStepConditionAction"; +import { Bind } from "@webiny/form"; +import { Select } from "@webiny/ui/Select"; +import styled from "@emotion/styled"; +import { isSuccessStepElementType } from "../../../../shared/constants"; + +const Wrapper = styled.div` + display: flex; + column-gap: 10px; +`; + +const ActionSettings: ConditionActionParamsComponent = ({ funnel }) => { + const funnelSteps = funnel.steps.filter(step => !isSuccessStepElementType(step.id)); + + return ( + + + + + + + + + ); +}; + +export const OnSubmitActivateStepConditionActionPlugin = () => { + return ( + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/conditionActions/OnSubmitEndFunnelConditionActionPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/conditionActions/OnSubmitEndFunnelConditionActionPlugin.tsx new file mode 100644 index 00000000000..c310d1a943a --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/conditionActions/OnSubmitEndFunnelConditionActionPlugin.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { + ConditionActionParamsComponent, + PbEditorFunnelConditionActionPlugin +} from "../PbEditorFunnelConditionActionPlugin"; +import { OnSubmitEndFunnelConditionAction } from "../../../../shared/models/conditionActions/OnSubmitEndFunnelConditionAction"; +import { Bind } from "@webiny/form"; +import { Select } from "@webiny/ui/Select"; +import { isSuccessStepElementType } from "../../../../shared/constants"; + +const ActionSettings: ConditionActionParamsComponent = ({ funnel }) => { + return ( + + + + ); +}; + +export const OnSubmitEndFunnelConditionActionPlugin = () => { + return ( + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/conditionActions/index.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/conditionActions/index.tsx new file mode 100644 index 00000000000..9027f842290 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/conditionActions/index.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { DisableFieldConditionActionPlugin } from "./DisableFieldConditionActionPlugin"; +import { HideFieldConditionActionPlugin } from "./HideFieldConditionActionPlugin"; +import { OnSubmitActivateStepConditionActionPlugin } from "./OnSubmitActivateStepConditionActionPlugin"; +import { OnSubmitEndFunnelConditionActionPlugin } from "./OnSubmitEndFunnelConditionActionPlugin"; + +export const ConditionActionPlugins = () => { + return ( + <> + + + + + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/conditionOperators/EqConditionOperatorPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/conditionOperators/EqConditionOperatorPlugin.tsx new file mode 100644 index 00000000000..217b37bd49b --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/conditionOperators/EqConditionOperatorPlugin.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { + PbEditorFunnelConditionOperatorPlugin, + ConditionOperatorParamsComponent +} from "../PbEditorFunnelConditionOperatorPlugin"; +import { EqConditionOperator } from "../../../../shared/models/conditionOperators/EqConditionOperator"; +import { Input } from "@webiny/ui/Input"; +import { Bind } from "@webiny/form"; + +const OperatorSettings: ConditionOperatorParamsComponent = () => { + return ( + + + + ); +}; + +export const EqConditionOperatorPlugin = () => { + return ( + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/conditionOperators/NeqConditionOperatorPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/conditionOperators/NeqConditionOperatorPlugin.tsx new file mode 100644 index 00000000000..905176fca91 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/conditionOperators/NeqConditionOperatorPlugin.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { + PbEditorFunnelConditionOperatorPlugin, + ConditionOperatorParamsComponent +} from "../PbEditorFunnelConditionOperatorPlugin"; +import { Input } from "@webiny/ui/Input"; +import { Bind } from "@webiny/form"; +import { NeqConditionOperator } from "../../../../shared/models/conditionOperators/NeqConditionOperator"; + +const OperatorSettings: ConditionOperatorParamsComponent = () => { + return ( + + + + ); +}; + +export const NeqConditionOperatorPlugin = () => { + return ( + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/conditionOperators/index.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/conditionOperators/index.tsx new file mode 100644 index 00000000000..dc1661a06a6 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/conditionOperators/index.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { EqConditionOperatorPlugin } from "./EqConditionOperatorPlugin"; +import { NeqConditionOperatorPlugin } from "./NeqConditionOperatorPlugin"; + +export const ConditionOperatorPlugins = () => { + return ( + <> + + + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/editorActionOverrides/OverrideDeleteElementActionPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/editorActionOverrides/OverrideDeleteElementActionPlugin.tsx new file mode 100644 index 00000000000..6d8e462a945 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/editorActionOverrides/OverrideDeleteElementActionPlugin.tsx @@ -0,0 +1,142 @@ +import React from "react"; +import { PbEditorOverrideActionHandlerPlugin } from "../PbEditorOverrideEventActionPlugin"; +import { + deleteElementAction, + DeleteElementActionEvent +} from "@webiny/app-page-builder/editor/recoil/actions"; +import type { DeleteElementActionArgsType } from "@webiny/app-page-builder/editor/recoil/actions/deleteElement/types"; +import type { EventActionCallable } from "@webiny/app-page-builder/types"; +import { Snackbar } from "@webiny/ui/Snackbar"; +import { useDisclosure } from "../../useDisclosure"; +import { ElementTreeTraverser } from "../../ElementTreeTraverser"; +import { + CONTAINER_ELEMENT_ID, + isContainerElementType, + isFieldElementType, + isStepElementType +} from "../../../../shared/constants"; +import { ContainerElement } from "../../../pageElements/container/types"; + +const DO_NOTHING = { actions: [] }; + +export const OverrideDeleteElementActionPlugin = () => { + const { + open: showSnackbar, + close: hideSnackbar, + isOpen: snackbarShown, + data: snackbarMessage + } = useDisclosure(); + + return ( + <> + { + const traverser = new ElementTreeTraverser(); + + return handler.on(DeleteElementActionEvent, (async (...params) => { + const [state, , args] = params; + if (!args) { + return DO_NOTHING; + } + + const { element } = args; + + // 1. Prevent deletion of the funnel container element. + if (isContainerElementType(element.type)) { + showSnackbar("Cannot delete the funnel container element."); + return DO_NOTHING; + } + + // 2. Prevent deletion of steps and step grids (first child of a funnel step). + const isStepGrid = element.data.isFunnelStepGrid; + if (isStepElementType(element.type) || isStepGrid) { + showSnackbar( + "A step cannot be deleted directly. To remove a step, select the container element and delete it from the right sidebar." + ); + return DO_NOTHING; + } + + // 3. Prevent deletion of fields in specific cases. + if (isFieldElementType(element.type)) { + // 2.1 Prevent deleting a field if it's mentioned in conditional rules. + const containerElement = (await state.getElementById( + CONTAINER_ELEMENT_ID + )) as ContainerElement; + + // Might not be performant, but it works / is good enough. + const conditionRulesJsonString = JSON.stringify( + containerElement.data.conditionRules + ); + + const fieldId = element.data.id; + if (conditionRulesJsonString.includes(fieldId)) { + showSnackbar( + "Cannot delete this field because it is used in conditional rules." + ); + return DO_NOTHING; + } + + const result = await deleteElementAction(...params); + + if (result.state?.elements) { + result.state.elements = { + ...result.state.elements, + + // Update container (remove field from `fields` array). + [containerElement.id]: { + ...containerElement, + data: { + ...containerElement.data, + fields: containerElement.data.fields.filter( + field => field.id !== fieldId + ) + } + } + }; + } + + return result; + } + + // 4. Prevent deleting elements that contain the funnel container or a field. + const withDescendants = await state.getElementTree({ element }); + + let preventDeletion = false; + let message = ""; + traverser.traverse(withDescendants, element => { + if (isContainerElementType(element.type)) { + preventDeletion = true; + message = + "Cannot delete this element because it contains the funnel container."; + return false; + } + + if (isFieldElementType(element.type)) { + preventDeletion = true; + message = + "Cannot delete this element because it contains one or more fields. Please remove the fields first."; + return false; + } + + return; + }); + + if (preventDeletion) { + showSnackbar(message); + return DO_NOTHING; + } + + return deleteElementAction(...params); + }) as EventActionCallable); + }} + /> + + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/editorActionOverrides/OverrideDropElementActionPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/editorActionOverrides/OverrideDropElementActionPlugin.tsx new file mode 100644 index 00000000000..baa2c782dee --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/editorActionOverrides/OverrideDropElementActionPlugin.tsx @@ -0,0 +1,120 @@ +import React from "react"; +import { PbEditorOverrideActionHandlerPlugin } from "../PbEditorOverrideEventActionPlugin"; +import { + dropElementAction, + DropElementActionEvent +} from "@webiny/app-page-builder/editor/recoil/actions"; +import type { DropElementActionArgsType } from "@webiny/app-page-builder/editor/recoil/actions/dropElement/types"; +import type { EventActionCallable } from "@webiny/app-page-builder/types"; +import { Snackbar } from "@webiny/ui/Snackbar"; +import { useDisclosure } from "../../useDisclosure"; +import { + CONTAINER_ELEMENT_ID, + isButtonElementType, + isFieldElementType +} from "../../../../shared/constants"; +import { ElementTreeTraverser } from "../../ElementTreeTraverser"; + +export interface Handler { + name: string; + canHandle: () => boolean; + handle: (params: { + args: DropElementActionArgsType; + getDocumentElement: () => Promise; + getElementById: (id: string) => Promise; + getElementTree: (params: { element: any }) => Promise; + }) => Promise; +} + +const DO_NOTHING = { actions: [] }; + +export const OverrideDropElementActionPlugin = () => { + const { + open: showSnackbar, + close: hideSnackbar, + isOpen: snackbarShown, + data: snackbarMessage + } = useDisclosure(); + + return ( + <> + { + const traverser = new ElementTreeTraverser(); + + return handler.on(DropElementActionEvent, (async (...params) => { + const [state, , args] = params; + if (!args) { + return DO_NOTHING; + } + + const { target, source } = args; + + // 1. Handle field drops. + if (isFieldElementType(source.type) || isButtonElementType(source.type)) { + // 1. Check if the field has been droped within the container element. + const containerElement = await state.getElementById( + CONTAINER_ELEMENT_ID + ); + + const containerWithDescendants = await state.getElementTree({ + element: containerElement + }); + + let isDroppedWithinContainer = false; + traverser.traverse(containerWithDescendants, element => { + const isTargetElement = element.id === target.id; + if (isTargetElement) { + // The fact that we found the target within the container + // tells us that the field was also dropped within it. + isDroppedWithinContainer = true; + return false; + } + + return; + }); + + if (!isDroppedWithinContainer) { + showSnackbar("Cannot drop fields outside of the funnel container."); + return DO_NOTHING; + } + + // 2. Check if the field has been dropped within the success (last) step. + const lastStepElementWithDescendants = + containerWithDescendants.elements[ + containerElement.elements.length - 1 + ]; + + let isDroppedWithinTheLastStep = false; + traverser.traverse(lastStepElementWithDescendants, element => { + const isTargetElement = element.id === target.id; + if (isTargetElement) { + // The fact that we found the target within the container + // tells us that the field was also dropped within it. + isDroppedWithinTheLastStep = true; + return false; + } + + return; + }); + + if (isDroppedWithinTheLastStep) { + showSnackbar("Cannot drop fields within the success page."); + return DO_NOTHING; + } + } + + return dropElementAction(...params); + }) as EventActionCallable); + }} + /> + + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/editorActionOverrides/index.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/editorActionOverrides/index.tsx new file mode 100644 index 00000000000..d954680bad3 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/editorActionOverrides/index.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { OverrideDropElementActionPlugin } from "./OverrideDropElementActionPlugin"; +import { OverrideDeleteElementActionPlugin } from "./OverrideDeleteElementActionPlugin"; + +export const PbEditorOverrideEventActionPlugins = () => { + return ( + <> + + + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/GteLengthFieldValidatorPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/GteLengthFieldValidatorPlugin.tsx new file mode 100644 index 00000000000..fd464e6c256 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/GteLengthFieldValidatorPlugin.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { PbEditorFunnelFieldValidatorPlugin } from "../PbEditorFunnelFieldValidatorPlugin"; +import { Cell, Grid } from "@webiny/ui/Grid"; +import { validation } from "@webiny/validation"; +import { Input } from "@webiny/ui/Input"; +import { Bind } from "@webiny/form"; + +const ValidatorSettings = () => { + return ( + + + + + + + + ); +}; + +export const GteFieldValidatorPlugin = () => ( + +); diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/LteLengthFieldValidatorPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/LteLengthFieldValidatorPlugin.tsx new file mode 100644 index 00000000000..f140c971c17 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/LteLengthFieldValidatorPlugin.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { PbEditorFunnelFieldValidatorPlugin } from "../PbEditorFunnelFieldValidatorPlugin"; +import { Cell, Grid } from "@webiny/ui/Grid"; +import { validation } from "@webiny/validation"; +import { Input } from "@webiny/ui/Input"; +import { Bind } from "@webiny/form"; + +const ValidatorSettings = () => { + return ( + + + + + + + + ); +}; + +export const LteFieldValidatorPlugin = () => ( + +); diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/MaxLengthFieldValidatorPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/MaxLengthFieldValidatorPlugin.tsx new file mode 100644 index 00000000000..c8f8911b2ad --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/MaxLengthFieldValidatorPlugin.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { PbEditorFunnelFieldValidatorPlugin } from "../PbEditorFunnelFieldValidatorPlugin"; +import { Cell, Grid } from "@webiny/ui/Grid"; +import { validation } from "@webiny/validation"; +import { Input } from "@webiny/ui/Input"; +import { Bind } from "@webiny/form"; + +const ValidatorSettings = () => { + return ( + + + + + + + + ); +}; + +export const MaxLengthFieldValidatorPlugin = () => ( + +); diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/MinLengthFieldValidatorPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/MinLengthFieldValidatorPlugin.tsx new file mode 100644 index 00000000000..90fb02c3f46 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/MinLengthFieldValidatorPlugin.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { PbEditorFunnelFieldValidatorPlugin } from "../PbEditorFunnelFieldValidatorPlugin"; +import { Cell, Grid } from "@webiny/ui/Grid"; +import { validation } from "@webiny/validation"; +import { Input } from "@webiny/ui/Input"; +import { Bind } from "@webiny/form"; + +const ValidatorSettings = () => { + return ( + + + + + + + + ); +}; + +export const MinLengthFieldValidatorPlugin = () => ( + +); diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/PatternFieldValidatorPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/PatternFieldValidatorPlugin.tsx new file mode 100644 index 00000000000..04988c26694 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/PatternFieldValidatorPlugin.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { + PbEditorFunnelFieldValidatorPlugin, + RenderSettings +} from "../PbEditorFunnelFieldValidatorPlugin"; +import { Cell, Grid } from "@webiny/ui/Grid"; +import { validation } from "@webiny/validation"; +import { Input } from "@webiny/ui/Input"; +import { Bind, useForm } from "@webiny/form"; +import { Select } from "@webiny/ui/Select"; +import { patternPresets } from "./PatternFieldValidatorPlugin/patternPresets"; + +const ValidatorSettings: RenderSettings = ({ setMessage }) => { + const { setValue, data } = useForm(); + const inputsDisabled = data.params.extra.preset !== "custom"; + + const selectOptions: any = patternPresets.map(pattern => ( + + )); + + return ( + + + { + if (value === "custom") { + setMessage("Invalid value."); + return; + } + + setValue("params.extra.regex", null); + setValue("params.extra.flags", null); + + const selectedPreset = patternPresets.find(preset => preset.type === value); + + if (!selectedPreset) { + return; + } + + setMessage(selectedPreset.defaultErrorMessage); + }} + > + + + + + + + + + + + + + + + ); +}; + +export const PatternFieldValidatorPlugin = () => ( + +); diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/PatternFieldValidatorPlugin/patternPresets.ts b/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/PatternFieldValidatorPlugin/patternPresets.ts new file mode 100644 index 00000000000..c9eb70984e7 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/PatternFieldValidatorPlugin/patternPresets.ts @@ -0,0 +1,22 @@ +export const patternPresets = [ + { + type: "email", + name: "Email", + defaultErrorMessage: "The value must be a valid email address." + }, + { + type: "lowercase", + name: "Lower case", + defaultErrorMessage: "The value must be in lowercase." + }, + { + type: "uppercase", + name: "Upper case", + defaultErrorMessage: "The value must be in uppercase." + }, + { + type: "url", + name: "URL", + defaultErrorMessage: "The value must be a valid URL." + } +]; diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/RequiredFieldValidatorPlugin.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/RequiredFieldValidatorPlugin.tsx new file mode 100644 index 00000000000..a039bd9ac73 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/RequiredFieldValidatorPlugin.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import { PbEditorFunnelFieldValidatorPlugin } from "../PbEditorFunnelFieldValidatorPlugin"; + +export const RequiredFieldValidatorPlugin = () => ( + +); diff --git a/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/index.tsx b/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/index.tsx new file mode 100644 index 00000000000..fa571f3055d --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/plugins/fieldValidators/index.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { RequiredFieldValidatorPlugin } from "./RequiredFieldValidatorPlugin"; +import { MinLengthFieldValidatorPlugin } from "./MinLengthFieldValidatorPlugin"; +import { MaxLengthFieldValidatorPlugin } from "./MaxLengthFieldValidatorPlugin"; +import { LteFieldValidatorPlugin } from "./LteLengthFieldValidatorPlugin"; +import { GteFieldValidatorPlugin } from "./GteLengthFieldValidatorPlugin"; +import { PatternFieldValidatorPlugin } from "./PatternFieldValidatorPlugin"; + +export const FieldValidatorPlugins = () => { + return ( + <> + + + + + + + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/useDisclosure.ts b/extensions/funnelBuilder/src/frontend/admin/useDisclosure.ts new file mode 100644 index 00000000000..e739738e229 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/useDisclosure.ts @@ -0,0 +1,32 @@ +import { useCallback, useState } from "react"; + +export const useDisclosure = (isOpenDefault = false) => { + const [isOpen, defaultSetIsOpen] = useState(isOpenDefault); + const [data, setData] = useState(null); + + const setIsOpen = useCallback( + (isOpen: boolean | ((prev: boolean) => boolean), data?: TData) => { + defaultSetIsOpen(isOpen); + if (typeof data !== "undefined") { + setData(data); + } + }, + [] + ); + + const open = useCallback((data?: TData) => { + setIsOpen(true, data); + }, []); + + const close = useCallback(() => setIsOpen(false), []); + + const toggle = useCallback((toSet?: boolean) => { + if (typeof toSet === "undefined") { + setIsOpen(state => !state); + } else { + setIsOpen(Boolean(toSet)); + } + }, []); + + return { isOpen, setIsOpen, open, close, toggle, data }; +}; diff --git a/extensions/funnelBuilder/src/frontend/admin/useThemeSettingsQuery.ts b/extensions/funnelBuilder/src/frontend/admin/useThemeSettingsQuery.ts new file mode 100644 index 00000000000..3b01068b796 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/admin/useThemeSettingsQuery.ts @@ -0,0 +1,66 @@ +import gql from "graphql-tag"; +import { useQuery } from "@apollo/react-hooks"; +import { ThemeSettings } from "../../shared/types"; + +export const GET_CURRENT_THEME_SETTINGS = gql` + query GetThemeSettings { + themeSettings { + data { + id + theme { + primaryColor + secondaryColor + logo + } + } + error { + code + message + data + } + } + } +`; + +interface GetThemeSettingsResponse { + themeSettings: { + data: ThemeSettings | undefined; + error: + | undefined + | { + code: string; + message: string; + data: Record; + }; + }; +} + +export function useThemeSettingsQuery() { + const query = useQuery(GET_CURRENT_THEME_SETTINGS); + + const { data, error, loading } = query; + + if (loading) { + return { + themeSettings: undefined, + error: undefined, + loading: true + }; + } + + if (!data) { + return { + themeSettings: undefined, + error: error, + loading: false + }; + } + + const response = data.themeSettings; + + return { + themeSettings: response.data, + error: response.error, + loading + }; +} diff --git a/extensions/funnelBuilder/src/frontend/pageElements/ElementControlsDecorator.tsx b/extensions/funnelBuilder/src/frontend/pageElements/ElementControlsDecorator.tsx new file mode 100644 index 00000000000..463a6dfa515 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/ElementControlsDecorator.tsx @@ -0,0 +1,94 @@ +import React from "react"; +import { ElementControls } from "@webiny/app-page-builder/editor/contexts/EditorPageElementsProvider/ElementControls"; +import { useRenderer } from "@webiny/app-page-builder-elements"; +import { + useActiveElementId, + useElementById, + useUpdateElement +} from "@webiny/app-page-builder/editor"; +import styled from "@emotion/styled"; +import { ReactComponent as EditIcon } from "@material-design-icons/svg/outlined/edit.svg"; +import { IconButton } from "@webiny/ui/Button"; +import { isFieldElementType } from "../../shared/constants"; +import { Tooltip } from "@webiny/ui/Tooltip"; +import { FieldSettingsDialog } from "../admin/FieldSettingsDialog"; +import { useDisclosure } from "../admin/useDisclosure"; +import { FunnelFieldDefinitionModel } from "../../shared/models/FunnelFieldDefinitionModel"; +import { useContainer } from "./container/ContainerProvider"; + +const EditFieldButtonWrapper = styled.div` + position: absolute; + top: 0; + right: 0; + z-index: 1000; +`; + +const EditFieldButton = styled(IconButton)` + padding: 0; + margin: 4px; + width: 36px; + height: 36px; + + svg { + width: 20px; + height: 20px; + } +`; + +export const DecoratedElementControls = ElementControls.createDecorator(Component => { + return function DecoratedElementControls(props) { + const [activeElementId] = useActiveElementId(); + const { funnelVm } = useContainer(); + const { getElement } = useRenderer(); + const element = getElement(); + const [editorElement] = useElementById(element.id); + const updateElement = useUpdateElement(); + + const { + open: showFieldSettingsDialog, + close: hideFieldSettingsDialog, + isOpen: isFieldSettingsDialogShown, + data: selectedField + } = useDisclosure(); + + if (!isFieldElementType(element.type)) { + return ; + } + + const field = funnelVm.getFieldById(element.data.id); + if (!field) { + return ; + } + + const isActive = activeElementId === element.id; + const isHighlighted = editorElement?.isHighlighted ?? false; + if (!isActive && !isHighlighted) { + return ; + } + + return ( + <> + + + } + onClick={() => showFieldSettingsDialog(field.clone())} + /> + + + + { + updateElement({ ...editorElement!, data }, { history: false }); + hideFieldSettingsDialog(); + }} + /> + + + + ); + }; +}); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/ElementToolbarPreview.tsx b/extensions/funnelBuilder/src/frontend/pageElements/ElementToolbarPreview.tsx new file mode 100644 index 00000000000..144de2bba79 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/ElementToolbarPreview.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import styled from "@emotion/styled"; +import { Typography } from "@webiny/ui/Typography"; + +const StyledPreview = styled.div` + display: flex; + flex-direction: column; + align-items: center; + cursor: pointer; +`; + +export interface ToolbarPreviewProps { + icon?: React.ReactNode; + title: string; + description: string; +} + +export const ElementToolbarPreview = ({ icon, title, description }: ToolbarPreviewProps) => ( + + {icon} + {title} + {description} + +); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/FunnelBuilderPageElementGroup.tsx b/extensions/funnelBuilder/src/frontend/pageElements/FunnelBuilderPageElementGroup.tsx new file mode 100644 index 00000000000..2255bbb1ed5 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/FunnelBuilderPageElementGroup.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { PbEditorPageElementGroupPlugin } from "../admin/plugins/PbEditorPageElementGroupPlugin"; +import { ReactComponent as FunnelBuilderIcon } from "@material-design-icons/svg/outlined/filter_alt.svg"; + +export const FunnelBuilderPageElementGroup = () => { + return ( + } + /> + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/container/ContainerProvider.tsx b/extensions/funnelBuilder/src/frontend/pageElements/container/ContainerProvider.tsx new file mode 100644 index 00000000000..88af81e8e32 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/container/ContainerProvider.tsx @@ -0,0 +1,155 @@ +import React, { useContext, useEffect, useMemo, useSyncExternalStore } from "react"; +import { useLoader, useRenderer } from "@webiny/app-page-builder-elements"; +import { FunnelVm } from "../viewModels/FunnelVm"; +import { FunnelModelDto } from "../../../shared/models/FunnelModel"; +import { FunnelSubmissionVm } from "../viewModels/FunnelSubmissionVm"; +import { request } from "graphql-request"; +import { getGqlApiUrl } from "@webiny/app-website"; +import { ThemeSettings } from "../../../shared/types"; + +interface ContainerContextValue { + funnelVm: FunnelVm; + funnelSubmissionVm: FunnelSubmissionVm; + theme: ThemeSettings["theme"]; +} + +const createDefaultThemeSettings = (): ThemeSettings => { + return { + theme: { + primaryColor: "", + secondaryColor: "", + logo: "" + } + }; +}; + +const createInitialContextValue = (): ContainerContextValue => { + const funnelVm = new FunnelVm(); + const funnelSubmissionVm = new FunnelSubmissionVm(funnelVm.funnel); + + return { + funnelVm, + funnelSubmissionVm, + theme: createDefaultThemeSettings()["theme"] + }; +}; + +const ContainerContext = React.createContext(createInitialContextValue()); + +export interface ContainerProviderProps { + children: React.ReactNode; + + // Used only within the Admin (editor) renderer. + updateElementData?: (data: FunnelModelDto) => void; +} + +const globalContainer: { current: ContainerContextValue } = { + current: createInitialContextValue() +}; + +const GET_THEME_SETTINGS = /* GraphQL */ ` + query GetThemeSettings { + themeSettings { + data { + id + theme { + primaryColor + secondaryColor + logo + } + } + error { + code + message + data + } + } + } +`; + +export const ContainerProvider = ({ + children, + updateElementData = () => undefined +}: ContainerProviderProps) => { + const { getElement } = useRenderer(); + const element = getElement(); + + // 1. FunnelVm. + const funnelVm = useMemo(() => { + return new FunnelVm(element.data); + }, []); + + useEffect(() => { + return funnelVm.subscribe(updateElementData); + }, [funnelVm, updateElementData]); + + useSyncExternalStore(funnelVm.subscribe.bind(funnelVm), funnelVm.getChecksum.bind(funnelVm)); + + // 2. FunnelSubmissionVm. + const funnelSubmissionVm = useMemo(() => { + return new FunnelSubmissionVm(funnelVm.funnel); + }, [funnelVm.getChecksum()]); + + useSyncExternalStore( + funnelSubmissionVm.subscribe.bind(funnelSubmissionVm), + funnelSubmissionVm.getChecksum.bind(funnelSubmissionVm) + ); + + const value = useMemo(() => { + return { + funnelVm, + funnelSubmissionVm + }; + }, [funnelVm, funnelSubmissionVm]); + + useEffect(() => { + Object.assign(globalContainer.current, value); + }, [value]); + + const { data, error } = useLoader(() => { + return request(getGqlApiUrl(), GET_THEME_SETTINGS).then(res => { + if (res.themeSettings.error) { + throw new Error(res.themeSettings.error.message); + } + + return res.themeSettings.data; + }); + }); + + const themeSettings = useMemo(() => { + if (!data) { + return createDefaultThemeSettings(); + } + + return { + theme: { + primaryColor: data.theme.primaryColor, + secondaryColor: data.theme.secondaryColor, + logo: data.theme.logo + } + } as ThemeSettings; + }, [data]); + + if (error) { + console.error("An error occurred while fetching theme settings:", error); + return <>An error occurred: {error.message}; + } + + return ( + + {children} + + ); +}; + +ContainerProvider.displayName = "ContainerProvider"; + +export const useContainer = () => { + return useContext(ContainerContext); +}; + +export const getContainerStore = () => { + return globalContainer.current; +}; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/container/admin/AdvancedSettings.tsx b/extensions/funnelBuilder/src/frontend/pageElements/container/admin/AdvancedSettings.tsx new file mode 100644 index 00000000000..b1ae5d8a02f --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/container/admin/AdvancedSettings.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { StepsListSection } from "./AdvancedSettings/StepsListSection"; +import { ConditionRulesSection } from "./AdvancedSettings/ConditionRulesSection"; + +export const AdvancedSettings = () => { + return ( + <> + + + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/container/admin/AdvancedSettings/ConditionRulesSection.tsx b/extensions/funnelBuilder/src/frontend/pageElements/container/admin/AdvancedSettings/ConditionRulesSection.tsx new file mode 100644 index 00000000000..24a3d172179 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/container/admin/AdvancedSettings/ConditionRulesSection.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import styled from "@emotion/styled"; +import { ButtonSecondary } from "@webiny/ui/Button"; +import Accordion from "@webiny/app-page-builder/editor/plugins/elementSettings/components/Accordion"; +import { + useActiveElementId, + useElementById, + useElementWithChildren, + useUpdateElement +} from "@webiny/app-page-builder/editor"; +import { ContainerElementWithChildren } from "../../types"; +import { FunnelModelDto } from "../../../../../shared/models/FunnelModel"; +import { useDisclosure } from "../../../../admin/useDisclosure"; +import { ConditionRulesDialog } from "../../../../admin/ConditionRulesDialog"; + +const EditConditionRulesButton = styled(ButtonSecondary)` + display: block; + margin: 4px auto; +`; + +export const ConditionRulesSection = () => { + const [activeElementId] = useActiveElementId(); + const containerElementWithChildren = useElementWithChildren( + activeElementId! + ) as ContainerElementWithChildren; + + const { + open: showConditionRulesDialog, + close: hideConditionRulesDialog, + isOpen: isConditionRulesDialogShown, + data: conditionRulesDialogData + } = useDisclosure(); + + const conditionalRulesCount = containerElementWithChildren.data.conditionRules.length; + + const [editorElement] = useElementById(activeElementId); + const updateElement = useUpdateElement(); + + return ( + + <> + { + const conditionRulesClone = structuredClone( + containerElementWithChildren.data + ); + showConditionRulesDialog(conditionRulesClone); + }} + > + Conditional Rules  + {conditionalRulesCount > 0 && <>({conditionalRulesCount})} + + + { + updateElement({ ...editorElement!, data }, { history: true }); + hideConditionRulesDialog(); + }} + /> + + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/container/admin/AdvancedSettings/Icon.tsx b/extensions/funnelBuilder/src/frontend/pageElements/container/admin/AdvancedSettings/Icon.tsx new file mode 100644 index 00000000000..c545e2598ad --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/container/admin/AdvancedSettings/Icon.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import styled from "@emotion/styled"; + +const Wrapper = styled.div>` + cursor: pointer; + fill: var(--mdc-theme-text-secondary-on-background); + ${({ disabled }) => { + if (disabled) { + return ` + cursor: not-allowed; + pointer-events: none; + opacity: 0.7; + `; + } + return ""; + }} + svg { + height: ${({ size = 20 }) => size}px; + width: ${({ size = 20 }) => size}px; + } +`; + +export interface IconProps extends React.HTMLAttributes { + element: React.ReactNode; + size?: number; + disabled?: boolean; +} + +export const Icon = React.forwardRef(({ element, ...props }, ref) => { + return ( + + {element} + + ); +}); + +Icon.displayName = "Icon"; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/container/admin/AdvancedSettings/StepsListSection.tsx b/extensions/funnelBuilder/src/frontend/pageElements/container/admin/AdvancedSettings/StepsListSection.tsx new file mode 100644 index 00000000000..4c3f4886e9c --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/container/admin/AdvancedSettings/StepsListSection.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import styled from "@emotion/styled"; +import { ButtonIcon, ButtonSecondary } from "@webiny/ui/Button"; +import { StepsListItem } from "./StepsListSection/StepsListItem"; + +// Sorting. +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors +} from "@dnd-kit/core"; +import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy +} from "@dnd-kit/sortable"; +import Accordion from "@webiny/app-page-builder/editor/plugins/elementSettings/components/Accordion"; + +// Icons. +import { ReactComponent as AddIcon } from "@material-design-icons/svg/outlined/add.svg"; +import { useStepsForm } from "./useStepsForm"; +import { isSuccessStepElementType } from "../../../../../shared/constants"; + +const StyledAccordion = styled(Accordion)` + overflow: hidden; + + .accordion-content { + padding: 0; + } +`; + +const AddPageButton = styled(ButtonSecondary)` + display: block; + margin: 20px auto; +`; + +export const StepsListSection = () => { + const { containerElementWithChildren, createStep, moveStep } = useStepsForm(); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates + }) + ); + + return ( + + { + const { active, over } = event; + if (active.id === over?.id) { + return; + } + + const { steps } = containerElementWithChildren.data; + const fromIndex = steps.findIndex(step => step.id === active.id); + const toIndex = steps.findIndex(step => step.id === over?.id); + + moveStep(fromIndex, toIndex); + }} + modifiers={[restrictToVerticalAxis]} + > + + {containerElementWithChildren.data.steps + .filter(step => !isSuccessStepElementType(step.id)) + .map(step => ( + + ))} + + } /> Add page + + + + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/container/admin/AdvancedSettings/StepsListSection/StepsListItem.tsx b/extensions/funnelBuilder/src/frontend/pageElements/container/admin/AdvancedSettings/StepsListSection/StepsListItem.tsx new file mode 100644 index 00000000000..b0673563010 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/container/admin/AdvancedSettings/StepsListSection/StepsListItem.tsx @@ -0,0 +1,131 @@ +import React from "react"; +import styled from "@emotion/styled"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Icon } from "../Icon"; +import { Input } from "@webiny/ui/Input"; +import { Bind, Form } from "@webiny/form"; +import { validation } from "@webiny/validation"; +import { useConfirmationDialog } from "@webiny/app-admin/hooks/useConfirmationDialog"; +import { useDisclosure } from "../../../../../admin/useDisclosure"; + +// Icons. +import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete.svg"; +import { ReactComponent as DragIndicatorIcon } from "@material-design-icons/svg/outlined/drag_indicator.svg"; +import { ReactComponent as EditIcon } from "@material-design-icons/svg/outlined/edit.svg"; +import { useStepsForm } from "../useStepsForm"; +import { FunnelStepModelDto } from "../../../../../../shared/models/FunnelStepModel"; + +const ListItemStyled = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid var(--mdc-theme-background); +`; + +const PageTitleContainer = styled.div` + display: flex; + gap: 4px; + align-items: center; + + svg { + height: 20px; + } +`; + +const IconsContainer = styled.div` + display: flex; + gap: 4px; +`; + +const StyledDragIcon = styled(DragIndicatorIcon)` + cursor: grab; +`; + +interface StepsListItemProps { + step: FunnelStepModelDto; +} + +export const StepsListItem = ({ step }: StepsListItemProps) => { + const { + open: showTitleInput, + close: hideEditTitleInput, + isOpen: isTitleInputShown + } = useDisclosure(); + + const { showConfirmation } = useConfirmationDialog({ + title: "Remove step", + message:

Are you sure you want to remove this step?

+ }); + + const { updateStep, deleteStep, canDeleteSteps } = useStepsForm(); + + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ + id: step.id + }); + + const submitTitleForm = (data: { title: string }) => { + updateStep({ + ...step, + title: data.title + }); + hideEditTitleInput(); + }; + + const style = { + transform: CSS.Transform.toString(transform), + transition + }; + + return ( + + + {isTitleInputShown ? ( + + data={{ title: step.title }} + onSubmit={submitTitleForm} + > + {({ submit }) => ( + + { + // @ts-expect-error + if (e.key === "Enter") { + submit(); + } + + // On Escape, cancel changes and hide the input. + // @ts-expect-error + if (e.key === "Escape") { + hideEditTitleInput(); + } + }} + /> + + )} + + ) : ( + <> + {step.title} + } onClick={() => showTitleInput()} /> + + )} + + + + } {...attributes} {...listeners} /> + } + onClick={() => { + showConfirmation(async () => deleteStep(step.id)); + }} + /> + + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/container/admin/AdvancedSettings/useStepsForm.ts b/extensions/funnelBuilder/src/frontend/pageElements/container/admin/AdvancedSettings/useStepsForm.ts new file mode 100644 index 00000000000..8178a0359ba --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/container/admin/AdvancedSettings/useStepsForm.ts @@ -0,0 +1,154 @@ +import { + useActiveElementId, + useElementWithChildren, + useUpdateElement +} from "@webiny/app-page-builder/editor"; +import { useCallback } from "react"; +import { arrayMove } from "@dnd-kit/sortable"; +import { ContainerElementWithChildren } from "../../types"; +import { getRandomId } from "../../../../../shared/getRandomId"; +import { createStepElement } from "../../../../../shared/createStepElement"; +import { useSnackbar } from "@webiny/app-admin"; +import { FunnelStepModelDto } from "../../../../../shared/models/FunnelStepModel"; +import { getContainerStore } from "../../ContainerProvider"; + +export const useStepsForm = () => { + const [activeElementId] = useActiveElementId(); + const containerElementWithChildren = useElementWithChildren( + activeElementId! + ) as ContainerElementWithChildren; + + const updateElement = useUpdateElement(); + const { showSnackbar } = useSnackbar(); + + const canDeleteSteps = containerElementWithChildren.data.steps.length > 2; + + const deleteStep = useCallback( + (stepId: string) => { + const { funnelVm } = getContainerStore(); + const hasFields = funnelVm.getFields().some(f => { + return f.stepId === stepId; + }); + + if (hasFields) { + // We can't delete a step that has fields. We need to remove the fields first. + showSnackbar("Please remove all fields from this step before deleting it."); + return; + } + + updateElement( + { + ...containerElementWithChildren, + data: { + ...containerElementWithChildren.data, + steps: containerElementWithChildren.data.steps.filter( + step => step.id !== stepId + ) + }, + elements: containerElementWithChildren.elements.filter( + element => element.data.stepId !== stepId + ) + }, + { history: true } + ); + + setTimeout(() => { + funnelVm.activateFirstAvailableStep(); + }, 100); + showSnackbar("Step deleted successfully."); + }, + [containerElementWithChildren, updateElement] + ); + + const updateStep = useCallback( + (step: FunnelStepModelDto) => { + updateElement( + { + ...containerElementWithChildren, + data: { + ...containerElementWithChildren.data, + steps: [ + ...containerElementWithChildren.data.steps.map(existingStep => { + if (existingStep.id === step.id) { + return step; + } + return existingStep; + }) + ] + } + }, + { history: true } + ); + }, + [containerElementWithChildren, updateElement] + ); + + const createStep = useCallback(() => { + const initialStepData = { + id: getRandomId(), + title: "New Page" + }; + + // We insert the step before the last one. We always keep the last step as + // the last one because that's the success page step. + const lastStepIndex = containerElementWithChildren.data.steps.length - 1; + + updateElement( + { + ...containerElementWithChildren, + data: { + ...containerElementWithChildren.data, + steps: [ + ...containerElementWithChildren.data.steps.slice(0, lastStepIndex), + initialStepData, + ...containerElementWithChildren.data.steps.slice(lastStepIndex) + ] + }, + + // @ts-ignore Incompatible types. Ignoring for now. + elements: [ + ...containerElementWithChildren.elements.slice(0, lastStepIndex), + createStepElement(initialStepData.id), + ...containerElementWithChildren.elements.slice(lastStepIndex) + ] + }, + { history: true } + ); + + setTimeout(() => { + // We do this within a timeout, just so we're safe we're dealing with the latest data. + const { funnelVm } = getContainerStore(); + funnelVm.activateStep(initialStepData.id); + }, 100); + }, [containerElementWithChildren, updateElement]); + + const moveStep = useCallback( + (oldIndex: number, newIndex: number) => { + updateElement( + { + ...containerElementWithChildren, + data: { + ...containerElementWithChildren.data, + steps: arrayMove( + containerElementWithChildren.data.steps, + oldIndex, + newIndex + ) + }, + elements: arrayMove(containerElementWithChildren.elements, oldIndex, newIndex) + }, + { history: false } + ); + }, + [containerElementWithChildren, updateElement] + ); + + return { + containerElementWithChildren, + canDeleteSteps, + updateStep, + deleteStep, + createStep, + moveStep + }; +}; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/container/admin/ContainerAdminEventHandlers.tsx b/extensions/funnelBuilder/src/frontend/pageElements/container/admin/ContainerAdminEventHandlers.tsx new file mode 100644 index 00000000000..10e91317423 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/container/admin/ContainerAdminEventHandlers.tsx @@ -0,0 +1,116 @@ +import React, { useCallback, useEffect } from "react"; +import { + useActiveElementId, + useElementById, + useEventActionHandler, + useUpdateElement +} from "@webiny/app-page-builder/editor"; +import { + CreateElementActionEvent, + UpdateElementActionEvent +} from "@webiny/app-page-builder/editor/recoil/actions"; +import type { + EventActionCallable, + EventActionHandlerCallableArgs +} from "@webiny/app-page-builder/types"; +import type { CreateElementEventActionArgsType } from "@webiny/app-page-builder/editor/recoil/actions/createElement/types"; +import type { UpdateElementActionArgsType } from "@webiny/app-page-builder/editor/recoil/actions/updateElement/types"; +import { isContainerElementType, isFieldElementType } from "../../../../shared/constants"; +import { useContainer } from "../ContainerProvider"; +import { + FunnelFieldDefinitionModel, + FunnelFieldDefinitionModelDto +} from "../../../../shared/models/FunnelFieldDefinitionModel"; +import { useDisclosure } from "../../../admin/useDisclosure"; +import { FieldSettingsDialog } from "../../../admin/FieldSettingsDialog"; + +export const ContainerAdminEventHandlers = () => { + const eventHandler = useEventActionHandler(); + const updateElement = useUpdateElement(); + const { + open: showFieldSettingsDialog, + close: hideFieldSettingsDialog, + isOpen: isFieldSettingsDialogShown, + data: createdField + } = useDisclosure(); + + const container = useContainer(); + const { funnelVm } = container; + + const [activeElementId] = useActiveElementId(); + const [createdEditorElement] = useElementById(activeElementId); + + const createOnElementEventHandler = useCallback( + function ( + handler: (args: TArgs) => void + ): EventActionCallable { + return (_, __, args) => { + if (!args || !funnelVm) { + return { actions: [] }; + } + + handler(args); + + return { actions: [] }; + }; + }, + [funnelVm] + ); + + const onElementCreate = useCallback( + createOnElementEventHandler(args => { + const { element: createdElement } = args; + + if (!isFieldElementType(createdElement.type)) { + return; + } + + funnelVm.addField({ + ...createdElement.data, + stepId: funnelVm.getActiveStepId() + } as FunnelFieldDefinitionModelDto); + + const fieldClone = funnelVm.getFieldById(createdElement.data.id)!.clone(); + showFieldSettingsDialog(fieldClone); + }), + [funnelVm] + ); + + const onElementUpdate = useCallback( + createOnElementEventHandler(args => { + const { element: updatedElement } = args; + if (isFieldElementType(updatedElement.type)) { + funnelVm.updateField(updatedElement.data.id, updatedElement.data); + return; + } + + if (isContainerElementType(updatedElement.type)) { + funnelVm.funnel.populate(updatedElement.data); + return; + } + }), + [funnelVm] + ); + + useEffect(() => { + const offCreateElement = eventHandler.on(CreateElementActionEvent, onElementCreate); + const offUpdateElement = eventHandler.on(UpdateElementActionEvent, onElementUpdate); + + return () => { + offCreateElement(); + offUpdateElement(); + }; + }, [funnelVm]); + + return ( + { + updateElement({ ...createdEditorElement!, data }, { history: true }); + hideFieldSettingsDialog(); + }} + /> + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/container/admin/ContainerAdminPlugins.tsx b/extensions/funnelBuilder/src/frontend/pageElements/container/admin/ContainerAdminPlugins.tsx new file mode 100644 index 00000000000..fbefa4aefb8 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/container/admin/ContainerAdminPlugins.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { + PbEditorPageElementAdvancedSettingsPlugin, + PbEditorPageElementPlugin, + PbRenderElementPlugin +} from "@webiny/app-page-builder"; +import { OnCreateActions } from "@webiny/app-page-builder/types"; +import { ContainerAdminRenderer } from "./ContainerAdminRenderer"; +import { ContainerWebsiteRenderer } from "../website/ContainerWebsiteRenderer"; +import { AdvancedSettings } from "./AdvancedSettings"; +import { createContainerElement } from "../../../../shared/createContainerElement"; +import { CONTAINER_ELEMENT_TYPE } from "../../../../shared/constants"; + +export const ContainerAdminPlugins = () => ( + <> + + + } + /> + +); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/container/admin/ContainerAdminRenderer.tsx b/extensions/funnelBuilder/src/frontend/pageElements/container/admin/ContainerAdminRenderer.tsx new file mode 100644 index 00000000000..4f331c1bcdf --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/container/admin/ContainerAdminRenderer.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import { createRenderer, Elements, useRenderer } from "@webiny/app-page-builder-elements"; +import { ContainerAdminEventHandlers } from "./ContainerAdminEventHandlers"; +import { ContainerProvider, useContainer } from "../ContainerProvider"; +import { useElementWithChildren, useUpdateElement } from "@webiny/app-page-builder/editor"; +import { ContainerElementWithChildren } from "../types"; +import { FunnelModelDto } from "../../../../shared/models/FunnelModel"; +import styled from "@emotion/styled"; +import { Typography } from "@webiny/ui/Typography"; + +// Had to quickly recreate the Tabs component here because Webiny one +// was having re-rendering issues when adding / removing tabs. +const Tabs = styled.div` + display: flex; + justify-content: space-between; +`; + +const Tab = styled.div` + text-align: center; + flex: 1 0; + align-content: center; + border-bottom: 2px solid transparent; + transition: background-color 300ms, border-bottom 150ms; + height: 48px; + + &:hover { + background-color: #fff8f6; + cursor: pointer; + } + + &:active { + background-color: #fedfd7; + } + + &[data-active="true"] { + border-bottom: 2px solid #f94d20; + } +`; + +export const ContainerAdmin = () => { + const { getElement } = useRenderer(); + const element = getElement(); + const elementWithChildren = useElementWithChildren(element.id!) as ContainerElementWithChildren; + const { funnelVm } = useContainer(); + + return ( + <> + + {funnelVm.getSteps().map((step, index) => { + const isActive = index === funnelVm.getActiveStepIndex(); + return ( + { + funnelVm.activateStepIndex(index); + }} + > + {isActive ? ( + + {step.title} + + ) : ( + {step.title} + )} + + ); + })} + + + {/* @ts-ignore Had an issue with types here. It's fine to ignore. */} + + + ); +}; + +export const ContainerAdminRenderer = createRenderer(() => { + const { getElement } = useRenderer(); + const element = getElement(); + const updateElement = useUpdateElement(); + + const updateContainerElementData = (data: FunnelModelDto) => { + console.log("AJMO CHANGE UPDATE!"); + updateElement( + { + ...element, + data: { + ...element.data, + ...data + } + }, + // Ensures change is stored in history and page is updated on the backend. + { history: true } + ); + }; + + return ( +
+ + + + +
+ ); +}); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/container/types.ts b/extensions/funnelBuilder/src/frontend/pageElements/container/types.ts new file mode 100644 index 00000000000..a94b8fe2ca8 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/container/types.ts @@ -0,0 +1,28 @@ +import { PbEditorElement } from "@webiny/app-page-builder/types"; +import { FunnelModelDto } from "../../../shared/models/FunnelModel"; + +export interface PbEditorElementWithChildren extends PbEditorElement { + elements: PbEditorElement[]; +} + +export interface StepElement extends PbEditorElement { + data: { + stepId: string; + }; +} + +export interface StepElementWithChildren extends PbEditorElement { + data: { + stepId: string; + }; + elements: PbEditorElementWithChildren[]; +} + +export interface ContainerElement extends PbEditorElement { + data: FunnelModelDto; + elements: StepElement[]; +} + +export interface ContainerElementWithChildren extends ContainerElement { + elements: StepElementWithChildren[]; +} diff --git a/extensions/funnelBuilder/src/frontend/pageElements/container/website/ContainerWebsitePlugins.tsx b/extensions/funnelBuilder/src/frontend/pageElements/container/website/ContainerWebsitePlugins.tsx new file mode 100644 index 00000000000..39674d6d5be --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/container/website/ContainerWebsitePlugins.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { PbRenderElementPlugin } from "@webiny/app-website"; +import { ContainerWebsiteRenderer } from "./ContainerWebsiteRenderer"; +import { CONTAINER_ELEMENT_TYPE } from "../../../../shared/constants"; + +export const ContainerWebsitePlugins = () => ( + +); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/container/website/ContainerWebsiteRenderer.tsx b/extensions/funnelBuilder/src/frontend/pageElements/container/website/ContainerWebsiteRenderer.tsx new file mode 100644 index 00000000000..a7646a80e6f --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/container/website/ContainerWebsiteRenderer.tsx @@ -0,0 +1,27 @@ +import React, { useEffect } from "react"; +import { createRenderer, Elements, useRenderer } from "@webiny/app-page-builder-elements"; +import { ContainerProvider, useContainer } from "../ContainerProvider"; + +export const ContainerAdmin = () => { + const { getElement } = useRenderer(); + const element = getElement(); + const { funnelSubmissionVm } = useContainer(); + + useEffect(() => { + funnelSubmissionVm.start(); + }, []); + + return ( + <> + + + ); +}; + +export const ContainerWebsiteRenderer = createRenderer(() => { + return ( + + + + ); +}); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/controls/ControlsAdminPlugins.tsx b/extensions/funnelBuilder/src/frontend/pageElements/controls/ControlsAdminPlugins.tsx new file mode 100644 index 00000000000..af877d6276a --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/controls/ControlsAdminPlugins.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { PbEditorPageElementPlugin, PbRenderElementPlugin } from "@webiny/app-page-builder"; +import { ControlsRenderer } from "./ControlsRenderer"; +import { OnCreateActions } from "@webiny/app-page-builder/types"; +import { ELEMENT_TYPE } from "./constants"; +import { FUB_PAGE_ELEMENT_GROUP } from "../fields/utils"; +import { ReactComponent as ControlsIcon } from "@material-design-icons/svg/outlined/swap_horiz.svg"; +import { ElementToolbarPreview } from "../ElementToolbarPreview"; + +export const ControlsAdminPlugins = () => ( + <> + + } + /> + ); + } + }} + // Defines which types of element settings are available to the user. + settings={[ + "pb-editor-page-element-settings-delete", + "pb-editor-page-element-settings-visibility", + "pb-editor-page-element-style-settings-padding", + "pb-editor-page-element-style-settings-margin", + "pb-editor-page-element-style-settings-width", + "pb-editor-page-element-style-settings-height", + "pb-editor-page-element-style-settings-background" + ]} + // Defines onto which existing elements our element can be dropped. + // In most cases, using `["cell", "block"]` will suffice. + target={["cell", "block"]} + onCreate={OnCreateActions.OPEN_SETTINGS} + // `create` function creates the initial data for the page element. + create={options => { + return { + type: ELEMENT_TYPE, + elements: [], + data: { settings: {} }, + ...options + }; + }} + /> + +); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/controls/ControlsRenderer.tsx b/extensions/funnelBuilder/src/frontend/pageElements/controls/ControlsRenderer.tsx new file mode 100644 index 00000000000..1c8b0d38446 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/controls/ControlsRenderer.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { useForm } from "@webiny/form"; +import { createRenderer } from "@webiny/app-page-builder-elements"; +import styled from "@emotion/styled"; +import { useContainer } from "../container/ContainerProvider"; + +export const Wrapper = styled.div` + display: flex; + justify-content: space-between; + padding: 5px; +`; + +export const ControlButton = styled.button<{ color: string }>` + background: ${props => props.color}; + border: none; + border-radius: 4px; + padding: 10px; + color: white; + + & :disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; + +export const ControlsRenderer = createRenderer(() => { + const { submit } = useForm(); + const { funnelSubmissionVm, theme } = useContainer(); + + return ( + + +
Previous
+
+ submit()} color={theme.primaryColor}> +
+ {funnelSubmissionVm.isFinalStep() ? "Finish" : "Next"} +
+
+
+ ); +}); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/controls/ControlsWebsitePlugins.tsx b/extensions/funnelBuilder/src/frontend/pageElements/controls/ControlsWebsitePlugins.tsx new file mode 100644 index 00000000000..162b9b35d70 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/controls/ControlsWebsitePlugins.tsx @@ -0,0 +1,8 @@ +import React from "react"; +import { PbRenderElementPlugin } from "@webiny/app-website"; +import { ControlsRenderer } from "./ControlsRenderer"; +import { ELEMENT_TYPE } from "./constants"; + +export const ControlsWebsitePlugins = () => ( + +); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/controls/constants.ts b/extensions/funnelBuilder/src/frontend/pageElements/controls/constants.ts new file mode 100644 index 00000000000..07d59eebdcf --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/controls/constants.ts @@ -0,0 +1,3 @@ +import { createElementType } from "../../../shared/constants"; + +export const ELEMENT_TYPE = createElementType("controls"); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/controls/types.ts b/extensions/funnelBuilder/src/frontend/pageElements/controls/types.ts new file mode 100644 index 00000000000..9ae26b615d8 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/controls/types.ts @@ -0,0 +1,5 @@ +export type ControlsAction = "previousStep" | "nextStep" | "submit"; + +export interface ControlsElementData { + action: ControlsAction; +} diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/checkboxGroup/CheckboxGroupFieldAdminPlugins.tsx b/extensions/funnelBuilder/src/frontend/pageElements/fields/checkboxGroup/CheckboxGroupFieldAdminPlugins.tsx new file mode 100644 index 00000000000..0b59beb95a6 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/checkboxGroup/CheckboxGroupFieldAdminPlugins.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { PbRenderElementPlugin } from "@webiny/app-page-builder"; +import { CheckboxGroupFieldRenderer } from "./CheckboxGroupFieldRenderer"; +import { ELEMENT_TYPE } from "./constants"; +import { CheckboxGroupFieldSettings } from "./CheckboxGroupFieldSettings"; +import { PbEditorFunnelFieldSettingsPlugin } from "../../../admin/plugins/PbEditorFunnelFieldSettingsPlugin"; +import { PbEditorFunnelFieldPageElementPlugin } from "../../../admin/plugins/PbEditorFunnelFieldPageElementPlugin"; +import { ReactComponent as TextIcon } from "@material-design-icons/svg/outlined/check_box.svg"; + +export const CheckboxGroupFieldAdminPlugins = () => { + return ( + <> + + } + /> + + + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/checkboxGroup/CheckboxGroupFieldRenderer.tsx b/extensions/funnelBuilder/src/frontend/pageElements/fields/checkboxGroup/CheckboxGroupFieldRenderer.tsx new file mode 100644 index 00000000000..e117d98d391 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/checkboxGroup/CheckboxGroupFieldRenderer.tsx @@ -0,0 +1,104 @@ +import React from "react"; +import styled from "@emotion/styled"; +import { FieldErrorMessage } from "../components/FieldErrorMessage"; +import { FieldHelperMessage } from "../components/FieldHelperMessage"; +import { FieldLabel } from "../components/FieldLabel"; +import { Field } from "../components/Field"; +import { createFieldRenderer } from "../createFieldRenderer"; +import { CheckboxGroupField } from "../../../../shared/models/fields/CheckboxGroupField"; + +export const CheckboxGroup = styled.div` + align-items: center; + display: flex; + margin: 5px 50px 5px 2px; + width: 100%; +`; + +export const CheckboxButton = styled.input` + margin-left: 0; + background-color: ${props => props.theme.styles.colors["color5"]}; + min-width: 25px; + width: 25px; + height: 25px; + -webkit-appearance: none; + border-radius: ${props => props.theme.styles.borderRadius}; + + &:focus { + border-color: ${props => props.theme.styles.colors["color2"]}; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); + outline: none; + } + + &:checked { + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSJub25lIiBkPSJNMCAwaDI0djI0SDBWMHoiLz48cGF0aCBkPSJNMTkgM0g1Yy0xLjEgMC0yIC45LTIgMnYxNGMwIDEuMS45IDIgMiAyaDE0YzEuMSAwIDItLjkgMi0yVjVjMC0xLjEtLjktMi0yLTJ6bS04LjI5IDEzLjI5Yy0uMzkuMzktMS4wMi4zOS0xLjQxIDBMNS43MSAxMi43Yy0uMzktLjM5LS4zOS0xLjAyIDAtMS40MS4zOS0uMzkgMS4wMi0uMzkgMS40MSAwTDEwIDE0LjE3bDYuODgtNi44OGMuMzktLjM5IDEuMDItLjM5IDEuNDEgMCAuMzkuMzkuMzkgMS4wMiAwIDEuNDFsLTcuNTggNy41OXoiLz48L3N2Zz4="); + } + + & + label { + margin-left: 10px; + padding-top: 2px; + } +`; + +interface Option { + value: string; + label: string; +} + +interface ChangeParams { + option: Option; + value: string[]; + onChange: (values: string[]) => void; +} + +const change = ({ option, value, onChange }: ChangeParams) => { + const newValues = Array.isArray(value) ? [...value] : []; + if (newValues.includes(option.value)) { + newValues.splice(newValues.indexOf(option.value), 1); + } else { + newValues.push(option.value); + } + + onChange(newValues); +}; + +interface CheckedParams { + option: Option; + value: string[]; +} + +const checked = ({ option, value }: CheckedParams) => { + return Array.isArray(value) && value.includes(option.value); +}; + +export const CheckboxGroupFieldRenderer = createFieldRenderer(props => { + const { + validation, + value, + onChange, + isDisabled, + field: { definition: field } + } = props; + + return ( + + + {field.helpText && {field.helpText}} + {(field.extra.options || []).map((option: any) => ( + + change({ option, value, onChange })} + /> + + + ))} + + + ); +}); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/checkboxGroup/CheckboxGroupFieldSettings.tsx b/extensions/funnelBuilder/src/frontend/pageElements/fields/checkboxGroup/CheckboxGroupFieldSettings.tsx new file mode 100644 index 00000000000..57df6d949d6 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/checkboxGroup/CheckboxGroupFieldSettings.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { Cell, Grid } from "@webiny/ui/Grid"; +import OptionsList from "../components/fieldSettings/OptionsList"; + +export const CheckboxGroupFieldSettings = () => { + return ( + + + + + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/checkboxGroup/CheckboxGroupFieldWebsitePlugins.tsx b/extensions/funnelBuilder/src/frontend/pageElements/fields/checkboxGroup/CheckboxGroupFieldWebsitePlugins.tsx new file mode 100644 index 00000000000..ef06f3b9886 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/checkboxGroup/CheckboxGroupFieldWebsitePlugins.tsx @@ -0,0 +1,8 @@ +import React from "react"; +import { PbRenderElementPlugin } from "@webiny/app-website"; +import { CheckboxGroupFieldRenderer } from "./CheckboxGroupFieldRenderer"; +import { ELEMENT_TYPE } from "./constants"; + +export const CheckboxGroupFieldWebsitePlugins = () => ( + +); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/checkboxGroup/constants.ts b/extensions/funnelBuilder/src/frontend/pageElements/fields/checkboxGroup/constants.ts new file mode 100644 index 00000000000..35db19e2af3 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/checkboxGroup/constants.ts @@ -0,0 +1,3 @@ +import { createFieldElementType } from "../../../../shared/constants"; + +export const ELEMENT_TYPE = createFieldElementType("checkboxGroup"); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/components/Field.tsx b/extensions/funnelBuilder/src/frontend/pageElements/fields/components/Field.tsx new file mode 100644 index 00000000000..247c2d4d14f --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/components/Field.tsx @@ -0,0 +1,11 @@ +import styled from "@emotion/styled"; + +export const Field = styled.div<{ disabled: boolean }>` + width: 100%; + box-sizing: border-box; + ${props => + props.disabled + ? { cursor: "not-allowed", "*": { opacity: 0.75, pointerEvents: "none" } } + : {}} + ${props => props.theme.styles.typography.paragraphs.stylesById("paragraph1")}; +`; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/components/FieldErrorMessage.tsx b/extensions/funnelBuilder/src/frontend/pageElements/fields/components/FieldErrorMessage.tsx new file mode 100644 index 00000000000..7bc2ade3662 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/components/FieldErrorMessage.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; +import styled from "@emotion/styled"; + +const Wrapper = styled.div` + margin-left: 2px; + margin-top: 5px; + ${props => props.theme.styles.typography.paragraphs.stylesById("paragraph2")}; + color: ${props => props.theme.styles.colors["color1"]}; + + ${props => props.theme.breakpoints["mobile-landscape"]} { + text-align: left !important; + } +`; + +interface FieldErrorMessageProps { + message: React.ReactNode; + isValid: boolean | null; +} + +export const FieldErrorMessage = (props: FieldErrorMessageProps) => { + return {props.isValid === false ? props.message : ""}; +}; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/components/FieldHelperMessage.tsx b/extensions/funnelBuilder/src/frontend/pageElements/fields/components/FieldHelperMessage.tsx new file mode 100644 index 00000000000..3dc2e599130 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/components/FieldHelperMessage.tsx @@ -0,0 +1,13 @@ +import styled from "@emotion/styled"; + +export const FieldHelperMessage = styled.div` + margin-left: 2px; + margin-top: -5px; + margin-bottom: 5px; + ${props => props.theme.styles.typography.paragraphs.stylesById("paragraph2")}; + color: ${props => props.theme.styles.colors["color2"]}; + + ${props => props.theme.breakpoints["mobile-landscape"]} { + text-align: left !important; + } +`; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/components/FieldLabel.tsx b/extensions/funnelBuilder/src/frontend/pageElements/fields/components/FieldLabel.tsx new file mode 100644 index 00000000000..4144e3fc015 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/components/FieldLabel.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; +import styled from "@emotion/styled"; +import { FunnelFieldDefinitionModel } from "../../../../shared/models/FunnelFieldDefinitionModel"; + +export const FieldLabelStyled = styled.label` + width: 100%; + display: inline-block; + margin: 0 0 5px 1px; + + ${props => props.theme.breakpoints["mobile-landscape"]} { + text-align: left !important; + } + + .asterisk { + margin-left: 5px; + color: ${props => props.theme.styles.colors["color1"]}; + } +`; + +export interface FieldLabelProps { + field: FunnelFieldDefinitionModel; +} + +export const FieldLabel = ({ field }: FieldLabelProps) => { + return ( + + {field.label} + {field.validators?.some(validation => validation.type === "required") && ( + * + )} + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/components/fieldSettings/OptionsList.tsx b/extensions/funnelBuilder/src/frontend/pageElements/fields/components/fieldSettings/OptionsList.tsx new file mode 100644 index 00000000000..3fba2237cd7 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/components/fieldSettings/OptionsList.tsx @@ -0,0 +1,124 @@ +import React from "react"; +import styled from "@emotion/styled"; +import camelCase from "lodash/camelCase"; +import { OptionsListItem, SortableContainerContextProvider } from "./OptionsList/OptionsListItem"; +import { AddOptionInput } from "./OptionsList/AddOptionInput"; +import { FieldOption } from "./OptionsList/types"; +import { Icon } from "@webiny/ui/Icon"; +import { Cell, Grid } from "@webiny/ui/Grid"; +import { ReactComponent as HandleIcon } from "@material-design-icons/svg/outlined/drag_indicator.svg"; +import { validation } from "@webiny/validation"; +import { DragEndEvent } from "@dnd-kit/core"; +import { arrayMove } from "@dnd-kit/sortable"; +import { Bind, useBind } from "@webiny/form"; +import { getRandomId } from "../../../../../shared/getRandomId"; + +const OptionListItem = styled.li` + z-index: 10; + display: flex; + justify-content: space-between; + border-bottom: 1px solid var(--mdc-theme-background); + background: var(--mdc-theme-surface); + + &:hover { + background: var(--mdc-theme-background); + } + + &:last-child { + border: none; + } +`; + +const DragHandle = () => } />; + +interface OptionsListProps { + multiple?: boolean; +} + +interface OptionsListBindParams { + validation: any; + value: FieldOption[]; + onChange: (values: FieldOption[]) => void; +} + +const OptionsList = ({ multiple }: OptionsListProps) => { + const { + validation: optionsValidation, + value: optionsValue, + onChange: setOptionsValue + } = useBind({ + name: "extra.options", + validators: validation.create("required,minLength:1") + }) as OptionsListBindParams; + + const onDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (active.id === over?.id) { + return; + } + + const oldIndex = optionsValue.findIndex(option => option.id === active.id); + const newIndex = optionsValue.findIndex(option => option.id === over?.id); + + const sortedOptions = arrayMove(optionsValue, oldIndex, newIndex); + setOptionsValue(sortedOptions); + }; + + return ( + <> +
Options
+ + + { + const newValue = Array.isArray(optionsValue) ? [...optionsValue] : []; + newValue.push({ + id: getRandomId(), + value: camelCase(label), + label + }); + setOptionsValue(newValue); + }} + /> + + + +
+ {Array.isArray(optionsValue) && optionsValue.length > 0 ? ( + <> + + {optionsValue.map((item, index) => ( + + } + multiple={!!multiple} + option={item} + Bind={Bind} + // We probably want an edit option here as well. + // Did not do it because of lack of time. + // editOption={() => onEditOption(item, index)} + deleteOption={() => { + const newValue = [...optionsValue]; + newValue.splice(index, 1); + setOptionsValue(newValue); + }} + /> + + ))} + + + ) : ( +
No options added.
+ )} +
+ + ); +}; + +export default OptionsList; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/components/fieldSettings/OptionsList/AddOptionInput.tsx b/extensions/funnelBuilder/src/frontend/pageElements/fields/components/fieldSettings/OptionsList/AddOptionInput.tsx new file mode 100644 index 00000000000..bd1bda9077a --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/components/fieldSettings/OptionsList/AddOptionInput.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { Input } from "@webiny/ui/Input"; +import { BindComponentRenderPropValidation, Form } from "@webiny/form"; +import { FieldOption } from "./types"; + +interface AddOptionInputProps { + onAdd: (value: string) => void; + options: FieldOption[]; + validation: BindComponentRenderPropValidation; +} + +export const AddOptionInput = ({ + options, + onAdd, + validation: optionsValidation +}: AddOptionInputProps) => { + return ( +
+ {({ Bind }) => ( + { + if (!Array.isArray(options)) { + return true; + } + + if (options.find(item => item.value === value)) { + throw new Error(`Option with value "${value}" already exists.`); + } + return true; + }} + > + {({ value, onChange, validate, validation: inputValidation }) => { + const validation = + inputValidation && inputValidation.message + ? inputValidation + : optionsValidation; + return ( + { + if (value) { + const result = await validate(); + if (result !== false) { + onChange(""); + onAdd(value.trim()); + } + } + }} + placeholder={"Enter an option and press enter"} + /> + ); + }} + + )} +
+ ); +}; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/components/fieldSettings/OptionsList/OptionsListItem.tsx b/extensions/funnelBuilder/src/frontend/pageElements/fields/components/fieldSettings/OptionsList/OptionsListItem.tsx new file mode 100644 index 00000000000..e1a129c1e90 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/components/fieldSettings/OptionsList/OptionsListItem.tsx @@ -0,0 +1,187 @@ +import React from "react"; +import { Typography } from "@webiny/ui/Typography"; +import { Tooltip } from "@webiny/ui/Tooltip"; +import { IconButton } from "@webiny/ui/Button"; +import styled from "@emotion/styled"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { + DndContext, + useSensor, + useSensors, + PointerSensor, + KeyboardSensor, + closestCenter, + DragEndEvent, + UniqueIdentifier +} from "@dnd-kit/core"; +import { sortableKeyboardCoordinates, SortableContext } from "@dnd-kit/sortable"; + +import { Switch } from "@webiny/ui/Switch"; +import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete.svg"; +import { BindComponent } from "@webiny/form/types"; +import { FieldOption } from "./types"; + +const OptionList = styled.ul` + padding: 25px; + border: 1px solid var(--mdc-theme-on-background); +`; + +const OptionsListItemLeft = styled.div({ + display: "flex", + justifyContent: "left", + alignItems: "center", + ">div": { + display: "flex", + flexDirection: "column", + marginLeft: 10, + color: "var(--mdc-theme-on-surface)", + span: { + lineHeight: "125%" + } + } +}); + +const OptionsListItemRight = styled.div({ + display: "flex", + justifyContent: "right", + alignItems: "center" +}); + +interface DefaultValueSwitchProps { + multiple: boolean; + option: FieldOption; + value: string[] | string; + onChange: (value: string[] | string) => void; +} + +const DefaultValueSwitch = ({ + multiple, + option, + value: currentDefaultValue, + onChange: setDefaultValue +}: DefaultValueSwitchProps) => { + if (multiple) { + const selected = + Array.isArray(currentDefaultValue) && currentDefaultValue.includes(option.value); + + return ( + { + if (selected) { + const value = Array.isArray(currentDefaultValue) + ? [...currentDefaultValue] + : []; + + value.splice(value.indexOf(option.value), 1); + setDefaultValue(value); + } else { + const value = Array.isArray(currentDefaultValue) + ? [...currentDefaultValue] + : []; + value.push(option.value); + setDefaultValue(value); + } + }} + /> + ); + } + + const selected = currentDefaultValue === option.value; + return ( + { + const newValue = selected ? "" : option.value; + setDefaultValue(newValue); + }} + /> + ); +}; + +export type SortableContextItemsProp = ( + | UniqueIdentifier + | { + id: UniqueIdentifier; + } +)[]; + +interface SortableContainerWrapperProps { + optionsValue: FieldOption[]; + children: React.ReactNode; + onDragEnd: (event: DragEndEvent) => void; +} + +export const SortableContainerContextProvider = ({ + optionsValue, + children, + onDragEnd +}: SortableContainerWrapperProps) => { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates + }) + ); + + return ( + + + {children} + + + ); +}; + +type OptionsListItemProps = { + multiple: boolean; + dragHandle: React.ReactNode; + option: { label: string; value: string; id: string }; + Bind: BindComponent; + deleteOption: () => void; +}; + +export const OptionsListItem = (props: OptionsListItemProps) => { + const { multiple, dragHandle, Bind, option, deleteOption } = props; + + const { attributes, listeners, setNodeRef, transform } = useSortable({ id: option.id || "" }); + const style = { + transform: CSS.Transform.toString(transform) + }; + + return ( + <> +
+ + Drag to rearrange the order} + > + {dragHandle} + +
+ {option.label} + {option.value} +
+
+
+ + } onClick={deleteOption} /> + + + {({ onChange, value }) => ( + Set as default value}> + + + )} + + + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/components/fieldSettings/OptionsList/types.ts b/extensions/funnelBuilder/src/frontend/pageElements/fields/components/fieldSettings/OptionsList/types.ts new file mode 100644 index 00000000000..5e5a35fcfee --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/components/fieldSettings/OptionsList/types.ts @@ -0,0 +1,5 @@ +export interface FieldOption { + id: string; + value: string; + label: string; +} diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/createFieldRenderer.tsx b/extensions/funnelBuilder/src/frontend/pageElements/fields/createFieldRenderer.tsx new file mode 100644 index 00000000000..8ad0a9b82ba --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/createFieldRenderer.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import { createRenderer, useRenderer } from "@webiny/app-page-builder-elements"; +import styled from "@emotion/styled"; +import { useContainer } from "../container/ContainerProvider"; +import { FunnelSubmissionFieldModel } from "../../../shared/models/FunnelSubmissionFieldModel"; +import { FunnelFieldDefinitionModel } from "../../../shared/models/FunnelFieldDefinitionModel"; +import { FormComponentProps, useBind } from "@webiny/form"; + +const Error = styled.div` + padding: 12px; + strong { + font-weight: 600; + } +`; + +export interface FieldRendererProps< + TField extends FunnelFieldDefinitionModel = FunnelFieldDefinitionModel +> extends Required { + field: FunnelSubmissionFieldModel; + isDisabled: boolean; + isHidden: boolean; +} + +export const createFieldRenderer = < + TField extends FunnelFieldDefinitionModel = FunnelFieldDefinitionModel +>( + Component: React.ComponentType> +) => { + return createRenderer(() => { + const { getElement } = useRenderer(); + const element = getElement(); + + const { funnelSubmissionVm } = useContainer(); + + if (!funnelSubmissionVm) { + return Field not located within the Funnel Builder container.; + } + + const field = funnelSubmissionVm.getField(element.data.fieldId); + if (!field) { + return ( + + Field {element.data.fieldId} not found in the funnel. + + ); + } + + const { validate, validation, value, onChange } = useBind({ + name: field.definition.fieldId, + validators: field.ensureValid.bind(field), + defaultValue: field.getRawValue() + }); + + if (field.hidden) { + return null; + } + + return ( + } + /> + ); + }); +}; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/text/TextFieldAdminPlugins.tsx b/extensions/funnelBuilder/src/frontend/pageElements/fields/text/TextFieldAdminPlugins.tsx new file mode 100644 index 00000000000..be6daefaad4 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/text/TextFieldAdminPlugins.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { PbRenderElementPlugin } from "@webiny/app-page-builder"; +import { TextFieldRenderer } from "./TextFieldRenderer"; +import { ELEMENT_TYPE } from "./constants"; +import { TextFieldSettings } from "./TextFieldSettings"; +import { PbEditorFunnelFieldSettingsPlugin } from "../../../admin/plugins/PbEditorFunnelFieldSettingsPlugin"; +import { PbEditorFunnelFieldPageElementPlugin } from "../../../admin/plugins/PbEditorFunnelFieldPageElementPlugin"; +import { ReactComponent as TextIcon } from "@material-design-icons/svg/outlined/text_fields.svg"; + +export const TextFieldAdminPlugins = () => { + return ( + <> + + } + /> + + + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/text/TextFieldRenderer.tsx b/extensions/funnelBuilder/src/frontend/pageElements/fields/text/TextFieldRenderer.tsx new file mode 100644 index 00000000000..b98622c70c6 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/text/TextFieldRenderer.tsx @@ -0,0 +1,61 @@ +import React, { useCallback } from "react"; +import styled from "@emotion/styled"; +import { FieldErrorMessage } from "../components/FieldErrorMessage"; +import { FieldHelperMessage } from "../components/FieldHelperMessage"; +import { FieldLabel } from "../components/FieldLabel"; +import { Field } from "../components/Field"; +import { createFieldRenderer } from "../createFieldRenderer"; +import { TextField } from "../../../../shared/models/fields/TextField"; + +export const StyledInput = styled.input` + border: 1px solid ${props => props.theme.styles.colors["color5"]}; + background-color: ${props => props.theme.styles.colors["color5"]}; + width: 100%; + padding: 10px; + border-radius: ${props => props.theme.styles.borderRadius}; + box-sizing: border-box; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + ${props => props.theme.styles.typography.paragraphs.stylesById("paragraph1")}; + + &:focus { + border-color: ${props => props.theme.styles.colors["color2"]}; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); + outline: none; + } +`; + +export const TextFieldRenderer = createFieldRenderer(props => { + const { + validate, + validation, + value, + onChange, + isDisabled, + field: { definition: field } + } = props; + + const onBlur = useCallback( + (ev: React.SyntheticEvent) => { + ev.persist(); + validate(); + }, + [validate] + ); + + return ( + + + {field.helpText && {field.helpText}} + onChange(e.target.value)} + value={value || ""} + placeholder={field.extra.placeholderText} + name={field.fieldId} + id={field.fieldId} + /> + + + ); +}); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/text/TextFieldSettings.tsx b/extensions/funnelBuilder/src/frontend/pageElements/fields/text/TextFieldSettings.tsx new file mode 100644 index 00000000000..4789e9749df --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/text/TextFieldSettings.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { Cell, Grid } from "@webiny/ui/Grid"; +import { Bind } from "@webiny/form"; +import { Input } from "@webiny/ui/Input"; + +export const TextFieldSettings = () => { + return ( + + + + + + + + + + + + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/text/TextFieldWebsitePlugins.tsx b/extensions/funnelBuilder/src/frontend/pageElements/fields/text/TextFieldWebsitePlugins.tsx new file mode 100644 index 00000000000..824bed82b8e --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/text/TextFieldWebsitePlugins.tsx @@ -0,0 +1,8 @@ +import React from "react"; +import { PbRenderElementPlugin } from "@webiny/app-website"; +import { TextFieldRenderer } from "./TextFieldRenderer"; +import { ELEMENT_TYPE } from "./constants"; + +export const TextFieldWebsitePlugins = () => ( + +); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/text/constants.ts b/extensions/funnelBuilder/src/frontend/pageElements/fields/text/constants.ts new file mode 100644 index 00000000000..3fd9182a2c5 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/text/constants.ts @@ -0,0 +1,3 @@ +import { createFieldElementType } from "../../../../shared/constants"; + +export const ELEMENT_TYPE = createFieldElementType("text"); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/textarea/TextareaFieldAdminPlugins.tsx b/extensions/funnelBuilder/src/frontend/pageElements/fields/textarea/TextareaFieldAdminPlugins.tsx new file mode 100644 index 00000000000..dad94ed1b81 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/textarea/TextareaFieldAdminPlugins.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { PbRenderElementPlugin } from "@webiny/app-page-builder"; +import { TextareaFieldRenderer } from "./TextareaFieldRenderer"; +import { ELEMENT_TYPE } from "./constants"; +import { TextareaFieldSettings } from "./TextareaFieldSettings"; +import { PbEditorFunnelFieldSettingsPlugin } from "../../../admin/plugins/PbEditorFunnelFieldSettingsPlugin"; +import { PbEditorFunnelFieldPageElementPlugin } from "../../../admin/plugins/PbEditorFunnelFieldPageElementPlugin"; +import { ReactComponent as TextIcon } from "@material-design-icons/svg/outlined/text_fields.svg"; + +export const TextareaFieldAdminPlugins = () => { + return ( + <> + + } + /> + + + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/textarea/TextareaFieldRenderer.tsx b/extensions/funnelBuilder/src/frontend/pageElements/fields/textarea/TextareaFieldRenderer.tsx new file mode 100644 index 00000000000..d39dcd38092 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/textarea/TextareaFieldRenderer.tsx @@ -0,0 +1,62 @@ +import React, { useCallback } from "react"; +import styled from "@emotion/styled"; +import { FieldErrorMessage } from "../components/FieldErrorMessage"; +import { FieldHelperMessage } from "../components/FieldHelperMessage"; +import { FieldLabel } from "../components/FieldLabel"; +import { Field } from "../components/Field"; +import { createFieldRenderer } from "../createFieldRenderer"; +import { TextareaField } from "../../../../shared/models/fields/TextareaField"; + +const StyledTextarea = styled.textarea` + border: 1px solid ${props => props.theme.styles.colors["color5"]}; + background-color: ${props => props.theme.styles.colors["color5"]}; + width: 100%; + padding: 10px; + border-radius: ${props => props.theme.styles.borderRadius}; + box-sizing: border-box; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + ${props => props.theme.styles.typography.paragraphs.stylesById("paragraph1")}; + + &:focus { + border-color: ${props => props.theme.styles.colors["color2"]}; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); + outline: none; + } +`; + +export const TextareaFieldRenderer = createFieldRenderer(props => { + const { + validate, + validation, + value, + onChange, + isDisabled, + field: { definition: field } + } = props; + + const onBlur = useCallback( + (ev: React.SyntheticEvent) => { + ev.persist(); + validate(); + }, + [validate] + ); + + return ( + + + {field.helpText && {field.helpText}} + onChange(e.target.value)} + value={value || ""} + placeholder={field.extra.placeholderText} + rows={field.extra.rows} + name={field.fieldId} + id={field.fieldId} + /> + + + ); +}); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/textarea/TextareaFieldSettings.tsx b/extensions/funnelBuilder/src/frontend/pageElements/fields/textarea/TextareaFieldSettings.tsx new file mode 100644 index 00000000000..6a3de69bb0d --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/textarea/TextareaFieldSettings.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { Cell, Grid } from "@webiny/ui/Grid"; +import { Bind } from "@webiny/form"; +import { Input } from "@webiny/ui/Input"; + +export const TextareaFieldSettings = () => { + return ( + + + + + + + + + + + + + + + + + + ); +}; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/textarea/TextareaFieldWebsitePlugins.tsx b/extensions/funnelBuilder/src/frontend/pageElements/fields/textarea/TextareaFieldWebsitePlugins.tsx new file mode 100644 index 00000000000..032e35118fd --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/textarea/TextareaFieldWebsitePlugins.tsx @@ -0,0 +1,8 @@ +import React from "react"; +import { PbRenderElementPlugin } from "@webiny/app-website"; +import { TextareaFieldRenderer } from "./TextareaFieldRenderer"; +import { ELEMENT_TYPE } from "./constants"; + +export const TextareaFieldWebsitePlugins = () => ( + +); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/textarea/constants.ts b/extensions/funnelBuilder/src/frontend/pageElements/fields/textarea/constants.ts new file mode 100644 index 00000000000..85070513a83 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/textarea/constants.ts @@ -0,0 +1,3 @@ +import { createFieldElementType } from "../../../../shared/constants"; + +export const ELEMENT_TYPE = createFieldElementType("textarea"); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/fields/utils.ts b/extensions/funnelBuilder/src/frontend/pageElements/fields/utils.ts new file mode 100644 index 00000000000..b9e8702ef7b --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/fields/utils.ts @@ -0,0 +1,8 @@ +import { fieldFromDto } from "../../../shared/models/fields/fieldFactory"; + +export const FUB_PAGE_ELEMENT_GROUP = "funnelBuilder"; + +export const createInitialFieldData = (fieldType: string) => { + const field = fieldFromDto({ type: fieldType, stepId: "" }); + return field.toDto(); +}; diff --git a/extensions/funnelBuilder/src/frontend/pageElements/step/admin/StepAdminPlugins.tsx b/extensions/funnelBuilder/src/frontend/pageElements/step/admin/StepAdminPlugins.tsx new file mode 100644 index 00000000000..ec07d1603d9 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/step/admin/StepAdminPlugins.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { PbEditorPageElementPlugin, PbRenderElementPlugin } from "@webiny/app-page-builder"; +import { OnCreateActions } from "@webiny/app-page-builder/types"; +import { StepAdminRenderer } from "./StepAdminRenderer"; +import { StepWebsiteRenderer } from "../website/StepWebsiteRenderer"; +import { createStepElement } from "../../../../shared/createStepElement"; +import { CONTAINER_ELEMENT_TYPE, STEP_ELEMENT_TYPE } from "../../../../shared/constants"; + +export const StepAdminPlugins = () => ( + <> + + createStepElement()} + // We don't want to allow deleting the step element. + canDelete={() => false} + /> + +); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/step/admin/StepAdminRenderer.tsx b/extensions/funnelBuilder/src/frontend/pageElements/step/admin/StepAdminRenderer.tsx new file mode 100644 index 00000000000..143078b4e72 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/step/admin/StepAdminRenderer.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { createRenderer, Elements, useRenderer } from "@webiny/app-page-builder-elements"; +import { Form } from "@webiny/form"; +import { useContainer } from "../../container/ContainerProvider"; +import { StepElementData } from "../types"; +import { StepElementWithChildren } from "../../container/types"; +import { useActiveElementId, useElementWithChildren } from "@webiny/app-page-builder/editor"; +import { EmptyCell } from "@webiny/app-page-builder/editor/plugins/elements/cell/EmptyCell"; + +export const StepAdminRenderer = createRenderer(() => { + const { getElement } = useRenderer(); + const element = getElement(); + const elementWithChildren = useElementWithChildren(element.id!) as StepElementWithChildren; + + const [activeElementId] = useActiveElementId(); + const isActive = activeElementId === element.id; + + const { funnelVm } = useContainer(); + + if (funnelVm.getActiveStepId() !== element.data.stepId) { + return null; + } + + if (!elementWithChildren.elements.length) { + return ; + } + + return ( +
+
+ {/* @ts-ignore Incompatible types. Safe to ignore. */} + {() => } + +
+ ); +}); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/step/types.ts b/extensions/funnelBuilder/src/frontend/pageElements/step/types.ts new file mode 100644 index 00000000000..ae29e69a038 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/step/types.ts @@ -0,0 +1,9 @@ +import { PbEditorElement } from "@webiny/app-page-builder/types"; + +export interface StepElementData { + stepId: string; +} + +export interface StepElement extends PbEditorElement { + data: StepElementData; +} diff --git a/extensions/funnelBuilder/src/frontend/pageElements/step/website/StepWebsitePlugins.tsx b/extensions/funnelBuilder/src/frontend/pageElements/step/website/StepWebsitePlugins.tsx new file mode 100644 index 00000000000..fd685946981 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/step/website/StepWebsitePlugins.tsx @@ -0,0 +1,8 @@ +import React from "react"; +import { PbRenderElementPlugin } from "@webiny/app-website"; +import { StepWebsiteRenderer } from "./StepWebsiteRenderer"; +import { STEP_ELEMENT_TYPE } from "../../../../shared/constants"; + +export const StepWebsitePlugins = () => ( + +); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/step/website/StepWebsiteRenderer.tsx b/extensions/funnelBuilder/src/frontend/pageElements/step/website/StepWebsiteRenderer.tsx new file mode 100644 index 00000000000..c3a92138c50 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/step/website/StepWebsiteRenderer.tsx @@ -0,0 +1,35 @@ +import React, { useEffect } from "react"; +import { createRenderer, Elements, useRenderer } from "@webiny/app-page-builder-elements"; +import { Form } from "@webiny/form"; +import { useContainer } from "../../container/ContainerProvider"; +import { StepElementData } from "../types"; + +export const StepWebsiteRenderer = createRenderer(() => { + const { getElement } = useRenderer(); + const element = getElement(); + const { funnelSubmissionVm } = useContainer(); + + useEffect(() => { + funnelSubmissionVm.evaluateConditionRulesForActiveStep(); + }, []); + + if (funnelSubmissionVm.activeStepId !== element.data.stepId) { + return null; + } + + return ( +
+
{ + funnelSubmissionVm.setData(data); + }} + onSubmit={data => { + funnelSubmissionVm.setData(data); + funnelSubmissionVm.submitActiveStep(); + }} + > + {() => } + +
+ ); +}); diff --git a/extensions/funnelBuilder/src/frontend/pageElements/viewModels/FunnelSubmissionVm.ts b/extensions/funnelBuilder/src/frontend/pageElements/viewModels/FunnelSubmissionVm.ts new file mode 100644 index 00000000000..c3be8799791 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/viewModels/FunnelSubmissionVm.ts @@ -0,0 +1,78 @@ +import { FunnelModel } from "../../../shared/models/FunnelModel"; +import { FunnelSubmissionModel } from "../../../shared/models/FunnelSubmissionModel"; + +type Listener = () => void; + +export class FunnelSubmissionVm { + funnel: FunnelModel; + funnelSubmission: FunnelSubmissionModel; + listeners: Set = new Set(); + + constructor(funnel: FunnelModel) { + this.funnel = funnel; + this.funnelSubmission = new FunnelSubmissionModel(funnel); + } + + start() { + this.funnelSubmission.start(); + this.emitChange(); + } + + getField(fieldId: string) { + return this.funnelSubmission.getField(fieldId); + } + + fieldExists(fieldId: string) { + return this.funnelSubmission.fieldExists(fieldId); + } + + setData(data: any) { + this.funnelSubmission.setData(data); + this.emitChange(); + } + + submitActiveStep() { + this.funnelSubmission.submitActiveStep().then(this.emitChange.bind(this)); + } + + activatePreviousStep() { + this.funnelSubmission.activatePreviousStep(); + this.emitChange(); + } + + evaluateConditionRulesForActiveStep() { + this.funnelSubmission.evaluateRelatedConditionRules(); + this.emitChange(); + } + + get activeStepIndex() { + return this.funnelSubmission.getActiveStepIndex(); + } + + get activeStepId() { + return this.funnelSubmission.activeStepId; + } + + isFinalStep() { + return this.funnelSubmission.isFinalStep(); + } + + isFirstStep() { + return this.funnelSubmission.isFirstStep(); + } + + subscribe(listener: Listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private emitChange() { + for (const listener of this.listeners) { + listener(); + } + } + + getChecksum() { + return this.funnelSubmission.getChecksum(); + } +} diff --git a/extensions/funnelBuilder/src/frontend/pageElements/viewModels/FunnelVm.ts b/extensions/funnelBuilder/src/frontend/pageElements/viewModels/FunnelVm.ts new file mode 100644 index 00000000000..37b95f05cd8 --- /dev/null +++ b/extensions/funnelBuilder/src/frontend/pageElements/viewModels/FunnelVm.ts @@ -0,0 +1,165 @@ +import { FunnelModel, FunnelModelDto } from "../../../shared/models/FunnelModel"; +import { + FunnelFieldDefinitionModel, + FunnelFieldDefinitionModelDto +} from "../../../shared/models/FunnelFieldDefinitionModel"; +import { FunnelStepModel, FunnelStepModelDto } from "../../../shared/models/FunnelStepModel"; + +type Listener = (dto: FunnelModelDto) => void; + +export class FunnelVm { + funnel: FunnelModel; + activeStepId: string; + listeners: Set = new Set(); + + constructor(funnel?: FunnelModel | FunnelModelDto) { + if (funnel instanceof FunnelModel) { + this.funnel = funnel; + } else { + this.funnel = new FunnelModel(funnel); + } + + this.activeStepId = this.funnel.steps[0]?.id || ""; + } + + // Fields. 👇 + addField(dto: FunnelFieldDefinitionModelDto) { + const newField = new FunnelFieldDefinitionModel(dto); + this.funnel.fields.push(newField); + this.emitChange(); + } + + removeField(id: string) { + this.funnel.fields = this.funnel.fields.filter(field => field.id !== id); + this.emitChange(); + } + + updateField(fieldId: string, fieldData: Partial) { + const field = this.funnel.fields.find(field => field.id === fieldId); + if (field) { + field.populate(fieldData); + this.emitChange(); + } + } + + getFields() { + return this.funnel.fields; + } + + getFieldById(id: string) { + return this.funnel.fields.find(field => field.id === id); + } + + getFieldByFieldId(fieldId: string) { + return this.funnel.fields.find(field => field.fieldId === fieldId); + } + + getFieldsForActiveStep() { + const step = this.funnel.steps.find(step => step.id === this.activeStepId); + if (!step) { + return []; + } + return this.funnel.fields.filter(field => field.stepId === step.id); + } + + // Steps. 👇 + addStep(dto: FunnelStepModelDto) { + const newStep = new FunnelStepModel(dto); + this.funnel.steps.push(newStep); + this.emitChange(); + } + + removeStep(stepId: string) { + this.funnel.removeStep(stepId); + this.emitChange(); + } + + updateStep(stepId: string, stepData: Partial) { + this.funnel.updateStep(stepId, stepData); + this.emitChange(); + } + + getSteps() { + return this.funnel.steps; + } + + getActiveStepId() { + return this.activeStepId; + } + + getActiveStep() { + return this.funnel.steps.find(step => step.id === this.activeStepId); + } + + getActiveStepIndex() { + return this.funnel.steps.findIndex(step => step.id === this.activeStepId); + } + + getPreviousStepIndex() { + const currentIndex = this.getActiveStepIndex(); + return currentIndex > 0 ? currentIndex - 1 : -1; + } + + getNextStepIndex() { + const currentIndex = this.getActiveStepIndex(); + return currentIndex < this.funnel.steps.length - 1 ? currentIndex + 1 : -1; + } + + getAvailableStepIndex() { + const currentIndex = this.getActiveStepIndex(); + return currentIndex < this.funnel.steps.length - 1 ? currentIndex + 1 : -1; + } + + activateStepIndex(index: number) { + const step = this.funnel.steps[index]; + if (!step) { + return; + } + + this.activeStepId = step.id; + this.emitChange(); + } + + activateStep(stepId: string) { + const step = this.funnel.steps.find(step => step.id === stepId); + if (!step) { + console.log("neeeee"); + return; + } + + console.log("ideeee"); + + this.activeStepId = step.id; + this.emitChange(); + } + + activateFirstAvailableStep() { + const nextStepIndex = this.getAvailableStepIndex(); + if (nextStepIndex !== -1) { + this.activateStepIndex(nextStepIndex); + } + } + + // Other methods. 👇 + populateFunnel(funnel: Partial) { + this.funnel.populate(funnel); + this.emitChange(); + } + + subscribe(listener: Listener) { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + getChecksum() { + return [this.funnel.getChecksum(), this.getActiveStepId()].join(); + } + + private emitChange() { + for (const listener of this.listeners) { + listener(this.funnel.toDto()); + } + } +} diff --git a/extensions/funnelBuilder/src/shared/constants.ts b/extensions/funnelBuilder/src/shared/constants.ts new file mode 100644 index 00000000000..10c8f1cc50a --- /dev/null +++ b/extensions/funnelBuilder/src/shared/constants.ts @@ -0,0 +1,37 @@ +export const FUB_ELEMENT_TYPE_PREFIX = "fub-"; +export const FUB_FIELD_ELEMENT_TYPE_PREFIX = FUB_ELEMENT_TYPE_PREFIX + "field-"; + +export const createElementType = (type: string) => { + return `${FUB_ELEMENT_TYPE_PREFIX}${type}`; +}; + +export const createFieldElementType = (type: string) => { + return `${FUB_FIELD_ELEMENT_TYPE_PREFIX}${type}`; +}; + +export const isFieldElementType = (type?: string) => { + return type && type.startsWith(FUB_FIELD_ELEMENT_TYPE_PREFIX); +}; + +export const isContainerElementType = (type?: string) => { + return type === CONTAINER_ELEMENT_TYPE; +}; + +export const isStepElementType = (type?: string) => { + return type === STEP_ELEMENT_TYPE; +}; + +export const isSuccessStepElementType = (elementId?: string) => { + return elementId === SUCCESS_STEP_ELEMENT_ID; +}; + +export const isButtonElementType = (type?: string) => { + return type === BUTTON_ELEMENT_TYPE; +}; + +export const CONTAINER_ELEMENT_TYPE = createElementType("container"); +export const CONTAINER_ELEMENT_ID = createElementType("container"); +export const STEP_ELEMENT_TYPE = createElementType("step"); +export const SUCCESS_STEP_ELEMENT_ID = "success"; + +export const BUTTON_ELEMENT_TYPE = createElementType("button"); diff --git a/extensions/funnelBuilder/src/shared/createContainerElement.ts b/extensions/funnelBuilder/src/shared/createContainerElement.ts new file mode 100644 index 00000000000..640091f3f97 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/createContainerElement.ts @@ -0,0 +1,32 @@ +import { CONTAINER_ELEMENT_TYPE } from "./constants"; +import { createStepElement } from "./createStepElement"; +import { createSuccessStepElement } from "./createSuccessStepElement"; +import { getRandomId } from "./getRandomId"; +import { FunnelStepModelDto } from "./models/FunnelStepModel"; + +export const createContainerElement = () => { + const initialStepData: FunnelStepModelDto = { + id: getRandomId(), + title: "New page" + }; + + const successStepData: FunnelStepModelDto = { + id: "success", + title: "Success page" + }; + + return { + id: CONTAINER_ELEMENT_TYPE, + type: CONTAINER_ELEMENT_TYPE, + + // We are immediately creating a grid element inside our new page element. + // This was users can start adding content to the grid right away. + elements: [createStepElement(initialStepData.id), createSuccessStepElement()], + data: { + settings: {}, + fields: [], + steps: [initialStepData, successStepData], + conditionRules: [] + } + }; +}; diff --git a/extensions/funnelBuilder/src/shared/createObjectHash.ts b/extensions/funnelBuilder/src/shared/createObjectHash.ts new file mode 100644 index 00000000000..b6f46b59e28 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/createObjectHash.ts @@ -0,0 +1,10 @@ +import objectHash from "object-hash"; + +export const createObjectHash = (obj: any): string => { + return objectHash(obj, { + unorderedArrays: true, + unorderedSets: true, + unorderedObjects: true, + respectFunctionProperties: false + }); +}; diff --git a/extensions/funnelBuilder/src/shared/createStepElement.ts b/extensions/funnelBuilder/src/shared/createStepElement.ts new file mode 100644 index 00000000000..4bc5b12fdc2 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/createStepElement.ts @@ -0,0 +1,86 @@ +import { getRandomId } from "./getRandomId"; +import { createElementType } from "./constants"; + +export const createStepElement = (stepId?: string) => { + return { + id: getRandomId(), + type: createElementType("step"), + parent: undefined, + data: { + settings: {}, + stepId: stepId || getRandomId() + }, + elements: [ + { + id: getRandomId(), + type: "grid", + parent: undefined, + data: { + isFunnelStepGrid: true, + settings: { + width: { + desktop: { + value: "1100px" + } + }, + margin: { + desktop: { + top: "0px", + right: "0px", + bottom: "0px", + left: "0px", + advanced: true + } + }, + padding: { + desktop: { + all: "10px" + } + }, + grid: { + cellsType: "12" + }, + gridSettings: { + desktop: { + flexDirection: "row" + }, + "mobile-landscape": { + flexDirection: "column" + } + }, + horizontalAlignFlex: { desktop: "flex-start" }, + verticalAlign: { desktop: "flex-start" } + } + }, + elements: [ + { + id: getRandomId(), + type: "cell", + parent: undefined, + data: { + settings: { + margin: { + desktop: { + top: "0px", + right: "0px", + bottom: "0px", + left: "0px", + advanced: true + } + }, + padding: { + desktop: { + all: "0px" + } + }, + grid: { size: 12 }, + horizontalAlignFlex: { desktop: "flex-start" } + } + }, + elements: [] + } + ] + } + ] + }; +}; diff --git a/extensions/funnelBuilder/src/shared/createSuccessStepElement.ts b/extensions/funnelBuilder/src/shared/createSuccessStepElement.ts new file mode 100644 index 00000000000..1ce896f005c --- /dev/null +++ b/extensions/funnelBuilder/src/shared/createSuccessStepElement.ts @@ -0,0 +1,183 @@ +import { getRandomId } from "./getRandomId"; +import { createElementType } from "./constants"; + +export const createSuccessStepElement = () => { + return { + id: getRandomId(), + type: createElementType("step"), + parent: undefined, + data: { + settings: {}, + stepId: "success" + }, + elements: [ + { + id: getRandomId(), + type: "grid", + parent: undefined, + data: { + isFunnelStepGrid: true, + settings: { + width: { + desktop: { + value: "1100px" + } + }, + margin: { + desktop: { + top: "0px", + right: "0px", + bottom: "0px", + left: "0px", + advanced: true + } + }, + padding: { + desktop: { + all: "30px" + } + }, + grid: { + cellsType: "12" + }, + gridSettings: { + desktop: { + flexDirection: "row" + }, + "mobile-landscape": { + flexDirection: "column" + } + }, + horizontalAlignFlex: { + desktop: "flex-start" + }, + verticalAlign: { + desktop: "flex-start" + } + } + }, + elements: [ + { + id: getRandomId(), + type: "cell", + parent: undefined, + data: { + settings: { + margin: { + desktop: { + top: "0px", + right: "0px", + bottom: "0px", + left: "0px", + advanced: true + } + }, + padding: { + desktop: { + all: "0px" + } + }, + grid: { + size: 12 + }, + horizontalAlignFlex: { + desktop: "flex-start" + } + } + }, + elements: [ + { + id: getRandomId(), + type: "icon", + parent: undefined, + data: { + icon: { + icon: { + type: "icon", + name: "fa6_solid_check", + value: '', + category: "Interfaces", + width: 448, + color: "#7ed321" + }, + markup: '', + width: 50 + }, + settings: { + horizontalAlign: "center", + margin: { + desktop: { + all: "0px" + } + }, + padding: { + desktop: { + all: "0px" + } + } + } + }, + elements: [] + }, + { + id: getRandomId(), + type: "heading", + parent: undefined, + data: { + text: { + desktop: { + type: "heading", + alignment: "left", + tag: "h1" + }, + data: { + text: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Success!","type":"text","version":1}],"direction":"ltr","format":"center","indent":0,"type":"heading-element","version":1,"tag":"h1","styles":[{"styleId":"heading1","type":"typography"}]}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}' + } + }, + settings: { + margin: { + desktop: { + all: "0px" + } + }, + padding: { + desktop: { + all: "0px" + } + } + } + }, + elements: [] + }, + { + id: getRandomId(), + type: "paragraph", + parent: undefined, + data: { + text: { + data: { + text: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Thank you for your submission!","type":"text","version":1}],"direction":"ltr","format":"center","indent":0,"type":"paragraph-element","version":1,"textFormat":0,"styles":[{"styleId":"paragraph1","type":"typography"}]}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}' + } + }, + settings: { + margin: { + desktop: { + all: "0px" + } + }, + padding: { + desktop: { + all: "0px" + } + } + } + }, + elements: [] + } + ] + } + ] + } + ] + }; +}; diff --git a/extensions/funnelBuilder/src/shared/getRandomId.ts b/extensions/funnelBuilder/src/shared/getRandomId.ts new file mode 100644 index 00000000000..f2b87edd7da --- /dev/null +++ b/extensions/funnelBuilder/src/shared/getRandomId.ts @@ -0,0 +1,3 @@ +export const getRandomId = () => { + return Math.random().toString(36).substr(2, 7); +}; diff --git a/extensions/funnelBuilder/src/shared/models/AbstractModel.ts b/extensions/funnelBuilder/src/shared/models/AbstractModel.ts new file mode 100644 index 00000000000..1a709c99715 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/AbstractModel.ts @@ -0,0 +1,21 @@ +import { createObjectHash } from "../createObjectHash"; + +export abstract class AbstractModel { + abstract toDto(): TDto; + + populate(dto: Partial) { + for (const dtoKey in dto) { + if (dtoKey in this) { + const value = dto[dtoKey]; + if (value !== undefined) { + // @ts-ignore We can ignore this TS error. + this[dtoKey] = value; + } + } + } + } + + getChecksum(): string { + return createObjectHash(this.toDto()); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/FunnelConditionActionModel.ts b/extensions/funnelBuilder/src/shared/models/FunnelConditionActionModel.ts new file mode 100644 index 00000000000..dc3e9fb9cfb --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/FunnelConditionActionModel.ts @@ -0,0 +1,62 @@ +import { AbstractModel } from "./AbstractModel"; +import { getRandomId } from "../getRandomId"; +import { FunnelConditionRuleModel } from "./FunnelConditionRuleModel"; +import { FunnelStepModel } from "./FunnelStepModel"; + +export type ConditionActionParams> = { + extra: TExtra; +}; + +export type ConditionActionParamsDto> = ConditionActionParams; + +export interface FunnelConditionActionModelDto> { + id: string; + type: string; + params: ConditionActionParamsDto; // Additional parameters for the validator. +} + +export class FunnelConditionActionModel> extends AbstractModel< + FunnelConditionActionModelDto +> { + conditionRule: FunnelConditionRuleModel; + id: string; + type: string; + params: ConditionActionParams; + + static type = ""; + + // String shown in the conditional rules dialog (in the actions dropdown menu). + static optionLabel = ""; + + constructor( + conditionRule: FunnelConditionRuleModel, + dto?: Partial> + ) { + super(); + this.conditionRule = conditionRule; + this.id = dto?.id || getRandomId(); + this.type = dto?.type || ""; + this.params = { + extra: (dto?.params?.extra || {}) as TExtra + }; + } + + toDto(): FunnelConditionActionModelDto { + return { id: this.id, type: this.type, params: this.params }; + } + + isApplicable(): FunnelStepModel | undefined { + return undefined; + } + + static fromDto>( + conditionRule: FunnelConditionRuleModel, + dto: FunnelConditionActionModelDto + ): FunnelConditionActionModel { + // Could not import the module directly because of circular dependency. + return require("./conditionActions/conditionActionFactory").conditionActionFromDto( + conditionRule, + dto + ); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/FunnelConditionGroupModel.ts b/extensions/funnelBuilder/src/shared/models/FunnelConditionGroupModel.ts new file mode 100644 index 00000000000..f3934d85e75 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/FunnelConditionGroupModel.ts @@ -0,0 +1,42 @@ +import { FunnelConditionModel, FunnelConditionModelDto } from "./FunnelConditionModel"; +import { getRandomId } from "../getRandomId"; + +export type LogicalOperator = "and" | "or"; +export type ConditionGroupItem = FunnelConditionModel | FunnelConditionGroupModel; + +export interface FunnelConditionGroupModelDto { + id: string; + operator: LogicalOperator; + items: Array; +} + +export class FunnelConditionGroupModel { + id: string; + operator: LogicalOperator; + items: Array; + + constructor(dto?: FunnelConditionGroupModelDto) { + this.id = dto?.id || getRandomId(); + this.operator = dto?.operator ?? "and"; + this.items = (dto?.items || []).map(item => { + if ("sourceFieldId" in item) { + return FunnelConditionModel.fromDto(item); + } + return FunnelConditionGroupModel.fromDto(item); + }); + } + + toDto(): FunnelConditionGroupModelDto { + return { + id: this.id, + operator: this.operator, + items: this.items.map(item => { + return item.toDto(); + }) + }; + } + + static fromDto(dto: FunnelConditionGroupModelDto): FunnelConditionGroupModel { + return new FunnelConditionGroupModel(dto); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/FunnelConditionModel.ts b/extensions/funnelBuilder/src/shared/models/FunnelConditionModel.ts new file mode 100644 index 00000000000..9c03d27e9ef --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/FunnelConditionModel.ts @@ -0,0 +1,39 @@ +import { + FunnelConditionOperatorModel, + FunnelConditionOperatorModelDto +} from "./FunnelConditionOperatorModel"; +import { AbstractModel } from "./AbstractModel"; +import { getRandomId } from "../getRandomId"; + +export interface FunnelConditionModelDto { + id: string; + sourceFieldId: string; // the field whose value we're checking + operator: FunnelConditionOperatorModelDto; // the operator to use for comparison +} + +export class FunnelConditionModel extends AbstractModel { + id: string; + sourceFieldId: string; + operator: FunnelConditionOperatorModel; + + constructor(dto?: Partial) { + super(); + this.id = dto?.id || getRandomId(); + this.sourceFieldId = dto?.sourceFieldId || ""; + this.operator = dto?.operator + ? FunnelConditionOperatorModel.fromDto(dto.operator) + : new FunnelConditionOperatorModel(); + } + + toDto(): FunnelConditionModelDto { + return { + id: this.id, + sourceFieldId: this.sourceFieldId, + operator: this.operator.toDto() + }; + } + + static fromDto(dto: FunnelConditionModelDto): FunnelConditionModel { + return new FunnelConditionModel(dto); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/FunnelConditionOperatorModel.ts b/extensions/funnelBuilder/src/shared/models/FunnelConditionOperatorModel.ts new file mode 100644 index 00000000000..c092c46b29b --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/FunnelConditionOperatorModel.ts @@ -0,0 +1,54 @@ +import { AbstractModel } from "./AbstractModel"; +import { FunnelFieldValueModel } from "./FunnelFieldValueModel"; + +export type ConditionOperatorParams> = { + extra: TExtra; +}; + +export type ConditionOperatorParamsDto> = + ConditionOperatorParams; + +export interface FunnelConditionOperatorModelDto> { + type: string; + params: ConditionOperatorParamsDto; // Additional parameters for the validator. +} + +export class FunnelConditionOperatorModel< + TValue = FunnelFieldValueModel, + TExtra = Record +> extends AbstractModel> { + type: string; + params: ConditionOperatorParams; + + static type = ""; + + // String shown in the conditional rules dialog (in the operators dropdown menu). + static optionLabel = ""; + static supportedFieldValueTypes: string[] = []; + + constructor(dto?: Partial>) { + super(); + this.type = dto?.type || ""; + this.params = { + extra: (dto?.params?.extra || {}) as TExtra + }; + } + + // eslint-disable-next-line + evaluate(value: TValue) { + return true; + } + + toDto(): FunnelConditionOperatorModelDto { + return { type: this.type, params: this.params }; + } + + static fromDto>( + dto: FunnelConditionOperatorModelDto + ): FunnelConditionOperatorModel { + // Could not import the module directly because of circular dependency. + return require("./conditionOperators/conditionOperatorFactory").conditionOperatorFromDto( + dto + ); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/FunnelConditionRuleModel.ts b/extensions/funnelBuilder/src/shared/models/FunnelConditionRuleModel.ts new file mode 100644 index 00000000000..86ac9295c1d --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/FunnelConditionRuleModel.ts @@ -0,0 +1,46 @@ +import { + FunnelConditionGroupModel, + FunnelConditionGroupModelDto +} from "./FunnelConditionGroupModel"; +import { + FunnelConditionActionModel, + FunnelConditionActionModelDto +} from "./FunnelConditionActionModel"; +import { getRandomId } from "../getRandomId"; +import { FunnelModel } from "./FunnelModel"; + +export interface FunnelConditionRuleModelDto { + id: string; + // Root condition group. + conditionGroup: FunnelConditionGroupModelDto; + actions: FunnelConditionActionModelDto[]; +} + +export class FunnelConditionRuleModel { + funnel: FunnelModel; + id: string; + // Root condition group. + conditionGroup: FunnelConditionGroupModel; + actions: FunnelConditionActionModel[]; + + constructor(funnel: FunnelModel, dto?: FunnelConditionRuleModelDto) { + this.funnel = funnel; + this.id = dto?.id || getRandomId(); + this.conditionGroup = new FunnelConditionGroupModel(dto?.conditionGroup); + this.actions = (dto?.actions || []).map(action => { + return FunnelConditionActionModel.fromDto(this, action); + }); + } + + toDto(): FunnelConditionRuleModelDto { + return { + id: this.id, + conditionGroup: this.conditionGroup.toDto(), + actions: this.actions.map(action => action.toDto()) + }; + } + + static fromDto(funnel: FunnelModel, dto: FunnelConditionRuleModelDto) { + return new FunnelConditionRuleModel(funnel, dto); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/FunnelConditionRulesEvaluator.ts b/extensions/funnelBuilder/src/shared/models/FunnelConditionRulesEvaluator.ts new file mode 100644 index 00000000000..72a7cf137f0 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/FunnelConditionRulesEvaluator.ts @@ -0,0 +1,68 @@ +import { FunnelSubmissionModel } from "./FunnelSubmissionModel"; +import { FunnelConditionModel } from "./FunnelConditionModel"; +import { ConditionGroupItem, FunnelConditionGroupModel } from "./FunnelConditionGroupModel"; +import { FunnelConditionActionModel } from "./FunnelConditionActionModel"; + +export class FunnelConditionRulesEvaluator { + funnelSubmission: FunnelSubmissionModel; + + constructor(funnelSubmission: FunnelSubmissionModel) { + this.funnelSubmission = funnelSubmission; + } + + evaluateRelatedConditionRules(): FunnelConditionActionModel[] { + const actions: FunnelConditionActionModel[] = []; + + // Evaluate each condition rule. + for (const rule of this.funnelSubmission.funnel.conditionRules) { + // Check if any of the rule's actions target fields in the active step + const ruleHasRelevantActions = rule.actions.some(action => action.isApplicable()); + if (!ruleHasRelevantActions) { + continue; + } + + const ruleConditionsSatisfied = this.evaluateConditionGroup(rule.conditionGroup); + if (ruleConditionsSatisfied) { + actions.push(...rule.actions); + } + } + + return actions; + } + + private evaluateConditionGroup(group: FunnelConditionGroupModel): boolean { + // If there are no items, return true (empty condition is always satisfied) + if (group.items.length === 0) { + return true; + } + + // Evaluate each item in the group + const results = group.items.map(item => this.evaluateConditionGroupItem(item)); + + // Apply the logical operator + if (group.operator === "and") { + return results.every(result => result); + } else { + return results.some(result => result); + } + } + + private evaluateConditionGroupItem(item: ConditionGroupItem): boolean { + // If the item is a condition group, evaluate it recursively + if (item instanceof FunnelConditionGroupModel) { + return this.evaluateConditionGroup(item); + } + // Otherwise, it's a condition, so evaluate it + return this.evaluateCondition(item); + } + + private evaluateCondition(condition: FunnelConditionModel): boolean { + // Get the field from the submission + const field = this.funnelSubmission.getFieldById(condition.sourceFieldId); + if (!field) { + return false; + } + + return condition.operator.evaluate(field.value); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/FunnelFieldDefinitionModel.ts b/extensions/funnelBuilder/src/shared/models/FunnelFieldDefinitionModel.ts new file mode 100644 index 00000000000..783b6faaad3 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/FunnelFieldDefinitionModel.ts @@ -0,0 +1,99 @@ +import { AbstractValidator, FieldValidatorDto } from "./validators/AbstractValidator"; +import { validatorFromDto } from "./validators/validatorFactory"; +import { createObjectHash } from "../createObjectHash"; +import { FunnelFieldValueModel, FunnelFieldValueModelDto } from "./FunnelFieldValueModel"; +import { getRandomId } from "../getRandomId"; + +export interface FunnelFieldDefinitionModelDto> { + id?: string; + fieldId?: string; + stepId: string; + type: string; + label?: string; + helpText?: string; + validators?: FieldValidatorDto[]; + + // Defines the value type the field carries. Optionally, it allows + // for default value assignment (for example, for checkboxes). + value?: FunnelFieldValueModelDto; + extra?: TExtra; +} + +export class FunnelFieldDefinitionModel< + TValue = unknown, + TExtra extends Record = Record +> { + static type = ""; + + id: string; + type: string; + stepId: string; + fieldId: string; + label: string; + helpText: string; + validators: AbstractValidator[]; + + // Defines the value type the field carries. Optionally, it allows + // for default value assignment (for example, for checkboxes). + value: FunnelFieldValueModel; + + extra: TExtra; + + // Meta fields. + supportedValidatorTypes: string[] = []; + + constructor(dto: FunnelFieldDefinitionModelDto) { + this.id = dto.id || getRandomId(); + this.fieldId = dto.fieldId || getRandomId(); + this.stepId = dto.stepId; + this.type = dto.type; + this.label = dto.label || ""; + this.helpText = dto.helpText || ""; + this.validators = dto.validators?.map(validatorFromDto) ?? []; + this.value = FunnelFieldValueModel.fromDto( + dto.value as FunnelFieldValueModelDto + ); + this.extra = (dto.extra ?? {}) as TExtra; + } + + toDto(): FunnelFieldDefinitionModelDto { + return { + id: this.id, + fieldId: this.fieldId, + stepId: this.stepId, + type: this.type, + label: this.label, + helpText: this.helpText, + validators: this.validators.map(v => v.toDto()), + value: this.value.toDto(), + extra: this.extra + }; + } + + populate(dto: Partial>) { + this.fieldId = dto.fieldId || this.fieldId; + this.stepId = dto.stepId || this.stepId; + this.type = dto.type || this.type; + this.label = dto.label || this.label; + this.helpText = dto.helpText || this.helpText; + this.validators = dto.validators?.map(validatorFromDto) ?? this.validators; + + if (dto.value) { + this.value = FunnelFieldValueModel.fromDto(dto.value); + } + this.extra = (dto.extra ?? this.extra) as TExtra; + } + + getChecksum(): string { + return createObjectHash(this.toDto()); + } + + clone() { + return FunnelFieldDefinitionModel.fromDto(this.toDto()); + } + + static fromDto(dto: FunnelFieldDefinitionModelDto): FunnelFieldDefinitionModel { + // Could not import the module directly because of circular dependency. + return require("./fields/fieldFactory").fieldFromDto(dto); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/FunnelFieldValueModel.ts b/extensions/funnelBuilder/src/shared/models/FunnelFieldValueModel.ts new file mode 100644 index 00000000000..537a18f8f8a --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/FunnelFieldValueModel.ts @@ -0,0 +1,63 @@ +import { AbstractModel } from "./AbstractModel"; + +export interface FunnelFieldValueModelDto { + type: string; + array: boolean; + value: TValue; +} + +export class FunnelFieldValueModel< + TValue = unknown +> extends AbstractModel { + static type = ""; + + type: string; + array: boolean; + value: TValue; + + constructor(dto?: Partial>) { + super(); + this.type = dto?.type || ""; + this.array = dto?.array || false; + if (typeof dto?.value !== "undefined") { + this.value = dto.value; + } else { + this.value = this.getDefaultValue() as TValue; + } + } + + hasValue() { + if (this.array) { + return Array.isArray(this.value) && this.value.length > 0; + } + + return !!this.value; + } + + isEmpty() { + return !this.hasValue(); + } + + toDto(): FunnelFieldValueModelDto { + return { + type: this.type, + value: this.value, + array: this.array + }; + } + + clone() { + return FunnelFieldValueModel.fromDto(this.toDto()); + } + + getDefaultValue() { + return this.array ? [] : null; + } + + static fromDto( + dto: FunnelFieldValueModelDto + ): FunnelFieldValueModel { + // Could not import the module directly because of circular dependency. + return require("./fieldValues/fieldValueFactory").fieldValueFromDto(dto); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/FunnelModel.ts b/extensions/funnelBuilder/src/shared/models/FunnelModel.ts new file mode 100644 index 00000000000..d886862f2a6 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/FunnelModel.ts @@ -0,0 +1,79 @@ +import { FunnelStepModel, FunnelStepModelDto } from "./FunnelStepModel"; +import { + FunnelFieldDefinitionModel, + FunnelFieldDefinitionModelDto +} from "./FunnelFieldDefinitionModel"; +import { FunnelConditionRuleModel, FunnelConditionRuleModelDto } from "./FunnelConditionRuleModel"; +import { SuccessStep } from "./steps/SuccessStep"; + +export interface FunnelModelDto { + steps: FunnelStepModelDto[]; + fields: FunnelFieldDefinitionModelDto[]; + conditionRules: FunnelConditionRuleModelDto[]; +} + +export class FunnelModel { + fields: FunnelFieldDefinitionModel[] = []; + steps: FunnelStepModel[] = []; + conditionRules: FunnelConditionRuleModel[]; + + constructor(dto?: Partial) { + this.fields = dto?.fields?.map(f => FunnelFieldDefinitionModel.fromDto(f)) || []; + this.steps = dto?.steps?.map(s => FunnelStepModel.fromDto(s)) || [ + new FunnelStepModel(), + new SuccessStep() + ]; + if (!this.steps.find(step => step.id === "success")) { + this.steps.push(new SuccessStep()); + } + + this.conditionRules = + dto?.conditionRules?.map(dto => FunnelConditionRuleModel.fromDto(this, dto)) || []; + } + + // Steps. 👇 + updateStep(stepId: string, stepDto: Partial) { + const step = this.steps.find(s => s.id === stepId); + if (!step) { + return; + } + step.populate(stepDto); + } + + removeStep(id: string) { + this.steps = this.steps.filter(step => step.id !== id); + } + + getStep(id: string) { + return this.steps.find(step => step.id === id); + } + + // Other methods. 👇 + populate(funnelDto: Partial) { + if (funnelDto.fields) { + this.fields = funnelDto.fields.map(f => FunnelFieldDefinitionModel.fromDto(f)); + } + if (funnelDto.steps) { + this.steps = funnelDto.steps.map(s => FunnelStepModel.fromDto(s)); + } + } + + getChecksum() { + return this.steps + .map(step => step.getChecksum()) + .concat(this.fields.map(field => field.getChecksum())) + .join(""); + } + + toDto(): FunnelModelDto { + return { + steps: this.steps.map(s => s.toDto()), + fields: this.fields.map(f => f.toDto()), + conditionRules: this.conditionRules.map(rule => rule.toDto()) + }; + } + + static fromDto(dto: FunnelModelDto): FunnelModel { + return new FunnelModel(dto); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/FunnelStepModel.ts b/extensions/funnelBuilder/src/shared/models/FunnelStepModel.ts new file mode 100644 index 00000000000..b14cb82c3ec --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/FunnelStepModel.ts @@ -0,0 +1,29 @@ +import { getRandomId } from "../getRandomId"; +import { AbstractModel } from "./AbstractModel"; + +export interface FunnelStepModelDto { + id: string; + title: string; +} + +export class FunnelStepModel extends AbstractModel { + id: string; + title: string; + + constructor(dto?: FunnelStepModelDto) { + super(); + this.id = dto?.id ?? getRandomId(); + this.title = dto?.title ?? "Step"; + } + + toDto(): FunnelStepModelDto { + return { + id: this.id, + title: this.title + }; + } + + static fromDto(dto: FunnelStepModelDto): FunnelStepModel { + return new FunnelStepModel(dto); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/FunnelSubmissionFieldModel.ts b/extensions/funnelBuilder/src/shared/models/FunnelSubmissionFieldModel.ts new file mode 100644 index 00000000000..49fef22e574 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/FunnelSubmissionFieldModel.ts @@ -0,0 +1,129 @@ +import { FunnelFieldDefinitionModel } from "./FunnelFieldDefinitionModel"; +import { FunnelSubmissionModel } from "./FunnelSubmissionModel"; +import { FunnelFieldValueModel } from "./FunnelFieldValueModel"; +import { DisableFieldConditionAction } from "./conditionActions/DisableFieldConditionAction"; +import { HideFieldConditionAction } from "./conditionActions/HideFieldConditionAction"; + +export interface FunnelSubmissionFieldModelDto< + TFieldValue extends FunnelFieldValueModel = FunnelFieldValueModel +> { + value: TFieldValue; +} + +export type FunnelSubmissionFieldValidationResult = + | { + isValid: true; + errorMessage: null; + } + | { + isValid: false; + errorMessage: string; + }; + +export class FunnelSubmissionFieldModel< + TFunnelFieldDefinitionModel extends FunnelFieldDefinitionModel = FunnelFieldDefinitionModel +> { + submission: FunnelSubmissionModel; + definition: TFunnelFieldDefinitionModel; + value: TFunnelFieldDefinitionModel["value"]; + + constructor( + funnelSubmission: FunnelSubmissionModel, + funnelFieldDefinition: TFunnelFieldDefinitionModel, + funnelSubmissionFieldDto?: FunnelSubmissionFieldModelDto< + TFunnelFieldDefinitionModel["value"] + > + ) { + this.submission = funnelSubmission; + this.definition = funnelFieldDefinition; + if (funnelSubmissionFieldDto?.value) { + this.value = FunnelFieldValueModel.fromDto(funnelSubmissionFieldDto?.value); + } else { + this.value = this.definition.value.clone(); + } + } + + toDto(): FunnelSubmissionFieldModelDto { + return { + value: this.value + }; + } + + static fromDto< + TFunnelFieldDefinitionModel extends FunnelFieldDefinitionModel = FunnelFieldDefinitionModel + >( + funnelSubmission: FunnelSubmissionModel, + funnelFieldDefinition: FunnelFieldDefinitionModel, + dto: FunnelSubmissionFieldModelDto + ): FunnelSubmissionFieldModel { + return new FunnelSubmissionFieldModel(funnelSubmission, funnelFieldDefinition, dto); + } + + getValue() { + return this.value; + } + + getRawValue() { + return this.value.value; + } + + setValue(value: TFunnelFieldDefinitionModel["value"]) { + this.value.value = value; + } + + get disabled() { + // Get the actions from the evaluator + const actions = this.submission.getApplicableActions(); + + // Check if any action is to disable this field + return actions.some( + action => + action.type === DisableFieldConditionAction.type && + action.params.extra.targetFieldId === this.definition.id + ); + } + + get hidden() { + // Get the actions from the evaluator + const actions = this.submission.getApplicableActions(); + + // Check if any action is to disable this field + return actions.some( + action => + action.type === HideFieldConditionAction.type && + action.params.extra.targetFieldId === this.definition.id + ); + } + + async validate(): Promise { + if (this.hidden || this.disabled) { + return { + isValid: true, + errorMessage: null + }; + } + + const validators = this.definition.validators; + + for (const validator of validators) { + if (!(await validator.isValid(this.value))) { + return { + isValid: false, + errorMessage: validator.getErrorMessage() + }; + } + } + + return { + isValid: true, + errorMessage: null + }; + } + + async ensureValid() { + const result = await this.validate(); + if (!result.isValid) { + throw new Error(result.errorMessage); + } + } +} diff --git a/extensions/funnelBuilder/src/shared/models/FunnelSubmissionModel.ts b/extensions/funnelBuilder/src/shared/models/FunnelSubmissionModel.ts new file mode 100644 index 00000000000..7453f27ea69 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/FunnelSubmissionModel.ts @@ -0,0 +1,376 @@ +import { FunnelModel } from "./FunnelModel"; +import { + FunnelSubmissionFieldModel, + FunnelSubmissionFieldModelDto +} from "./FunnelSubmissionFieldModel"; +import { createObjectHash } from "../createObjectHash"; +import { FunnelConditionRulesEvaluator } from "./FunnelConditionRulesEvaluator"; +import { FunnelConditionActionModel } from "./FunnelConditionActionModel"; +import { OnSubmitEndFunnelConditionAction } from "./conditionActions/OnSubmitEndFunnelConditionAction"; +import { FunnelStepModel } from "./FunnelStepModel"; +import { OnSubmitActivateStepConditionAction } from "./conditionActions/OnSubmitActivateStepConditionAction"; + +export interface FunnelSubmissionModelDto { + fields: Record; + activeStepId: string; +} + +export interface FunnelEntryValidationResult { + isValid: boolean; + errors: Record; +} + +export type FunnelSubmissionData = Record; + +export type OnFinishedListener = (data: FunnelSubmissionData) => void | Promise; + +export type FunnelSubmissionStepSubmissionResult = + | { + success: true; + message: string; + errors: null; + data: Record; + } + | { + success: false; + message: string; + errors: Record; + data: Record; + }; + +export class FunnelSubmissionModel { + funnel: FunnelModel; + fields: Record; + activeStepId: string; + private onFinishListeners: Set = new Set(); + private conditionRules: { + evaluator: FunnelConditionRulesEvaluator; + applicableActions: FunnelConditionActionModel[]; + }; + + constructor(funnel: FunnelModel, funnelSubmissionDto?: FunnelSubmissionModelDto) { + this.funnel = funnel; + this.activeStepId = funnelSubmissionDto?.activeStepId || funnel.steps[0].id; + this.fields = funnel.fields.reduce((acc, field) => { + acc[field.fieldId] = new FunnelSubmissionFieldModel( + this, + field, + funnelSubmissionDto?.fields[field.fieldId] + ); + return acc; + }, {} as Record); + + this.conditionRules = { + evaluator: new FunnelConditionRulesEvaluator(this), + applicableActions: [] + }; + } + + toDto(): FunnelSubmissionModelDto { + return { + activeStepId: this.activeStepId, + fields: Object.values(this.fields).reduce((acc, field) => { + acc[field.definition.fieldId] = field.toDto(); + return acc; + }, {} as Record) + }; + } + + static fromDto(funnel: FunnelModel, dto: FunnelSubmissionModelDto): FunnelSubmissionModel { + return new FunnelSubmissionModel(funnel, dto); + } + + setData(data: Record) { + Object.keys(data).forEach(key => { + if (this.fields[key]) { + this.fields[key].value.value = data[key]; + } + }); + + this.evaluateRelatedConditionRules(); + } + + start() { + this.evaluateRelatedConditionRules(); + } + + onFinish(listener: OnFinishedListener) { + this.onFinishListeners.add(listener); + return () => { + this.onFinishListeners.delete(listener); + }; + } + + finish() { + for (const listener of this.onFinishListeners) { + listener(this.getData()); + } + } + + getData() { + return Object.values(this.fields).reduce((acc, field) => { + acc[field.definition.fieldId] = field.getRawValue(); + return acc; + }, {} as Record); + } + + getDataForActiveStep() { + const activeStepFields = this.getFieldsForActiveStep(); + return activeStepFields.reduce((acc, field) => { + acc[field.definition.fieldId] = field.getRawValue(); + return acc; + }, {} as Record); + } + + async validate() { + const validationResult: FunnelEntryValidationResult = { + isValid: true, + errors: {} + }; + + for (const field of Object.values(this.fields)) { + const fieldValidation = await field.validate(); + if (!fieldValidation.isValid) { + validationResult.isValid = false; + validationResult.errors[field.definition.fieldId] = fieldValidation.errorMessage; + } + } + + return validationResult; + } + + async submitActiveStep(): Promise { + if (this.isSuccessStep()) { + return { + message: "Cannot submit success step.", + success: false, + errors: {}, + data: {} + }; + } + const validationResult = await this.validateActiveStep(); + const data = this.getDataForActiveStep(); + + if (!validationResult.isValid) { + return { + message: "Field validation failed.", + success: false, + errors: validationResult.errors, + data + }; + } + + // Before activating the next step, we need to evaluate the condition rules. + this.evaluateRelatedConditionRules(); + + const successResult = { + message: "", + success: true, + errors: null, + data + } as FunnelSubmissionStepSubmissionResult; + + const success = () => { + if (this.isSuccessStep()) { + this.finish(); + } + + return successResult; + }; + + const activeActions = this.conditionRules.applicableActions; + if (!activeActions.length) { + this.activateNextStep(); + return success(); + } + + // Check if there's an action that requires us to end the funnel. + const mustEndFunnel = activeActions.some( + a => a.type === OnSubmitEndFunnelConditionAction.type + ); + + if (mustEndFunnel) { + this.activateSuccessStep(); + return success(); + } + + // Check if there's an action that requires us to activate a specific step. + // We can only activate one step at a time. + const [activateSpecificStepAction] = activeActions.filter( + a => a.type === OnSubmitActivateStepConditionAction.type + ); + + if (activateSpecificStepAction) { + const step = this.funnel.getStep(activateSpecificStepAction.params.extra.targetStepId); + if (step) { + this.activateStep(step); + return success(); + } + } + + this.activateNextStep(); + + return success(); + } + + async validateActiveStep() { + const activeStepFields = this.getFieldsForActiveStep(); + const validationResult: FunnelEntryValidationResult = { + isValid: true, + errors: {} + }; + + for (const field of activeStepFields) { + const fieldValidation = await field.validate(); + if (!fieldValidation.isValid) { + validationResult.isValid = false; + validationResult.errors[field.definition.fieldId] = fieldValidation.errorMessage; + } + } + + return validationResult; + } + + // Fields-related methods. 👇 + getField(fieldId: string) { + return this.fields[fieldId]; + } + + getFieldById(id: string) { + return Object.values(this.fields).find(field => field.definition.id === id); + } + + fieldExists(fieldId: string) { + return !!this.fields[fieldId]; + } + + getFieldsForActiveStep() { + const activeStep = this.funnel.steps.find(step => step.id === this.activeStepId); + if (!activeStep) { + return []; + } + + return Object.values(this.fields).filter( + field => field.definition.stepId === activeStep.id + ); + } + + // Steps-related methods. 👇 + getActiveStepIndex() { + return this.funnel.steps.findIndex(step => step.id === this.activeStepId); + } + + getActiveStep() { + return this.funnel.steps.find(step => step.id === this.activeStepId)!; + } + + getNextStepIndex() { + const activeIndex = this.getActiveStepIndex(); + if (activeIndex < this.funnel.steps.length - 1) { + return activeIndex + 1; + } + return activeIndex; + } + + getNextStep() { + const nextIndex = this.getNextStepIndex(); + return this.funnel.steps[nextIndex]; + } + + getPreviousStepIndex() { + const activeIndex = this.getActiveStepIndex(); + if (activeIndex > 0) { + return activeIndex - 1; + } + return null; + } + + getPreviousStep() { + const previousIndex = this.getPreviousStepIndex(); + if (previousIndex !== null) { + return this.funnel.steps[previousIndex]; + } + return null; + } + + getFinalStepIndex() { + return this.funnel.steps.length - 2; + } + + getFinalStep() { + const index = this.getFinalStepIndex(); + return this.funnel.steps[index]; + } + + getSuccessStepIndex() { + return this.funnel.steps.length - 1; + } + + getSuccessStep() { + const index = this.getSuccessStepIndex(); + return this.funnel.steps[index]; + } + + getStepsCount() { + return this.funnel.steps.length; + } + + isFirstStep() { + return this.getActiveStepIndex() === 0; + } + + /** + * Check if the current step is the last step before the success step. + */ + isFinalStep() { + return this.getActiveStepIndex() === this.funnel.steps.length - 2; + } + + /** + * Check if the current step is the success step. + */ + isSuccessStep() { + return this.getActiveStepIndex() === this.funnel.steps.length - 1; + } + + activateStep(step: FunnelStepModel) { + this.activeStepId = step.id; + } + + activateNextStep() { + if (this.isSuccessStep()) { + return; + } + + this.activateStep(this.getNextStep()); + } + + activateSuccessStep() { + this.activateStep(this.getSuccessStep()); + } + + activatePreviousStep() { + const activeIndex = this.getActiveStepIndex(); + if (activeIndex > 0) { + this.activeStepId = this.funnel.steps[activeIndex - 1].id; + } + } + + // Other methods. 👇 + getChecksum() { + return createObjectHash({ + applicableActions: this.conditionRules.applicableActions, + dto: this.toDto() + }); + } + + evaluateRelatedConditionRules() { + this.conditionRules.applicableActions = + this.conditionRules.evaluator.evaluateRelatedConditionRules(); + return this.conditionRules.applicableActions; + } + + getApplicableActions() { + return this.conditionRules.applicableActions; + } +} diff --git a/extensions/funnelBuilder/src/shared/models/__tests__/fieldValues.test.ts b/extensions/funnelBuilder/src/shared/models/__tests__/fieldValues.test.ts new file mode 100644 index 00000000000..989732074d2 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/__tests__/fieldValues.test.ts @@ -0,0 +1,17 @@ +import { fieldValueFromDto } from "../fieldValues/fieldValueFactory"; +import { StringArrayFieldValue } from "../fieldValues/StringArrayFieldValue"; + +describe("Value From DTO", () => { + test("ensure field values are correctly instantiated with no params", async () => { + const validator = fieldValueFromDto({ + type: "stringArray", + array: true, + value: [] + }); + + expect(validator).toBeInstanceOf(StringArrayFieldValue); + expect(validator.type).toBe("stringArray"); + expect(validator.value).toBeArrayOfSize(0); + expect(validator.array).toBe(true); + }); +}); diff --git a/extensions/funnelBuilder/src/shared/models/__tests__/funnelConditionRules.test.ts b/extensions/funnelBuilder/src/shared/models/__tests__/funnelConditionRules.test.ts new file mode 100644 index 00000000000..bb4778533a2 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/__tests__/funnelConditionRules.test.ts @@ -0,0 +1,225 @@ +import { FunnelModel } from "../FunnelModel"; +import { FunnelSubmissionModel } from "../FunnelSubmissionModel"; +import { createFirstNameFieldDto } from "./mocks/createFirstNameFieldDto"; +import { createLastNameFieldDto } from "./mocks/createLastNameFieldDto"; +import { createEmailFieldDto } from "./mocks/createEmailFieldDto"; +import { createColorsFieldDto } from "./mocks/createColorsFieldDto"; +import { OnSubmitEndFunnelConditionAction } from "../conditionActions/OnSubmitEndFunnelConditionAction"; +import { OnSubmitActivateStepConditionAction } from "../conditionActions/OnSubmitActivateStepConditionAction"; +import { DisableFieldConditionAction } from "../conditionActions/DisableFieldConditionAction"; + +describe("Condition Rules", () => { + test("neq Operator", async () => { + const funnel = new FunnelModel({ + steps: [ + { id: "step1", title: "Step 1" }, + { id: "step2", title: "Step 2" } + ], + fields: [ + createFirstNameFieldDto({ stepId: "step1" }), + createLastNameFieldDto({ stepId: "step2" }) + ], + conditionRules: [ + { + id: "rule1", + conditionGroup: { + id: "cg1", + operator: "and", + items: [ + { + id: "cg1-c1", + sourceFieldId: "firstName", + operator: { + type: "neq", + params: { extra: { value: "correct-first-name" } } + } + } + ] + }, + actions: [ + { + id: "cg1-a1", + type: DisableFieldConditionAction.type, + params: { + extra: { + targetFieldId: "lastName" + } + } + } + ] + } + ] + }); + + const submission1 = new FunnelSubmissionModel(funnel); + + submission1.start(); + + expect(submission1.getApplicableActions().length).toBe(1); + expect(submission1.getField("lastName").disabled).toBe(true); + + submission1.setData({ firstName: "correct-first-name" }); + expect(submission1.getApplicableActions().length).toBe(0); + expect(submission1.getField("lastName").disabled).toBe(false); + }); + + test("onSubmitEndFunnel", async () => { + const funnel = new FunnelModel({ + steps: [ + { id: "step1", title: "Step 1" }, + { id: "step2", title: "Step 2" }, + { id: "step3", title: "Step 3" }, + { id: "step4", title: "Step 4" } + ], + fields: [ + createFirstNameFieldDto({ stepId: "step1" }), + createLastNameFieldDto({ stepId: "step2" }), + createEmailFieldDto({ stepId: "step3" }), + createColorsFieldDto({ stepId: "step4" }) + ], + conditionRules: [ + { + id: "rule1", + conditionGroup: { + id: "cg1", + operator: "and", + items: [ + { + id: "cg1-c1", + sourceFieldId: "lastName", + operator: { type: "eq", params: { extra: { value: "magic" } } } + } + ] + }, + actions: [ + { + id: "cg1-a1", + type: OnSubmitEndFunnelConditionAction.type, + params: { + extra: { + evaluationStep: "step2" + } + } + } + ] + } + ] + }); + + let submission1Finished = false; + const submission1 = new FunnelSubmissionModel(funnel); + submission1.onFinish(() => { + submission1Finished = true; + }); + + submission1.setData({ firstName: "first-name" }); + await submission1.submitActiveStep(); + + expect(submission1.getActiveStep().id).toBe("step2"); + + submission1.setData({ lastName: "magic" }); + await submission1.submitActiveStep(); + + expect(submission1.isSuccessStep()).toBe(true); + expect(submission1Finished).toBe(true); + + let submission2Finished = false; + const submission2 = new FunnelSubmissionModel(funnel); + submission2.onFinish(() => { + submission2Finished = true; + }); + + submission2.setData({ firstName: "first-name" }); + await submission2.submitActiveStep(); + + expect(submission2.getActiveStep().id).toBe("step2"); + + submission2.setData({ lastName: "last-name" }); + await submission2.submitActiveStep(); + + expect(submission2.getActiveStep().id).toBe("step3"); + expect(submission2.isSuccessStep()).toBe(false); + expect(submission2Finished).toBe(false); + }); + + test("onSubmitActivateStep", async () => { + const funnel = new FunnelModel({ + steps: [ + { id: "step1", title: "Step 1" }, + { id: "step2", title: "Step 2" }, + { id: "step3", title: "Step 3" }, + { id: "step4", title: "Step 4" } + ], + fields: [ + createFirstNameFieldDto({ stepId: "step1" }), + createLastNameFieldDto({ stepId: "step2" }), + createEmailFieldDto({ stepId: "step3" }), + createColorsFieldDto({ stepId: "step4" }) + ], + conditionRules: [ + { + id: "rule1", + conditionGroup: { + id: "cg1", + operator: "and", + items: [ + { + id: "cg1-c1", + sourceFieldId: "lastName", + operator: { type: "eq", params: { extra: { value: "magic" } } } + } + ] + }, + actions: [ + { + id: "cg1-a1", + type: OnSubmitActivateStepConditionAction.type, + params: { + extra: { + evaluationStep: "step2", + targetStepId: "step4" + } + } + } + ] + } + ] + }); + + let submission1Finished = false; + const submission1 = new FunnelSubmissionModel(funnel); + submission1.onFinish(() => { + submission1Finished = true; + }); + + submission1.setData({ firstName: "first-name" }); + await submission1.submitActiveStep(); + + expect(submission1.getActiveStep().id).toBe("step2"); + + submission1.setData({ lastName: "magic" }); + await submission1.submitActiveStep(); + + expect(submission1.getActiveStep().id).toBe("step4"); + expect(submission1.isSuccessStep()).toBe(false); + expect(submission1Finished).toBe(false); + + let submission2Finished = false; + const submission2 = new FunnelSubmissionModel(funnel); + submission2.onFinish(() => { + submission2Finished = true; + }); + + submission2.setData({ firstName: "first-name" }); + await submission2.submitActiveStep(); + + expect(submission2.getActiveStep().id).toBe("step2"); + + submission2.setData({ lastName: "last-name" }); + await submission2.submitActiveStep(); + + expect(submission2.getActiveStep().id).toBe("step3"); + expect(submission2.isSuccessStep()).toBe(false); + expect(submission2Finished).toBe(false); + }); +}); diff --git a/extensions/funnelBuilder/src/shared/models/__tests__/funnelSteps.test.ts b/extensions/funnelBuilder/src/shared/models/__tests__/funnelSteps.test.ts new file mode 100644 index 00000000000..0890b57d70b --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/__tests__/funnelSteps.test.ts @@ -0,0 +1,15 @@ +import { FunnelModel } from "../FunnelModel"; + +describe("Funnel Steps", () => { + test("should always have success step", async () => { + const funnel = new FunnelModel({ + steps: [ + { id: "step1", title: "Step 1" }, + { id: "step2", title: "Step 2" } + ] + }); + + expect(funnel.steps.length).toBe(3); + expect(funnel.getStep("success")).toBeTruthy(); + }); +}); diff --git a/extensions/funnelBuilder/src/shared/models/__tests__/funnelSubmissions.test.ts b/extensions/funnelBuilder/src/shared/models/__tests__/funnelSubmissions.test.ts new file mode 100644 index 00000000000..fac0e602fd2 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/__tests__/funnelSubmissions.test.ts @@ -0,0 +1,352 @@ +import { FunnelModel } from "../FunnelModel"; +import { FunnelSubmissionModel } from "../FunnelSubmissionModel"; + +describe("Funnel Submissions", () => { + test("e2e test", async () => { + const funnel = new FunnelModel({ + steps: [ + { id: "step1", title: "Step 1" }, + { id: "step2", title: "Step 2" } + ], + fields: [ + { + id: "firstName", + fieldId: "firstName", + stepId: "step1", + type: "text", + label: "First Name", + helpText: "Enter your first name", + validators: [ + { + type: "required", + params: { extra: {}, errorMessage: "Value is required." } + }, + { + type: "minLength", + params: { + extra: { threshold: 2 }, + errorMessage: "This field must be at least 2 characters long." + } + } + ], + value: { + value: "", + type: "string", + array: false + }, + extra: { + placeholderText: "This needs to be a long text.", + rows: "10" + } + }, + { + id: "lastName", + fieldId: "lastName", + stepId: "step1", + type: "text", + label: "Last Name", + helpText: "Enter your last name", + validators: [ + { type: "required", params: {} }, + { + type: "minLength", + params: { + extra: { threshold: 2 }, + errorMessage: "This field must be at least 2 characters long." + } + } + ], + value: { + value: "", + type: "string", + array: false + }, + extra: {} + }, + { + id: "email", + fieldId: "email", + stepId: "step2", + type: "text", + label: "Email", + helpText: "Enter your email address", + validators: [ + { type: "required", params: { extra: {} } }, + { + type: "pattern", + params: { + errorMessage: "Value must be a valid email address.", + extra: { + preset: "email" + } + } + } + ], + value: { + value: "", + type: "string", + array: false + }, + extra: {} + }, + { + id: "location", + fieldId: "location", + stepId: "step2", + type: "text", + label: "Location", + helpText: "Location", + validators: [ + { + type: "required", + params: { extra: {}, errorMessage: "Value is required." } + }, + { + type: "minLength", + params: { + extra: { threshold: 2 }, + errorMessage: "This field must be at least 2 characters long." + } + } + ], + value: { + value: "Earth", + type: "string", + array: false + }, + extra: {} + }, + { + id: "colors", + fieldId: "colors", + stepId: "step2", + type: "checkboxGroup", + label: "Colors", + helpText: "Colors", + validators: [ + { + type: "required", + params: { extra: {}, errorMessage: "Please choose at least one color." } + } + ], + value: { + value: [], + array: true, + type: "stringArray" + }, + extra: { + options: [ + { value: "red", label: "Red" }, + { value: "green", label: "Green" }, + { value: "blue", label: "Blue" } + ] + } + } + ], + conditionRules: [ + { + id: "rule1", + conditionGroup: { + id: "conditionGroup1", + operator: "and", + items: [ + { + id: "condition1", + sourceFieldId: "firstName", + operator: { + type: "eq", + params: { extra: { value: "weird-value" } } + } + } + ] + }, + actions: [ + { + id: "action1", + type: "disableField", + params: { + extra: { + targetFieldId: "lastName" + } + } + } + ] + } + ] + }); + + const funnelSubmission = new FunnelSubmissionModel(funnel); + + let funnelFinished = false; + funnelSubmission.onFinish(() => { + funnelFinished = true; + }); + + funnelSubmission.start(); + + funnelSubmission.setData({ + firstName: "", + lastName: "D" + }); + + let submissionResult = await funnelSubmission.submitActiveStep(); + + expect(submissionResult).toEqual({ + message: "Field validation failed.", + data: { firstName: "", lastName: "D" }, + errors: { + firstName: "Value is required.", + lastName: "This field must be at least 2 characters long." + }, + success: false + }); + + let validationResult = await funnelSubmission.validateActiveStep(); + expect(validationResult).toEqual({ + isValid: false, + errors: { + firstName: "Value is required.", + lastName: "This field must be at least 2 characters long." + } + }); + + // Pass a short value for `firstName` field. + funnelSubmission.setData({ + firstName: "weird-value", + lastName: "D" + }); + + expect(funnelSubmission.getField("lastName").disabled).toBe(true); + + // Pass a short value for `firstName` field. + funnelSubmission.setData({ + firstName: "J", + lastName: "D" + }); + + expect(funnelSubmission.getField("lastName").disabled).toBe(false); + + submissionResult = await funnelSubmission.submitActiveStep(); + + expect(submissionResult).toEqual({ + data: { firstName: "J", lastName: "D" }, + message: "Field validation failed.", + errors: { + firstName: "This field must be at least 2 characters long.", + lastName: "This field must be at least 2 characters long." + }, + success: false + }); + + validationResult = await funnelSubmission.validateActiveStep(); + expect(validationResult).toEqual({ + isValid: false, + errors: { + lastName: "This field must be at least 2 characters long.", + firstName: "This field must be at least 2 characters long." + } + }); + + expect(funnelSubmission.getField("lastName").disabled).toBe(false); + + // Pass valid values for both `firstName` and `lastName`. + funnelSubmission.setData({ firstName: "John", lastName: "Doe" }); + + submissionResult = await funnelSubmission.submitActiveStep(); + + expect(submissionResult).toEqual({ + message: "", + data: { firstName: "John", lastName: "Doe" }, + errors: null, + success: true + }); + + funnelSubmission.submitActiveStep(); + + expect(funnelSubmission.getActiveStepIndex()).toEqual(1); + + submissionResult = await funnelSubmission.submitActiveStep(); + + expect(submissionResult).toEqual({ + message: "Field validation failed.", + data: { + colors: [], + email: "", + location: "Earth" + }, + errors: { colors: "Please choose at least one color.", email: "Value is required." }, + success: false + }); + + validationResult = await funnelSubmission.validateActiveStep(); + + expect(validationResult).toEqual({ + isValid: false, + errors: { colors: "Please choose at least one color.", email: "Value is required." } + }); + + funnelSubmission.setData({ email: "john@example", colors: ["red"] }); + + validationResult = await funnelSubmission.validateActiveStep(); + expect(validationResult).toEqual({ + errors: { email: "Value must be a valid email address." }, + isValid: false + }); + + funnelSubmission.setData({ + email: "john@example.com" + }); + + validationResult = await funnelSubmission.validateActiveStep(); + expect(validationResult).toEqual({ + isValid: true, + errors: {} + }); + + await funnelSubmission.submitActiveStep(); + + expect(funnelFinished).toBe(true); + + const dto = funnelSubmission.toDto(); + expect(dto).toEqual({ + activeStepId: "success", + fields: { + colors: { + value: { + array: true, + type: "stringArray", + value: ["red"] + } + }, + email: { + value: { + array: false, + type: "string", + value: "john@example.com" + } + }, + firstName: { + value: { + array: false, + type: "string", + value: "John" + } + }, + lastName: { + value: { + array: false, + type: "string", + value: "Doe" + } + }, + location: { + value: { + array: false, + type: "string", + value: "Earth" + } + } + } + }); + }); +}); diff --git a/extensions/funnelBuilder/src/shared/models/__tests__/mocks/createColorsFieldDto.ts b/extensions/funnelBuilder/src/shared/models/__tests__/mocks/createColorsFieldDto.ts new file mode 100644 index 00000000000..d8ee2e9e260 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/__tests__/mocks/createColorsFieldDto.ts @@ -0,0 +1,22 @@ +import { CheckboxGroupField, CheckboxGroupFieldDto } from "../../fields/CheckboxGroupField"; + +export const createColorsFieldDto = (dto: CheckboxGroupFieldDto) => { + return new CheckboxGroupField({ + id: "colors", + fieldId: "colors", + label: "Colors", + helpText: "Colors", + value: { + value: [], + type: "stringArray" + }, + extra: { + options: [ + { value: "red", label: "Red" }, + { value: "green", label: "Green" }, + { value: "blue", label: "Blue" } + ] + }, + ...dto + }).toDto(); +}; diff --git a/extensions/funnelBuilder/src/shared/models/__tests__/mocks/createEmailFieldDto.ts b/extensions/funnelBuilder/src/shared/models/__tests__/mocks/createEmailFieldDto.ts new file mode 100644 index 00000000000..30e8d95af55 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/__tests__/mocks/createEmailFieldDto.ts @@ -0,0 +1,15 @@ +import { TextField, TextFieldDto } from "../../fields/TextField"; + +export const createEmailFieldDto = (dto: TextFieldDto) => { + return new TextField({ + id: "email", + fieldId: "email", + label: "Email", + helpText: "Enter your email address", + value: { + value: "", + type: "string" + }, + ...dto + }).toDto(); +}; diff --git a/extensions/funnelBuilder/src/shared/models/__tests__/mocks/createFirstNameFieldDto.ts b/extensions/funnelBuilder/src/shared/models/__tests__/mocks/createFirstNameFieldDto.ts new file mode 100644 index 00000000000..48a771407d9 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/__tests__/mocks/createFirstNameFieldDto.ts @@ -0,0 +1,16 @@ +import { TextField, TextFieldDto } from "../../fields/TextField"; + +export const createFirstNameFieldDto = (dto: TextFieldDto) => { + return new TextField({ + id: "firstName", + fieldId: "firstName", + label: "First Name", + helpText: "Enter your first name", + validators: [], + value: { + value: "", + type: "string" + }, + ...dto + }).toDto(); +}; diff --git a/extensions/funnelBuilder/src/shared/models/__tests__/mocks/createLastNameFieldDto.ts b/extensions/funnelBuilder/src/shared/models/__tests__/mocks/createLastNameFieldDto.ts new file mode 100644 index 00000000000..a4456152f72 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/__tests__/mocks/createLastNameFieldDto.ts @@ -0,0 +1,16 @@ +import { TextField, TextFieldDto } from "../../fields/TextField"; + +export const createLastNameFieldDto = (dto: TextFieldDto) => { + return new TextField({ + id: "lastName", + fieldId: "lastName", + label: "Last Name", + helpText: "Enter your last name", + validators: [], + value: { + value: "", + type: "string" + }, + ...dto + }).toDto(); +}; diff --git a/extensions/funnelBuilder/src/shared/models/__tests__/validators.test.ts b/extensions/funnelBuilder/src/shared/models/__tests__/validators.test.ts new file mode 100644 index 00000000000..fb3129d53a4 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/__tests__/validators.test.ts @@ -0,0 +1,171 @@ +import { validatorFromDto } from "../validators/validatorFactory"; +import { MinLengthValidator } from "../validators/MinLengthValidator"; +import { RequiredValidator } from "../validators/RequiredValidator"; +import { FunnelModel } from "../FunnelModel"; +import { FunnelSubmissionModel } from "../FunnelSubmissionModel"; + +describe("Validator From DTO", () => { + test("ensure validator is correctly instantiated with no params", async () => { + const validator = validatorFromDto({ + type: "required", + params: {} + }); + + expect(validator).toBeInstanceOf(RequiredValidator); + expect(validator.type).toBe("required"); + expect(validator.params.errorMessage).toBe("Value is required."); + expect(validator.params.extra).toEqual({}); + }); + + test("ensure validator is correctly instantiated with provided params", async () => { + const validator = validatorFromDto({ + type: "minLength", + params: { + extra: { + threshold: 2 + }, + errorMessage: "This field must be at least 2 characters long." + } + }); + + expect(validator).toBeInstanceOf(MinLengthValidator); + expect(validator.type).toBe("minLength"); + expect(validator.params.errorMessage).toBe( + "This field must be at least 2 characters long." + ); + expect(validator.params.extra?.threshold).toBe(2); + }); + + test("pattern validator", async () => { + const funnel = new FunnelModel({ + steps: [{ id: "step1", title: "Step 1" }], + fields: [ + { + id: "ymd", + fieldId: "ymd", + stepId: "step1", + type: "text", + label: "Year/month/date", + helpText: "", + validators: [ + { + type: "pattern", + params: { + extra: { + preset: "custom", + regex: "^[0-9]{4}-[0-9]{2}-[0-9]{2}$", + flags: "" + }, + errorMessage: "Invalid YYYY-MM-DD." + } + } + ], + extra: {} + }, + { + id: "email", + fieldId: "email", + stepId: "step1", + type: "text", + label: "Email", + helpText: "Email", + validators: [ + { + type: "pattern", + params: { extra: { preset: "email" }, errorMessage: "Invalid email." } + } + ], + extra: {} + }, + { + id: "url", + fieldId: "url", + stepId: "step1", + type: "text", + label: "URL", + helpText: "URL", + validators: [ + { + type: "pattern", + params: { extra: { preset: "url" }, errorMessage: "Invalid URL." } + } + ], + extra: {} + }, + { + id: "lowerCaseValue", + fieldId: "lowerCaseValue", + stepId: "step1", + type: "text", + label: "Lower case value", + helpText: "Lower case value", + validators: [ + { + type: "pattern", + params: { + extra: { preset: "lowercase" }, + errorMessage: "Value must be all lower case." + } + } + ], + extra: {} + }, + { + id: "upperCaseValue", + fieldId: "upperCaseValue", + stepId: "step1", + type: "text", + label: "Upper case value", + helpText: "Upper case value", + validators: [ + { + type: "pattern", + params: { + extra: { preset: "uppercase" }, + errorMessage: "Value must be all upper case." + } + } + ], + extra: {} + } + ], + conditionRules: [] + }); + + const funnelSubmission = new FunnelSubmissionModel(funnel); + + funnelSubmission.setData({ + ymd: "yyyy-mm-dd", + email: "test", + url: "test", + lowerCaseValue: "TEST", + upperCaseValue: "test" + }); + + let validationResult = await funnelSubmission.validateActiveStep(); + expect(validationResult).toEqual({ + isValid: false, + errors: { + ymd: "Invalid YYYY-MM-DD.", + email: "Invalid email.", + url: "Invalid URL.", + lowerCaseValue: "Value must be all lower case.", + upperCaseValue: "Value must be all upper case." + } + }); + + funnelSubmission.setData({ + ymd: "2025-12-10", + email: "test@test.com", + url: "https://www.test.com", + lowerCaseValue: "test", + upperCaseValue: "TEST" + }); + + validationResult = await funnelSubmission.validateActiveStep(); + expect(validationResult).toEqual({ + isValid: true, + errors: {} + }); + }); +}); diff --git a/extensions/funnelBuilder/src/shared/models/conditionActions/DisableFieldConditionAction.ts b/extensions/funnelBuilder/src/shared/models/conditionActions/DisableFieldConditionAction.ts new file mode 100644 index 00000000000..6d892cb7d71 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/conditionActions/DisableFieldConditionAction.ts @@ -0,0 +1,42 @@ +import { + FunnelConditionActionModel, + FunnelConditionActionModelDto +} from "../FunnelConditionActionModel"; +import { FunnelConditionRuleModel } from "../FunnelConditionRuleModel"; + +interface DisableFieldConditionActionExtraParams { + targetFieldId: string; +} + +export class DisableFieldConditionAction extends FunnelConditionActionModel { + static override type = "disableField"; + static override optionLabel = "Disable field"; + + constructor( + conditionRule: FunnelConditionRuleModel, + dto?: FunnelConditionActionModelDto + ) { + super(conditionRule, { + type: "disableField", + params: { + extra: { + targetFieldId: dto?.params?.extra?.targetFieldId || "" + } + } + }); + } + + override isApplicable() { + const field = this.conditionRule.funnel.fields.find(f => { + return f.id === this.params.extra.targetFieldId; + }); + + if (!field) { + return; + } + + return this.conditionRule.funnel.steps.find(s => { + return s.id === field.stepId; + }); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/conditionActions/HideFieldConditionAction.ts b/extensions/funnelBuilder/src/shared/models/conditionActions/HideFieldConditionAction.ts new file mode 100644 index 00000000000..c8b4196ffd2 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/conditionActions/HideFieldConditionAction.ts @@ -0,0 +1,42 @@ +import { + FunnelConditionActionModel, + FunnelConditionActionModelDto +} from "../FunnelConditionActionModel"; +import { FunnelConditionRuleModel } from "../FunnelConditionRuleModel"; + +interface HideFieldConditionActionExtraParams { + targetFieldId: string; +} + +export class HideFieldConditionAction extends FunnelConditionActionModel { + static override type = "hideField"; + static override optionLabel = "Hide field"; + + constructor( + conditionRule: FunnelConditionRuleModel, + dto: FunnelConditionActionModelDto + ) { + super(conditionRule, { + type: "hideField", + params: { + extra: { + targetFieldId: dto.params?.extra?.targetFieldId || "" + } + } + }); + } + + override isApplicable() { + const field = this.conditionRule.funnel.fields.find(f => { + return f.id === this.params.extra.targetFieldId; + }); + + if (!field) { + return; + } + + return this.conditionRule.funnel.steps.find(s => { + return s.id === field.stepId; + }); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/conditionActions/OnSubmitActivateStepConditionAction.ts b/extensions/funnelBuilder/src/shared/models/conditionActions/OnSubmitActivateStepConditionAction.ts new file mode 100644 index 00000000000..0818abe77b1 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/conditionActions/OnSubmitActivateStepConditionAction.ts @@ -0,0 +1,35 @@ +import { + FunnelConditionActionModel, + FunnelConditionActionModelDto +} from "../FunnelConditionActionModel"; +import { FunnelConditionRuleModel } from "../FunnelConditionRuleModel"; + +interface OnSubmitActivateStepConditionActionExtraParams { + targetStepId: string; + evaluationStep: string; +} + +export class OnSubmitActivateStepConditionAction extends FunnelConditionActionModel { + static override type = "onSubmitActivateStep"; + static override optionLabel = "Go to step"; + + constructor( + conditionRule: FunnelConditionRuleModel, + dto: FunnelConditionActionModelDto + ) { + super(conditionRule, { + type: "onSubmitActivateStep", + params: { + extra: { + evaluationStep: dto.params?.extra?.evaluationStep || "", + targetStepId: dto.params?.extra?.targetStepId || "" + } + } + }); + } + + override isApplicable() { + const evaluationStep = this.params.extra.evaluationStep; + return this.conditionRule.funnel.steps.find(s => s.id === evaluationStep); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/conditionActions/OnSubmitEndFunnelConditionAction.ts b/extensions/funnelBuilder/src/shared/models/conditionActions/OnSubmitEndFunnelConditionAction.ts new file mode 100644 index 00000000000..23d0e309e1a --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/conditionActions/OnSubmitEndFunnelConditionAction.ts @@ -0,0 +1,33 @@ +import { + FunnelConditionActionModel, + FunnelConditionActionModelDto +} from "../FunnelConditionActionModel"; +import { FunnelConditionRuleModel } from "../FunnelConditionRuleModel"; + +interface OnSubmitEndFunnelConditionActionExtraParams { + evaluationStep: string; +} + +export class OnSubmitEndFunnelConditionAction extends FunnelConditionActionModel { + static override type = "onSubmitEndFunnel"; + static override optionLabel = "End funnel"; + + constructor( + conditionRule: FunnelConditionRuleModel, + dto: FunnelConditionActionModelDto + ) { + super(conditionRule, { + type: "onSubmitEndFunnel", + params: { + extra: { + evaluationStep: dto.params?.extra?.evaluationStep || "" + } + } + }); + } + + override isApplicable() { + const evaluationStep = this.params.extra.evaluationStep; + return this.conditionRule.funnel.steps.find(s => s.id === evaluationStep); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/conditionActions/conditionActionFactory.ts b/extensions/funnelBuilder/src/shared/models/conditionActions/conditionActionFactory.ts new file mode 100644 index 00000000000..98a53fc85fc --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/conditionActions/conditionActionFactory.ts @@ -0,0 +1,16 @@ +import { type FunnelConditionActionModelDto } from "../FunnelConditionActionModel"; +import { FunnelConditionRuleModel } from "../FunnelConditionRuleModel"; +import { registry } from "./registry"; + +export const listConditionActions = () => registry; + +export const conditionActionFromDto = ( + conditionRule: FunnelConditionRuleModel, + dto: FunnelConditionActionModelDto +) => { + const ActionClass = registry.find(actionClass => actionClass.type === dto.type); + if (!ActionClass) { + throw new Error(`Unknown condition action: ${dto.type}`); + } + return new ActionClass(conditionRule, dto); +}; diff --git a/extensions/funnelBuilder/src/shared/models/conditionActions/registry.ts b/extensions/funnelBuilder/src/shared/models/conditionActions/registry.ts new file mode 100644 index 00000000000..e152e00259c --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/conditionActions/registry.ts @@ -0,0 +1,11 @@ +import { DisableFieldConditionAction } from "./DisableFieldConditionAction"; +import { HideFieldConditionAction } from "./HideFieldConditionAction"; +import { OnSubmitActivateStepConditionAction } from "./OnSubmitActivateStepConditionAction"; +import { OnSubmitEndFunnelConditionAction } from "./OnSubmitEndFunnelConditionAction"; + +export const registry = [ + DisableFieldConditionAction, + HideFieldConditionAction, + OnSubmitActivateStepConditionAction, + OnSubmitEndFunnelConditionAction +]; diff --git a/extensions/funnelBuilder/src/shared/models/conditionOperators/EmptyConditionOperator.ts b/extensions/funnelBuilder/src/shared/models/conditionOperators/EmptyConditionOperator.ts new file mode 100644 index 00000000000..8d46ec95784 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/conditionOperators/EmptyConditionOperator.ts @@ -0,0 +1,21 @@ +import { FunnelConditionOperatorModel } from "../FunnelConditionOperatorModel"; +import { FunnelFieldValueModel } from "../FunnelFieldValueModel"; + +export class EmptyConditionOperator extends FunnelConditionOperatorModel { + static override supportedFieldValueTypes = ["*"]; + static override type = "empty"; + static override optionLabel = "is empty"; + + constructor() { + super({ + type: "empty", + params: { + extra: {} + } + }); + } + + override evaluate(value: FunnelFieldValueModel): boolean { + return value.isEmpty(); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/conditionOperators/EqConditionOperator.ts b/extensions/funnelBuilder/src/shared/models/conditionOperators/EqConditionOperator.ts new file mode 100644 index 00000000000..cc7b47ee17c --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/conditionOperators/EqConditionOperator.ts @@ -0,0 +1,35 @@ +import { + FunnelConditionOperatorModel, + FunnelConditionOperatorModelDto +} from "../FunnelConditionOperatorModel"; +import { FunnelFieldValueModel } from "../FunnelFieldValueModel"; + +type FieldValue = FunnelFieldValueModel; + +interface EqConditionOperatorExtraParams { + value?: string | number | boolean; +} + +export class EqConditionOperator extends FunnelConditionOperatorModel< + FieldValue, + EqConditionOperatorExtraParams +> { + static override supportedFieldValueTypes = ["string", "number", "boolean"]; + static override type = "eq"; + static override optionLabel = "equals"; + + constructor(dto: FunnelConditionOperatorModelDto) { + super({ + type: "eq", + params: { + extra: { + value: dto.params?.extra?.value + } + } + }); + } + + override evaluate(value: FieldValue): boolean { + return value.value === this.params.extra.value; + } +} diff --git a/extensions/funnelBuilder/src/shared/models/conditionOperators/GtConditionOperator.ts b/extensions/funnelBuilder/src/shared/models/conditionOperators/GtConditionOperator.ts new file mode 100644 index 00000000000..4af93100276 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/conditionOperators/GtConditionOperator.ts @@ -0,0 +1,38 @@ +import { + FunnelConditionOperatorModel, + FunnelConditionOperatorModelDto +} from "../FunnelConditionOperatorModel"; +import { FunnelFieldValueModel } from "../FunnelFieldValueModel"; + +type FieldValue = FunnelFieldValueModel; + +interface GtConditionOperatorExtraParams { + threshold?: number; +} + +export class GtConditionOperator extends FunnelConditionOperatorModel< + FieldValue, + GtConditionOperatorExtraParams +> { + static override supportedFieldValueTypes = ["number"]; + static override type = "gt"; + static override optionLabel = "greater than"; + constructor(dto: FunnelConditionOperatorModelDto) { + super({ + type: "gt", + params: { + extra: { + threshold: dto.params?.extra?.threshold + } + } + }); + } + + override evaluate(value: FieldValue): boolean { + if (!this.params.extra.threshold) { + return false; + } + + return value.hasValue() && value.value > this.params.extra.threshold; + } +} diff --git a/extensions/funnelBuilder/src/shared/models/conditionOperators/GteConditionOperator.ts b/extensions/funnelBuilder/src/shared/models/conditionOperators/GteConditionOperator.ts new file mode 100644 index 00000000000..2971f479ea0 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/conditionOperators/GteConditionOperator.ts @@ -0,0 +1,39 @@ +import { + FunnelConditionOperatorModel, + FunnelConditionOperatorModelDto +} from "../FunnelConditionOperatorModel"; +import { FunnelFieldValueModel } from "../FunnelFieldValueModel"; + +type FieldValue = FunnelFieldValueModel; + +interface GteConditionOperatorExtraParams { + threshold?: number; +} + +export class GteConditionOperator extends FunnelConditionOperatorModel< + FieldValue, + GteConditionOperatorExtraParams +> { + static override supportedFieldValueTypes = ["number"]; + static override type = "gte"; + static override optionLabel = "greater than or equal"; + + constructor(dto: FunnelConditionOperatorModelDto) { + super({ + type: "gte", + params: { + extra: { + threshold: dto.params?.extra?.threshold + } + } + }); + } + + override evaluate(value: FieldValue): boolean { + if (!this.params.extra.threshold) { + return false; + } + + return value.hasValue() && value.value >= this.params.extra.threshold; + } +} diff --git a/extensions/funnelBuilder/src/shared/models/conditionOperators/IncludesConditionOperator.ts b/extensions/funnelBuilder/src/shared/models/conditionOperators/IncludesConditionOperator.ts new file mode 100644 index 00000000000..1c68bfdb844 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/conditionOperators/IncludesConditionOperator.ts @@ -0,0 +1,53 @@ +import { + FunnelConditionOperatorModel, + FunnelConditionOperatorModelDto +} from "../FunnelConditionOperatorModel"; +import { FunnelFieldValueModel } from "../FunnelFieldValueModel"; + +type FieldValue = FunnelFieldValueModel; + +interface IncludesConditionOperatorExtraParams { + value?: string | number | boolean; +} + +export class IncludesConditionOperator extends FunnelConditionOperatorModel< + FieldValue, + IncludesConditionOperatorExtraParams +> { + static override supportedFieldValueTypes = [ + "string", + "stringArray", + "number", + "numberArray", + "booleanArray" + ]; + static override type = "includes"; + static override optionLabel = "includes"; + + constructor(dto: FunnelConditionOperatorModelDto) { + super({ + type: "includes", + params: { + extra: { + value: dto.params?.extra?.value + } + } + }); + } + + override evaluate(value: FieldValue): boolean { + if (!value.hasValue()) { + return false; + } + + if (!this.params.extra.value) { + return false; + } + + if (value.array) { + return Array.isArray(value.value) && value.value.includes(this.params.extra.value); + } + + return String(value.value).includes(String(this.params.extra.value)); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/conditionOperators/LtConditionOperator.ts b/extensions/funnelBuilder/src/shared/models/conditionOperators/LtConditionOperator.ts new file mode 100644 index 00000000000..f01e8824eac --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/conditionOperators/LtConditionOperator.ts @@ -0,0 +1,39 @@ +import { + FunnelConditionOperatorModel, + FunnelConditionOperatorModelDto +} from "../FunnelConditionOperatorModel"; +import { FunnelFieldValueModel } from "../FunnelFieldValueModel"; + +type FieldValue = FunnelFieldValueModel; + +interface LtConditionOperatorExtraParams { + threshold?: number; +} + +export class LtConditionOperator extends FunnelConditionOperatorModel< + FieldValue, + LtConditionOperatorExtraParams +> { + static override supportedFieldValueTypes = ["number"]; + static override type = "lt"; + static override optionLabel = "lower than"; + + constructor(dto: FunnelConditionOperatorModelDto) { + super({ + type: "lt", + params: { + extra: { + threshold: dto.params?.extra?.threshold + } + } + }); + } + + override evaluate(value: FieldValue): boolean { + if (!this.params.extra.threshold) { + return false; + } + + return value.hasValue() && value.value < this.params.extra.threshold; + } +} diff --git a/extensions/funnelBuilder/src/shared/models/conditionOperators/LteConditionOperator.ts b/extensions/funnelBuilder/src/shared/models/conditionOperators/LteConditionOperator.ts new file mode 100644 index 00000000000..85828118482 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/conditionOperators/LteConditionOperator.ts @@ -0,0 +1,39 @@ +import { + FunnelConditionOperatorModel, + FunnelConditionOperatorModelDto +} from "../FunnelConditionOperatorModel"; +import { FunnelFieldValueModel } from "../FunnelFieldValueModel"; + +type FieldValue = FunnelFieldValueModel; + +interface LteConditionOperatorExtraParams { + threshold?: number; +} + +export class LteConditionOperator extends FunnelConditionOperatorModel< + FieldValue, + LteConditionOperatorExtraParams +> { + static override supportedFieldValueTypes = ["number"]; + static override type = "lte"; + static override optionLabel = "lower than or equal"; + + constructor(dto: FunnelConditionOperatorModelDto) { + super({ + type: "lte", + params: { + extra: { + threshold: dto.params?.extra?.threshold + } + } + }); + } + + override evaluate(value: FieldValue): boolean { + if (!this.params.extra.threshold) { + return false; + } + + return value.hasValue() && value.value <= this.params.extra.threshold; + } +} diff --git a/extensions/funnelBuilder/src/shared/models/conditionOperators/NeqConditionOperator.ts b/extensions/funnelBuilder/src/shared/models/conditionOperators/NeqConditionOperator.ts new file mode 100644 index 00000000000..9e5cce083a1 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/conditionOperators/NeqConditionOperator.ts @@ -0,0 +1,35 @@ +import { + FunnelConditionOperatorModel, + FunnelConditionOperatorModelDto +} from "../FunnelConditionOperatorModel"; +import { FunnelFieldValueModel } from "../FunnelFieldValueModel"; + +type FieldValue = FunnelFieldValueModel; + +interface NeqConditionOperatorExtraParams { + value?: string | number | boolean; +} + +export class NeqConditionOperator extends FunnelConditionOperatorModel< + FieldValue, + NeqConditionOperatorExtraParams +> { + static override supportedFieldValueTypes = ["string", "number", "boolean"]; + static override type = "neq"; + static override optionLabel = "not equal"; + + constructor(dto: FunnelConditionOperatorModelDto) { + super({ + type: "neq", + params: { + extra: { + value: dto.params?.extra?.value + } + } + }); + } + + override evaluate(value: FieldValue): boolean { + return value.value !== this.params.extra.value; + } +} diff --git a/extensions/funnelBuilder/src/shared/models/conditionOperators/NotEmptyConditionOperator.ts b/extensions/funnelBuilder/src/shared/models/conditionOperators/NotEmptyConditionOperator.ts new file mode 100644 index 00000000000..34f74ff8a25 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/conditionOperators/NotEmptyConditionOperator.ts @@ -0,0 +1,21 @@ +import { FunnelConditionOperatorModel } from "../FunnelConditionOperatorModel"; +import { FunnelFieldValueModel } from "../FunnelFieldValueModel"; + +export class NotEmptyConditionOperator extends FunnelConditionOperatorModel { + static override supportedFieldValueTypes = ["*"]; + static override type = "notEmpty"; + static override optionLabel = "not empty"; + + constructor() { + super({ + type: "notEmpty", + params: { + extra: {} + } + }); + } + + override evaluate(value: FunnelFieldValueModel): boolean { + return value.hasValue(); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/conditionOperators/NotIncludesConditionOperator.ts b/extensions/funnelBuilder/src/shared/models/conditionOperators/NotIncludesConditionOperator.ts new file mode 100644 index 00000000000..ab6aec3251a --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/conditionOperators/NotIncludesConditionOperator.ts @@ -0,0 +1,47 @@ +import { + FunnelConditionOperatorModel, + FunnelConditionOperatorModelDto +} from "../FunnelConditionOperatorModel"; +import { FunnelFieldValueModel } from "../FunnelFieldValueModel"; + +type FieldValue = FunnelFieldValueModel; + +interface NotIncludesConditionOperatorExtraParams { + value?: string; +} + +export class NotIncludesConditionOperator extends FunnelConditionOperatorModel< + FieldValue, + NotIncludesConditionOperatorExtraParams +> { + static override supportedFieldValueTypes = ["string", "stringArray", "number", "numberArray"]; + static override type = "notIncludes"; + static override optionLabel = "not includes"; + + constructor(dto: FunnelConditionOperatorModelDto) { + super({ + type: "notIncludes", + params: { + extra: { + value: dto.params?.extra?.value + } + } + }); + } + + override evaluate(value: FieldValue): boolean { + if (!value.hasValue()) { + return true; + } + + if (!this.params.extra.value) { + return false; + } + + if (value.array) { + return !Array.isArray(value.value) || !value.value.includes(this.params.extra.value); + } + + return !String(value.value).includes(String(this.params.extra.value)); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/conditionOperators/conditionOperatorFactory.ts b/extensions/funnelBuilder/src/shared/models/conditionOperators/conditionOperatorFactory.ts new file mode 100644 index 00000000000..713679a6552 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/conditionOperators/conditionOperatorFactory.ts @@ -0,0 +1,19 @@ +import { type FunnelConditionOperatorModelDto } from "../FunnelConditionOperatorModel"; +import { registry } from "./registry"; + +export const getConditionOperatorsByValueType = (valueType: string) => { + return registry.filter(operatorClass => { + return ( + operatorClass.supportedFieldValueTypes.includes("*") || + operatorClass.supportedFieldValueTypes.includes(valueType) + ); + }); +}; + +export const conditionOperatorFromDto = (dto: FunnelConditionOperatorModelDto) => { + const OperatorClass = registry.find(operatorClass => operatorClass.type === dto.type); + if (!OperatorClass) { + throw new Error(`Unknown condition operator: ${dto.type}`); + } + return new OperatorClass(dto); +}; diff --git a/extensions/funnelBuilder/src/shared/models/conditionOperators/registry.ts b/extensions/funnelBuilder/src/shared/models/conditionOperators/registry.ts new file mode 100644 index 00000000000..e22398f17fe --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/conditionOperators/registry.ts @@ -0,0 +1,23 @@ +import { EmptyConditionOperator } from "./EmptyConditionOperator"; +import { EqConditionOperator } from "./EqConditionOperator"; +import { GtConditionOperator } from "./GtConditionOperator"; +import { GteConditionOperator } from "./GteConditionOperator"; +import { IncludesConditionOperator } from "./IncludesConditionOperator"; +import { LtConditionOperator } from "./LtConditionOperator"; +import { LteConditionOperator } from "./LteConditionOperator"; +import { NeqConditionOperator } from "./NeqConditionOperator"; +import { NotEmptyConditionOperator } from "./NotEmptyConditionOperator"; +import { NotIncludesConditionOperator } from "./NotIncludesConditionOperator"; + +export const registry = [ + EmptyConditionOperator, + EqConditionOperator, + GtConditionOperator, + GteConditionOperator, + IncludesConditionOperator, + LtConditionOperator, + LteConditionOperator, + NeqConditionOperator, + NotEmptyConditionOperator, + NotIncludesConditionOperator +]; diff --git a/extensions/funnelBuilder/src/shared/models/fieldValues/BooleanArrayFieldValue.ts b/extensions/funnelBuilder/src/shared/models/fieldValues/BooleanArrayFieldValue.ts new file mode 100644 index 00000000000..6bf717792da --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/fieldValues/BooleanArrayFieldValue.ts @@ -0,0 +1,8 @@ +import { FunnelFieldValueModel, FunnelFieldValueModelDto } from "../FunnelFieldValueModel"; + +export class BooleanArrayFieldValue extends FunnelFieldValueModel { + static override type = "booleanArray"; + constructor(dto: FunnelFieldValueModelDto) { + super({ ...dto, type: "booleanArray", array: true, value: dto.value }); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/fieldValues/BooleanFieldValue.ts b/extensions/funnelBuilder/src/shared/models/fieldValues/BooleanFieldValue.ts new file mode 100644 index 00000000000..94adac73947 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/fieldValues/BooleanFieldValue.ts @@ -0,0 +1,8 @@ +import { FunnelFieldValueModel, FunnelFieldValueModelDto } from "../FunnelFieldValueModel"; + +export class BooleanFieldValue extends FunnelFieldValueModel { + static override type = "boolean"; + constructor(dto: FunnelFieldValueModelDto) { + super({ ...dto, type: "boolean" }); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/fieldValues/NumberArrayFieldValue.ts b/extensions/funnelBuilder/src/shared/models/fieldValues/NumberArrayFieldValue.ts new file mode 100644 index 00000000000..a31f7556c64 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/fieldValues/NumberArrayFieldValue.ts @@ -0,0 +1,8 @@ +import { FunnelFieldValueModel, FunnelFieldValueModelDto } from "../FunnelFieldValueModel"; + +export class NumberArrayFieldValue extends FunnelFieldValueModel { + static override type = "numberArray"; + constructor(dto: FunnelFieldValueModelDto) { + super({ ...dto, type: "numberArray", array: true, value: dto.value }); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/fieldValues/NumberFieldValue.ts b/extensions/funnelBuilder/src/shared/models/fieldValues/NumberFieldValue.ts new file mode 100644 index 00000000000..215631d44a0 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/fieldValues/NumberFieldValue.ts @@ -0,0 +1,8 @@ +import { FunnelFieldValueModel, FunnelFieldValueModelDto } from "../FunnelFieldValueModel"; + +export class NumberFieldValue extends FunnelFieldValueModel { + static override type = "number"; + constructor(dto: FunnelFieldValueModelDto) { + super({ ...dto, type: "number" }); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/fieldValues/StringArrayFieldValue.ts b/extensions/funnelBuilder/src/shared/models/fieldValues/StringArrayFieldValue.ts new file mode 100644 index 00000000000..6e3c98179e2 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/fieldValues/StringArrayFieldValue.ts @@ -0,0 +1,8 @@ +import { FunnelFieldValueModel, FunnelFieldValueModelDto } from "../FunnelFieldValueModel"; + +export class StringArrayFieldValue extends FunnelFieldValueModel { + static override type = "stringArray"; + constructor(dto: FunnelFieldValueModelDto) { + super({ ...dto, type: "stringArray", array: true, value: dto.value }); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/fieldValues/StringFieldValue.ts b/extensions/funnelBuilder/src/shared/models/fieldValues/StringFieldValue.ts new file mode 100644 index 00000000000..3b6c3eddbb6 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/fieldValues/StringFieldValue.ts @@ -0,0 +1,8 @@ +import { FunnelFieldValueModel, FunnelFieldValueModelDto } from "../FunnelFieldValueModel"; + +export class StringFieldValue extends FunnelFieldValueModel { + static override type = "string"; + constructor(dto: FunnelFieldValueModelDto) { + super({ ...dto, type: "string" }); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/fieldValues/fieldValueFactory.ts b/extensions/funnelBuilder/src/shared/models/fieldValues/fieldValueFactory.ts new file mode 100644 index 00000000000..ef9a150e60d --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/fieldValues/fieldValueFactory.ts @@ -0,0 +1,10 @@ +import { type FunnelFieldValueModelDto } from "../FunnelFieldValueModel"; +import { registry } from "./registry"; + +export const fieldValueFromDto = (dto: FunnelFieldValueModelDto) => { + const FieldValueClass = registry.find(fieldValueClass => fieldValueClass.type === dto.type); + if (!FieldValueClass) { + throw new Error(`Unknown field value: ${dto.type}`); + } + return new FieldValueClass(dto); +}; diff --git a/extensions/funnelBuilder/src/shared/models/fieldValues/registry.ts b/extensions/funnelBuilder/src/shared/models/fieldValues/registry.ts new file mode 100644 index 00000000000..403ffba79a4 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/fieldValues/registry.ts @@ -0,0 +1,15 @@ +import { StringFieldValue } from "./StringFieldValue"; +import { NumberFieldValue } from "./NumberFieldValue"; +import { BooleanFieldValue } from "./BooleanFieldValue"; +import { StringArrayFieldValue } from "./StringArrayFieldValue"; +import { NumberArrayFieldValue } from "./NumberArrayFieldValue"; +import { BooleanArrayFieldValue } from "./BooleanArrayFieldValue"; + +export const registry = [ + StringFieldValue, + StringArrayFieldValue, + NumberFieldValue, + NumberArrayFieldValue, + BooleanFieldValue, + BooleanArrayFieldValue +]; diff --git a/extensions/funnelBuilder/src/shared/models/fields/CheckboxGroupField.ts b/extensions/funnelBuilder/src/shared/models/fields/CheckboxGroupField.ts new file mode 100644 index 00000000000..2f3acc3daaa --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/fields/CheckboxGroupField.ts @@ -0,0 +1,30 @@ +import { + FunnelFieldDefinitionModel, + FunnelFieldDefinitionModelDto +} from "../FunnelFieldDefinitionModel"; + +export type CheckboxGroupFieldDto = Omit< + FunnelFieldDefinitionModelDto, + "type" +>; + +export interface CheckboxGroupFieldExtra { + options: Array<{ id: string; value: string; label: string }>; +} + +export class CheckboxGroupField extends FunnelFieldDefinitionModel< + string[], + CheckboxGroupFieldExtra +> { + static override type = "checkboxGroup"; + override supportedValidatorTypes = ["required"]; + + constructor(dto: CheckboxGroupFieldDto) { + super({ + ...dto, + value: { type: "stringArray", array: true, value: dto?.value?.value || [] }, + type: "checkboxGroup", + extra: dto.extra || { options: [] } + }); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/fields/NumberField.ts b/extensions/funnelBuilder/src/shared/models/fields/NumberField.ts new file mode 100644 index 00000000000..d7dec4cda16 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/fields/NumberField.ts @@ -0,0 +1,24 @@ +import { + FunnelFieldDefinitionModel, + FunnelFieldDefinitionModelDto +} from "../FunnelFieldDefinitionModel"; + +export type NumberFieldDto = Omit, "type">; + +export interface NumberFieldExtra { + placeholderText: string; +} + +export class NumberField extends FunnelFieldDefinitionModel { + static override type = "number"; + override supportedValidatorTypes = ["required", "gte", "lte"]; + + constructor(dto: NumberFieldDto) { + super({ + ...dto, + value: { type: "number", array: false, value: dto?.value?.value || 0 }, + type: "number", + extra: dto.extra || { placeholderText: "" } + }); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/fields/TextField.ts b/extensions/funnelBuilder/src/shared/models/fields/TextField.ts new file mode 100644 index 00000000000..7b27d030e8b --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/fields/TextField.ts @@ -0,0 +1,24 @@ +import { + FunnelFieldDefinitionModel, + FunnelFieldDefinitionModelDto +} from "../FunnelFieldDefinitionModel"; + +export type TextFieldDto = Omit, "type">; + +export interface TextFieldExtra { + placeholderText: string; +} + +export class TextField extends FunnelFieldDefinitionModel { + static override type = "text"; + override supportedValidatorTypes = ["required", "minLength", "maxLength", "pattern"]; + + constructor(dto: TextFieldDto) { + super({ + ...dto, + value: { type: "string", array: false, value: dto?.value?.value || "" }, + type: "text", + extra: dto.extra || { placeholderText: "" } + }); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/fields/TextareaField.ts b/extensions/funnelBuilder/src/shared/models/fields/TextareaField.ts new file mode 100644 index 00000000000..afec9ede6db --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/fields/TextareaField.ts @@ -0,0 +1,28 @@ +import { + FunnelFieldDefinitionModel, + FunnelFieldDefinitionModelDto +} from "../FunnelFieldDefinitionModel"; + +export type TextareaFieldDto = Omit< + FunnelFieldDefinitionModelDto, + "type" +>; + +export interface TextareaFieldExtra { + placeholderText: string; + rows: number; +} + +export class TextareaField extends FunnelFieldDefinitionModel { + static override type = "textarea"; + override supportedValidatorTypes = ["required", "minLength", "maxLength", "pattern"]; + + constructor(dto: TextareaFieldDto) { + super({ + ...dto, + value: { type: "string", array: false, value: dto?.value?.value || "" }, + type: "textarea", + extra: dto.extra || { placeholderText: "", rows: 4 } + }); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/fields/fieldFactory.ts b/extensions/funnelBuilder/src/shared/models/fields/fieldFactory.ts new file mode 100644 index 00000000000..0e6ac708523 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/fields/fieldFactory.ts @@ -0,0 +1,12 @@ +import { FunnelFieldDefinitionModelDto } from "../FunnelFieldDefinitionModel"; +import { registry } from "./registry"; + +export const fieldFromDto = (dto: FunnelFieldDefinitionModelDto) => { + const FieldDefinitionClass = registry.find( + fieldDefinitionClass => fieldDefinitionClass.type === dto.type + ); + if (!FieldDefinitionClass) { + throw new Error(`Unknown field: ${dto.type}`); + } + return new FieldDefinitionClass(dto); +}; diff --git a/extensions/funnelBuilder/src/shared/models/fields/registry.ts b/extensions/funnelBuilder/src/shared/models/fields/registry.ts new file mode 100644 index 00000000000..a2661ebc962 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/fields/registry.ts @@ -0,0 +1,6 @@ +import { TextField } from "./TextField"; +import { TextareaField } from "./TextareaField"; +import { CheckboxGroupField } from "./CheckboxGroupField"; +import { NumberField } from "./NumberField"; + +export const registry = [CheckboxGroupField, NumberField, TextareaField, TextField]; diff --git a/extensions/funnelBuilder/src/shared/models/steps/SuccessStep.ts b/extensions/funnelBuilder/src/shared/models/steps/SuccessStep.ts new file mode 100644 index 00000000000..a10d1ddc272 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/steps/SuccessStep.ts @@ -0,0 +1,10 @@ +import { FunnelStepModel } from "../FunnelStepModel"; + +export class SuccessStep extends FunnelStepModel { + constructor() { + super({ + id: "success", + title: "Success page" + }); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/validators/AbstractValidator.ts b/extensions/funnelBuilder/src/shared/models/validators/AbstractValidator.ts new file mode 100644 index 00000000000..276b190bb10 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/validators/AbstractValidator.ts @@ -0,0 +1,47 @@ +import { FunnelFieldValueModel } from "../FunnelFieldValueModel"; + +export type FieldValidatorParams> = { + errorMessage: string; // Error message to be displayed when validation fails. + extra: TExtra; +}; + +export type FieldValidatorParamsDto> = Partial< + FieldValidatorParams +>; + +export interface FieldValidatorDto> { + type: string; + params: FieldValidatorParamsDto; // Additional parameters for the validator. +} + +export abstract class AbstractValidator> { + static type = ""; + + type: string; + params: FieldValidatorParams; + + constructor(dto: FieldValidatorDto) { + this.type = dto.type; + this.params = { + errorMessage: dto.params?.errorMessage || "Invalid value.", + extra: (dto.params?.extra || {}) as TExtraParams + }; + } + + getErrorMessage() { + return this.params.errorMessage; + } + + toDto(): FieldValidatorDto { + return { type: this.type, params: this.params }; + } + + async validate(value: any): Promise { + const isValid = await this.isValid(value); + if (!isValid) { + throw new Error(this.getErrorMessage()); + } + } + + abstract isValid(value: FunnelFieldValueModel): boolean | Promise; +} diff --git a/extensions/funnelBuilder/src/shared/models/validators/GteValidator.ts b/extensions/funnelBuilder/src/shared/models/validators/GteValidator.ts new file mode 100644 index 00000000000..017a96db1db --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/validators/GteValidator.ts @@ -0,0 +1,41 @@ +import { validation } from "@webiny/validation"; +import { AbstractValidator, FieldValidatorParamsDto } from "./AbstractValidator"; +import { FunnelFieldValueModel } from "../FunnelFieldValueModel"; + +interface GteValidatorExtraParams { + threshold?: number; +} + +export class GteValidator extends AbstractValidator { + static override type = "gte"; + + constructor(dto: FieldValidatorParamsDto = {}) { + super({ + type: "gte", + params: { + errorMessage: dto.errorMessage || "Value is too small.", + extra: { + threshold: dto.extra?.threshold + } + } + }); + } + + isValid(value: FunnelFieldValueModel) { + if (value.isEmpty()) { + return true; + } + + // Array values are not supported by this validator (can be expanded later if needed). + if (value.array) { + return true; + } + + if (!this.params.extra?.threshold) { + return true; + } + + const validators = `gte:${this.params.extra.threshold}`; + return validation.validateSync(value.value, validators, { throw: false }) === true; + } +} diff --git a/extensions/funnelBuilder/src/shared/models/validators/LteValidator.ts b/extensions/funnelBuilder/src/shared/models/validators/LteValidator.ts new file mode 100644 index 00000000000..a2483e5f0a9 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/validators/LteValidator.ts @@ -0,0 +1,41 @@ +import { validation } from "@webiny/validation"; +import { AbstractValidator, FieldValidatorParamsDto } from "./AbstractValidator"; +import { FunnelFieldValueModel } from "../FunnelFieldValueModel"; + +interface LteValidatorExtraParams { + threshold?: number; +} + +export class LteValidator extends AbstractValidator { + static override type = "lte"; + + constructor(params: FieldValidatorParamsDto = {}) { + super({ + type: "lte", + params: { + errorMessage: params.errorMessage || "Value is too great.", + extra: { + threshold: params.extra?.threshold + } + } + }); + } + + isValid(value: FunnelFieldValueModel) { + if (value.isEmpty()) { + return true; + } + + // Array values are not supported by this validator (can be expanded later if needed). + if (value.array) { + return true; + } + + if (!this.params.extra?.threshold) { + return true; + } + + const validators = `lte:${this.params.extra.threshold}`; + return validation.validateSync(value.value, validators, { throw: false }) === true; + } +} diff --git a/extensions/funnelBuilder/src/shared/models/validators/MaxLengthValidator.ts b/extensions/funnelBuilder/src/shared/models/validators/MaxLengthValidator.ts new file mode 100644 index 00000000000..885cc8b8ee8 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/validators/MaxLengthValidator.ts @@ -0,0 +1,41 @@ +import { validation } from "@webiny/validation"; +import { AbstractValidator, FieldValidatorParamsDto } from "./AbstractValidator"; +import { FunnelFieldValueModel } from "../FunnelFieldValueModel"; + +interface MaxLengthValidatorExtraParams { + threshold?: number; +} + +export class MaxLengthValidator extends AbstractValidator { + static override type = "maxLength"; + + constructor(params: FieldValidatorParamsDto = {}) { + super({ + type: "maxLength", + params: { + errorMessage: params.errorMessage || "Value is too long.", + extra: { + threshold: params.extra?.threshold + } + } + }); + } + + isValid(value: FunnelFieldValueModel) { + if (value.isEmpty()) { + return true; + } + + // Array values are not supported by this validator (can be expanded later if needed). + if (value.array) { + return true; + } + + if (!this.params.extra?.threshold) { + return true; + } + + const validators = `maxLength:${this.params.extra.threshold}`; + return validation.validateSync(value.value, validators, { throw: false }) === true; + } +} diff --git a/extensions/funnelBuilder/src/shared/models/validators/MinLengthValidator.ts b/extensions/funnelBuilder/src/shared/models/validators/MinLengthValidator.ts new file mode 100644 index 00000000000..c19b10ff552 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/validators/MinLengthValidator.ts @@ -0,0 +1,41 @@ +import { validation } from "@webiny/validation"; +import { AbstractValidator, FieldValidatorParamsDto } from "./AbstractValidator"; +import { FunnelFieldValueModel } from "../FunnelFieldValueModel"; + +interface MinLengthValidatorExtraParams { + threshold?: number; +} + +export class MinLengthValidator extends AbstractValidator { + static override type = "minLength"; + + constructor(params: FieldValidatorParamsDto = {}) { + super({ + type: "minLength", + params: { + errorMessage: params.errorMessage || "Value is too short.", + extra: { + threshold: params.extra?.threshold + } + } + }); + } + + isValid(value: FunnelFieldValueModel) { + if (value.isEmpty()) { + return true; + } + + // Array values are not supported by this validator (can be expanded later if needed). + if (value.array) { + return true; + } + + if (!this.params.extra?.threshold) { + return true; + } + + const validators = `minLength:${this.params.extra.threshold}`; + return validation.validateSync(value.value, validators, { throw: false }) === true; + } +} diff --git a/extensions/funnelBuilder/src/shared/models/validators/PatternValidator.ts b/extensions/funnelBuilder/src/shared/models/validators/PatternValidator.ts new file mode 100644 index 00000000000..fb474c8123c --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/validators/PatternValidator.ts @@ -0,0 +1,56 @@ +import { AbstractValidator, FieldValidatorParamsDto } from "./AbstractValidator"; +import { patternPresets } from "./PatternValidator/patternPresets"; +import { FunnelFieldValueModel } from "../FunnelFieldValueModel"; + +interface PatternValidatorExtraParams { + preset?: string; + regex?: string; + flags?: string; +} + +export class PatternValidator extends AbstractValidator { + static override type = "pattern"; + + constructor(params: FieldValidatorParamsDto = {}) { + super({ + type: "pattern", + params: { + errorMessage: params.errorMessage || "Invalid value.", + extra: params.extra || { + preset: "custom", + regex: "", + flags: "" + } + } + }); + } + + isValid(value: FunnelFieldValueModel) { + if (value.isEmpty()) { + return true; + } + + // Array values are not supported by this validator (can be expanded later if needed). + if (value.array) { + return true; + } + + const params = this.params; + + let pattern: Pick | undefined = undefined; + if (params.extra.preset === "custom") { + pattern = params.extra; + } else { + const patternPreset = patternPresets.find(item => item.type === params.extra.preset); + if (patternPreset) { + pattern = patternPreset; + } + } + + if (!pattern || !pattern.regex) { + return true; + } + + return new RegExp(pattern.regex, pattern.flags).test(String(value.value)); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/validators/PatternValidator/patternPresets.ts b/extensions/funnelBuilder/src/shared/models/validators/PatternValidator/patternPresets.ts new file mode 100644 index 00000000000..4f3e04725f2 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/validators/PatternValidator/patternPresets.ts @@ -0,0 +1,26 @@ +export const patternPresets = [ + { + type: "email", + name: "Email", + regex: `^\\w[\\+\\w.-]*@([\\w-]+\\.)+[\\w-]+$`, + flags: "i" + }, + { + type: "lowercase", + name: "Lower case", + regex: `^([a-z]*)$`, + flags: "" + }, + { + type: "uppercase", + name: "Upper case", + regex: `^([A-Z]*)$`, + flags: "" + }, + { + type: "url", + name: "URL", + regex: "^((ftp|http|https):\\/\\/)?([a-zA-Z0-9]+(\\.[a-zA-Z0-9]+)+.*)$", + flags: "i" + } +]; diff --git a/extensions/funnelBuilder/src/shared/models/validators/RequiredValidator.ts b/extensions/funnelBuilder/src/shared/models/validators/RequiredValidator.ts new file mode 100644 index 00000000000..779bfdcf6e8 --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/validators/RequiredValidator.ts @@ -0,0 +1,20 @@ +import { AbstractValidator, FieldValidatorParamsDto } from "./AbstractValidator"; +import { FunnelFieldValueModel } from "../FunnelFieldValueModel"; + +export class RequiredValidator extends AbstractValidator { + static override type = "required"; + + constructor(params: FieldValidatorParamsDto = {}) { + super({ + type: "required", + params: { + errorMessage: params.errorMessage || "Value is required.", + extra: {} + } + }); + } + + isValid(value: FunnelFieldValueModel) { + return value.hasValue(); + } +} diff --git a/extensions/funnelBuilder/src/shared/models/validators/registry.ts b/extensions/funnelBuilder/src/shared/models/validators/registry.ts new file mode 100644 index 00000000000..3a133f8b5bd --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/validators/registry.ts @@ -0,0 +1,15 @@ +import { RequiredValidator } from "./RequiredValidator"; +import { GteValidator } from "./GteValidator"; +import { LteValidator } from "./LteValidator"; +import { MaxLengthValidator } from "./MaxLengthValidator"; +import { MinLengthValidator } from "./MinLengthValidator"; +import { PatternValidator } from "./PatternValidator"; + +export const registry = [ + GteValidator, + LteValidator, + MaxLengthValidator, + MinLengthValidator, + PatternValidator, + RequiredValidator +]; diff --git a/extensions/funnelBuilder/src/shared/models/validators/validatorFactory.ts b/extensions/funnelBuilder/src/shared/models/validators/validatorFactory.ts new file mode 100644 index 00000000000..effbef9cc9d --- /dev/null +++ b/extensions/funnelBuilder/src/shared/models/validators/validatorFactory.ts @@ -0,0 +1,11 @@ +import { FieldValidatorDto } from "./AbstractValidator"; + +import { registry } from "./registry"; + +export const validatorFromDto = (dto: FieldValidatorDto) => { + const ValidatorClass = registry.find(validatorClass => validatorClass.type === dto.type); + if (!ValidatorClass) { + throw new Error(`Unknown validator: ${dto.type}`); + } + return new ValidatorClass(dto.params); +}; diff --git a/extensions/funnelBuilder/src/shared/types.ts b/extensions/funnelBuilder/src/shared/types.ts new file mode 100644 index 00000000000..175719dc0db --- /dev/null +++ b/extensions/funnelBuilder/src/shared/types.ts @@ -0,0 +1,7 @@ +export interface ThemeSettings { + theme: { + primaryColor: string; + secondaryColor: string; + logo: string; + }; +} diff --git a/extensions/funnelBuilder/src/website.tsx b/extensions/funnelBuilder/src/website.tsx new file mode 100644 index 00000000000..1b9d81c586a --- /dev/null +++ b/extensions/funnelBuilder/src/website.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { ContainerWebsitePlugins } from "./frontend/pageElements/container/website/ContainerWebsitePlugins"; +import { TextFieldWebsitePlugins } from "./frontend/pageElements/fields/text/TextFieldWebsitePlugins"; +import { TextareaFieldWebsitePlugins } from "./frontend/pageElements/fields/textarea/TextareaFieldWebsitePlugins"; +import { CheckboxGroupFieldWebsitePlugins } from "./frontend/pageElements/fields/checkboxGroup/CheckboxGroupFieldWebsitePlugins"; +import { ControlsWebsitePlugins } from "./frontend/pageElements/controls/ControlsWebsitePlugins"; + +import { StepWebsitePlugins } from "./frontend/pageElements/step/website/StepWebsitePlugins"; + +export const Extension = () => ( + <> + {/* Container Page Element */} + + + + {/* Fields. */} + + + + + +); diff --git a/extensions/funnelBuilder/tsconfig.json b/extensions/funnelBuilder/tsconfig.json new file mode 100644 index 00000000000..596e2cf729a --- /dev/null +++ b/extensions/funnelBuilder/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/extensions/theme/src/layouts/pages/Static.tsx b/extensions/theme/src/layouts/pages/Static.tsx index 678fef46bd5..7e7dc0f3f84 100644 --- a/extensions/theme/src/layouts/pages/Static.tsx +++ b/extensions/theme/src/layouts/pages/Static.tsx @@ -1,44 +1,11 @@ import React from "react"; -import { Header } from "./Static/Header"; -import { Footer } from "./Static/Footer"; -import styled from "@emotion/styled"; -import { Global, css } from "@emotion/react"; -const globalStyles = css` - html { - scroll-behavior: smooth; - } - - @media screen and (prefers-reduced-motion: reduce) { - html { - scroll-behavior: smooth; - } - } -`; - -const Layout = styled.div` - min-height: 100vh; - display: flex; - flex-direction: column; - - footer { - margin-top: auto; - } -`; - -interface StaticProps { +interface FunnelEmbedProps { children: React.ReactNode; } -const Static = ({ children }: StaticProps) => { - return ( - - -
-
{children}
-