;
+}
+
+export const ConditionRulesDialog = ({
+ data,
+ open,
+ onClose,
+ onSubmit
+}: ConditionRulesDialogProps) => {
+ return (
+
+ );
+};
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 (
+
+ );
+ })
+ )}
+
+ );
+};
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 (
+
+ );
+};
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:
+
+
+
+ {conditionGroup.items.length === 0 ? (
+
+
+ No conditions added yet.
+
+
+ ) : (
+ conditionGroup.items.map(conditionGroupItem => {
+ const isConditionGroup = "items" in conditionGroupItem;
+ if (isConditionGroup) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ })
+ )}
+
+ );
+};
diff --git a/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog/useConditionRulesForm.ts b/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog/useConditionRulesForm.ts
new file mode 100644
index 00000000000..9114e1db0fb
--- /dev/null
+++ b/extensions/funnelBuilder/src/frontend/admin/ConditionRulesDialog/useConditionRulesForm.ts
@@ -0,0 +1,308 @@
+import { useBind, useForm } from "@webiny/form";
+import { getRandomId } from "../../../shared/getRandomId";
+import { FunnelModelDto } from "../../../shared/models/FunnelModel";
+import { FunnelConditionRuleModelDto } from "../../../shared/models/FunnelConditionRuleModel";
+import { FunnelConditionModelDto } from "../../../shared/models/FunnelConditionModel";
+import { FunnelConditionActionModelDto } from "../../../shared/models/FunnelConditionActionModel";
+import { DisableFieldConditionAction } from "../../../shared/models/conditionActions/DisableFieldConditionAction";
+import {
+ FunnelConditionGroupModelDto,
+ LogicalOperator
+} from "../../../shared/models/FunnelConditionGroupModel";
+
+type ConditionRulesDto = FunnelConditionRuleModelDto[];
+
+const findConditionGroup = (
+ conditionGroupId: string,
+ rootConditionGroup: FunnelConditionGroupModelDto
+): FunnelConditionGroupModelDto | null => {
+ if (rootConditionGroup.id === conditionGroupId) {
+ return rootConditionGroup;
+ }
+
+ for (const item of rootConditionGroup.items) {
+ if ("sourceFieldId" in item) {
+ continue;
+ }
+ const found = findConditionGroup(conditionGroupId, item);
+ if (found) {
+ return found;
+ }
+ }
+
+ return null;
+};
+
+const findParentConditionGroup = (
+ conditionGroupId: string,
+ rootConditionGroup: FunnelConditionGroupModelDto
+): FunnelConditionGroupModelDto | null => {
+ for (const item of rootConditionGroup.items) {
+ if ("sourceFieldId" in item) {
+ continue;
+ }
+ if (item.id === conditionGroupId) {
+ return rootConditionGroup;
+ }
+ const found = findParentConditionGroup(conditionGroupId, item);
+ if (found) {
+ return found;
+ }
+ }
+
+ return null;
+};
+
+export const useConditionRulesForm = () => {
+ // Get the condition rules from the model
+ const { data: funnel } = useForm();
+ const conditionRulesBind = useBind({ name: "conditionRules" });
+
+ const rules = conditionRulesBind.value as ConditionRulesDto;
+
+ // Rules. 👇
+ const updateRules = (updateFn: (rulesClone: ConditionRulesDto) => ConditionRulesDto) => {
+ conditionRulesBind.onChange(updateFn(structuredClone(rules)));
+ };
+
+ const addRule = () => {
+ updateRules(rules => {
+ return rules.concat({
+ id: getRandomId(),
+ conditionGroup: { id: getRandomId(), items: [], operator: "and" },
+ actions: []
+ });
+ });
+ };
+
+ const removeRule = (ruleId: string) => {
+ updateRules(rules => {
+ return rules.filter(rule => rule.id !== ruleId);
+ });
+ };
+
+ const getRuleIndex = (rule: FunnelConditionRuleModelDto) => {
+ return funnel.conditionRules.findIndex(current => {
+ return current.id === rule.id;
+ });
+ };
+
+ // Conditions. 👇
+ const addCondition = (conditionGroupId: string) => {
+ updateRules(rules => {
+ for (const rule of rules) {
+ const rootConditionGroup = rule.conditionGroup;
+ const groupInRule = findConditionGroup(conditionGroupId, rootConditionGroup);
+ if (groupInRule) {
+ groupInRule.items.push({
+ id: getRandomId(),
+ sourceFieldId: "",
+ operator: { type: "empty" }
+ } as FunnelConditionModelDto);
+ break;
+ }
+ }
+
+ return rules;
+ });
+ };
+
+ const removeCondition = (conditionGroupId: string, conditionId: string) => {
+ updateRules(rules => {
+ for (const rule of rules) {
+ const rootConditionGroup = rule.conditionGroup;
+ const groupInRule = findConditionGroup(conditionGroupId, rootConditionGroup);
+ if (groupInRule) {
+ groupInRule.items = groupInRule.items.filter(
+ condition => condition.id !== conditionId
+ );
+ break;
+ }
+ }
+
+ return rules;
+ });
+ };
+
+ const updateCondition = (conditionGroupId: string, condition: FunnelConditionModelDto) => {
+ updateRules(rules => {
+ for (const rule of rules) {
+ const rootConditionGroup = rule.conditionGroup;
+ const groupInRule = findConditionGroup(conditionGroupId, rootConditionGroup);
+ if (groupInRule) {
+ const conditionInItems = groupInRule.items.find(
+ item => item.id === condition.id
+ );
+
+ if (conditionInItems && "sourceFieldId" in conditionInItems) {
+ conditionInItems.sourceFieldId = condition.sourceFieldId;
+ conditionInItems.operator = condition.operator;
+ }
+
+ break;
+ }
+ }
+
+ return rules;
+ });
+ };
+
+ const getConditionsCount = (ruleId: string) => {
+ const rule = rules.find(rule => rule.id === ruleId);
+ if (!rule) {
+ return 0;
+ }
+
+ let count = 0;
+ const traverseConditionGroup = (conditionGroup: FunnelConditionGroupModelDto) => {
+ for (const item of conditionGroup.items) {
+ if ("sourceFieldId" in item) {
+ count++;
+ } else {
+ traverseConditionGroup(item);
+ }
+ }
+ };
+
+ traverseConditionGroup(rule.conditionGroup);
+ return count;
+ };
+
+ // Condition groups.👇
+ const updateConditionGroupOperator = (conditionGroupId: string, operator: LogicalOperator) => {
+ updateRules(rules => {
+ for (const rule of rules) {
+ const rootConditionGroup = rule.conditionGroup;
+ const groupInRule = findConditionGroup(conditionGroupId, rootConditionGroup);
+ if (groupInRule) {
+ groupInRule.operator = operator;
+ break;
+ }
+ }
+
+ return rules;
+ });
+ };
+
+ const addConditionGroup = (conditionGroupId: string) => {
+ updateRules(rules => {
+ for (const rule of rules) {
+ const rootConditionGroup = rule.conditionGroup;
+ const groupInRule = findConditionGroup(conditionGroupId, rootConditionGroup);
+ if (groupInRule) {
+ groupInRule.items.push({
+ id: getRandomId(),
+ operator: "and",
+ items: []
+ });
+ break;
+ }
+ }
+
+ return rules;
+ });
+ };
+
+ const removeConditionGroup = (conditionGroupId: string) => {
+ updateRules(rules => {
+ for (const rule of rules) {
+ const rootConditionGroup = rule.conditionGroup;
+ const groupInRule = findConditionGroup(conditionGroupId, rootConditionGroup);
+ if (groupInRule) {
+ const parentGroup = findParentConditionGroup(
+ conditionGroupId,
+ rootConditionGroup
+ );
+ if (parentGroup) {
+ parentGroup.items = parentGroup.items.filter(
+ group => group.id !== conditionGroupId
+ );
+ }
+ break;
+ }
+ }
+
+ return rules;
+ });
+ };
+
+ const addAction = (ruleId: string) => {
+ updateRules(rules => {
+ const rule = rules.find(rule => rule.id === ruleId);
+
+ if (!rule) {
+ return rules;
+ }
+
+ rule.actions.push({
+ id: getRandomId(),
+ type: DisableFieldConditionAction.type,
+ params: { extra: {} }
+ });
+
+ return rules;
+ });
+ };
+
+ const removeAction = (ruleId: string, actionId: string) => {
+ updateRules(rules => {
+ const rule = rules.find(rule => rule.id === ruleId);
+
+ if (!rule) {
+ return rules;
+ }
+
+ rule.actions = rule.actions.filter(action => action.id !== actionId);
+
+ return rules;
+ });
+ };
+
+ const updateAction = (ruleId: string, action: FunnelConditionActionModelDto) => {
+ updateRules(rules => {
+ const rule = rules.find(current => current.id === ruleId);
+ if (!rule) {
+ return rules;
+ }
+
+ const ruleAction = rule.actions.find(current => current.id === action.id);
+ if (!ruleAction) {
+ return rules;
+ }
+
+ ruleAction.type = action.type;
+ ruleAction.params = action.params;
+
+ return rules;
+ });
+ };
+
+ const getActionsCount = (ruleId: string) => {
+ const rule = rules.find(rule => rule.id === ruleId);
+ if (!rule) {
+ return 0;
+ }
+
+ return rule.actions.length;
+ };
+
+ return {
+ addCondition,
+ removeCondition,
+ updateCondition,
+ getConditionsCount,
+ updateRules,
+ updateConditionGroupOperator,
+ addRule,
+ removeRule,
+ getRuleIndex,
+ addConditionGroup,
+ removeConditionGroup,
+ addAction,
+ removeAction,
+ updateAction,
+ getActionsCount,
+ funnel,
+ rules
+ };
+};
diff --git a/extensions/funnelBuilder/src/frontend/admin/ElementTreeTraverser.ts b/extensions/funnelBuilder/src/frontend/admin/ElementTreeTraverser.ts
new file mode 100644
index 00000000000..383b240f44f
--- /dev/null
+++ b/extensions/funnelBuilder/src/frontend/admin/ElementTreeTraverser.ts
@@ -0,0 +1,18 @@
+import { PbEditorElementTree } from "@webiny/app-page-builder/types";
+
+export class ElementTreeTraverser {
+ traverse(
+ currentTreeElement: PbEditorElementTree,
+ visitor: (currentTreeElement: PbEditorElementTree) => void | false
+ ): void | false {
+ const result = visitor(currentTreeElement);
+ if (result !== false) {
+ for (const node of currentTreeElement.elements) {
+ const result = this.traverse(node, visitor);
+ if (result === false) {
+ return result;
+ }
+ }
+ }
+ }
+}
diff --git a/extensions/funnelBuilder/src/frontend/admin/FieldSettingsDialog.tsx b/extensions/funnelBuilder/src/frontend/admin/FieldSettingsDialog.tsx
new file mode 100644
index 00000000000..de7a70d91a4
--- /dev/null
+++ b/extensions/funnelBuilder/src/frontend/admin/FieldSettingsDialog.tsx
@@ -0,0 +1,68 @@
+import React, { useMemo } from "react";
+import { Dialog, DialogActions, DialogContent, DialogTitle } from "@webiny/ui/Dialog";
+import { Form, FormOnSubmit } from "@webiny/form";
+import { Tab, Tabs } from "@webiny/ui/Tabs";
+import { ButtonDefault, ButtonPrimary } from "@webiny/ui/Button";
+import { GeneralTab } from "./FieldSettingsDialog/GeneralTab";
+import { ValidatorsTab } from "./FieldSettingsDialog/ValidatorsTab";
+import {
+ FunnelFieldDefinitionModel,
+ FunnelFieldDefinitionModelDto
+} from "../../shared/models/FunnelFieldDefinitionModel";
+import { ClassNames } from "@emotion/react";
+
+interface EditFieldDialogProps {
+ field: FunnelFieldDefinitionModel;
+ onClose: () => void;
+ open: boolean;
+ onSubmit: FormOnSubmit;
+}
+
+const dialogContentCss = {
+ width: 875,
+ minHeight: 400,
+ maxHeight: 600
+};
+
+export const FieldSettingsDialog = ({ field, open, onClose, onSubmit }: EditFieldDialogProps) => {
+ const initialFormData = useMemo(() => {
+ if (!field) {
+ return {};
+ }
+ return field.toDto();
+ }, [field]);
+
+ return (
+
+ );
+};
diff --git a/extensions/funnelBuilder/src/frontend/admin/FieldSettingsDialog/GeneralTab.tsx b/extensions/funnelBuilder/src/frontend/admin/FieldSettingsDialog/GeneralTab.tsx
new file mode 100644
index 00000000000..0ccb8338399
--- /dev/null
+++ b/extensions/funnelBuilder/src/frontend/admin/FieldSettingsDialog/GeneralTab.tsx
@@ -0,0 +1,113 @@
+import React, { useCallback, useEffect, useRef } from "react";
+import { Input } from "@webiny/ui/Input";
+import { Cell, Grid } from "@webiny/ui/Grid";
+import camelCase from "lodash/camelCase";
+import { validation } from "@webiny/validation";
+import { Validator } from "@webiny/validation/types";
+import { Bind, useForm } from "@webiny/form";
+import { plugins } from "@webiny/plugins";
+import { type Plugin } from "@webiny/plugins/types";
+import { PbEditorFunnelFieldSettingsPluginProps } from "../plugins/PbEditorFunnelFieldSettingsPlugin";
+import { FunnelFieldDefinitionModel } from "../../../shared/models/FunnelFieldDefinitionModel";
+import { useContainer } from "../../pageElements/container/ContainerProvider";
+
+interface GeneralTabProps {
+ field: FunnelFieldDefinitionModel;
+ open: boolean;
+}
+
+export const GeneralTab = ({ open }: GeneralTabProps) => {
+ const { setValue, data: field } = useForm();
+
+ const { funnelVm } = useContainer();
+ const inputRef = useRef(null);
+
+ const afterChangeLabel = useCallback((value: string): void => {
+ setValue("fieldId", camelCase(value));
+ }, []);
+
+ useEffect(() => {
+ setTimeout(() => {
+ if (!inputRef.current) {
+ return;
+ }
+
+ // On dialog open, we focus the first input field.
+ if (open) {
+ inputRef.current.focus();
+ }
+ }, 150);
+ }, [open]);
+
+ const uniqueFieldIdValidator: Validator = useCallback(() => {
+ const existingField = funnelVm.getFieldByFieldId(field.fieldId);
+ if (!existingField) {
+ return true;
+ }
+
+ if (existingField.id === field.id) {
+ return true;
+ }
+ throw new Error("A field with this field ID already exists.");
+ }, [field.fieldId]);
+
+ const fieldIdValidator: Validator = useCallback((fieldId: string) => {
+ if (!fieldId) {
+ return true;
+ }
+
+ if (/^[a-zA-Z0-9_-]*$/.test(fieldId)) {
+ return true;
+ }
+ throw Error('Field ID may contain only letters, numbers and "-" and "_" characters.');
+ }, []);
+
+ const fieldSettingsPlugin = plugins.byType("pb-editor-funnel-field-settings").find(plugin => {
+ return plugin.fieldType === field.type;
+ }) as Plugin;
+
+ let additionalSettings: React.ReactNode = null;
+ if (fieldSettingsPlugin) {
+ const RendererComponent = fieldSettingsPlugin.renderer;
+ additionalSettings = (
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+ |
+
+ {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 && (
+
+ )}
+
+ );
+ })}
+ >
+ );
+};
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 ? (
+
+ ) : (
+ <>
+ {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 (
+
+ );
+};
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 (
+
+
+
+ );
+});
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 (
+
+
+
+ );
+});
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}
-
-
- );
+const Static = ({ children }: FunnelEmbedProps) => {
+ return <>{children}>;
};
export default Static;
diff --git a/extensions/theme/src/theme.ts b/extensions/theme/src/theme.ts
index 016d0232926..31900dfca70 100644
--- a/extensions/theme/src/theme.ts
+++ b/extensions/theme/src/theme.ts
@@ -11,7 +11,7 @@ export const breakpoints = {
// Colors.
export const colors = {
- color1: "#fa5723", // Primary.
+ color1: "purple", // Primary.
color2: "#00ccb0", // Secondary.
color3: "#0a0a0a", // Text primary.
color4: "#616161", // Text secondary.
diff --git a/package.json b/package.json
index f9a3902b3ef..d8c66bd8a3c 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"apps/api/pageBuilder/import/*",
"apps/api/pageBuilder/export/*",
"extensions/theme",
+ "extensions/funnelBuilder",
"scripts/buildPackages",
"scripts/prepublishOnly"
]
diff --git a/packages/api-aco/src/folder/folder.model.ts b/packages/api-aco/src/folder/folder.model.ts
index 073b7837eb6..95e4d809e56 100644
--- a/packages/api-aco/src/folder/folder.model.ts
+++ b/packages/api-aco/src/folder/folder.model.ts
@@ -113,6 +113,17 @@ const permissionsField = () =>
}
});
+const extensionsField = () =>
+ createModelField({
+ label: "Extensions",
+ fieldId: "extensions",
+ type: "object",
+ settings: {
+ layout: [],
+ fields: []
+ }
+ });
+
export const FOLDER_MODEL_ID = "acoFolder";
export const createFolderModel = () => {
@@ -127,6 +138,13 @@ export const createFolderModel = () => {
// flp: true
},
titleFieldId: "title",
- fields: [titleField(), slugField(), typeField(), parentIdField(), permissionsField()]
+ fields: [
+ titleField(),
+ slugField(),
+ typeField(),
+ parentIdField(),
+ permissionsField(),
+ extensionsField()
+ ]
});
};
diff --git a/packages/api-file-manager-aco/tsconfig.build.json b/packages/api-file-manager-aco/tsconfig.build.json
index 000aa8550e1..513919c0630 100644
--- a/packages/api-file-manager-aco/tsconfig.build.json
+++ b/packages/api-file-manager-aco/tsconfig.build.json
@@ -2,8 +2,8 @@
"extends": "../../tsconfig.build.json",
"include": ["src"],
"references": [
- { "path": "../api/tsconfig.build.json" },
{ "path": "../api-aco/tsconfig.build.json" },
+ { "path": "../api/tsconfig.build.json" },
{ "path": "../api-admin-users/tsconfig.build.json" },
{ "path": "../api-headless-cms/tsconfig.build.json" },
{ "path": "../api-i18n/tsconfig.build.json" },
diff --git a/packages/api-file-manager-aco/tsconfig.json b/packages/api-file-manager-aco/tsconfig.json
index 54c861d5290..cbea3636d1d 100644
--- a/packages/api-file-manager-aco/tsconfig.json
+++ b/packages/api-file-manager-aco/tsconfig.json
@@ -2,8 +2,8 @@
"extends": "../../tsconfig.json",
"include": ["src", "__tests__"],
"references": [
- { "path": "../api" },
{ "path": "../api-aco" },
+ { "path": "../api" },
{ "path": "../api-admin-users" },
{ "path": "../api-headless-cms" },
{ "path": "../api-i18n" },
@@ -23,10 +23,10 @@
"paths": {
"~/*": ["./src/*"],
"~tests/*": ["./__tests__/*"],
- "@webiny/api/*": ["../api/src/*"],
- "@webiny/api": ["../api/src"],
"@webiny/api-aco/*": ["../api-aco/src/*"],
"@webiny/api-aco": ["../api-aco/src"],
+ "@webiny/api/*": ["../api/src/*"],
+ "@webiny/api": ["../api/src"],
"@webiny/api-admin-users/*": ["../api-admin-users/src/*"],
"@webiny/api-admin-users": ["../api-admin-users/src"],
"@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"],
diff --git a/packages/api-file-manager-s3/src/plugins/graphqlFileStorageS3.ts b/packages/api-file-manager-s3/src/plugins/graphqlFileStorageS3.ts
index 43ffb13fc85..80fba5e9171 100644
--- a/packages/api-file-manager-s3/src/plugins/graphqlFileStorageS3.ts
+++ b/packages/api-file-manager-s3/src/plugins/graphqlFileStorageS3.ts
@@ -25,6 +25,7 @@ const plugin: GraphQLSchemaPlugin = {
}
input PreSignedPostPayloadInput {
+ id: ID
name: String!
type: String!
size: Long!
diff --git a/packages/api-headless-cms/src/validators/maxLength.ts b/packages/api-headless-cms/src/validators/maxLength.ts
index 9b3f1d922fb..65b76ed01bc 100644
--- a/packages/api-headless-cms/src/validators/maxLength.ts
+++ b/packages/api-headless-cms/src/validators/maxLength.ts
@@ -11,7 +11,7 @@ export const createMaxLengthValidator = (): CmsModelFieldValidatorPlugin => {
const maxLengthValue = validator.settings?.value;
if (typeof maxLengthValue !== "undefined") {
return validation
- .validate(value, `maxLength:${maxLengthValue}`)
+ .validate(value, `maxLength:${maxLengthValue}`, { throw: false })
.then(v => v === true)
.catch(() => false);
}
diff --git a/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts b/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts
index b523ec37d03..239b80bef5b 100644
--- a/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts
+++ b/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts
@@ -69,6 +69,7 @@ export interface CreatePageStorageOperationsParams {
elasticsearch: Client;
plugins: PluginsContainer;
}
+
export const createPageStorageOperations = (
params: CreatePageStorageOperationsParams
): PageStorageOperations => {
@@ -86,6 +87,11 @@ export const createPageStorageOperations = (
SK: createLatestSortKey()
};
+ const publishedKeys = {
+ ...versionKeys,
+ SK: createPublishedSortKey()
+ };
+
const entityBatch = createEntityWriteBatch({
entity,
put: [
@@ -103,17 +109,41 @@ export const createPageStorageOperations = (
});
const esData = getESLatestPageData(plugins, page, input);
- try {
- await entityBatch.execute();
- await put({
- entity: esEntity,
- item: {
+ const elasticsearchEntityBatch = createEntityWriteBatch({
+ entity: esEntity,
+ put: [
+ {
index: configurations.es(page).index,
data: esData,
...latestKeys
}
+ ]
+ });
+
+ if (page.status === "published") {
+ entityBatch.put({
+ ...page,
+ ...publishedKeys,
+ TYPE: createPublishedType()
+ });
+
+ entityBatch.put({
+ ...page,
+ TYPE: createPublishedPathType(),
+ PK: createPathPartitionKey(page),
+ SK: createPathSortKey(page)
});
+
+ elasticsearchEntityBatch.put({
+ index: configurations.es(page).index,
+ data: getESPublishedPageData(plugins, page),
+ ...publishedKeys
+ });
+ }
+ try {
+ await entityBatch.execute();
+ await elasticsearchEntityBatch.execute();
return page;
} catch (ex) {
throw new WebinyError(
diff --git a/packages/api-page-builder-so-ddb/src/operations/pages/index.ts b/packages/api-page-builder-so-ddb/src/operations/pages/index.ts
index 1938bace62b..75fb2868e0a 100644
--- a/packages/api-page-builder-so-ddb/src/operations/pages/index.ts
+++ b/packages/api-page-builder-so-ddb/src/operations/pages/index.ts
@@ -81,6 +81,7 @@ export interface CreatePageStorageOperationsParams {
entity: Entity;
plugins: PluginsContainer;
}
+
export const createPageStorageOperations = (
params: CreatePageStorageOperationsParams
): PageStorageOperations => {
@@ -98,6 +99,11 @@ export const createPageStorageOperations = (
SK: createLatestSortKey(page)
};
+ const publishedKeys = {
+ PK: createPublishedPartitionKey(page),
+ SK: createPublishedSortKey(page)
+ };
+
const titleLC = page.title.toLowerCase();
/**
* We need to create
@@ -122,6 +128,16 @@ export const createPageStorageOperations = (
]
});
+ if (page.status === "published") {
+ entityBatch.put({
+ ...page,
+ ...publishedKeys,
+ GSI1_PK: createPathPartitionKey(page),
+ GSI1_SK: page.path,
+ TYPE: createPublishedType()
+ });
+ }
+
try {
await entityBatch.execute();
return page;
diff --git a/packages/api-page-builder/__tests__/graphql/createPage.test.ts b/packages/api-page-builder/__tests__/graphql/createPage.test.ts
new file mode 100644
index 00000000000..dea0a692297
--- /dev/null
+++ b/packages/api-page-builder/__tests__/graphql/createPage.test.ts
@@ -0,0 +1,209 @@
+import useGqlHandler from "./useGqlHandler";
+
+jest.setTimeout(100000);
+
+describe("CRUD Test", () => {
+ const handler = useGqlHandler();
+
+ const { createCategory, createPageV2, getPage, getPublishedPage } = handler;
+
+ it("creating pages via the new createPagesV2 mutation", async () => {
+ await createCategory({
+ data: {
+ slug: `slug`,
+ name: `name`,
+ url: `/some-url/`,
+ layout: `layout`
+ }
+ });
+
+ const page = {
+ id: "67e15c96026bd2000222d698#0001",
+ pid: "67e15c96026bd2000222d698",
+ category: "slug",
+ version: 1,
+ title: "Welcome to Webiny",
+ path: "/welcome-to-webiny",
+ content: {
+ id: "Fv1PpPWu-",
+ type: "document",
+ data: {
+ settings: {}
+ },
+ elements: []
+ },
+ status: "published",
+ publishedOn: "2025-03-24T13:22:30.918Z",
+ settings: {
+ general: {
+ snippet: null,
+ tags: null,
+ layout: "static",
+ image: null
+ },
+ social: {
+ meta: [],
+ title: null,
+ description: null,
+ image: null
+ },
+ seo: {
+ title: null,
+ description: null,
+ meta: []
+ }
+ },
+ createdOn: "2025-03-24T13:22:30.363Z",
+ createdBy: {
+ id: "67e15c7d026bd2000222d67a",
+ displayName: "ad min",
+ type: "admin"
+ }
+ };
+
+ // The V2 of the createPage mutation should allow us to create pages with
+ // predefined `createdOn`, `createdBy`, `id`, and also immediately have the
+ // page published.
+ await createPageV2({ data: page });
+
+ const [getPageResponse] = await getPage({ id: page.id });
+
+ expect(getPageResponse).toMatchObject({
+ data: {
+ pageBuilder: {
+ getPage: {
+ data: {
+ id: "67e15c96026bd2000222d698#0001",
+ pid: "67e15c96026bd2000222d698",
+ editor: "page-builder",
+ category: {
+ slug: "slug"
+ },
+ version: 1,
+ title: "Welcome to Webiny",
+ path: "/welcome-to-webiny",
+ url: "https://www.test.com/welcome-to-webiny",
+ content: {
+ id: "Fv1PpPWu-",
+ type: "document",
+ data: {
+ settings: {}
+ },
+ elements: []
+ },
+ savedOn: "2025-03-24T13:22:30.363Z",
+ status: "published",
+ locked: true,
+ publishedOn: "2025-03-24T13:22:30.918Z",
+ revisions: [
+ {
+ id: "67e15c96026bd2000222d698#0001",
+ status: "published",
+ locked: true,
+ version: 1
+ }
+ ],
+ settings: {
+ general: {
+ snippet: null,
+ tags: null,
+ layout: "static",
+ image: null
+ },
+ social: {
+ meta: [],
+ title: null,
+ description: null,
+ image: null
+ },
+ seo: {
+ title: null,
+ description: null,
+ meta: []
+ }
+ },
+ createdFrom: null,
+ createdOn: "2025-03-24T13:22:30.363Z",
+ createdBy: {
+ id: "67e15c7d026bd2000222d67a",
+ displayName: "ad min",
+ type: "admin"
+ }
+ },
+ error: null
+ }
+ }
+ }
+ });
+
+ const [getPublishedPageResponse] = await getPublishedPage({ id: page.id });
+
+ expect(getPublishedPageResponse).toMatchObject({
+ data: {
+ pageBuilder: {
+ getPublishedPage: {
+ data: {
+ id: "67e15c96026bd2000222d698#0001",
+ pid: "67e15c96026bd2000222d698",
+ editor: "page-builder",
+ category: {
+ slug: "slug"
+ },
+ version: 1,
+ title: "Welcome to Webiny",
+ path: "/welcome-to-webiny",
+ url: "https://www.test.com/welcome-to-webiny",
+ content: {
+ id: "Fv1PpPWu-",
+ type: "document",
+ data: {
+ settings: {}
+ },
+ elements: []
+ },
+ savedOn: "2025-03-24T13:22:30.363Z",
+ status: "published",
+ locked: true,
+ publishedOn: "2025-03-24T13:22:30.918Z",
+ revisions: [
+ {
+ id: "67e15c96026bd2000222d698#0001",
+ status: "published",
+ locked: true,
+ version: 1
+ }
+ ],
+ settings: {
+ general: {
+ snippet: null,
+ tags: null,
+ layout: "static",
+ image: null
+ },
+ social: {
+ meta: [],
+ title: null,
+ description: null,
+ image: null
+ },
+ seo: {
+ title: null,
+ description: null,
+ meta: []
+ }
+ },
+ createdFrom: null,
+ createdOn: "2025-03-24T13:22:30.363Z",
+ createdBy: {
+ id: "67e15c7d026bd2000222d67a",
+ displayName: "ad min",
+ type: "admin"
+ }
+ },
+ error: null
+ }
+ }
+ }
+ });
+ });
+});
diff --git a/packages/api-page-builder/__tests__/graphql/graphql/pages.ts b/packages/api-page-builder/__tests__/graphql/graphql/pages.ts
index 9e2cfeaa999..34ae1f7c1c0 100644
--- a/packages/api-page-builder/__tests__/graphql/graphql/pages.ts
+++ b/packages/api-page-builder/__tests__/graphql/graphql/pages.ts
@@ -198,7 +198,21 @@ export const createPageCreateGraphQl = (params: CreateDataFieldsParams = {}) =>
`;
};
+export const createPageCreateV2GraphQl = (params: CreateDataFieldsParams = {}) => {
+ return /* GraphQL */ `
+ mutation CreatePageV2($data: PbCreatePageV2Input!) {
+ pageBuilder {
+ createPageV2(data: $data) {
+ data ${createDataFields(params)}
+ error ${ERROR_FIELD}
+ }
+ }
+ }
+ `;
+};
+
export const CREATE_PAGE = createPageCreateGraphQl();
+export const CREATE_PAGE_V2 = createPageCreateV2GraphQl();
export const createPageUpdateGraphQl = (params: CreateDataFieldsParams = {}) => {
return /* GraphQL */ `
diff --git a/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts b/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts
index d20f93a6350..7c681647fd4 100644
--- a/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts
+++ b/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts
@@ -26,6 +26,7 @@ import {
} from "./graphql/pageElements";
import {
CREATE_PAGE,
+ CREATE_PAGE_V2,
DELETE_PAGE,
DUPLICATE_PAGE,
GET_PAGE,
@@ -241,6 +242,9 @@ export default ({ permissions, identity, plugins }: Params = {}) => {
async createPage(variables: Record) {
return invoke({ body: { query: CREATE_PAGE, variables } });
},
+ async createPageV2(variables: Record) {
+ return invoke({ body: { query: CREATE_PAGE_V2, variables } });
+ },
async duplicatePage(variables: Record) {
return invoke({ body: { query: DUPLICATE_PAGE, variables } });
},
diff --git a/packages/api-page-builder/__tests__/translations/translatableCollection/DeleteTranslatableCollectionUseCase.test.ts b/packages/api-page-builder/__tests__/translations/translatableCollection/DeleteTranslatableCollectionUseCase.test.ts
new file mode 100644
index 00000000000..b269ea28818
--- /dev/null
+++ b/packages/api-page-builder/__tests__/translations/translatableCollection/DeleteTranslatableCollectionUseCase.test.ts
@@ -0,0 +1,38 @@
+import { useHandler } from "~tests/translations/useHandler";
+import {
+ DeleteTranslatableCollectionUseCase,
+ GetTranslatableCollectionUseCase,
+ SaveTranslatableCollectionUseCase
+} from "~/translations";
+
+describe("DeleteTranslatableCollectionUseCase", () => {
+ it("should delete a collection", async () => {
+ const { handler } = useHandler();
+ const context = await handler();
+
+ // Setup
+ const saveTranslatableCollection = new SaveTranslatableCollectionUseCase(context);
+ const newCollection = await saveTranslatableCollection.execute({
+ collectionId: "collection:1",
+ items: [
+ { itemId: "element:1", value: "Value 1" },
+ { itemId: "element:2", value: "Value 2" }
+ ]
+ });
+
+ const getTranslatableCollection = new GetTranslatableCollectionUseCase(context);
+ const collection = await getTranslatableCollection.execute(newCollection.getCollectionId());
+
+ expect(collection).toBeTruthy();
+ expect(collection!.getCollectionId()).toEqual(newCollection.getCollectionId());
+
+ // Test
+ const deleteTranslatableCollection = new DeleteTranslatableCollectionUseCase(context);
+ await deleteTranslatableCollection.execute({
+ collectionId: "collection:1"
+ });
+
+ const checkCollection = await getTranslatableCollection.execute("collection:1");
+ expect(checkCollection).toBeUndefined();
+ });
+});
diff --git a/packages/api-page-builder/__tests__/translations/translatedCollection/DeleteTranslatedCollectionUseCase.test.ts b/packages/api-page-builder/__tests__/translations/translatedCollection/DeleteTranslatedCollectionUseCase.test.ts
new file mode 100644
index 00000000000..d0192645444
--- /dev/null
+++ b/packages/api-page-builder/__tests__/translations/translatedCollection/DeleteTranslatedCollectionUseCase.test.ts
@@ -0,0 +1,131 @@
+import { useHandler } from "~tests/translations/useHandler";
+import {
+ SaveTranslatableCollectionUseCase,
+ SaveTranslatableCollectionParams,
+ SaveTranslatedCollectionUseCase,
+ DeleteTranslatedCollectionUseCase,
+ GetTranslatedCollectionUseCase
+} from "~/translations";
+import { PbContext } from "~/graphql/types";
+
+const createTranslatableCollection = async (
+ context: PbContext,
+ params: SaveTranslatableCollectionParams
+) => {
+ const saveCollection = new SaveTranslatableCollectionUseCase(context);
+ await saveCollection.execute(params);
+};
+
+describe("DeleteTranslatedCollectionUseCase", () => {
+ it("should delete an entire collection with all translations", async () => {
+ const { handler } = useHandler();
+ const context = await handler();
+
+ // Setup
+ await createTranslatableCollection(context, {
+ collectionId: "collection:1",
+ items: [
+ { itemId: "element:1", value: "Value 1" },
+ { itemId: "element:2", value: "Value 2" },
+ { itemId: "element:3", value: "Value 3" }
+ ]
+ });
+
+ const saveTranslatedCollection = new SaveTranslatedCollectionUseCase(context);
+ await saveTranslatedCollection.execute({
+ collectionId: "collection:1",
+ languageCode: "en",
+ items: [
+ { itemId: "element:1", value: "Translated Value 1 EN" },
+ { itemId: "element:2", value: "Translated Value 2 EN" }
+ ]
+ });
+
+ await saveTranslatedCollection.execute({
+ collectionId: "collection:1",
+ languageCode: "de",
+ items: [
+ { itemId: "element:1", value: "Translated Value 1 DE" },
+ { itemId: "element:2", value: "Translated Value 2 DE" }
+ ]
+ });
+
+ // Test
+ const deleteTranslatedCollection = new DeleteTranslatedCollectionUseCase(context);
+ await deleteTranslatedCollection.execute({ collectionId: "collection:1" });
+
+ const getTranslatedCollection = new GetTranslatedCollectionUseCase(context);
+
+ await expect(
+ getTranslatedCollection.execute({
+ collectionId: "collection:1",
+ languageCode: "en"
+ })
+ ).rejects.toThrow("not found");
+
+ await expect(
+ getTranslatedCollection.execute({
+ collectionId: "collection:1",
+ languageCode: "de"
+ })
+ ).rejects.toThrow("not found");
+ });
+
+ it("should delete a collection for a given language", async () => {
+ const { handler } = useHandler();
+ const context = await handler();
+
+ // Setup
+ await createTranslatableCollection(context, {
+ collectionId: "collection:1",
+ items: [
+ { itemId: "element:1", value: "Value 1" },
+ { itemId: "element:2", value: "Value 2" },
+ { itemId: "element:3", value: "Value 3" }
+ ]
+ });
+
+ const saveTranslatedCollection = new SaveTranslatedCollectionUseCase(context);
+ await saveTranslatedCollection.execute({
+ collectionId: "collection:1",
+ languageCode: "en",
+ items: [
+ { itemId: "element:1", value: "Translated Value 1 EN" },
+ { itemId: "element:2", value: "Translated Value 2 EN" }
+ ]
+ });
+
+ await saveTranslatedCollection.execute({
+ collectionId: "collection:1",
+ languageCode: "de",
+ items: [
+ { itemId: "element:1", value: "Translated Value 1 DE" },
+ { itemId: "element:2", value: "Translated Value 2 DE" }
+ ]
+ });
+
+ // Test
+ const deleteTranslatedCollection = new DeleteTranslatedCollectionUseCase(context);
+ await deleteTranslatedCollection.execute({
+ collectionId: "collection:1",
+ languageCode: "en"
+ });
+
+ const getTranslatedCollection = new GetTranslatedCollectionUseCase(context);
+
+ await expect(
+ getTranslatedCollection.execute({
+ collectionId: "collection:1",
+ languageCode: "en"
+ })
+ ).rejects.toThrow("not found");
+
+ const deCollection = await getTranslatedCollection.execute({
+ collectionId: "collection:1",
+ languageCode: "de"
+ });
+
+ expect(deCollection.getCollectionId()).toBe("collection:1");
+ expect(deCollection.getLanguageCode()).toBe("de");
+ });
+});
diff --git a/packages/api-page-builder/src/graphql/crud/categories.crud.ts b/packages/api-page-builder/src/graphql/crud/categories.crud.ts
index 158335ce06e..f94e6e68ac6 100644
--- a/packages/api-page-builder/src/graphql/crud/categories.crud.ts
+++ b/packages/api-page-builder/src/graphql/crud/categories.crud.ts
@@ -23,6 +23,8 @@ import {
import { createZodError, removeUndefinedValues } from "@webiny/utils";
import { CategoriesPermissions } from "~/graphql/crud/permissions/CategoriesPermissions";
import { PagesPermissions } from "~/graphql/crud/permissions/PagesPermissions";
+import { getDate } from "~/graphql/crud/utils/getDate";
+import { getIdentity } from "~/graphql/crud/utils/getIdentity";
export interface CreateCategoriesCrudParams {
context: PbContext;
@@ -172,17 +174,14 @@ export const createCategoriesCrud = (params: CreateCategoriesCrudParams): Catego
}
const identity = context.security.getIdentity();
+ const currentDateTime = new Date();
const data = validationResult.data;
const category: Category = {
...data,
- createdOn: new Date().toISOString(),
- createdBy: {
- id: identity.id,
- type: identity.type,
- displayName: identity.displayName
- },
+ createdOn: getDate(input.createdOn, currentDateTime),
+ createdBy: getIdentity(input.createdBy, identity),
tenant: getTenantId(),
locale: getLocaleCode()
};
diff --git a/packages/api-page-builder/src/graphql/crud/categories/validation.ts b/packages/api-page-builder/src/graphql/crud/categories/validation.ts
index 9dd72aee443..bd1b10e31fc 100644
--- a/packages/api-page-builder/src/graphql/crud/categories/validation.ts
+++ b/packages/api-page-builder/src/graphql/crud/categories/validation.ts
@@ -8,7 +8,15 @@ const baseValidation = zod.object({
export const createCategoryCreateValidation = () => {
return baseValidation.extend({
- slug: zod.string().min(1).max(100)
+ slug: zod.string().min(1).max(100),
+ createdOn: zod.date().optional(),
+ createdBy: zod
+ .object({
+ id: zod.string(),
+ type: zod.string(),
+ displayName: zod.string().nullable()
+ })
+ .optional()
});
};
diff --git a/packages/api-page-builder/src/graphql/crud/menus.crud.ts b/packages/api-page-builder/src/graphql/crud/menus.crud.ts
index 8bdb7804c9a..2efa4b84f62 100644
--- a/packages/api-page-builder/src/graphql/crud/menus.crud.ts
+++ b/packages/api-page-builder/src/graphql/crud/menus.crud.ts
@@ -23,6 +23,8 @@ import {
} from "~/graphql/crud/menus/validation";
import { createZodError, removeUndefinedValues } from "@webiny/utils";
import { MenusPermissions } from "~/graphql/crud/permissions/MenusPermissions";
+import { getIdentity } from "./utils/getIdentity";
+import { getDate } from "./utils/getDate";
export interface CreateMenuCrudParams {
context: PbContext;
@@ -175,16 +177,13 @@ export const createMenuCrud = (params: CreateMenuCrudParams): MenusCrud => {
}
const identity = context.security.getIdentity();
+ const currentDateTime = new Date();
const menu: Menu = {
...data,
items: data.items || [],
- createdOn: new Date().toISOString(),
- createdBy: {
- id: identity.id,
- type: identity.type,
- displayName: identity.displayName
- },
+ createdOn: getDate(input.createdOn, currentDateTime),
+ createdBy: getIdentity(input.createdBy, identity),
tenant: getTenantId(),
locale: getLocaleCode()
};
diff --git a/packages/api-page-builder/src/graphql/crud/menus/validation.ts b/packages/api-page-builder/src/graphql/crud/menus/validation.ts
index b048c915bef..f150ccf9a29 100644
--- a/packages/api-page-builder/src/graphql/crud/menus/validation.ts
+++ b/packages/api-page-builder/src/graphql/crud/menus/validation.ts
@@ -8,7 +8,15 @@ const baseValidation = zod.object({
export const createMenuCreateValidation = () => {
return baseValidation.extend({
- slug: zod.string().min(1).max(100)
+ slug: zod.string().min(1).max(100),
+ createdOn: zod.date().optional(),
+ createdBy: zod
+ .object({
+ id: zod.string(),
+ type: zod.string(),
+ displayName: zod.string().nullable()
+ })
+ .optional()
});
};
diff --git a/packages/api-page-builder/src/graphql/crud/pages.crud.ts b/packages/api-page-builder/src/graphql/crud/pages.crud.ts
index 7f8fb4a8e84..5105867920c 100644
--- a/packages/api-page-builder/src/graphql/crud/pages.crud.ts
+++ b/packages/api-page-builder/src/graphql/crud/pages.crud.ts
@@ -52,6 +52,8 @@ import {
import { createCompression } from "~/graphql/crud/pages/compression";
import { PagesPermissions } from "./permissions/PagesPermissions";
import { PageContent } from "./pages/PageContent";
+import { getDate } from "~/graphql/crud/utils/getDate";
+import { getIdentity } from "~/graphql/crud/utils/getIdentity";
const STATUS_DRAFT = "draft";
const STATUS_PUBLISHED = "published";
@@ -289,12 +291,12 @@ export const createPageCrud = (params: CreatePageCrudParams): PagesCrud => {
async processPageContent(page) {
return processPageContent(page, pageElementProcessors);
},
- async createPage(this: PageBuilderContextObject, slug, meta): Promise {
+ async createPage(this: PageBuilderContextObject, categorySlug, meta): Promise {
await pagesPermissions.ensure({ rwd: "w" });
- const category = await this.getCategory(slug);
+ const category = await this.getCategory(categorySlug);
if (!category) {
- throw new NotFoundError(`Category with slug "${slug}" not found.`);
+ throw new NotFoundError(`Category with slug "${categorySlug}" not found.`);
}
const title = "Untitled";
@@ -399,7 +401,160 @@ export const createPageCrud = (params: CreatePageCrudParams): PagesCrud => {
await storageOperations.pages.create({
input: {
- slug
+ slug: categorySlug
+ },
+ page: await compressPage(page)
+ });
+ await onPageAfterCreate.publish({ page, meta });
+
+ return page;
+ } catch (ex) {
+ throw new WebinyError(
+ ex.message || "Could not create new page.",
+ ex.code || "CREATE_PAGE_ERROR",
+ {
+ ...(ex.data || {}),
+ page
+ }
+ );
+ }
+ },
+
+ async createPageV2(this: PageBuilderContextObject, input, meta): Promise {
+ await pagesPermissions.ensure({ rwd: "w" });
+
+ const categorySlug = input.category;
+ if (!categorySlug) {
+ throw new WebinyError("Category slug is missing.", "CATEGORY_SLUG_MISSING");
+ }
+
+ const category = await this.getCategory(categorySlug);
+ if (!category) {
+ throw new NotFoundError(`Category with slug "${categorySlug}" not found.`);
+ }
+
+ const title = input.title || "Untitled";
+
+ let pagePath = input.path;
+ if (!pagePath) {
+ if (category.slug === "static") {
+ pagePath = normalizePath("untitled-" + uniqid.time()) as string;
+ } else {
+ pagePath = normalizePath(
+ [category.url, "untitled-" + uniqid.time()].join("/").replace(/\/\//g, "/")
+ ) as string;
+ }
+ }
+
+ const result = await createPageCreateValidation().safeParseAsync({
+ category: category.slug
+ });
+ if (!result.success) {
+ throw createZodError(result.error);
+ }
+
+ const currentIdentity = context.security.getIdentity();
+ const currentDateTime = new Date();
+
+ let pageId = "",
+ version = 1;
+ if (input.id) {
+ const splitId = input.id.split("#");
+ pageId = splitId[0];
+ version = Number(splitId[1]);
+ } else if (input.pid) {
+ pageId = input.pid;
+ } else {
+ pageId = mdbid();
+ }
+
+ if (input.version) {
+ version = input.version;
+ }
+
+ const id = createIdentifier({
+ id: pageId,
+ version: 1
+ });
+
+ const rawSettings = input.settings || {
+ general: {
+ layout: category.layout
+ },
+ social: {
+ description: null,
+ image: null,
+ meta: [],
+ title: null
+ },
+ seo: {
+ title: null,
+ description: null,
+ meta: []
+ }
+ };
+
+ const validation = createPageSettingsUpdateValidation();
+ const settingsValidationResult = validation.safeParse(rawSettings);
+ if (!settingsValidationResult.success) {
+ throw createZodError(settingsValidationResult.error);
+ }
+
+ const settings = settingsValidationResult.data;
+ const status = input.status || STATUS_DRAFT;
+ const locked = status !== STATUS_DRAFT;
+
+ let publishedOn = null;
+ if (status === STATUS_PUBLISHED) {
+ publishedOn = getDate(input.publishedOn, currentDateTime);
+ }
+
+ const page: Page = {
+ id,
+ pid: pageId,
+ locale: getLocaleCode(),
+ tenant: getTenantId(),
+ editor: DEFAULT_EDITOR,
+ category: category.slug,
+ title,
+ path: pagePath,
+ version,
+ status,
+ locked,
+ publishedOn,
+ createdFrom: null,
+ settings: {
+ ...settings,
+ general: {
+ ...settings.general,
+ tags: settings.general?.tags || undefined
+ },
+ social: {
+ ...settings.social,
+ meta: settings.social?.meta || []
+ },
+ seo: {
+ ...settings.seo,
+ meta: settings.seo?.meta || []
+ }
+ },
+ createdOn: getDate(input.createdOn, currentDateTime),
+ savedOn: getDate(input.createdOn, currentDateTime),
+ createdBy: getIdentity(input.createdBy, currentIdentity),
+ ownedBy: getIdentity(input.createdBy, currentIdentity),
+ content: input.content || PageContent.createEmpty().getValue(),
+ webinyVersion: context.WEBINY_VERSION
+ };
+
+ try {
+ await onPageBeforeCreate.publish({
+ page,
+ meta
+ });
+
+ await storageOperations.pages.create({
+ input: {
+ slug: categorySlug
},
page: await compressPage(page)
});
diff --git a/packages/api-page-builder/src/graphql/crud/utils/formatDate.ts b/packages/api-page-builder/src/graphql/crud/utils/formatDate.ts
new file mode 100644
index 00000000000..b3063eb352f
--- /dev/null
+++ b/packages/api-page-builder/src/graphql/crud/utils/formatDate.ts
@@ -0,0 +1,12 @@
+/**
+ * Should not be used by users as method is prone to breaking changes.
+ * @internal
+ */
+export const formatDate = (date?: Date | string | null): string | null => {
+ if (!date) {
+ return null;
+ } else if (date instanceof Date) {
+ return date.toISOString();
+ }
+ return new Date(date).toISOString();
+};
diff --git a/packages/api-page-builder/src/graphql/crud/utils/getDate.ts b/packages/api-page-builder/src/graphql/crud/utils/getDate.ts
new file mode 100644
index 00000000000..2d4cd08fc5f
--- /dev/null
+++ b/packages/api-page-builder/src/graphql/crud/utils/getDate.ts
@@ -0,0 +1,14 @@
+import { formatDate } from "./formatDate";
+
+export const getDate = (
+ input?: Date | string | null,
+ defaultValue?: Date | string | null
+): T => {
+ if (!input) {
+ return formatDate(defaultValue) as T;
+ }
+ if (input instanceof Date) {
+ return formatDate(input) as T;
+ }
+ return formatDate(new Date(input)) as T;
+};
diff --git a/packages/api-page-builder/src/graphql/crud/utils/getIdentity.ts b/packages/api-page-builder/src/graphql/crud/utils/getIdentity.ts
new file mode 100644
index 00000000000..994e94d0662
--- /dev/null
+++ b/packages/api-page-builder/src/graphql/crud/utils/getIdentity.ts
@@ -0,0 +1,16 @@
+import { SecurityIdentity } from "@webiny/api-security/types";
+
+export const getIdentity = (
+ input: SecurityIdentity | null | undefined,
+ defaultValue: T | null = null
+): T => {
+ const identity = input?.id && input?.displayName && input?.type ? input : defaultValue;
+ if (!identity) {
+ return null as T;
+ }
+ return {
+ id: identity.id,
+ displayName: identity.displayName,
+ type: identity.type
+ } as T;
+};
diff --git a/packages/api-page-builder/src/graphql/graphql/base.gql.ts b/packages/api-page-builder/src/graphql/graphql/base.gql.ts
index ad5016f52fc..0175ff29e75 100644
--- a/packages/api-page-builder/src/graphql/graphql/base.gql.ts
+++ b/packages/api-page-builder/src/graphql/graphql/base.gql.ts
@@ -23,6 +23,12 @@ export const createBaseGraphQL = (): GraphQLSchemaPlugin => {
type: String
}
+ input PbIdentityInput {
+ id: ID!
+ displayName: String!
+ type: String!
+ }
+
type PbError {
code: String
message: String
diff --git a/packages/api-page-builder/src/graphql/graphql/categories.gql.ts b/packages/api-page-builder/src/graphql/graphql/categories.gql.ts
index 7f17ec6db47..a729d829931 100644
--- a/packages/api-page-builder/src/graphql/graphql/categories.gql.ts
+++ b/packages/api-page-builder/src/graphql/graphql/categories.gql.ts
@@ -29,6 +29,8 @@ export const createCategoryGraphQL = (): GraphQLSchemaPlugin => {
slug: String!
url: String!
layout: String!
+ createdBy: PbIdentityInput
+ createdOn: DateTime
}
# Response types
diff --git a/packages/api-page-builder/src/graphql/graphql/menus.gql.ts b/packages/api-page-builder/src/graphql/graphql/menus.gql.ts
index d37aceaaa01..c9c98e21124 100644
--- a/packages/api-page-builder/src/graphql/graphql/menus.gql.ts
+++ b/packages/api-page-builder/src/graphql/graphql/menus.gql.ts
@@ -23,6 +23,8 @@ export const createMenuGraphQL = (): GraphQLSchemaPlugin => {
slug: String!
description: String
items: [JSON]
+ createdBy: PbIdentityInput
+ createdOn: DateTime
}
# Response types
diff --git a/packages/api-page-builder/src/graphql/graphql/pages.gql.ts b/packages/api-page-builder/src/graphql/graphql/pages.gql.ts
index 5f910fdafab..1c938afc965 100644
--- a/packages/api-page-builder/src/graphql/graphql/pages.gql.ts
+++ b/packages/api-page-builder/src/graphql/graphql/pages.gql.ts
@@ -109,6 +109,24 @@ const createBasePageGraphQL = (): GraphQLSchemaPlugin => {
dataBindings: [DataBindingInput!]
}
+ input PbCreatePageV2Input {
+ id: ID
+ pid: ID
+ category: ID
+ title: String
+ version: Int
+ path: String
+ content: JSON
+ savedOn: DateTime
+ status: String
+ publishedOn: DateTime
+ settings: PbPageSettingsInput
+ createdOn: DateTime
+ createdBy: PbIdentityInput
+ dataSources: [DataSourceInput!]
+ dataBindings: [DataBindingInput!]
+ }
+
input PbPageSettingsInput {
_empty: String
}
@@ -242,6 +260,8 @@ const createBasePageGraphQL = (): GraphQLSchemaPlugin => {
extend type PbMutation {
createPage(from: ID, category: String, meta: JSON): PbPageResponse
+ createPageV2(data: PbCreatePageV2Input!): PbPageResponse
+
# Update page by given ID.
updatePage(id: ID!, data: PbUpdatePageInput!): PbPageResponse
@@ -457,6 +477,12 @@ const createBasePageGraphQL = (): GraphQLSchemaPlugin => {
return context.pageBuilder.createPage(category as string, meta);
});
},
+ createPageV2: async (_, args: any, context) => {
+ return resolve(() => {
+ const { data } = args;
+ return context.pageBuilder.createPageV2(data);
+ });
+ },
deletePage: async (_, args: any, context) => {
return resolve(async () => {
diff --git a/packages/api-page-builder/src/graphql/types.ts b/packages/api-page-builder/src/graphql/types.ts
index fc2ad2e23c7..a61f56163ae 100644
--- a/packages/api-page-builder/src/graphql/types.ts
+++ b/packages/api-page-builder/src/graphql/types.ts
@@ -9,6 +9,7 @@ import { Context as BaseContext } from "@webiny/handler/types";
import {
BlockCategory,
Category,
+ CreatedBy,
DefaultSettings,
DynamicDocument,
Menu,
@@ -17,6 +18,7 @@ import {
PageElement,
PageSettings,
PageSpecialType,
+ PageStatus,
PageTemplate,
PageTemplateInput,
Settings,
@@ -32,8 +34,10 @@ export interface ListPagesParamsWhere {
category?: string;
status?: string;
tags?: { query: string[]; rule?: "any" | "all" };
+
[key: string]: any;
}
+
export interface ListPagesParams {
limit?: number;
after?: string | null;
@@ -75,6 +79,7 @@ export interface OnPageBeforeCreateTopicParams {
page: TPage;
meta?: Record;
}
+
/**
* @category Lifecycle events
*/
@@ -82,6 +87,7 @@ export interface OnPageAfterCreateTopicParams {
page: TPage;
meta?: Record;
}
+
/**
* @category Lifecycle events
*/
@@ -90,6 +96,7 @@ export interface OnPageBeforeUpdateTopicParams {
page: TPage;
input: Record;
}
+
/**
* @category Lifecycle events
*/
@@ -98,6 +105,7 @@ export interface OnPageAfterUpdateTopicParams {
page: TPage;
input: Record;
}
+
/**
* @category Lifecycle events
*/
@@ -105,6 +113,7 @@ export interface OnPageBeforeCreateFromTopicParams {
original: TPage;
page: TPage;
}
+
/**
* @category Lifecycle events
*/
@@ -112,6 +121,7 @@ export interface OnPageAfterCreateFromTopicParams {
original: TPage;
page: TPage;
}
+
/**
* @category Lifecycle events
*/
@@ -121,6 +131,7 @@ export interface OnPageBeforeDeleteTopicParams {
publishedPage: TPage | null;
deleteMethod: "deleteAll" | "delete";
}
+
/**
* @category Lifecycle events
*/
@@ -130,6 +141,7 @@ export interface OnPageAfterDeleteTopicParams {
publishedPage: TPage | null;
deleteMethod: "deleteAll" | "delete";
}
+
/**
* @category Lifecycle events
*/
@@ -138,6 +150,7 @@ export interface OnPageBeforePublishTopicParams {
latestPage: TPage;
publishedPage: TPage | null;
}
+
/**
* @category Lifecycle events
*/
@@ -146,6 +159,7 @@ export interface OnPageAfterPublishTopicParams {
latestPage: TPage;
publishedPage: TPage | null;
}
+
/**
* @category Lifecycle events
*/
@@ -153,6 +167,7 @@ export interface OnPageBeforeUnpublishTopicParams {
page: TPage;
latestPage: TPage;
}
+
/**
* @category Lifecycle events
*/
@@ -193,34 +208,54 @@ export interface PageElementProcessor {
*/
export interface PagesCrud {
addPageElementProcessor(processor: PageElementProcessor): void;
+
processPageContent(content: Page): Promise;
+
getPage(id: string, options?: GetPagesOptions): Promise;
+
listLatestPages(
args: ListPagesParams,
options?: ListLatestPagesOptions
): Promise<[TPage[], ListMeta]>;
+
listPublishedPages(
args: ListPagesParams
): Promise<[TPage[], ListMeta]>;
+
listPagesTags(args: { search: { query: string } }): Promise;
+
getPublishedPageById(args: {
id: string;
preview?: boolean;
}): Promise;
+
getPublishedPageByPath(args: { path: string }): Promise;
+
listPageRevisions(id: string): Promise;
+
createPage(
category: string,
meta?: Record
): Promise;
+
+ createPageV2(
+ data: PbCreatePageV2Input,
+ meta?: Record
+ ): Promise;
+
createPageFrom(
page: string,
meta?: Record