From 8b6a05c1f5f71963db910d3f7dfe35750d714863 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis Date: Fri, 1 Dec 2023 15:39:52 +0000 Subject: [PATCH 1/7] feat: created rules for condition group --- apps/admin/src/plugins/formBuilder.ts | 2 + .../theme/layouts/forms/DefaultFormLayout.tsx | 19 +- .../layouts/forms/DefaultFormLayout/Field.tsx | 2 + .../src/plugins/crud/forms.crud.ts | 3 +- .../src/plugins/crud/forms.models.ts | 3 +- .../src/plugins/graphql/form.ts | 34 ++ packages/api-form-builder/src/types.ts | 17 + .../Context/functions/deleteField.ts | 14 +- .../Context/functions/getContainerLayout.ts | 31 ++ .../functions/handleDeleteConditionGroup.ts | 55 +++ .../Context/functions/handleMoveField.ts | 37 ++ .../{ => handleMoveField}/getFieldPosition.ts | 10 +- .../functions/handleMoveField/moveField.ts | 132 ++++++++ .../handleMoveField/moveFieldBetween.ts | 89 +++++ .../Context/functions/handleMoveRow.ts | 55 +++ .../functions/handleMoveRow/moveRow.ts | 23 ++ .../functions/handleMoveRow/moveRowBetween.ts | 25 ++ .../FormEditor/Context/functions/index.ts | 10 +- .../FormEditor/Context/functions/moveField.ts | 68 ---- .../functions/moveFieldBetweenSteps.ts | 74 ---- .../FormEditor/Context/functions/moveRow.ts | 23 -- .../Context/functions/moveRowBetweenSteps.ts | 20 -- .../FormEditor/Context/functions/moveStep.ts | 29 +- .../Context/functions/validateStepRule.ts | 39 +++ .../components/FormEditor/Context/graphql.ts | 26 ++ .../Context/useFormEditorFactory.tsx | 319 ++++++++++++------ .../admin/components/FormEditor/Draggable.tsx | 10 +- .../admin/components/FormEditor/Droppable.tsx | 27 +- .../Tabs/EditTab/ConditionGroup.tsx | 298 ++++++++++++++++ .../Tabs/EditTab/EditFieldDialog.tsx | 38 ++- .../EditFieldDialog/DefaultBehaviour.tsx | 49 +++ .../EditFieldDialog/RuleActionSelect.tsx | 49 +++ .../EditFieldDialog/RulesConditions.tsx | 130 +++++++ .../Tabs/EditTab/EditFieldDialog/RulesTab.tsx | 277 +++++++++++++++ .../FormEditor/Tabs/EditTab/EditTab.tsx | 131 +++---- .../EditTab/FormStep/EditFormStepDialog.tsx | 105 ------ .../FormStep/EditFormStepDialog/DateTime.tsx | 100 ++++++ .../EditFormStepDialog/EditFormStepDialog.tsx | 133 ++++++++ .../EditFormStepDialog/GeneralTab.tsx | 16 + .../EditFormStepDialog/RuleActionSelect.tsx | 87 +++++ .../EditFormStepDialog/RuleCondition.tsx | 114 +++++++ .../FormStep/EditFormStepDialog/RulesTab.tsx | 263 +++++++++++++++ .../fieldsValidationConditions.ts | 100 ++++++ .../FormStep/EditFormStepDialog/helpers.ts | 36 ++ .../renderConditionValueController.tsx | 114 +++++++ .../updateRuleConditions.ts | 28 ++ .../Tabs/EditTab/FormStep/FormStep.tsx | 277 ++++++++------- .../FormStep/useValidateConditionGroupRule.ts | 49 +++ .../FormEditor/Tabs/EditTab/Styled.ts | 90 ++++- .../editor/defaultBar/PublishFormButton.tsx | 60 ++-- .../plugins/editor/formFields/checkboxes.tsx | 1 + .../editor/formFields/conditionGroup.tsx | 35 ++ .../plugins/editor/formFields/dateTime.tsx | 1 + .../plugins/editor/formFields/hidden.tsx | 1 + .../plugins/editor/formFields/number.tsx | 1 + .../editor/formFields/radioButtons.tsx | 1 + .../plugins/editor/formFields/select.tsx | 1 + .../admin/plugins/editor/formFields/text.tsx | 1 + .../plugins/editor/formFields/textarea.tsx | 1 + .../src/components/Form/FormRender.tsx | 94 +++++- .../Form/functions/getNextStepIndex.ts | 218 ++++++++++++ .../src/components/Form/functions/index.ts | 1 + .../src/components/Form/graphql.ts | 13 + .../src/hooks/useFormDragAndDrop.ts | 139 ++++++++ packages/app-form-builder/src/types.ts | 93 ++++- .../src/renderers/form/FormRender.tsx | 97 +++++- .../FormRender/functions/getNextStepIndex.ts | 218 ++++++++++++ .../form/FormRender/functions/index.ts | 1 + .../src/renderers/form/dataLoaders/graphql.ts | 13 + .../src/renderers/form/types.ts | 22 +- .../app-page-builder-elements/src/types.ts | 16 + 71 files changed, 4037 insertions(+), 671 deletions(-) create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Context/functions/getContainerLayout.ts create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleDeleteConditionGroup.ts create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveField.ts rename packages/app-form-builder/src/admin/components/FormEditor/Context/functions/{ => handleMoveField}/getFieldPosition.ts (62%) create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveField/moveField.ts create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveField/moveFieldBetween.ts create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveRow.ts create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveRow/moveRow.ts create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveRow/moveRowBetween.ts delete mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveField.ts delete mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveFieldBetweenSteps.ts delete mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveRow.ts delete mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveRowBetweenSteps.ts create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Context/functions/validateStepRule.ts create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/ConditionGroup.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/DefaultBehaviour.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RuleActionSelect.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesConditions.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab.tsx delete mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/DateTime.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/EditFormStepDialog.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/GeneralTab.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleActionSelect.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleCondition.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/fieldsValidationConditions.ts create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/helpers.ts create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/renderConditionValueController.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/updateRuleConditions.ts create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/useValidateConditionGroupRule.ts create mode 100644 packages/app-form-builder/src/admin/plugins/editor/formFields/conditionGroup.tsx create mode 100644 packages/app-form-builder/src/components/Form/functions/getNextStepIndex.ts create mode 100644 packages/app-form-builder/src/hooks/useFormDragAndDrop.ts create mode 100644 packages/app-page-builder-elements/src/renderers/form/FormRender/functions/getNextStepIndex.ts diff --git a/apps/admin/src/plugins/formBuilder.ts b/apps/admin/src/plugins/formBuilder.ts index d04961978c3..33076facc8e 100644 --- a/apps/admin/src/plugins/formBuilder.ts +++ b/apps/admin/src/plugins/formBuilder.ts @@ -13,6 +13,7 @@ import editorFieldNumber from "@webiny/app-form-builder/admin/plugins/editor/for import editorFieldRadioButtons from "@webiny/app-form-builder/admin/plugins/editor/formFields/radioButtons"; import editorFieldCheckboxes from "@webiny/app-form-builder/admin/plugins/editor/formFields/checkboxes"; import editorFieldDateTime from "@webiny/app-form-builder/admin/plugins/editor/formFields/dateTime"; +import editorFieldConditionGroup from "@webiny/app-form-builder/admin/plugins/editor/formFields/conditionGroup"; import editorFieldFirstName from "@webiny/app-form-builder/admin/plugins/editor/formFields/contact/firstName"; import editorFieldLastName from "@webiny/app-form-builder/admin/plugins/editor/formFields/contact/lastName"; import editorFieldEmail from "@webiny/app-form-builder/admin/plugins/editor/formFields/contact/email"; @@ -84,6 +85,7 @@ export default [ editorFieldRadioButtons, editorFieldCheckboxes, editorFieldDateTime, + editorFieldConditionGroup, editorFieldFirstName, editorFieldLastName, editorFieldEmail, diff --git a/apps/theme/layouts/forms/DefaultFormLayout.tsx b/apps/theme/layouts/forms/DefaultFormLayout.tsx index 537486e69da..5f39d8ee481 100644 --- a/apps/theme/layouts/forms/DefaultFormLayout.tsx +++ b/apps/theme/layouts/forms/DefaultFormLayout.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { Form } from "@webiny/form"; import { FormLayoutComponent } from "@webiny/app-form-builder/types"; import styled from "@emotion/styled"; @@ -48,6 +48,8 @@ const DefaultFormLayout: FormLayoutComponent = ({ submit, goToNextStep, goToPreviousStep, + validateStepConditions, + setFormState, isLastStep, isFirstStep, isMultiStepForm, @@ -65,6 +67,12 @@ const DefaultFormLayout: FormLayoutComponent = ({ // Is the form successfully submitted? const [formSuccess, setFormSuccess] = useState(false); + const [defData, setDefData] = useState(getDefaultValues()); + + useEffect(() => { + setDefData(getDefaultValues()); + }, [formData.fields]); + // All form fields - an array of rows where each row is an array that contain fields. const fields = getFields(currentStepIndex); @@ -89,7 +97,14 @@ const DefaultFormLayout: FormLayoutComponent = ({ return ( /* "onSubmit" callback gets triggered once all the fields are valid. */ /* We also pass the default values for all fields via the getDefaultValues callback. */ -
+ { + validateStepConditions(data, currentStepIndex); + setFormState(data); + }} + > {({ submit }) => ( {isMultiStepForm && {currentStep?.title}} diff --git a/apps/theme/layouts/forms/DefaultFormLayout/Field.tsx b/apps/theme/layouts/forms/DefaultFormLayout/Field.tsx index 30a05939a91..ae335a34d8d 100644 --- a/apps/theme/layouts/forms/DefaultFormLayout/Field.tsx +++ b/apps/theme/layouts/forms/DefaultFormLayout/Field.tsx @@ -31,6 +31,8 @@ export const Field: React.FC<{ return ; case "datetime": return ; + case "condition-group": + return null; default: return Cannot render field.; } diff --git a/packages/api-form-builder/src/plugins/crud/forms.crud.ts b/packages/api-form-builder/src/plugins/crud/forms.crud.ts index e750165325c..4a78a6a3004 100644 --- a/packages/api-form-builder/src/plugins/crud/forms.crud.ts +++ b/packages/api-form-builder/src/plugins/crud/forms.crud.ts @@ -355,7 +355,8 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { steps: [ { title: "Step 1", - layout: [] + layout: [], + rules: [] } ], settings: await new models.FormSettingsModel().toJSON(), diff --git a/packages/api-form-builder/src/plugins/crud/forms.models.ts b/packages/api-form-builder/src/plugins/crud/forms.models.ts index c1ae3c5c5e1..db073c0eb51 100644 --- a/packages/api-form-builder/src/plugins/crud/forms.models.ts +++ b/packages/api-form-builder/src/plugins/crud/forms.models.ts @@ -46,7 +46,8 @@ export const FormStepsModel = withFields({ value: {}, instanceOf: withFields({ title: string(), - layout: object({ value: [] }) + layout: object({ value: [] }), + rules: object({ value: [] }) })() }) })(); diff --git a/packages/api-form-builder/src/plugins/graphql/form.ts b/packages/api-form-builder/src/plugins/graphql/form.ts index b81bcca0a09..3932f63471e 100644 --- a/packages/api-form-builder/src/plugins/graphql/form.ts +++ b/packages/api-form-builder/src/plugins/graphql/form.ts @@ -57,6 +57,23 @@ const plugin: GraphQLSchemaPlugin = { input FbFormStepInput { title: String layout: [[String]] + rules: [FbFormRuleInput] + } + + input FbFormRuleInput { + title: String + action: String + chain: String + id: String + conditions: [FbFormConditionInput] + isValid: Boolean + } + + input FbFormConditionInput { + id: String + fieldName: String + filterType: String + filterValue: String } input FbFieldOptionsInput { @@ -79,6 +96,23 @@ const plugin: GraphQLSchemaPlugin = { type FbFormStepType { title: String layout: [[String]] + rules: [FbFormRuleType] + } + + type FbFormRuleType { + title: String + action: String + chain: String + id: String + conditions: [FbFormConditionType] + isValid: Boolean + } + + type FbFormConditionType { + id: String + fieldName: String + filterType: String + filterValue: String } type FbFormFieldType { diff --git a/packages/api-form-builder/src/types.ts b/packages/api-form-builder/src/types.ts index 8d69c9ba340..fe8cd245066 100644 --- a/packages/api-form-builder/src/types.ts +++ b/packages/api-form-builder/src/types.ts @@ -19,8 +19,25 @@ interface FbSubmissionMeta { interface FbFormStep { title: string; layout: string[][]; + rules: FbFormRule[]; } +export type FbFormRule = { + action: string; + chain: string; + id: string; + title: string; + conditions: FbFormCondition[]; + isValid: boolean; +}; + +export type FbFormCondition = { + id: string; + fieldName: string; + filterType: string; + filterValue: string; +}; + interface FbFormFieldValidator { name: string; message: any; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/deleteField.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/deleteField.ts index a07e265f87d..f251dc3d469 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/deleteField.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/deleteField.ts @@ -3,9 +3,10 @@ import { FbFormModelField, FbFormModel, FbFormModelFieldsLayout, FbFormStep } fr interface Params { field: FbFormModelField; data: FbFormModel; - targetStepId: string; + containerType?: "step" | "conditionGroup"; + containerId: string; } -export default ({ field, data, targetStepId }: Params): FbFormModel => { +export default ({ field, data, containerType, containerId }: Params): FbFormModel => { // Remove the field from fields list... const fieldIndex = data.fields.findIndex(item => item._id === field._id); data.fields.splice(fieldIndex, 1); @@ -18,10 +19,13 @@ export default ({ field, data, targetStepId }: Params): FbFormModel => { // ...and rebuild the layout object. const layout: FbFormModelFieldsLayout = []; - const targetStepLayout = data.steps.find(s => s.id === targetStepId) as FbFormStep; + const destinationContainerLayout = + containerType === "conditionGroup" + ? (data.fields.find(f => f._id === containerId)?.settings as FbFormStep) + : (data.steps.find(step => step.id === containerId) as FbFormStep); let currentRowIndex = 0; - targetStepLayout.layout.forEach(row => { + destinationContainerLayout.layout.forEach(row => { row.forEach(fieldId => { const field = data.fields.find(item => item._id === fieldId); if (!field) { @@ -36,6 +40,6 @@ export default ({ field, data, targetStepId }: Params): FbFormModel => { layout[currentRowIndex] && layout[currentRowIndex].length && currentRowIndex++; }); - targetStepLayout.layout = layout; + destinationContainerLayout.layout = layout; return data; }; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/getContainerLayout.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/getContainerLayout.ts new file mode 100644 index 00000000000..5184ced2589 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/getContainerLayout.ts @@ -0,0 +1,31 @@ +import { FbFormModel, FbFormStep, DropDestination, DropSource } from "~/types"; + +interface ContainerLayoutParams { + data: FbFormModel; + destination: DropDestination; + source: DropSource; +} +/* + This is a helper function that gets layout from the container based on its type. + * If "source" or "destination" is Condition Group then it would take layout from the settings of the Condition Group field. + * If "source" or "destination" is Step that it would take layout from the step itself. +*/ +export default (params: ContainerLayoutParams) => { + const { data, destination, source } = params; + + const sourceContainer = + source.containerType === "conditionGroup" + ? (data.fields.find(field => field._id === source.containerId)?.settings as FbFormStep) + : (data.steps.find(step => step.id === source.containerId) as FbFormStep); + + const destinationContainer = + destination.containerType === "conditionGroup" + ? (data.fields.find(field => field._id === destination.containerId) + ?.settings as FbFormStep) + : (data.steps.find(step => step.id === destination.containerId) as FbFormStep); + + return { + sourceContainer, + destinationContainer + }; +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleDeleteConditionGroup.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleDeleteConditionGroup.ts new file mode 100644 index 00000000000..b575ac3aa2b --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleDeleteConditionGroup.ts @@ -0,0 +1,55 @@ +import { FbFormModelField, FbFormStep, FbFormModel } from "~/types"; + +import { deleteField } from "./index"; + +interface DeleteConditionGroupParams { + data: FbFormModel; + formStep: FbFormStep; + stepFields: FbFormModelField[]; + conditionGroup: FbFormModelField; + conditionGroupFields: FbFormModelField[]; +} +/* + When we delete condition group we also need to delete fields inside of it, + because those fields belong directly (they are being stored in the setting of the condition group) to the condition group and not the step. +*/ +export default (params: DeleteConditionGroupParams) => { + const { data, formStep, stepFields, conditionGroup, conditionGroupFields } = params; + + const deleteConditionGroup = () => { + const layout = stepFields.map(field => { + if (field._id === conditionGroup._id) { + deleteField({ + field, + data, + containerType: "step", + containerId: formStep.id + }); + return; + } else { + return field; + } + }); + + return layout; + }; + + const deleteConditionGroupFields = () => { + const layout = conditionGroupFields.map(field => { + if (!conditionGroup._id) { + return; + } + deleteField({ + field, + data, + containerType: "conditionGroup", + containerId: conditionGroup._id + }); + }); + + return layout; + }; + deleteConditionGroupFields(); + + deleteConditionGroup(); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveField.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveField.ts new file mode 100644 index 00000000000..7c3dabaf223 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveField.ts @@ -0,0 +1,37 @@ +import { FbFormModel, FbFormModelField, DropTarget, DropSource, DropDestination } from "~/types"; +import moveField from "./handleMoveField/moveField"; +import moveFieldBetween from "./handleMoveField/moveFieldBetween"; + +interface HandleMoveField { + data: FbFormModel; + field: FbFormModelField | string; + target: DropTarget; + source: DropSource; + destination: DropDestination; +} + +export default ({ data, field, target, source, destination }: HandleMoveField) => { + if (source.containerId === destination.containerId) { + // This condition should cover: + // 1. When we move field in scope of one Step; + // 2. When we move field in scope of one Condition Group. + moveField({ + field, + data, + target, + destination, + source + }); + } else { + // This condition should cover: + // 1. When we move field in scope of two different Steps; + // 2. When we move field in scope of two different Condition Groups. + moveFieldBetween({ + data, + field, + target, + source, + destination + }); + } +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/getFieldPosition.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveField/getFieldPosition.ts similarity index 62% rename from packages/app-form-builder/src/admin/components/FormEditor/Context/functions/getFieldPosition.ts rename to packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveField/getFieldPosition.ts index 509d12e1b61..0db74a94de0 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/getFieldPosition.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveField/getFieldPosition.ts @@ -1,17 +1,17 @@ -import { FbFormModelField, FieldIdType, FieldLayoutPositionType, FbFormStep } from "~/types"; +import { FbFormModelField, FieldIdType, FieldLayoutPositionType } from "~/types"; interface GetFieldPositionResult extends Omit { index: number; } interface GetFieldPositionParams { field: FbFormModelField | FieldIdType; - data: FbFormStep; + layout: string[][]; } -export default ({ field, data }: GetFieldPositionParams): GetFieldPositionResult | null => { +export default ({ field, layout }: GetFieldPositionParams): GetFieldPositionResult | null => { const id = typeof field === "string" ? field : field._id; - for (let rowIndex = 0; rowIndex < data.layout.length; rowIndex++) { - const row = data.layout[rowIndex]; + for (let rowIndex = 0; rowIndex < layout.length; rowIndex++) { + const row = layout[rowIndex]; for (let fieldIndex = 0; fieldIndex < row.length; fieldIndex++) { if (row[fieldIndex] !== id) { continue; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveField/moveField.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveField/moveField.ts new file mode 100644 index 00000000000..9e239a03480 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveField/moveField.ts @@ -0,0 +1,132 @@ +import { + FbFormModel, + FbFormModelField, + FbFormStep, + DropTarget, + DropDestination, + DropSource +} from "~/types"; +import getFieldPosition from "./getFieldPosition"; + +/** + * Remove all rows that have zero fields in it. + * @param data + */ + +interface MoveField { + data: FbFormModel; + field: FbFormModelField | string; + target: DropTarget; + destination: DropDestination; + /* + We need "source" in case we are moving fields between condition group and step in scope of ONE STEP. + */ + source?: DropSource; +} + +const cleanupEmptyRows = ({ destination, data }: MoveField): void => { + const destinationLayout = + destination.containerType === "conditionGroup" + ? data.fields.find(f => f._id === destination.containerId)?.settings + : data.steps.find(step => step.id === destination.containerId); + + if (destinationLayout) { + destinationLayout.layout = destinationLayout?.layout.filter( + (row: string[][]) => row.length > 0 + ); + } +}; + +const moveField = (params: MoveField) => { + const { data, field, destination } = params; + const destinationContainerLayout = data.steps.find( + step => step.id === destination.containerId + ) as FbFormStep; + const fieldId = typeof field === "string" ? field : field._id; + if (!fieldId) { + console.log("Missing data when moving field."); + return; + } + if (destination.containerType === "conditionGroup") { + const destinationLayout = data.fields.find(f => f._id === destination.containerId); + + if (destinationLayout?.settings.layout) { + destinationLayout.settings.layout = destinationLayout?.settings.layout.filter( + (row: any) => Boolean(row) + ); + + const existingFieldPosition = getFieldPosition({ + field: fieldId, + layout: destinationLayout.settings.layout + }); + + if (existingFieldPosition) { + destinationLayout.settings.layout[existingFieldPosition.row].splice( + existingFieldPosition.index, + 1 + ); + } + + // Setting a form field into a new non-existing row. + if (!destinationLayout.settings.layout[destination.position.row]) { + destinationLayout.settings.layout[destination.position.row] = [fieldId]; + return; + } + + // If row exists, we drop the field at the specified index. + if (destination.position.index === null) { + // Create a new row with the new field at the given row index. + destinationLayout.settings.layout.splice(destination.position.row, 0, [fieldId]); + return; + } + + destinationLayout.settings.layout[destination.position.row].splice( + destination.position.index, + 0, + fieldId + ); + } + } + + if (destinationContainerLayout) { + destinationContainerLayout.layout = destinationContainerLayout?.layout.filter(row => + Boolean(row) + ); + + const existingFieldPosition = getFieldPosition({ + field: fieldId, + layout: destinationContainerLayout.layout + }); + + if (existingFieldPosition) { + destinationContainerLayout.layout[existingFieldPosition.row].splice( + existingFieldPosition.index, + 1 + ); + } + + // Setting a form field into a new non-existing row. + if (!destinationContainerLayout?.layout[destination.position.row]) { + destinationContainerLayout.layout[destination.position.row] = [fieldId]; + return; + } + + // If row exists, we drop the field at the specified index. + if (destination.position.index === null) { + // Create a new row with the new field at the given row index. + destinationContainerLayout.layout.splice(destination.position.row, 0, [fieldId]); + return; + } + + destinationContainerLayout.layout[destination.position.row].splice( + destination.position.index, + 0, + fieldId + ); + } +}; + +export default (params: MoveField) => { + moveField(params); + cleanupEmptyRows(params); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveField/moveFieldBetween.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveField/moveFieldBetween.ts new file mode 100644 index 00000000000..7cddc1af504 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveField/moveFieldBetween.ts @@ -0,0 +1,89 @@ +import { FbFormModel, FbFormModelField, DropTarget, DropDestination, DropSource } from "~/types"; +import getFieldPosition from "./getFieldPosition"; +import { getContainerLayout } from "../index"; + +/** + * Remove all rows that have zero fields in it. + * @param data + */ + +interface MoveFieldBetweenParams { + data: FbFormModel; + field: FbFormModelField | string; + target: DropTarget; + destination: DropDestination; + source: DropSource; +} + +const cleanupEmptyRows = (params: MoveFieldBetweenParams): void => { + const { data, destination, source } = params; + + const { + sourceContainer: sourceContainerLayout, + destinationContainer: destinationContainerLayout + } = getContainerLayout({ data, source, destination }); + + if (sourceContainerLayout) { + sourceContainerLayout.layout = sourceContainerLayout?.layout.filter(row => row.length > 0); + } + + destinationContainerLayout.layout = destinationContainerLayout.layout.filter( + row => row.length > 0 + ); +}; +/* + The difference between moving field between steps, step and condition group or between two condition groups: + * When we move field between steps we are going to change property "layout" of those steps; + * When we move field between step and condition group we are going to change property "layout" of step + and the property "layout" that is being stored inside of the Condition Group settings; + * When we move field between condition groups we are going to change property "layout" of those condition groups and we don't need information about steps in which + those condition groups are being stored, because we are not affecting layout of steps in this case. +*/ +const moveFieldBetween = (params: MoveFieldBetweenParams) => { + const { data, field, destination, source } = params; + const fieldId = typeof field === "string" ? field : field._id; + + if (!fieldId) { + console.log("Missing data when moving field."); + console.log(params); + return; + } + + const { + sourceContainer: sourceContainerLayout, + destinationContainer: destinationContainerLayout + } = getContainerLayout({ data, source, destination }); + + const existingPosition = getFieldPosition({ + field: fieldId, + layout: sourceContainerLayout.layout || destinationContainerLayout.layout + }); + + if (existingPosition) { + sourceContainerLayout.layout[existingPosition.row].splice(existingPosition.index, 1); + } + + // Setting a form field into a new non-existing row. + if (!destinationContainerLayout?.layout[destination.position.row]) { + destinationContainerLayout.layout[destination.position.row] = [fieldId]; + return; + } + + // If row exists, we drop the field at the specified index. + if (destination.position.index === null) { + // Create a new row with the new field at the given row index. + destinationContainerLayout.layout.splice(destination.position.row, 0, [fieldId]); + return; + } + + destinationContainerLayout.layout[destination.position.row].splice( + destination.position.index, + 0, + fieldId + ); +}; + +export default (params: MoveFieldBetweenParams) => { + moveFieldBetween(params); + cleanupEmptyRows(params); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveRow.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveRow.ts new file mode 100644 index 00000000000..5d82f01d501 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveRow.ts @@ -0,0 +1,55 @@ +import { FbFormModel, DropSource, DropDestination } from "~/types"; +import { getContainerLayout } from "./index"; +import moveRow from "./handleMoveRow/moveRow"; +import moveRowBetween from "./handleMoveRow/moveRowBetween"; + +interface HandleMoveRowParams { + data: FbFormModel; + source: DropSource; + destination: DropDestination; + sourceRow: number; + destinationRow: number; +} +/* + The difference between moving row between steps, step and condition group or between two condition groups: + * When we move row between steps we are going to change property "layout" of those steps. + * When we move row between step and condition group we are going to change property "layout" of step and the property "layout" of the Condition Group. + * When we move row between condition groups we are going to change property "layout" of those condition groups and we don't need information about steps in which those, + those condition groups are being stored, because we are not affecting layout of steps in this case. +*/ +export default ({ + data, + sourceRow, + destinationRow, + source, + destination +}: HandleMoveRowParams): void => { + if (source.containerId === destination.containerId) { + // This condition should cover such cases: + // 1) When we move rows in scope of one Step; + // 2) When we move rows in scope of one Condition Group + const { sourceContainer } = getContainerLayout({ data, source, destination }); + if (sourceContainer) { + moveRow({ + sourceRow, + destinationRow, + sourceContainer + }); + } + } else { + // This condition should cover such cases: + // 1) When we move rows in scope of two different Steps + // 2) When we move rows in scope of two different Condition Groups. + const { sourceContainer, destinationContainer } = getContainerLayout({ + data, + source, + destination + }); + moveRowBetween({ + sourceContainer, + destinationContainer, + sourceRow, + destinationRow + }); + } +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveRow/moveRow.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveRow/moveRow.ts new file mode 100644 index 00000000000..00bad91b2c3 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveRow/moveRow.ts @@ -0,0 +1,23 @@ +import { FbFormStep } from "~/types"; +interface MoveRowParams { + sourceRow: number; + destinationRow: number; + sourceContainer: FbFormStep; +} + +export default ({ sourceContainer, sourceRow, destinationRow }: MoveRowParams) => { + sourceContainer.layout = + sourceRow < destinationRow + ? [ + ...sourceContainer.layout.slice(0, sourceRow), + ...sourceContainer.layout.slice(sourceRow + 1, destinationRow), + sourceContainer.layout[sourceRow], + ...sourceContainer.layout.slice(destinationRow) + ] + : [ + ...sourceContainer.layout.slice(0, destinationRow), + sourceContainer.layout[sourceRow], + ...sourceContainer.layout.slice(destinationRow, sourceRow), + ...sourceContainer.layout.slice(sourceRow + 1) + ]; +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveRow/moveRowBetween.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveRow/moveRowBetween.ts new file mode 100644 index 00000000000..9ffc3e58ea4 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveRow/moveRowBetween.ts @@ -0,0 +1,25 @@ +import { FbFormStep } from "~/types"; + +interface MoveRowBetweenParams { + sourceContainer: FbFormStep; + destinationContainer: FbFormStep; + sourceRow: number; + destinationRow: number; +} + +export default ({ + sourceContainer, + destinationContainer, + sourceRow, + destinationRow +}: MoveRowBetweenParams) => { + destinationContainer.layout.splice( + destinationRow, + 0, + sourceContainer?.layout[sourceRow] as string[] + ); + sourceContainer.layout = [ + ...sourceContainer.layout.slice(0, sourceRow), + ...sourceContainer.layout.slice(sourceRow + 1) + ]; +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/index.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/index.ts index 2bf9e1596ab..aa2092e2fbb 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/index.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/index.ts @@ -1,7 +1,7 @@ -export { default as getFieldPosition } from "./getFieldPosition"; -export { default as moveField } from "./moveField"; export { default as deleteField } from "./deleteField"; -export { default as moveRow } from "./moveRow"; export { default as moveStep } from "./moveStep"; -export { default as moveFieldBetweenSteps } from "./moveFieldBetweenSteps"; -export { default as moveRowBetweenSteps } from "./moveRowBetweenSteps"; +export { default as handleMoveField } from "./handleMoveField"; +export { default as handleMoveRow } from "./handleMoveRow"; +export { default as getContainerLayout } from "./getContainerLayout"; +export { default as handleDeleteConditionGroup } from "./handleDeleteConditionGroup"; +export { default as validateStepRule } from "./validateStepRule"; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveField.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveField.ts deleted file mode 100644 index 4847b58e5b2..00000000000 --- a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveField.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { - FbFormModel, - FbFormModelField, - FbFormStep, - FieldIdType, - FieldLayoutPositionType -} from "~/types"; -import getFieldPosition from "./getFieldPosition"; - -/** - * Remove all rows that have zero fields in it. - * @param data - */ - -const cleanupEmptyRows = (params: MoveFieldParams): void => { - const { data, targetStepId } = params; - const targetStep = data.steps.find(s => s.id === targetStepId) as FbFormStep; - - targetStep.layout = targetStep?.layout.filter(row => row.length > 0); -}; - -interface MoveFieldParams { - field: FieldIdType | FbFormModelField; - position: FieldLayoutPositionType; - data: FbFormModel; - targetStepId: string; -} - -const moveField = (params: MoveFieldParams) => { - const { field, position, data, targetStepId } = params; - const { row, index } = position; - const fieldId = typeof field === "string" ? field : field._id; - if (!fieldId) { - console.log("Missing data when moving field."); - console.log(params); - return; - } - - const targetStepLayout = data.steps.find(s => s.id === targetStepId) as FbFormStep; - targetStepLayout.layout = targetStepLayout.layout.filter(row => Boolean(row)); - const existingPosition = getFieldPosition({ - field: fieldId, - data: targetStepLayout - }); - if (existingPosition) { - targetStepLayout.layout[existingPosition.row].splice(existingPosition.index, 1); - } - - // Setting a form field into a new non-existing row. - if (!targetStepLayout?.layout[row]) { - targetStepLayout.layout[row] = [fieldId]; - return; - } - - // If row exists, we drop the field at the specified index. - if (index === null) { - // Create a new row with the new field at the given row index, - targetStepLayout.layout.splice(row, 0, [fieldId]); - return; - } - - targetStepLayout.layout[row].splice(index, 0, fieldId); -}; - -export default (params: MoveFieldParams) => { - moveField(params); - cleanupEmptyRows(params); -}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveFieldBetweenSteps.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveFieldBetweenSteps.ts deleted file mode 100644 index 0c532141007..00000000000 --- a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveFieldBetweenSteps.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { - FbFormModel, - FbFormModelField, - FbFormStep, - FieldIdType, - FieldLayoutPositionType -} from "~/types"; -import getFieldPosition from "./getFieldPosition"; - -/** - * Remove all rows that have zero fields in it. - * @param data - */ - -const cleanupEmptyRows = (params: MoveFieldBetweenRowsParams): void => { - const { data, targetStepId, sourceStepId } = params; - const targetStep = data.steps.find(s => s.id === targetStepId) as FbFormStep; - const sourceStep = sourceStepId !== undefined && data.steps.find(s => s.id === sourceStepId); - - if (sourceStep) { - sourceStep.layout = sourceStep?.layout.filter(row => row.length > 0); - } - - targetStep.layout = targetStep.layout.filter(row => row.length > 0); -}; - -interface MoveFieldBetweenRowsParams { - field: FieldIdType | FbFormModelField; - position: FieldLayoutPositionType; - data: FbFormModel; - targetStepId: string; - sourceStepId: string; -} - -const moveFieldBetweenSteps = (params: MoveFieldBetweenRowsParams) => { - const { field, position, data, targetStepId, sourceStepId } = params; - const { row, index } = position; - const fieldId = typeof field === "string" ? field : field._id; - if (!fieldId) { - console.log("Missing data when moving field."); - console.log(params); - return; - } - - const targetStepLayout = data.steps.find(s => s.id === targetStepId) as FbFormStep; - const sourceStepLayout = data.steps.find(s => s.id === sourceStepId) as FbFormStep; - const existingPosition = getFieldPosition({ - field: fieldId, - data: sourceStepLayout || targetStepLayout - }); - if (existingPosition) { - sourceStepLayout.layout[existingPosition.row].splice(existingPosition.index, 1); - } - - // Setting a form field into a new non-existing row. - if (!targetStepLayout?.layout[row]) { - targetStepLayout.layout[row] = [fieldId]; - return; - } - - // If row exists, we drop the field at the specified index. - if (index === null) { - // Create a new row with the new field at the given row index, - targetStepLayout.layout.splice(row, 0, [fieldId]); - return; - } - - targetStepLayout.layout[row].splice(index, 0, fieldId); -}; - -export default (params: MoveFieldBetweenRowsParams) => { - moveFieldBetweenSteps(params); - cleanupEmptyRows(params); -}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveRow.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveRow.ts deleted file mode 100644 index 451ad82e232..00000000000 --- a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveRow.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { FbFormStep } from "~/types"; - -interface MoveRowParams { - source: number; - destination: number; - data: FbFormStep; -} -export default ({ data, source, destination }: MoveRowParams): void => { - data.layout = - source < destination - ? [ - ...data.layout.slice(0, source), - ...data.layout.slice(source + 1, destination), - data.layout[source], - ...data.layout.slice(destination) - ] - : [ - ...data.layout.slice(0, destination), - data.layout[source], - ...data.layout.slice(destination, source), - ...data.layout.slice(source + 1) - ]; -}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveRowBetweenSteps.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveRowBetweenSteps.ts deleted file mode 100644 index 5fbbbb8f607..00000000000 --- a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveRowBetweenSteps.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { FbFormStep, FbFormModel } from "~/types"; - -interface MoveRowParams { - source: number; - destination: number; - data: FbFormModel; - targetStepId: string; - sourceStep: FbFormStep; -} - -export default ({ data, source, destination, targetStepId, sourceStep }: MoveRowParams): void => { - const sourceStepLayout = data.steps.find(step => step.id === sourceStep.id) as FbFormStep; - const targetStepLayout = data.steps.find(step => step.id === targetStepId) as FbFormStep; - - sourceStepLayout.layout = [ - ...sourceStep.layout.slice(0, source), - ...sourceStep.layout.slice(source + 1) - ]; - targetStepLayout.layout.splice(destination, 0, sourceStep?.layout[source] as string[]); -}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveStep.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveStep.ts index 0f486ffed50..cfda82f06ae 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveStep.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveStep.ts @@ -1,24 +1,21 @@ -import { FbFormStep } from "~/types"; +import { FbFormStep, MoveStepParams } from "~/types"; -interface MoveStepParams { +interface MoveStep extends MoveStepParams { data: FbFormStep[]; - /* formStep is the step that we are dragging */ - formStep: FbFormStep; - step: FbFormStep; } -const moveStep = (params: MoveStepParams) => { - const { step, data, formStep } = params; +export default (params: MoveStep) => { + const { target, destination, data } = params; - /* step1 is the step that will change it's position with */ - const step1 = data.findIndex((v: FbFormStep) => v.id === step.id); - /* step2 is the step that is being dragged */ - const step2 = data.findIndex((v: FbFormStep) => v.id === formStep.id); + // "targetStep" is the step that is being dragged + const targetStep = data.find((v: FbFormStep) => v.id === target.containerId); + const targetStepIndex = data.findIndex((v: FbFormStep) => v.id === target.containerId); - data.splice(step1, 1, formStep); - data.splice(step2, 1, step); -}; + const destinationStep = data.find((v: FbFormStep) => v.id === destination.containerId); + const destinationStepIndex = data.findIndex( + (v: FbFormStep) => v.id === destination.containerId + ); -export default (params: any) => { - moveStep(params); + data.splice(targetStepIndex, 1, destinationStep!); + data.splice(destinationStepIndex, 1, targetStep!); }; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/validateStepRule.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/validateStepRule.ts new file mode 100644 index 00000000000..023d64b616d --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/validateStepRule.ts @@ -0,0 +1,39 @@ +import { FbFormStep, FbFormModelField, FbFormRule } from "~/types"; + +interface Props { + fields: (FbFormModelField | null)[]; + rule: FbFormRule; + stepIndex: number; + steps: FbFormStep[]; +} + +export default ({ fields, rule, stepIndex, steps }: Props) => { + const getFieldById = (id: string): FbFormModelField | null => { + return fields.find(field => field?._id === id) || null; + }; + + const step = steps[stepIndex]; + const stepsLayout = + stepIndex === 0 + ? step.layout.flat() + : [...step.layout, ...steps[stepIndex - 1].layout].flat(); + + const allowedFileds = stepsLayout + .map(id => { + const field = getFieldById(id); + + return field; + }) + .flat(1); + + let isValidFields = true; + + rule.conditions.forEach(condition => { + if (!allowedFileds.some(field => field?.fieldId === condition.fieldName)) { + isValidFields = false; + return; + } + }); + + return isValidFields; +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/graphql.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/graphql.ts index a2729d11c73..ddec4e99878 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Context/graphql.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/graphql.ts @@ -82,6 +82,19 @@ export const GET_FORM = gql` steps { title layout + rules { + title + action + chain + id + conditions { + id + fieldName + filterType + filterValue + } + isValid + } } settings ${SETTINGS_FIELDS} triggers @@ -124,6 +137,19 @@ export const UPDATE_REVISION = gql` steps { title layout + rules { + title + action + chain + id + conditions { + id + fieldName + filterType + filterValue + } + isValid + } } settings ${SETTINGS_FIELDS} triggers diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/useFormEditorFactory.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Context/useFormEditorFactory.tsx index 5dbfd3dc88b..5c64cd6cc2e 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Context/useFormEditorFactory.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/useFormEditorFactory.tsx @@ -12,13 +12,14 @@ import { } from "./graphql"; import { deleteField, - getFieldPosition, - moveField, - moveFieldBetweenSteps, - moveRow, - moveRowBetweenSteps, - moveStep + moveStep, + handleMoveRow, + handleMoveField, + validateStepRule, + handleDeleteConditionGroup } from "./functions"; +import moveField from "./functions/handleMoveField/moveField"; +import getFieldPosition from "./functions/handleMoveField/getFieldPosition"; import { plugins } from "@webiny/plugins"; import { @@ -30,7 +31,11 @@ import { FbUpdateFormInput, FieldIdType, FieldLayoutPositionType, - MoveFieldParams + FbFormRule, + MoveStepParams, + DropTarget, + DropDestination, + DropSource } from "~/types"; import { ApolloClient } from "apollo-client"; import { @@ -46,11 +51,6 @@ interface SetDataCallable { (value: FbFormModel): FbFormModel; } -interface MoveStepParams { - step: FbFormStep; - formStep: FbFormStep; -} - type State = FormEditorProviderContextState; export interface FormEditor { apollo: ApolloClient; @@ -59,7 +59,10 @@ export interface FormEditor { state: State; addStep: () => void; deleteStep: (id: string) => void; - updateStep: (title: string, id: string | null) => void; + updateStep: ( + { title, rules }: { title: string; rules: FbFormRule[] }, + id: string | null + ) => void; getForm: (id: string) => Promise<{ data: GetFormQueryResponse }>; saveForm: ( data: FbFormModel | null @@ -68,28 +71,61 @@ export interface FormEditor { getFields: () => FbFormModelField[]; getLayoutFields: (targetStepId: string) => FbFormModelField[][]; getField: (query: Partial>) => FbFormModelField | null; + deleteConditionGroup: ({ + formStep, + conditionGroup + }: { + formStep: FbFormStep; + conditionGroup: FbFormModelField; + }) => void; + getConditionGroupLayoutFields: (conditionGroupId: string) => FbFormModelField[][]; getFieldPlugin: ( query: Partial> ) => FbBuilderFieldPlugin | null; - insertField: ( - field: FbFormModelField, - position: FieldLayoutPositionType, - targetStepId: string - ) => void; - moveField: (params: MoveFieldParams) => void; + insertField: ({ + data, + target, + destination, + source + }: { + data: FbFormModelField; + target: DropTarget; + destination: DropDestination; + source?: DropSource; + }) => void; + moveField: ({ + target, + field, + source, + destination + }: { + target: DropTarget; + field: FbFormModelField | string; + source: DropSource; + destination: DropDestination; + }) => void; moveRow: ( - source: number, - destination: number, - targetStepId: string, - sourceStepId?: any + sourceRow: number, + destinationRow: number, + source: DropSource, + destination: DropDestination ) => void; moveStep: (params: MoveStepParams) => void; updateField: (field: FbFormModelField) => void; - deleteField: (field: FbFormModelField, targetStepId: string) => void; + deleteField: ({ + field, + containerType, + containerId + }: { + field: FbFormModelField; + containerType?: "conditionGroup" | "step"; + containerId: string; + }) => void; getFieldPosition: ( field: FieldIdType | FbFormModelField, data: FbFormStep ) => FieldLayoutPositionType | null; + validateStepRules: (data: FbFormModel) => FbFormModel; } const extractFieldErrors = (error: FbErrorResponse, form: FbFormModel): FormEditorFieldError[] => { @@ -171,9 +207,10 @@ export const useFormEditorFactory = ( // Or when we need to delete corresponding step. const modifiedData = { ...data, - steps: data?.steps.map(formStep => ({ + steps: data?.steps.map((formStep, index) => ({ ...formStep, - id: mdbid() + id: mdbid(), + index })) }; @@ -194,7 +231,7 @@ export const useFormEditorFactory = ( data = { ...data, steps: data.steps.map(formStep => - pick(formStep, ["title", "layout"]) + pick(formStep, ["title", "layout", "rules"]) ) as unknown as FbFormStep[] }; if (!data) { @@ -296,6 +333,21 @@ export const useFormEditorFactory = ( .filter(Boolean) as FbFormModelField[]; }); }, + getConditionGroupLayoutFields: conditionGroupId => { + const conditionGroupLayout = state.data.fields + .find(field => field._id === conditionGroupId) + ?.settings.layout.filter((row: string[]) => Boolean(row)); + + return conditionGroupLayout.map((row: string[]) => { + return row + .map((id: string) => { + return self.getField({ + _id: id + }); + }) + .filter(Boolean) as FbFormModelField[]; + }); + }, /** * Return field plugin. @@ -350,50 +402,98 @@ export const useFormEditorFactory = ( self.setData(data => { data.steps.push({ id: mdbid(), - title: `Step ${data.steps.length + 1}`, - layout: [] + title: `Step`, + layout: [], + rules: [], + index: data.steps.length }); - return data; + return { + ...self.validateStepRules(data) + }; }); }, - deleteStep: (targetStepId: string) => { - const stepFields = self.getLayoutFields(targetStepId).flat(1); + deleteStep: (stepId: string) => { + const stepFields = self.getLayoutFields(stepId).flat(1); const deleteStepFields = (data: FbFormModel) => { - const stepLayout = stepFields.map(field => - deleteField({ field, data, targetStepId }) - ); + const formStep = data.steps.find(step => step.id === stepId) as FbFormStep; + const stepLayout = stepFields.map(field => { + if (field.type === "condition-group" && field._id) { + handleDeleteConditionGroup({ + data, + formStep, + stepFields, + conditionGroup: field, + conditionGroupFields: self + .getConditionGroupLayoutFields(field._id) + .flat(1) + }); + } else { + deleteField({ + field, + data, + containerId: stepId + }); + } + }); return stepLayout; }; self.setData(data => { - const deleteStepIndex = data.steps.findIndex(step => step.id === targetStepId); + const deleteStepIndex = data.steps.findIndex(step => step.id === stepId); deleteStepFields(data); data.steps.splice(deleteStepIndex, 1); - return data; + return { + ...self.validateStepRules(data) + }; }); }, - updateStep: (stepTitle, id) => { - if (!stepTitle) { + updateStep: ({ title, rules }, id) => { + if (!title) { showSnackbar("Step title cannot be empty"); } else { self.setData(data => { const stepIndex = data.steps.findIndex(step => step.id === id); - data.steps[stepIndex].title = stepTitle; - return data; + data.steps[stepIndex].title = title; + data.steps[stepIndex].rules = rules; + + return { + ...self.validateStepRules(data) + }; }); } }, + deleteConditionGroup: ({ formStep, conditionGroup }) => { + if (!conditionGroup._id) { + return; + } + + const stepFields = self.getLayoutFields(formStep.id).flat(1); + const conditionGroupFields = self + .getConditionGroupLayoutFields(conditionGroup._id) + .flat(1); + + self.setData(data => { + handleDeleteConditionGroup({ + data, + formStep, + stepFields, + conditionGroup, + conditionGroupFields + }); + return { + ...self.validateStepRules(data) + }; + }); + }, /** * Inserts a new field into the target position. */ - insertField: (data, position, targetStepId) => { + insertField: ({ data, destination, target, source }) => { const field = cloneDeep(data); - if (!field._id) { - field._id = shortid.generate(); - } + field._id = shortid.generate(); if (!data.name) { throw new Error(`Field "name" missing.`); @@ -413,10 +513,11 @@ export const useFormEditorFactory = ( data.fields.push(field); moveField({ - field, - position, data, - targetStepId + field, + target, + destination, + source }); // We are dropping a new field at the specified index. @@ -427,69 +528,49 @@ export const useFormEditorFactory = ( /** * Moves field to the given target position. */ - moveField: ({ field, position, targetStepId, sourceStepId }) => { - // If sourceStepId ("source step" is the step from which we take a field) is different, - // to a targetStepId ("target step" is the step in which we want to move a field) then we need to use function "moveFieldBetweenRows", - // if targetStepId equals to sourceStepId then it means that we are moving field inside of the same step. - if (targetStepId === sourceStepId) { - self.setData(data => { - moveField({ - field, - position, - data, - targetStepId - }); - return data; - }); - } else { - self.setData(data => { - moveFieldBetweenSteps({ - field, - position, - data, - targetStepId, - sourceStepId - }); - return data; + moveField: ({ field, target, source, destination }) => { + self.setData(data => { + handleMoveField({ + data, + field, + target, + source, + destination }); - } + return { + ...self.validateStepRules(data) + }; + }); }, - moveStep: ({ step, formStep }) => { + moveStep: ({ target, destination }) => { self.setData(data => { moveStep({ - step, - formStep, + target, + destination, data: data.steps }); - return data; + return { + ...self.validateStepRules(data) + }; }); }, /** * Moves row to a destination row. */ - moveRow: (source, destination, targetStepId, sourceStep) => { - if (targetStepId === sourceStep.id) { - self.setData(data => { - moveRow({ - data: data.steps.find(v => v.id === targetStepId) as FbFormStep, - source, - destination - }); - return data; - }); - } else { - self.setData(data => { - moveRowBetweenSteps({ - data, - source, - destination, - targetStepId, - sourceStep - }); - return data; + moveRow: (sourceRow, destinationRow, source, destination) => { + self.setData(data => { + handleMoveRow({ + data, + sourceRow, + destinationRow, + source, + destination }); - } + return { + ...self.validateStepRules(data) + }; + }); }, /** @@ -504,17 +585,49 @@ export const useFormEditorFactory = ( break; } } - return data; + return { + ...self.validateStepRules(data) + }; }); }, + validateStepRules: data => { + const steps = data.steps.map((step, index) => { + // We need this check in case we moved step with rules to the bottom of the steps list, + // because last step cannot have rules, and if it has then we need to mark it as broken. + if (data.steps[data.steps.length - 1].id === step.id && step.rules.length) { + const rules = step.rules.map(rule => { + return { ...rule, isValid: false }; + }); + + return { ...step, rules }; + } else if (step.rules.length) { + const rules = step.rules.map(rule => { + const isValid = validateStepRule({ + rule, + fields: data.fields, + stepIndex: index, + steps: data.steps + }); + + return { ...rule, isValid }; + }); + return { ...step, rules }; + } else { + return step; + } + }); + return { ...data, steps }; + }, /** * Deletes a field (both from the list of field and the layout). */ - deleteField: (field, targetStepId) => { + deleteField: ({ field, containerId, containerType }) => { self.setData(data => { - deleteField({ field, data, targetStepId }); - return data; + deleteField({ field, data, containerId, containerType }); + return { + ...self.validateStepRules(data) + }; }); }, @@ -522,7 +635,7 @@ export const useFormEditorFactory = ( * Returns row / index position for given field. */ getFieldPosition: (field, data) => { - return getFieldPosition({ field, data }); + return getFieldPosition({ field, layout: data.layout }); } }; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Draggable.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Draggable.tsx index 298ec145f79..31aeae024de 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Draggable.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Draggable.tsx @@ -2,7 +2,6 @@ import React, { ReactElement } from "react"; import { useDrag, DragPreviewImage, ConnectDragSource } from "react-dnd"; import { DragSourceMonitor } from "react-dnd/lib/interfaces/monitors"; import { DragObjectWithType } from "react-dnd/lib/interfaces/hooksApi"; -import { FbFormStep } from "~/types"; const emptyImage = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; @@ -17,8 +16,15 @@ interface BeginDragProps { row: number; index?: number; }; - formStep?: FbFormStep; name?: string; + id?: string; + /* + "container" contains info about source element. + */ + container?: { + type: "step" | "conditionGroup"; + id: string; + }; } type BeginDrag = (props: BeginDragProps, monitor: DragSourceMonitor) => void; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Droppable.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Droppable.tsx index 62c87d1d836..9b5b347f0ad 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Droppable.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Droppable.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { ConnectDropTarget, DragObjectWithType, useDrop } from "react-dnd"; -import { FbFormStep, FieldLayoutPositionType } from "~/types"; +import { FieldLayoutPositionType } from "~/types"; export type DroppableChildrenFunction = (params: { isDragging: boolean; @@ -23,9 +23,14 @@ export interface DroppableCollectedProps { export interface IsVisibleCallableParams { type: string; isDragging: boolean; - ui: string; - pos?: Partial; - formStep?: FbFormStep; + name?: string; + ui: "row" | "field" | "conditionGroup" | "step"; + id?: string; + pos: FieldLayoutPositionType; + container?: { + type: "step" | "conditionGroup"; + id: string; + }; } export interface IsVisibleCallable { (params: IsVisibleCallableParams): boolean; @@ -34,15 +39,21 @@ export interface IsVisibleCallable { We need to extend DragObjectWithType type because it does not support fields, that we set through "beginDrag". * "ui" propetry gives us information about the Entity that we are moving. - "Entity" can be step, field, row or custom. "Entity" will be custom in case we are moving field from a "Custom Field" menu. + "Entity" can be step, field, row or conditionGroup. * "name" property contains the type of the field, it can be text, number or one of the available fields. - * "pos" propety contains info about Entity position that we are moving + * "pos" propety contains info about Entity position that we are moving. + * "container" propety contains info about source "Entity". pos can be undefined in case we are moving field from a "Custom Field" menu. */ export interface DragObjectWithFieldInfo extends DragObjectWithType { - ui: string; + ui: "row" | "field" | "conditionGroup" | "step"; name: string; - pos?: Partial; + id?: string; + pos: FieldLayoutPositionType; + container: { + type: "step" | "conditionGroup"; + id: string; + }; } export interface OnDropCallable { (item: DragObjectWithFieldInfo): DroppableDropResult | undefined; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/ConditionGroup.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/ConditionGroup.tsx new file mode 100644 index 00000000000..196ddc7d18a --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/ConditionGroup.tsx @@ -0,0 +1,298 @@ +import React from "react"; +import { ReactComponent as EditIcon } from "../../icons/edit.svg"; +import { ReactComponent as DeleteIcon } from "../../icons/delete.svg"; +import { useFormEditor } from "../../Context"; +import { FbFormModelField, FbFormStep } from "~/types"; +import { Accordion } from "@webiny/ui/Accordion"; +import { AccordionItem } from "@webiny/ui/Accordion"; +import { Center, Vertical, Horizontal } from "../../DropZone"; +import Field from "./Field"; +import { RowContainer, rowHandle, Row, fieldContainer } from "./Styled"; +import Draggable from "../../Draggable"; +import { Icon } from "@webiny/ui/Icon"; +import { ReactComponent as HandleIcon } from "../../../../icons/round-drag_indicator-24px.svg"; +import { ComposeHadleDropParams } from "~/hooks/useFormDragAndDrop"; +interface FieldProps { + field: FbFormModelField; + onEdit: (field: FbFormModelField) => void; + onDelete: ({ + field, + containerId, + containerType + }: { + field: FbFormModelField; + containerId: string; + containerType?: "conditionGroup" | "step"; + }) => void; + onDrop: (params: ComposeHadleDropParams) => undefined; + targetStepId: string; + formStep: FbFormStep; + deleteConditionGroup: ({ + formStep, + conditionGroup + }: { + formStep: FbFormStep; + conditionGroup: FbFormModelField; + }) => void; +} + +const ConditionalGroupField: React.FC = props => { + const { + field: conditionGroupField, + onEdit, + onDrop, + deleteConditionGroup, + onDelete, + formStep + } = props; + const { getField } = useFormEditor(); + + const getFields = () => { + return (conditionGroupField?.settings?.layout || []).map((row: any) => { + return row + .map((id: any) => { + return getField({ + _id: id + }); + }) + .filter(Boolean) as FbFormModelField[]; + }); + }; + + const fields = getFields().map((fields: any) => + fields + .filter((field: any) => field._id !== conditionGroupField._id) + .filter((field: any) => field.length !== 0) + ) as FbFormModelField[][]; + + return ( + + + } + onClick={() => onEdit(conditionGroupField)} + /> + } + onClick={() => { + deleteConditionGroup({ + formStep, + conditionGroup: conditionGroupField + }); + }} + /> + + } + > + {fields.length === 0 ? ( +
{ + onDrop({ + item, + destination: { + containerId: conditionGroupField._id, + containerType: "conditionGroup", + position: { + row: 0, + index: 0 + } + } + }); + return undefined; + }} + > + {`Drop your first field here`} +
+ ) : ( + fields.map((row, index) => { + return ( + + {({ drag, isDragging }) => ( + +
+ } /> +
+ { + onDrop({ + item, + destination: { + containerId: conditionGroupField._id, + containerType: "conditionGroup", + position: { + row: index, + index: null + } + } + }); + return undefined; + }} + isVisible={target => { + const isVisible = + target.ui !== "step" && + target.name !== "conditionGroup"; + return isVisible; + }} + /> + + {row.map((field, fieldIndex) => ( + + {({ drag }) => ( +
+ { + onDrop({ + item, + destination: { + containerId: + conditionGroupField._id, + containerType: + "conditionGroup", + position: { + row: index, + index: fieldIndex + } + } + }); + return undefined; + }} + isVisible={target => { + const isVisible = + target.ui !== "step" && + target.ui !== "row" && + target.name !== + "conditionGroup"; + return isVisible; + }} + /> + { + onDelete({ + field, + containerId: + conditionGroupField._id || + "", + containerType: + "conditionGroup" + }); + }} + /> + + {/* Field end */} + {fieldIndex === row.length - 1 && ( + { + const condition = + row.length < 4 || + target.pos.row === + index; + const isVisible = + target.ui === "field" && + target.name !== + "conditionGroup"; + return ( + isVisible && condition + ); + }} + onDrop={item => { + onDrop({ + item, + destination: { + containerId: + conditionGroupField._id, + containerType: + "conditionGroup", + position: { + row: index, + index: + fieldIndex + + 1 + } + } + }); + return undefined; + }} + /> + )} +
+ )} +
+ ))} +
+ {/* Row end */} + {index === fields.length - 1 && ( + { + onDrop({ + item, + destination: { + containerId: conditionGroupField._id, + containerType: "conditionGroup", + position: { + row: index + 1, + index: null + } + } + }); + return undefined; + }} + isVisible={target => { + const isVisible = + target.ui !== "step" && + target.name !== "conditionGroup"; + return isVisible; + }} + /> + )} +
+ )} +
+ ); + }) + )} +
+
+ ); +}; + +export default ConditionalGroupField; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog.tsx index 5dfceba547d..111269b2297 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog.tsx @@ -20,6 +20,7 @@ import { i18n } from "@webiny/app/i18n"; const t = i18n.namespace("FormEditor.EditFieldDialog"); import { useFormEditor } from "../../Context"; import { FbBuilderFieldPlugin, FbFormModelField } from "~/types"; +import { RulesTab } from "./EditFieldDialog/RulesTab"; const dialogBody = css({ "&.webiny-ui-dialog__content": { @@ -57,9 +58,39 @@ const EditFieldDialog: React.FC = ({ field, onSubmit, ...p } setCurrent(cloneDeep(field)); setIsNewField(!field._id); - setScreen(field.type ? "fieldOptions" : "fieldType"); + setScreen( + field?.settings?.isConditionGroup + ? "conditionGroup" + : field?.type + ? "fieldOptions" + : "fieldType" + ); }, [field]); + // In case we dragged "Condition Group" we want to render Settings Dialog for "Condition Group" field, + // instead of dialog that we render when we drag "Custom Field". + useEffect(() => { + if (screen === "conditionGroup") { + plugins + .byType("form-editor-field-type") + .filter(pl => !pl.field.group) + .map(pl => { + const newCurrent = pl.field.createField(); + if (current) { + // User edited existing field, that's why we still want to + // keep a couple of previous values. + const { _id, label, fieldId, helpText } = current; + newCurrent._id = _id; + newCurrent.label = label; + newCurrent.fieldId = fieldId; + newCurrent.helpText = helpText; + } + setCurrent(newCurrent); + setScreen("fieldOptions"); + }); + } + }, [screen]); + const onClose = useCallback(() => { setCurrent(null); props.onClose(); @@ -96,6 +127,11 @@ const EditFieldDialog: React.FC = ({ field, onSubmit, ...p )} + {field?.type === "condition-group" && ( + + + + )} void; +} + +const defaultBehaviour = [ + { + label: "Show the fields in the conditional group", + value: "show" + }, + { + label: "Hide the fields in the conditional group", + value: "hide" + } +]; + +export const SelectDefaultBehaviour: React.FC = ({ defaultBehaviourValue, onChange }) => { + return ( + + + + By default if no rule is met + + + + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RuleActionSelect.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RuleActionSelect.tsx new file mode 100644 index 00000000000..496e84a101f --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RuleActionSelect.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { Select } from "@webiny/ui/Select"; +import styled from "@emotion/styled"; + +const RuleAction = styled("div")` + display: flex; + align-items: center; + margin-top: 70px; + position: relative; + & > span { + font-size: 22px; + } + &::before { + display: block; + content: ""; + width: 100%; + position: absolute; + top: -25px; + border-top: 1px solid gray; + } +`; + +const ActionSelect = styled(Select)` + margin-left: 35px; + margin-right: 15px; + width: 250px; +`; + +interface Props { + value: string; + onChange: (value: string) => void; +} + +export const RuleActionSelect: React.FC = ({ value, onChange }) => { + return ( + + Then + + + + + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesConditions.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesConditions.tsx new file mode 100644 index 00000000000..4883cb43891 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesConditions.tsx @@ -0,0 +1,130 @@ +import React from "react"; +import styled from "@emotion/styled"; + +import { Select } from "@webiny/ui/Select"; +import { IconButton } from "@webiny/ui/Button"; + +import { fieldConditionOptions } from "~/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/fieldsValidationConditions"; +import { renderConditionValueController } from "~/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/renderConditionValueController"; + +import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete_outline.svg"; + +import cloneDeep from "lodash/cloneDeep"; +import findIndex from "lodash/findIndex"; +import { FbFormModelField, FbFormCondition, FbFormRule } from "~/types"; + +const SelectFieldWrapper = styled.div` + display: flex; + margin: 15px 0; + & > span { + font-size: 22px; + } +`; + +const CondtionsWrapper = styled.div` + display: flex; + margin-left: 20px; +`; + +const SelectCondition = styled(Select)` + margin-right: 15px; + margin-left: 63px; + width: 250px; +`; + +const ConditionValue = styled.div` + width: 397px; +`; + +const FieldSelect = styled(Select)` + margin-left: 70px; +`; + +interface Props { + rule: FbFormRule; + condition: FbFormCondition; + fields: (FbFormModelField | null)[]; + rulesValue: Array; + conditionIndex: number; + onChangeRule: (params: FbFormRule) => void; + deleteCondition: () => void; +} + +export const RuleConditions: React.FC = ({ + rulesValue, + fields, + condition, + rule, + conditionIndex, + onChangeRule, + deleteCondition +}) => { + const fieldType = fields.find(field => field?.fieldId === condition?.fieldName)?.type || ""; + + const onChange = (property: string, value: string) => { + const ruleIndex = findIndex(rulesValue, { id: rule.id }); + const rules = cloneDeep(rulesValue); + const conditions = cloneDeep(rules[ruleIndex].conditions || []); + + conditions[conditionIndex] = { + ...rules[ruleIndex].conditions[conditionIndex], + [property]: value + }; + + rules[ruleIndex].conditions = conditions; + + onChangeRule({ + ...rule, + conditions + }); + }; + + return ( + <> + + If + onChange("fieldName", value)} + > + {fields.map((field: any) => ( + + ))} + + deleteCondition()} />} /> + + {!condition.fieldName ? ( + <> + ) : ( + + onChange("filterType", val)} + value={condition.filterType} + > + {fieldConditionOptions + .find(filter => filter.type === fieldType) + ?.options.map(option => ( + + ))} + + {/* This field depends on selected field type */} + + {renderConditionValueController({ + condition, + fields, + handleOnChange: onChange + })} + + + )} + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab.tsx new file mode 100644 index 00000000000..6c354392cab --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab.tsx @@ -0,0 +1,277 @@ +import React from "react"; + +import { FormRenderPropParams } from "@webiny/form"; +import { Icon } from "@webiny/ui/Icon"; +import { AccordionItem } from "@webiny/ui/Accordion"; +import { Alert } from "@webiny/ui/Alert"; +import { mdbid } from "@webiny/utils"; + +import { ReactComponent as InfoIcon } from "@material-design-icons/svg/outlined/info.svg"; +import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete_outline.svg"; + +import { RuleConditions } from "./RulesConditions"; +import { conditionChainOptions } from "~/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/fieldsValidationConditions"; +import { + RulesTabWrapper, + AddRuleButtonWrapper, + RuleButtonDescription, + StyledAccordion, + ConditionSetupWrapper, + AddRuleButton, + AddConditionButton, + ConditionsChainSelect +} from "~/admin/components/FormEditor/Tabs/EditTab/Styled"; +import { useFormEditor } from "~/admin/components/FormEditor/Context"; +import { RuleActionSelect } from "./RuleActionSelect"; +import { SelectDefaultBehaviour } from "./DefaultBehaviour"; +import { FbFormModelField, FbFormModel, FbFormRule, FbFormCondition } from "~/types"; + +const getFields = (id: string, formData: FbFormModel) => { + const filedIds = formData.steps.map(step => step.layout).flat(2); + const currentFieldIndex = filedIds.indexOf(id); + const availableFiledIds = filedIds.slice(0, currentFieldIndex); + + return availableFiledIds.map(id => formData.fields.find(field => field._id === id) || null); +}; + +interface RulesTabProps { + field: FbFormModelField; + form: FormRenderPropParams; +} + +export const RulesTab = ({ field, form }: RulesTabProps) => { + const { Bind } = form; + + const { data: formData } = useFormEditor(); + const fields = field._id ? getFields(field._id, formData) : []; + const areRulesInValid = field?.settings?.rules?.some( + (rule: FbFormRule) => rule.isValid === false + ); + + return ( + + {areRulesInValid && ( + + + At the moment one or more of your rules are broken. To correct the state + please check your rules and ensure they are referencing fields that still + exists and are place inside the current or one of the previous steps. + + + )} + + {({ value: defaultBehaviourValue, onChange: onChangeDefaultBehaviour }) => ( + + )} + + + {({ value: rulesValue, onChange: onChangeRules }) => ( + <> + {rulesValue && + (rulesValue as FbFormRule[]).map((rule, ruleIndex) => ( + + + } + onClick={() => + onChangeRules( + (rulesValue as FbFormRule[]).filter( + rulesValueItem => + rulesValueItem.id !== rule.id + ) + ) + } + /> + + } + > + + {({ value: ruleValue, onChange: onChangeRule }) => ( + <> + {rule.conditions.length === 0 ? ( + + onChangeRule({ + ...ruleValue, + conditions: [ + ...(ruleValue.conditions || + []), + { + fieldName: "", + filterType: "", + filterValue: "", + id: mdbid() + } + ] + }) + } + > + + Add Condition + + ) : ( + <> + {ruleValue.conditions.map( + ( + condition: FbFormCondition, + conditionIndex: number + ) => { + return ( + + + onChangeRule({ + ...ruleValue, + conditions: + ( + ruleValue.conditions as FbFormCondition[] + ).filter( + ruleValueCondition => + ruleValueCondition.id !== + condition.id + ) + }) + } + /> + {condition.id === + ruleValue + .conditions[ + ruleValue + .conditions + .length - 1 + ].id && ( + <> + + onChangeRule( + { + ...ruleValue, + conditions: + [ + ...(ruleValue.conditions || + []), + { + fieldName: + "", + filterType: + "", + filterValue: + "", + id: mdbid() + } + ] + } + ) + } + > + + Add + Condition + + + onChangeRule( + { + ...ruleValue, + chain: val + } + ) + } + > + {conditionChainOptions.map( + chainOption => ( + + ) + )} + + + onChangeRule( + { + ...ruleValue, + action: val + } + ) + } + /> + + )} + + ); + } + )} + + )} + + )} + + + + ))} + + { + onChangeRules([ + ...(rulesValue || []), + { + title: "Rule", + id: mdbid(), + conditions: [], + action: "hide", + isValid: true, + chain: "matchAny" + } + ]); + }} + > + + Add Rule + + + } /> + Click here to learn how field rules work + + + + )} + + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditTab.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditTab.tsx index ed2153bfb00..2b058c7b261 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditTab.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditTab.tsx @@ -1,9 +1,9 @@ import React, { useState } from "react"; -import { Horizontal } from "../../DropZone"; -import Draggable from "../../Draggable"; +import { Horizontal } from "~/admin/components/FormEditor/DropZone"; +import Draggable from "~/admin/components/FormEditor/Draggable"; import { EditContainer, RowContainer } from "./Styled"; import { FormEditorFieldError, useFormEditor } from "../../Context"; -import { FbFormStep } from "~/types"; +import { FbFormStep, MoveStepParams } from "~/types"; import { Alert } from "@webiny/ui/Alert"; import styled from "@emotion/styled"; @@ -11,7 +11,8 @@ import { IconButton } from "@webiny/ui/Button"; import { ReactComponent as AddIcon } from "@material-design-icons/svg/outlined/add_circle_outline.svg"; import { FormStep } from "./FormStep/FormStep"; -import { EditFormStepDialog } from "./FormStep/EditFormStepDialog"; +import { EditFormStepDialog } from "./FormStep/EditFormStepDialog/EditFormStepDialog"; +import { DragObjectWithFieldInfo, IsVisibleCallableParams } from "../../Droppable"; const Block = styled("span")({ display: "block" @@ -80,68 +81,85 @@ const RowContainerWrapper = styled.div` export const EditTab: React.FC = () => { const { getLayoutFields, - insertField, updateField, deleteField, data, errors, - moveField, - moveRow, - getFieldPlugin, addStep, deleteStep, updateStep, - moveStep + moveStep, + deleteConditionGroup } = useFormEditor(); - const [isEditStep, setIsEditStep] = useState<{ isOpened: boolean; id: string | null }>({ + const [editStep, setEditStep] = useState<{ + isOpened: boolean; + step: FbFormStep; + }>({ isOpened: false, - id: null + step: {} as FbFormStep }); - const stepTitle = data.steps.find(step => step.id === isEditStep.id)?.title || ""; + const stepTitle = data.steps.find(step => step.id === editStep.step.id)?.title || ""; - const handleStepMove = (source: any, step: FbFormStep): void => { - const { pos, formStep } = source; - - if (pos) { - if (pos.index === null) { + const handleStepMove = (item: DragObjectWithFieldInfo, formStep: FbFormStep) => { + if (item.pos) { + if (item.pos.index === null) { return; } } - moveStep({ - step, - formStep - }); + const moveStepParams: MoveStepParams = { + target: { + containerId: item.container?.id || "", + position: item.pos + }, + destination: { + containerId: formStep.id + } + }; + + moveStep(moveStepParams); + + return undefined; }; // This function will render drop zones on the top of the step, // if steps are locatted above "source" ("source" step is the step that we move). - const renderTopDropZone = (sourceStepId: string | undefined, targetStepId: string) => { - if (!sourceStepId) { + const renderTopDropZone = (item: IsVisibleCallableParams, targetStepId: string) => { + if (item.ui !== "step") { + return false; + } + + if (!item.container?.id) { return false; } + const stepsIds = data.steps.reduce( (prevVal, currVal) => [...prevVal, currVal.id], [] as string[] ); - return stepsIds.slice(0, stepsIds.indexOf(sourceStepId)).includes(targetStepId); + return stepsIds.slice(0, stepsIds.indexOf(item.container?.id)).includes(targetStepId); }; // This function will render drop zones on the top of the step, // if steps are locatted below "source" ("source" step is the step that we move). - const renderBottomDropZone = (sourceStepId: string | undefined, targetStepId: string) => { - if (!sourceStepId) { + const renderBottomDropZone = (item: IsVisibleCallableParams, targetStepId: string) => { + if (item.ui !== "step") { + return false; + } + + if (!item.container?.id) { return false; } + const stepsIds = data.steps.reduce( (prevVal, currVal) => [...prevVal, currVal.id], [] as string[] ); - return stepsIds.slice(stepsIds.indexOf(sourceStepId)).includes(targetStepId); + return stepsIds.slice(stepsIds.indexOf(item.container?.id)).includes(targetStepId); }; return ( @@ -149,7 +167,15 @@ export const EditTab: React.FC = () => { {data.steps.map((formStep: FbFormStep, index: number) => ( {({ drag, isDragging }) => ( @@ -170,46 +196,26 @@ export const EditTab: React.FC = () => { title={formStep.title} onDelete={() => deleteStep(formStep.id)} onEdit={() => { - setIsEditStep({ + setEditStep({ isOpened: true, - id: formStep.id + step: formStep }); }} deleteStepDisabled={data.steps.length <= 1} - moveRow={moveRow} - moveField={moveField} - getFieldPlugin={getFieldPlugin} - insertField={insertField} getLayoutFields={getLayoutFields} updateField={updateField} deleteField={deleteField} - data={data} + deleteConditionGroup={deleteConditionGroup} /> { - handleStepMove(item, formStep); - return undefined; - }} - isVisible={item => { - return ( - item.ui === "step" && - renderTopDropZone(item?.formStep?.id, formStep.id) - ); - }} + onDrop={item => handleStepMove(item, formStep)} + isVisible={item => renderTopDropZone(item, formStep.id)} /> { - handleStepMove(item, formStep); - return undefined; - }} - isVisible={item => { - return ( - item.ui === "step" && - renderBottomDropZone(item?.formStep?.id, formStep.id) - ); - }} + onDrop={item => handleStepMove(item, formStep)} + isVisible={item => renderBottomDropZone(item, formStep.id)} /> {data.steps[data.steps.length - 1].id === formStep.id && ( @@ -222,12 +228,15 @@ export const EditTab: React.FC = () => { )} ))} - + {editStep.isOpened && ( + + )} ); }; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog.tsx deleted file mode 100644 index 3e31000b531..00000000000 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React from "react"; -import styled from "@emotion/styled"; - -import { Dialog as BaseDialog } from "@webiny/ui/Dialog"; -import { Form, FormOnSubmit } from "@webiny/form"; -import { Input } from "@webiny/ui/Input"; -import { ButtonPrimary, ButtonSecondary } from "@webiny/ui/Button"; -import { validation } from "@webiny/validation"; - -const EditStepDialog = styled(BaseDialog)` - font-size: 1.4rem; - color: #fff; - font-weight: 600; - - & .mdc-dialog__surface { - width: 575px; - } -`; - -const DialogHeader = styled.div` - height: 30px; - background-color: #00ccb0; - padding: 20px 20px; - - & span { - vertical-align: middle; - } -`; - -const DialogBody = styled.div` - padding: 20px 20px; - min-height: 75px; -`; - -const DialogActions = styled.div` - display: flex; - align-items: center; - justify-content: flex-end; - padding: 20px 20px; - border-top: 1px solid rgba(212, 212, 212, 0.5); - - & .webiny-ui-button--primary { - margin-left: 20px; - } -`; - -export interface DialogProps { - isEditStep: { - isOpened: boolean; - id: string | null; - }; - stepTitle: string; - setIsEditStep: (params: { isOpened: boolean; id: string | null }) => void; - updateStep: (title: string, id: string | null) => void; -} - -type SubmitData = { title: string }; - -export const EditFormStepDialog = ({ - isEditStep, - stepTitle, - setIsEditStep, - updateStep -}: DialogProps) => { - const onSubmit: FormOnSubmit = (_, form) => { - updateStep(form.data.title, isEditStep.id); - setIsEditStep({ isOpened: false, id: null }); - }; - return ( - <> - - setIsEditStep({ - isOpened: false, - id: null - }) - } - > - - {({ Bind, submit }) => ( - <> - - Change Step Title - - - - - - - - setIsEditStep({ isOpened: false, id: null })} - > - Cancel - - Save - - - )} - - - - ); -}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/DateTime.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/DateTime.tsx new file mode 100644 index 00000000000..126caeeb138 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/DateTime.tsx @@ -0,0 +1,100 @@ +import React, { useState } from "react"; +import styled from "@emotion/styled"; + +import { Input } from "@webiny/ui/Input"; +import { Select } from "@webiny/ui/Select"; +import { UTC_TIMEZONES } from "@webiny/utils"; +import { FbFormCondition } from "~/types"; + +interface Props { + value: string; + settings: Record; + handleOnChange: ( + conditionProperty: keyof FbFormCondition, + conditionPropertyValue: string + ) => void; +} + +const DateTimeWrapper = styled("div")` + display: flex; + & .webiny-ui-input { + margin-right: 15px; + } +`; + +const DateTimeWithTimeZone = ({ + handleOnChange, + value +}: Pick) => { + const valueTimeZone = value.match(/\+(?:\S){1,}/gm); + const valueDateTime = value.replace(/\+(?:\S){1,}/gm, ""); + const [dateTime, setDateTime] = useState(valueDateTime || ""); + const [timeZone, setTimeZone] = useState(valueTimeZone?.[0] || "+03:00"); + + return ( + <> + { + setDateTime(value); + handleOnChange("filterValue", `${value}${timeZone}`); + }} + /> + + + ); +}; + +export const DateTime: React.FC = ({ settings, handleOnChange, value }) => { + if (settings.format === "time") { + return ( + + handleOnChange("filterValue", value)} + /> + + ); + } else if (settings.format === "dateTimeWithoutTimezone") { + return ( + + handleOnChange("filterValue", value)} + /> + + ); + } else if (settings.format === "dateTimeWithTimezone") { + return ( + + + + ); + } else { + return ( + + handleOnChange("filterValue", value)} + /> + + ); + } +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/EditFormStepDialog.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/EditFormStepDialog.tsx new file mode 100644 index 00000000000..841b68668db --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/EditFormStepDialog.tsx @@ -0,0 +1,133 @@ +import React from "react"; +import styled from "@emotion/styled"; + +import { Form, FormOnSubmit } from "@webiny/form"; +import { validation } from "@webiny/validation"; +import { Dialog as BaseDialog, DialogContent } from "@webiny/ui/Dialog"; +import { Input } from "@webiny/ui/Input"; +import { ButtonPrimary, ButtonSecondary } from "@webiny/ui/Button"; +import { Tabs, Tab } from "@webiny/ui/Tabs"; + +import { RulesTab } from "./RulesTab"; + +import { FbFormModel, FbFormStep, FbFormRule } from "~/types"; + +const EditStepDialog = styled(BaseDialog)` + font-size: 1.4rem; + color: #fff; + font-weight: 600; + & .mdc-dialog__surface { + width: 875px; + } +`; + +const DialogHeader = styled.div` + height: 30px; + background-color: #00ccb0; + padding: 20px 20px; + & span { + vertical-align: middle; + } +`; + +const DialogBody = styled.div` + padding: 20px 20px; + min-height: 75px; +`; + +const DialogActions = styled.div` + display: flex; + align-items: center; + justify-content: flex-end; + padding: 20px 20px; + border-top: 1px solid rgba(212, 212, 212, 0.5); + & .webiny-ui-button--primary { + margin-left: 20px; + } +`; + +export interface DialogProps { + editStep: { + isOpened: boolean; + step: FbFormStep; + }; + stepTitle: string; + setEditStep: (params: { isOpened: boolean; step: FbFormStep }) => void; + updateStep: ( + { title, rules }: { title: string; rules: FbFormRule[] }, + id: string | null + ) => void; + formData: FbFormModel; +} + +type SubmitData = { title: string; rules: FbFormRule[] }; + +export const EditFormStepDialog = ({ + editStep, + stepTitle, + setEditStep, + updateStep, + formData +}: DialogProps) => { + const closeEditStepDialog = () => { + setEditStep({ + isOpened: false, + step: {} as FbFormStep + }); + }; + + const onSubmit: FormOnSubmit = (_, form) => { + const data = { + title: form.data.title, + rules: form.data.rules + }; + + updateStep(data, editStep.step.id); + closeEditStepDialog(); + }; + + return ( + <> + +
+ {({ Bind, submit }) => ( + <> + + Change Step Title + + + + + + + + + + + + + + + + + + + + Cancel + + Save + + + )} +
+
+ + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/GeneralTab.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/GeneralTab.tsx new file mode 100644 index 00000000000..86d927d0747 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/GeneralTab.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { Input } from "@webiny/ui/Input"; + +export const GeneralTab = ({ + stepTitle, + setStepTitle +}: { + stepTitle: string; + setStepTitle: (title: string) => void; +}) => { + return ( +
+ +
+ ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleActionSelect.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleActionSelect.tsx new file mode 100644 index 00000000000..8f9e6609be9 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleActionSelect.tsx @@ -0,0 +1,87 @@ +import React, { useState, useEffect } from "react"; +import { Select } from "@webiny/ui/Select"; +import styled from "@emotion/styled"; +import { ruleActionOptions } from "./fieldsValidationConditions"; +import { FbFormStep, FbFormRule } from "~/types"; + +const RuleAction = styled("div")` + display: flex; + align-items: center; + margin-top: 70px; + position: relative; + & > span { + font-size: 22px; + } + &::before { + display: block; + content: ""; + width: 100%; + position: absolute; + top: -25px; + border-top: 1px solid gray; + } +`; + +const ActionSelect = styled(Select)` + margin-left: 35px; + margin-right: 15px; + width: 250px; +`; + +const ActionOptionSelect = styled(Select)` + width: 250px; +`; + +interface Props { + rule: FbFormRule; + steps: FbFormStep[]; + currentStep: FbFormStep; + ruleIndex: number; + onChangeAction: (value: string) => void; +} + +export const RuleActionSelect: React.FC = ({ rule, steps, currentStep, onChangeAction }) => { + const defaultActionValue = rule.action === "submit" ? "submit" : "goToStep"; + const [ruleAction, setRuleAction] = useState(defaultActionValue); + + // We can only select steps that are below current step. + const availableSteps = steps.slice(steps.findIndex(step => step.id === currentStep.id) + 1); + + useEffect(() => { + if (ruleAction === "submit") { + onChangeAction("submit"); + } + }, [ruleAction]); + + return ( + + Then + setRuleAction(val)} + > + {ruleActionOptions.map((action, index) => ( + + ))} + + {ruleAction === "goToStep" && ( + + {availableSteps.map((step, index) => ( + + ))} + + )} + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleCondition.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleCondition.tsx new file mode 100644 index 00000000000..2375ceb1568 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleCondition.tsx @@ -0,0 +1,114 @@ +import React from "react"; +import { Select } from "@webiny/ui/Select"; +import styled from "@emotion/styled"; +import { FbFormModelField } from "~/types"; +import { IconButton } from "@webiny/ui/Button"; +import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete_outline.svg"; +import { fieldConditionOptions } from "./fieldsValidationConditions"; +import { FbFormRule, FbFormCondition } from "~/types"; +import { renderConditionValueController } from "./renderConditionValueController"; +import { updateRuleConditions } from "./updateRuleConditions"; + +const SelectFieldWrapper = styled.div` + display: flex; + margin: 15px 0; + & > span { + font-size: 22px; + } +`; + +const CondtionsWrapper = styled.div` + display: flex; + margin-left: 20px; +`; + +const SelectCondition = styled(Select)` + margin-right: 15px; + margin-left: 63px; + width: 250px; +`; + +const ConditionValue = styled.div` + width: 397px; +`; + +const FieldSelect = styled(Select)` + margin-left: 70px; +`; + +interface Params { + condition: FbFormCondition; + rule: FbFormRule; + fields: (FbFormModelField | null)[]; + conditionIndex: number; + onChange: (rule: FbFormRule) => void; + onDelete: () => void; +} + +export const RuleCondition: React.FC = params => { + const { condition, rule, fields, conditionIndex, onChange, onDelete } = params; + const fieldType = fields.find(field => field?.fieldId === condition.fieldName)?.type || ""; + + const handleOnChange = ( + conditionProperty: keyof FbFormCondition, + conditionPropertyValue: string + ) => { + onChange( + updateRuleConditions({ + rule: rule, + conditionIndex, + conditionProperty, + conditionPropertyValue + }) + ); + }; + + return ( + <> + + If + handleOnChange("fieldName", value)} + > + {fields.map((field, index) => ( + + ))} + + } /> + + {!condition.fieldName ? ( + <> + ) : ( + + handleOnChange("filterType", value)} + > + {fieldConditionOptions + .find(filter => filter.type === fieldType) + ?.options.map((option, index) => ( + + ))} + + {/* This field depends on selected field type */} + + {renderConditionValueController({ + condition, + fields, + handleOnChange + })} + + + )} + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab.tsx new file mode 100644 index 00000000000..29a7e65194e --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab.tsx @@ -0,0 +1,263 @@ +import React from "react"; +import { Icon } from "@webiny/ui/Icon"; +import { AccordionItem } from "@webiny/ui/Accordion"; +import { Alert } from "@webiny/ui/Alert"; +import { mdbid } from "@webiny/utils"; + +import { ReactComponent as InfoIcon } from "@material-design-icons/svg/outlined/info.svg"; +import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete_outline.svg"; + +import { RuleActionSelect } from "./RuleActionSelect"; +import { conditionChainOptions } from "./fieldsValidationConditions"; +import { getAvailableFields } from "./helpers"; +import { + RulesTabWrapper, + AddRuleButtonWrapper, + RuleButtonDescription, + StyledAccordion, + ConditionSetupWrapper, + AddRuleButton, + AddConditionButton, + ConditionsChainSelect +} from "../../Styled"; + +import { FbFormStep, FbFormModel, FbFormRule } from "~/types"; +import { BindComponent } from "@webiny/form/types"; +import { RuleCondition } from "./RuleCondition"; + +interface RulesTabProps { + bind: BindComponent; + step: FbFormStep; + formData: FbFormModel; +} + +export const RulesTab = ({ bind: Bind, step, formData }: RulesTabProps) => { + const fields = getAvailableFields({ step, formData }); + + const areRulesBroken = step.rules?.some(rule => rule.isValid === false); + const isCurrentStepLast = + formData.steps.findIndex(steps => steps.id === step.id) === formData.steps.length - 1; + + const rulesDisabledMessage = "You cannot add rules to the last step!"; + + // We also check whether last step has rules, + // if yes then we most block ability to add new rules and conditions. + if (isCurrentStepLast && !step?.rules?.length) { + return ( + +

{rulesDisabledMessage}

+
+ ); + } + + return ( + + {areRulesBroken !== undefined && areRulesBroken === true && ( + + + At the moment one or more of your rules are broken. To correct the state + please check your rules and ensure they are referencing fields that still + exists and are place inside the current or one of the previous steps. + + + )} + + {({ value: stepRules, onChange: onChangeRules }) => ( + <> + {stepRules && + (stepRules as FbFormRule[]).map((rule, ruleIndex) => ( + + + } + onClick={() => + onChangeRules( + (stepRules as FbFormRule[]).filter( + rulesValueItem => + rulesValueItem.id !== rule.id + ) + ) + } + /> + + } + > + + {({ + value: stepRule, + onChange: onChangeRule + }: { + value: FbFormRule; + onChange: (params: any) => void; + }) => ( + <> + {stepRule.conditions.length === 0 ? ( + { + onChangeRule({ + ...stepRule, + conditions: [ + ...(stepRule.conditions || + []), + { + fieldName: "", + filterType: "", + filterValue: "", + id: mdbid() + } + ] + }); + }} + disabled={isCurrentStepLast} + > + + Add Condition + + ) : ( + stepRule.conditions.map( + (condition, conditionIndex) => ( + + { + onChangeRule({ + ...stepRule, + conditions: + stepRule.conditions.filter( + ruleCondition => + ruleCondition.id !== + condition.id + ) + }); + }} + /> + {condition.id === + stepRule.conditions[ + stepRule.conditions + .length - 1 + ].id && ( + <> + { + onChangeRule({ + ...stepRule, + conditions: + [ + ...(stepRule.conditions || + []), + { + fieldName: + "", + filterType: + "", + filterValue: + "", + id: mdbid() + } + ] + }); + }} + > + + Add Condition + + { + onChangeRule({ + ...stepRule, + chain: val + }); + }} + > + {conditionChainOptions.map( + ( + chainOption, + index + ) => ( + + ) + )} + + { + onChangeRule({ + ...stepRule, + action: val + }); + }} + /> + + )} + + ) + ) + )} + + )} + + + + ))} + + { + onChangeRules([ + ...(stepRules || []), + { + title: "Rule", + id: mdbid(), + conditions: [], + action: "hide", + isValid: true, + chain: "matchAny" + } + ]); + }} + > + + Add Rule + + + } /> + Click here to learn how step rules work + + + + )} + + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/fieldsValidationConditions.ts b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/fieldsValidationConditions.ts new file mode 100644 index 00000000000..3226a723341 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/fieldsValidationConditions.ts @@ -0,0 +1,100 @@ +export const conditionChainOptions = [ + { label: "AND", value: "matchAll" }, + { label: "OR", value: "matchAny" } +]; + +export const fieldConditionOptions = [ + { + type: "text", + options: [ + { label: "Equals", value: "is" }, + { label: "Not Equals", value: "not_is" }, + { label: "Starts With", value: "starts" }, + { label: "Does Not Starts With", value: "not_starts" }, + { label: "Ends With", value: "ends" }, + { label: "Does Not Ends With", value: "not_ends" }, + { label: "Contains", value: "contains" }, + { label: "Not Contains", value: "not_contains" } + ] + }, + { + type: "textarea", + options: [ + { label: "Equals", value: "is" }, + { label: "Not Equals", value: "not_is" }, + { label: "Starts With", value: "starts" }, + { label: "Does Not Starts With", value: "not_starts" }, + { label: "Ends With", value: "ends" }, + { label: "Does Not Ends With", value: "not_ends" }, + { label: "Contains", value: "contains" }, + { label: "Not Contains", value: "not_contains" } + ] + }, + { + type: "radio", + options: [ + { label: "Is", value: "is" }, + { label: "Is Not", value: "not_is" } + ] + }, + { + type: "number", + options: [ + { label: "Is", value: "is" }, + { label: "Is Smaller", value: "lt" }, + { label: "Is Smaller Or Equal", value: "lte" }, + { label: "Is Larger", value: "gt" }, + { label: "Is Larger Or Equal", value: "gte" } + ] + }, + { + type: "hidden", + options: [ + { label: "Equals", value: "is" }, + { label: "Not Equals", value: "not_is" }, + { label: "Starts With", value: "starts" }, + { label: "Does Not Starts With", value: "not_starts" }, + { label: "Ends With", value: "ends" }, + { label: "Does Not Ends With", value: "not_ends" }, + { label: "Contains", value: "contains" }, + { label: "Not Contains", value: "not_contains" } + ] + }, + { + type: "datetime", + options: [ + { label: "In", value: "in" }, + { label: "Not", value: "not" }, + { label: "Not In", value: "not_in" }, + { label: "Lower", value: "time_lt" }, + { label: "Lower or equal", value: "time_lte" }, + { label: "Greater", value: "time_gt" }, + { label: "Greater or equal", value: "time_gte" } + ] + }, + { + type: "checkbox", + options: [ + { label: "Is Selected", value: "is" }, + { label: "Is Not Selected", value: "not_is" } + ] + }, + { + type: "select", + options: [ + { label: "Is", value: "is" }, + { label: "Is Not", value: "not_is" } + ] + } +]; + +export const ruleActionOptions = [ + { + value: "goToStep", + label: "Go to step" + }, + { + value: "submit", + label: "Submit" + } +]; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/helpers.ts b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/helpers.ts new file mode 100644 index 00000000000..f2ce76e1a36 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/helpers.ts @@ -0,0 +1,36 @@ +import findIndex from "lodash/findIndex"; + +import { FbFormStep, FbFormModel, FbFormModelField } from "~/types"; + +interface Props { + step: FbFormStep; + formData: FbFormModel; +} + +export const getAvailableFields = ({ step, formData }: Props) => { + const getFieldById = (id: string): FbFormModelField | null => { + return formData.fields.find(field => field._id === id) || null; + }; + + // Checking if the step for which we adding rules is first in array of steps, + // if yes than we will only display it's own fields in condition field select, + // if not, than we will also display fields from previous step. (line #23) + const indexOfTheCurrentStep = findIndex(formData.steps, { id: step.id }); + if (step.layout) { + const layout = + indexOfTheCurrentStep === 0 + ? step.layout + : [...step.layout, ...formData.steps[indexOfTheCurrentStep - 1].layout]; + + return layout + .map(row => { + return row.map(id => { + const field = getFieldById(id); + + return field; + }); + }) + .flat(1); + } + return []; +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/renderConditionValueController.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/renderConditionValueController.tsx new file mode 100644 index 00000000000..ce15fac9841 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/renderConditionValueController.tsx @@ -0,0 +1,114 @@ +import React from "react"; +import { Select } from "@webiny/ui/Select"; +import { Input } from "@webiny/ui/Input"; +import { FbFormModelField, FbFormCondition } from "~/types"; +import { DateTime } from "./DateTime"; + +interface Props { + condition: FbFormCondition; + fields: (FbFormModelField | null)[]; + handleOnChange: ( + conditionProperty: keyof FbFormCondition, + conditionPropertyValue: string + ) => void; +} + +export const renderConditionValueController: React.FC = ({ + condition, + fields, + handleOnChange +}) => { + const fieldType = fields.find(field => field?.fieldId === condition.fieldName)?.type || ""; + + const fieldOptions = fields.find(field => field?.fieldId === condition.fieldName)?.options; + + /* + We need this settings in case we have selected DateTime field, + because timezone is being stored inside of field setting. + */ + const fieldSettings = + fields.find(field => field?.fieldId === condition.fieldName)?.settings || ""; + + switch (fieldType) { + case "text": + return ( + handleOnChange("filterValue", value)} + value={condition.filterValue} + /> + ); + case "select": + return ( + + ); + case "radio": + return ( + + ); + case "checkbox": + return ( + + ); + case "number": + return ( + handleOnChange("filterValue", value)} + value={condition.filterValue} + /> + ); + case "datetime": + return ( + } + handleOnChange={handleOnChange} + value={condition.filterValue} + /> + ); + case "hidden": + return ( + handleOnChange("filterValue", value)} + value={condition.filterValue} + /> + ); + default: + return Please, select field; + } +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/updateRuleConditions.ts b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/updateRuleConditions.ts new file mode 100644 index 00000000000..961df9b3b6e --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/updateRuleConditions.ts @@ -0,0 +1,28 @@ +import cloneDeep from "lodash/cloneDeep"; +import { FbFormRule, FbFormCondition } from "~/types"; + +interface UpdateRuleConditionsProps { + rule: FbFormRule; + conditionIndex: number; + conditionProperty: keyof FbFormCondition; + conditionPropertyValue: string; +} + +export const updateRuleConditions = ({ + rule, + conditionIndex, + conditionProperty, + conditionPropertyValue +}: UpdateRuleConditionsProps): FbFormRule => { + const conditions = cloneDeep(rule.conditions || []); + + conditions[conditionIndex] = { + ...rule.conditions[conditionIndex], + [conditionProperty]: conditionPropertyValue + }; + + return { + ...rule, + conditions + }; +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStep.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStep.tsx index 7ad6ab96db1..da25c5b79e2 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStep.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStep.tsx @@ -1,17 +1,11 @@ import React, { useCallback, useState } from "react"; import cloneDeep from "lodash/cloneDeep"; -import { - FbFormModelField, - FieldLayoutPositionType, - FbBuilderFieldPlugin, - MoveFieldParams, - FbFormModel, - FbFormStep -} from "~/types"; -import Draggable from "../../../Draggable"; +import { FbFormModelField, FbFormStep, DropDestination } from "~/types"; +import Draggable from "~/admin/components/FormEditor/Draggable"; import EditFieldDialog from "../EditFieldDialog"; import Field from "../Field"; +import ConditionalGroupField from "../ConditionGroup"; import { rowHandle, fieldHandle, @@ -19,15 +13,18 @@ import { Row, RowContainer, StyledAccordion, - StyledAccordionItem + StyledAccordionItem, + conditionGroupContainer, + StepRulesTag } from "../Styled"; import { Icon } from "@webiny/ui/Icon"; import { AccordionItem } from "@webiny/ui/Accordion"; import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete_outline.svg"; import { ReactComponent as EditIcon } from "@material-design-icons/svg/outlined/edit.svg"; -import { ReactComponent as HandleIcon } from "../../../../../icons/round-drag_indicator-24px.svg"; -import { Center, Vertical, Horizontal } from "../../../DropZone"; +import { ReactComponent as HandleIcon } from "~/admin/components/FormEditor/icons/round-drag_indicator-24px.svg"; +import { Center, Vertical, Horizontal } from "~/admin/components/FormEditor/DropZone"; +import { useFormDragAndDrop } from "~/hooks/useFormDragAndDrop"; import { i18n } from "@webiny/app/i18n"; const t = i18n.namespace("FormsApp.Editor.EditTab"); @@ -35,43 +32,40 @@ const t = i18n.namespace("FormsApp.Editor.EditTab"); export const FormStep = ({ title, deleteStepDisabled, - data, formStep, onDelete, onEdit, - moveRow, - moveField, - getFieldPlugin, - insertField, getLayoutFields, updateField, - deleteField + deleteField, + deleteConditionGroup }: { title: string; deleteStepDisabled: boolean; - data: FbFormModel; formStep: FbFormStep; onDelete: () => void; onEdit: () => void; - moveRow: ( - source: number, - destination: number, - targetStepId: string, - sourceStep: FbFormStep - ) => void; - moveField: (params: MoveFieldParams) => void; - getFieldPlugin: ({ name }: { name: string }) => FbBuilderFieldPlugin | null; - insertField: ( - field: FbFormModelField, - position: FieldLayoutPositionType, - stepId: string - ) => void; getLayoutFields: (stepId: string) => FbFormModelField[][]; updateField: (field: FbFormModelField) => void; - deleteField: (field: FbFormModelField, stepId: string) => void; + deleteField: ({ + field, + containerId, + containerType + }: { + field: FbFormModelField; + containerId: string; + containerType?: "conditionGroup" | "step"; + }) => void; + deleteConditionGroup: ({ + formStep, + conditionGroup + }: { + formStep: FbFormStep; + conditionGroup: FbFormModelField; + }) => void; }) => { const [editingField, setEditingField] = useState(null); - const [dropTarget, setDropTarget] = useState(null); + const [dropDestination, setDropDestination] = useState(null); const editField = useCallback((field: FbFormModelField | null) => { if (!field) { @@ -81,56 +75,10 @@ export const FormStep = ({ setEditingField(cloneDeep(field)); }, []); - // TODO @ts-refactor figure out source type - const handleDropField = useCallback( - (source: any, position: FieldLayoutPositionType): void => { - const { pos, name, ui, formStep: sourceStep } = source; - - if (name === "custom") { - /** - * We can cast because field is empty in the start - */ - editField({} as FbFormModelField); - setDropTarget(position); - return; - } - - if (ui === "row") { - // Reorder rows. - // Reorder logic is different depending on the source and target position. - // pos.formStep is a source step from which we move row. - // formStep is a target step in which we move row. - moveRow(pos.row, position.row, formStep.id, sourceStep); - return; - } - - // If source pos is set, we are moving an existing field. - if (pos) { - if (pos.index === null) { - console.log("Tried to move Form Field but its position index is null."); - console.log(source); - return; - } - // Here we are getting field from the source step ("source step" is a step from which we take a field) - const fieldId = sourceStep.layout[pos.row][pos.index]; - moveField({ - field: fieldId, - position, - targetStepId: formStep.id, - sourceStepId: sourceStep.id - }); - return; - } - - // Find field plugin which handles the dropped field type "name". - const plugin = getFieldPlugin({ name }); - if (!plugin) { - return; - } - insertField(plugin.field.createField(), position, formStep.id); - }, - [data] - ); + const { composeHandleDropParams, createCustomField } = useFormDragAndDrop({ + editField, + setDropDestination + }); const fields = getLayoutFields(formStep.id); @@ -145,6 +93,11 @@ export const FormStep = ({ open={true} actions={ + {formStep.rules.length ? ( + {"Rules Attached"} + ) : ( + <> + )} } onClick={onEdit} /> } @@ -157,13 +110,16 @@ export const FormStep = ({ {fields.length === 0 && (
{ - // We don't want to drop steps inside of steps - if (item.ui === "step") { - return undefined; - } - handleDropField(item, { - row: 0, - index: 0 + composeHandleDropParams({ + item, + destination: { + containerId: formStep.id, + containerType: "step", + position: { + row: 0, + index: 0 + } + } }); return undefined; }} @@ -173,7 +129,14 @@ export const FormStep = ({ )} {fields.map((row, index) => ( {( @@ -194,9 +157,16 @@ export const FormStep = ({ { - handleDropField(item, { - row: index, - index: null + composeHandleDropParams({ + item, + destination: { + containerId: formStep.id, + containerType: "step", + position: { + row: index, + index: null + } + } }); return undefined; }} @@ -210,39 +180,80 @@ export const FormStep = ({ beginDrag={{ ui: "field", name: field.name, + id: field._id, pos: { row: index, index: fieldIndex }, - formStep + container: { + type: "step", + id: formStep.id + } }} > {({ drag }) => ( -
+
{ - handleDropField(item, { - row: index, - index: fieldIndex + composeHandleDropParams({ + item, + destination: { + containerId: formStep.id, + containerType: "step", + position: { + row: index, + index: fieldIndex + } + } }); return undefined; }} - isVisible={item => - item.ui === "field" && - (row.length < 4 || - item?.pos?.row === index) - } + isVisible={item => { + return ( + item.ui === "field" && + (row.length < 4 || + item?.pos?.row === index) + ); + }} /> -
- - deleteField(field, formStep.id) - } - /> -
+ {field.name === "conditionGroup" ? ( +
+ +
+ ) : ( +
+ + deleteField({ + field, + containerId: + formStep.id, + containerType: "step" + }) + } + /> +
+ )} {/* Field end */} {fieldIndex === row.length - 1 && ( @@ -254,9 +265,18 @@ export const FormStep = ({ item?.pos?.row === index) } onDrop={item => { - handleDropField(item, { - row: index, - index: fieldIndex + 1 + composeHandleDropParams({ + item, + destination: { + containerId: + formStep.id, + containerType: "step", + position: { + row: index, + index: + fieldIndex + 1 + } + } }); return undefined; }} @@ -272,9 +292,16 @@ export const FormStep = ({ { - handleDropField(item, { - row: index + 1, - index: null + composeHandleDropParams({ + item, + destination: { + containerId: formStep.id, + containerType: "step", + position: { + row: index + 1, + index: null + } + } }); return undefined; }} @@ -295,10 +322,16 @@ export const FormStep = ({ if (data._id) { updateField(data); - } else if (!dropTarget) { + } else if (!dropDestination) { console.log("Missing drop target on EditFieldDialog submit."); } else { - insertField(data, dropTarget, formStep.id); + /* + Here we are inserting a custom field. + */ + createCustomField({ + data, + dropDestination + }); } editField(null); }} diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/useValidateConditionGroupRule.ts b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/useValidateConditionGroupRule.ts new file mode 100644 index 00000000000..10e3b5657c4 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/useValidateConditionGroupRule.ts @@ -0,0 +1,49 @@ +import { FbFormStep, FbFormModelField, FbFormCondition, FbFormRule } from "~/types"; + +interface Props { + fields: (FbFormModelField | null)[]; + steps: FbFormStep[]; +} + +export const useValidateConditionGroupRule = ({ fields, steps }: Props) => { + const getFieldById = (id: string): FbFormModelField | null => { + return fields.find(field => field?._id === id) || null; + }; + + const conditionGroupFields = fields.find(field => field?.type === "condition-group"); + let stepIndexWithConditionGroup = 0; + + steps.forEach((step, index) => { + if (step.layout.flat(1).includes(conditionGroupFields?._id || "") === true) { + stepIndexWithConditionGroup = index; + } + }); + + const step = steps[stepIndexWithConditionGroup]; + const stepsLayout = + stepIndexWithConditionGroup === 0 + ? step.layout.flat() + : [...step.layout, ...steps[stepIndexWithConditionGroup - 1].layout].flat(); + + const allowedFileds = stepsLayout + .map(id => { + const field = getFieldById(id); + + return field; + }) + .flat(1); + + const filtered = allowedFileds.filter(field => field?.type !== "condition-group"); + let isConditioGroupFieldsValid = true; + + conditionGroupFields?.settings.rules?.forEach((rule: FbFormRule) => { + rule.conditions.forEach((condition: FbFormCondition) => { + if (!filtered.some(field => field?.fieldId === condition.fieldName)) { + isConditioGroupFieldsValid = false; + return; + } + }); + }); + + return isConditioGroupFieldsValid; +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts index de78b57d532..979d3558484 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts @@ -1,10 +1,16 @@ import { css } from "emotion"; import styled from "@emotion/styled"; import { Accordion, AccordionItem } from "@webiny/ui/Accordion"; +import { ButtonSecondary } from "@webiny/ui/Button"; +import { Select } from "@webiny/ui/Select"; -export const StyledAccordion = styled(Accordion)` +export const StyledAccordion = styled(Accordion)<{ margingap?: string }>` background: var(--mdc-theme-background); box-shadow: none; + & > ul { + padding: 0 0 0 0 !important; + } + ${props => `margin-top: ${props.margingap}`} `; export const StyledAccordionItem = styled(AccordionItem)` @@ -13,6 +19,73 @@ export const StyledAccordionItem = styled(AccordionItem)` } `; +export const StepRulesTag = styled.div<{ isValid: boolean }>` + display: inline-block; + padding: 5px 20px 7px 20px; + background-color: white; + border-radius: 5px; + border: 1px solid; + border-color: ${props => + props.isValid ? props.theme.styles.colors.color4 : props.theme.styles.colors.color1}; + margin-right: 10px; + cursor: default; + font-size: 16px; + font-weight: normal; + color: ${props => + props.isValid ? props.theme.styles.colors.color4 : props.theme.styles.colors.color1}; +`; + +export const RulesTabWrapper = styled.div` + margin: 20px 20px; + display: flex; + flex-direction: column; +`; + +export const AddRuleButtonWrapper = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin-top: 15px; +`; + +export const RuleButtonDescription = styled.div` + display: flex; + align-items: center; + margin-top: 5px; + & > span { + margin-left: 5px; + font-size: 14px; + } +`; + +export const ConditionSetupWrapper = styled.div``; + +export const AddRuleButton = styled(ButtonSecondary)` + width: 150px; +`; + +export const AddConditionButton = styled(ButtonSecondary)` + border: none; + margin: 10px 0 10px 80px; + padding: 0; +`; + +export const ConditionsChainSelect = styled(Select)` + width: 250px; + margin-left: 80px; +`; + +export const DefaultBehaviourWrapper = styled.div` + display: flex; + justify-content: flex-start; + align-items: center; + & .webiny-ui-select { + width: 350px; + margin-left: 40px; + } +`; + export const EditContainer = styled("div")({ padding: 40, position: "relative" @@ -46,6 +119,21 @@ export const Row = styled("div")({ overflowX: "auto" }); +export const conditionGroupContainer = css({ + position: "relative", + flex: "1 100%", + backgroundColor: "white", + padding: "0 15px", + margin: 10, + transition: "box-shadow 225ms", + color: "var(--mdc-theme-on-surface)", + cursor: "grab", + "&:hover": { + boxShadow: + "var(--mdc-theme-on-background) 1px 1px 1px, var(--mdc-theme-on-background) 1px 1px 2px" + } +}); + export const fieldContainer = css({ position: "relative", flex: "1 100%", diff --git a/packages/app-form-builder/src/admin/plugins/editor/defaultBar/PublishFormButton.tsx b/packages/app-form-builder/src/admin/plugins/editor/defaultBar/PublishFormButton.tsx index 0dc2841624c..cf76711351b 100644 --- a/packages/app-form-builder/src/admin/plugins/editor/defaultBar/PublishFormButton.tsx +++ b/packages/app-form-builder/src/admin/plugins/editor/defaultBar/PublishFormButton.tsx @@ -34,6 +34,8 @@ const PublishFormButton: React.FC = () => { return null; } + const isStepRulesValid = data.steps.every(step => step.rules.every(rule => rule.isValid)); + return ( { data-testid={"fb.editor.default-bar.publish"} onClick={() => { showConfirmation(async () => { - await publish({ - variables: { - revision: data.id - }, - update(_, response) { - if (!response.data) { - showSnackbar( - "Missing response data on Publish Revision Mutation." - ); - return; - } - const { data: revision, error } = - response.data.formBuilder.publishRevision || {}; + if (isStepRulesValid) { + await publish({ + variables: { + revision: data.id + }, + update(_, response) { + if (!response.data) { + showSnackbar( + "Missing response data on Publish Revision Mutation." + ); + return; + } + const { data: revision, error } = + response.data.formBuilder.publishRevision || {}; - if (error) { - showSnackbar(error.message); - return; - } + if (error) { + showSnackbar(error.message); + return; + } - history.push( - `/form-builder/forms?id=${encodeURIComponent(revision.id)}` - ); + history.push( + `/form-builder/forms?id=${encodeURIComponent( + revision.id + )}` + ); - // Let's wait a bit, because we are also redirecting the user. - setTimeout(() => { - showSnackbar(t`Your form was published successfully!`); - }, 500); - } - }); + // Let's wait a bit, because we are also redirecting the user. + setTimeout(() => { + showSnackbar(t`Your form was published successfully!`); + }, 500); + } + }); + } else { + showSnackbar(t`Some step rules are broken!`); + } }); }} > diff --git a/packages/app-form-builder/src/admin/plugins/editor/formFields/checkboxes.tsx b/packages/app-form-builder/src/admin/plugins/editor/formFields/checkboxes.tsx index 414a4e931b2..8cbde46d57d 100644 --- a/packages/app-form-builder/src/admin/plugins/editor/formFields/checkboxes.tsx +++ b/packages/app-form-builder/src/admin/plugins/editor/formFields/checkboxes.tsx @@ -16,6 +16,7 @@ const plugin: FbBuilderFieldPlugin = { icon: , createField() { return { + _id: "", fieldId: "", type: this.type, name: this.name, diff --git a/packages/app-form-builder/src/admin/plugins/editor/formFields/conditionGroup.tsx b/packages/app-form-builder/src/admin/plugins/editor/formFields/conditionGroup.tsx new file mode 100644 index 00000000000..c6159caf2a8 --- /dev/null +++ b/packages/app-form-builder/src/admin/plugins/editor/formFields/conditionGroup.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { FbBuilderFieldPlugin } from "~/types"; +import { ReactComponent as TextIcon } from "./icons/round-text_fields-24px.svg"; + +const plugin: FbBuilderFieldPlugin = { + type: "form-editor-field-type", + name: "form-editor-field-type-condition-group", + field: { + type: "condition-group", + name: "conditionGroup", + label: "Condition Group", + description: "Condition Group, show or hide group based on rule", + icon: , + createField() { + return { + _id: "", + fieldId: "", + type: this.type, + name: this.name, + validation: [], + settings: { + defaultValue: "", + layout: [], + defaultBehaviour: "show", + rules: [] + } + }; + }, + renderSettings() { + return null; + } + } +}; + +export default plugin; diff --git a/packages/app-form-builder/src/admin/plugins/editor/formFields/dateTime.tsx b/packages/app-form-builder/src/admin/plugins/editor/formFields/dateTime.tsx index d9f65745f55..57a227d318e 100644 --- a/packages/app-form-builder/src/admin/plugins/editor/formFields/dateTime.tsx +++ b/packages/app-form-builder/src/admin/plugins/editor/formFields/dateTime.tsx @@ -19,6 +19,7 @@ const plugin: FbBuilderFieldPlugin = { icon: , createField() { return { + _id: "", fieldId: "", type: this.type, name: this.name, diff --git a/packages/app-form-builder/src/admin/plugins/editor/formFields/hidden.tsx b/packages/app-form-builder/src/admin/plugins/editor/formFields/hidden.tsx index 703261056bf..dafe154a7eb 100644 --- a/packages/app-form-builder/src/admin/plugins/editor/formFields/hidden.tsx +++ b/packages/app-form-builder/src/admin/plugins/editor/formFields/hidden.tsx @@ -15,6 +15,7 @@ const plugin: FbBuilderFieldPlugin = { icon: , createField() { return { + _id: "", fieldId: "", type: this.type, name: this.name, diff --git a/packages/app-form-builder/src/admin/plugins/editor/formFields/number.tsx b/packages/app-form-builder/src/admin/plugins/editor/formFields/number.tsx index 984a6281656..74a3f0f1be9 100644 --- a/packages/app-form-builder/src/admin/plugins/editor/formFields/number.tsx +++ b/packages/app-form-builder/src/admin/plugins/editor/formFields/number.tsx @@ -16,6 +16,7 @@ const plugin: FbBuilderFieldPlugin = { validators: ["required", "gte", "lte", "in"], createField() { return { + _id: "", fieldId: "", type: this.type, name: this.name, diff --git a/packages/app-form-builder/src/admin/plugins/editor/formFields/radioButtons.tsx b/packages/app-form-builder/src/admin/plugins/editor/formFields/radioButtons.tsx index 101fb388bed..d000b488fbc 100644 --- a/packages/app-form-builder/src/admin/plugins/editor/formFields/radioButtons.tsx +++ b/packages/app-form-builder/src/admin/plugins/editor/formFields/radioButtons.tsx @@ -16,6 +16,7 @@ const plugin: FbBuilderFieldPlugin = { icon: , createField() { return { + _id: "", fieldId: "", type: this.type, name: this.name, diff --git a/packages/app-form-builder/src/admin/plugins/editor/formFields/select.tsx b/packages/app-form-builder/src/admin/plugins/editor/formFields/select.tsx index 2b0e50107de..473a68e29d9 100644 --- a/packages/app-form-builder/src/admin/plugins/editor/formFields/select.tsx +++ b/packages/app-form-builder/src/admin/plugins/editor/formFields/select.tsx @@ -17,6 +17,7 @@ const plugin: FbBuilderFieldPlugin = { icon: , createField() { return { + _id: "", fieldId: "", type: this.type, name: this.name, diff --git a/packages/app-form-builder/src/admin/plugins/editor/formFields/text.tsx b/packages/app-form-builder/src/admin/plugins/editor/formFields/text.tsx index 636f6e3bbe5..f6f702fba4b 100644 --- a/packages/app-form-builder/src/admin/plugins/editor/formFields/text.tsx +++ b/packages/app-form-builder/src/admin/plugins/editor/formFields/text.tsx @@ -16,6 +16,7 @@ const plugin: FbBuilderFieldPlugin = { icon: , createField() { return { + _id: "", fieldId: "", type: this.type, name: this.name, diff --git a/packages/app-form-builder/src/admin/plugins/editor/formFields/textarea.tsx b/packages/app-form-builder/src/admin/plugins/editor/formFields/textarea.tsx index 9cb06fbde5c..5ef2bdf0545 100644 --- a/packages/app-form-builder/src/admin/plugins/editor/formFields/textarea.tsx +++ b/packages/app-form-builder/src/admin/plugins/editor/formFields/textarea.tsx @@ -16,6 +16,7 @@ const plugin: FbBuilderFieldPlugin = { icon: , createField() { return { + _id: "", fieldId: "", type: this.type, name: this.name, diff --git a/packages/app-form-builder/src/components/Form/FormRender.tsx b/packages/app-form-builder/src/components/Form/FormRender.tsx index 45309e84d76..8418a4141d1 100644 --- a/packages/app-form-builder/src/components/Form/FormRender.tsx +++ b/packages/app-form-builder/src/components/Form/FormRender.tsx @@ -1,6 +1,6 @@ import { plugins } from "@webiny/plugins"; import cloneDeep from "lodash/cloneDeep"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { useApolloClient } from "@apollo/react-hooks"; import { createReCaptchaComponent, createTermsOfServiceComponent } from "./components"; import { @@ -8,9 +8,12 @@ import { handleFormTriggers, onFormMounted, reCaptchaEnabled, - termsOfServiceEnabled + termsOfServiceEnabled, + getNextStepIndex } from "./functions"; +import { checkIfConditionsMet } from "./functions/getNextStepIndex"; + import { FormRenderPropsType, FbFormRenderComponentProps, @@ -20,7 +23,8 @@ import { FbFormModelField, FormRenderFbFormModelField, FbFormModel, - FbFormLayout + FbFormLayout, + FbFormRule } from "~/types"; import { FbFormLayoutPlugin } from "~/plugins"; @@ -44,6 +48,7 @@ const FormRender: React.FC = props => { const client = useApolloClient(); const data = props.data || ({} as FbFormModel); const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [formState, setFormState] = useState(); const [layoutRenderKey, setLayoutRenderKey] = useState(new Date().getTime().toString()); const resetLayoutRenderKey = useCallback(() => { @@ -61,12 +66,15 @@ const FormRender: React.FC = props => { }); }, [data.id]); + const [modifiedSteps, setModifiedSteps] = useState(data.steps); + // We need this useEffect in case when user has deleted a step and he was on that step on the preview tab, // so it won't trigger an error when we trying to view the step that we have deleted, // we will simpy change currentStep to the first step. useEffect(() => { setCurrentStepIndex(0); - }, [data.steps.length]); + setModifiedSteps(data.steps); + }, [data.steps, data.fields.length]); const reCaptchaResponseToken = useRef(""); const termsOfServiceAccepted = useRef(false); @@ -76,7 +84,11 @@ const FormRender: React.FC = props => { } const goToNextStep = () => { - setCurrentStepIndex(prevStep => (prevStep += 1)); + setCurrentStepIndex(prevStep => { + const nextStep = (prevStep += 1); + validateStepConditions(formState, nextStep); + return nextStep; + }); }; const goToPreviousStep = () => { @@ -86,17 +98,21 @@ const FormRender: React.FC = props => { const formData: FbFormModel = cloneDeep(data); const { fields, settings, steps } = formData; + const resolvedSteps = useMemo(() => { + return modifiedSteps || steps; + }, [steps, modifiedSteps]); + console.log("resolvedSteps", resolvedSteps); // Check if the form is a multi step. const isMultiStepForm = formData.steps.length > 1; const isFirstStep = currentStepIndex === 0; - const isLastStep = currentStepIndex === steps.length - 1; + const isLastStep = currentStepIndex === resolvedSteps.length - 1; // We need this check in case we deleted last step and at the same time we were previewing it. const currentStep = - steps[currentStepIndex] === undefined - ? steps[formData.steps.length - 1] - : steps[currentStepIndex]; + resolvedSteps[currentStepIndex] === undefined + ? resolvedSteps[resolvedSteps.length - 1] + : resolvedSteps[currentStepIndex]; const getFieldById = (id: string): FbFormModelField | null => { return fields.find(field => field._id === id) || null; @@ -106,11 +122,67 @@ const FormRender: React.FC = props => { return fields.find(field => field.fieldId === id) || null; }; + const validateStepConditions = (formData: Record, stepIndex: number) => { + const currentStep = resolvedSteps[stepIndex]; + + const nextStepIndex = getNextStepIndex({ + formData, + rules: currentStep.rules + }); + + console.log("nextStepIndex", nextStepIndex); + + if (nextStepIndex === "submit") { + setModifiedSteps([...modifiedSteps.slice(0, stepIndex + 1)]); + } else if (nextStepIndex !== "") { + setModifiedSteps([ + ...modifiedSteps.slice(0, stepIndex + 1), + ...steps.slice(+nextStepIndex) + ]); + } else { + setModifiedSteps([ + ...modifiedSteps.slice(0, stepIndex + 1), + ...steps.slice(currentStep.index + 1) + ]); + } + }; + // We need to have "stepIndex" prop in order to get corresponding fields for the current step. const getFields = (stepIndex = 0): FormRenderFbFormModelField[][] => { const stepFields = - steps[stepIndex] === undefined ? steps[steps.length - 1] : steps[stepIndex]; + resolvedSteps[stepIndex] === undefined + ? resolvedSteps[resolvedSteps.length - 1] + : resolvedSteps[stepIndex]; const fieldLayout = cloneDeep(stepFields.layout.filter(Boolean)); + + // Here we are adding condition group fields into step layout. + fieldLayout.forEach((row, fieldIndex) => { + row.forEach(fieldId => { + const field = getFieldById(fieldId); + if (!field) { + return; + } + + if (field.settings.rules !== undefined) { + field.settings?.rules.forEach((rule: FbFormRule) => { + if (checkIfConditionsMet({ formData: formState, rule })) { + if (rule.action === "show") { + fieldLayout.splice(fieldIndex, 1, ...field.settings.layout); + } else { + fieldLayout.splice(fieldIndex, field.settings.layout.length, [ + field._id + ]); + } + } else { + if (field.settings.defaultBehaviour === "show") { + fieldLayout.splice(fieldIndex, 1, ...field.settings.layout); + } + } + }); + } + }); + }); + const validatorPlugins = plugins.byType("fb-form-field-validator"); @@ -251,6 +323,8 @@ const FormRender: React.FC = props => { submit, goToNextStep, goToPreviousStep, + validateStepConditions, + setFormState, isLastStep, isFirstStep, currentStepIndex, diff --git a/packages/app-form-builder/src/components/Form/functions/getNextStepIndex.ts b/packages/app-form-builder/src/components/Form/functions/getNextStepIndex.ts new file mode 100644 index 00000000000..d7362d71c19 --- /dev/null +++ b/packages/app-form-builder/src/components/Form/functions/getNextStepIndex.ts @@ -0,0 +1,218 @@ +import includes from "lodash/includes"; +import endsWith from "lodash/endsWith"; +import startsWith from "lodash/startsWith"; +import eq from "lodash/eq"; +import lte from "lodash/lte"; +import lt from "lodash/lt"; +import gte from "lodash/gte"; +import gt from "lodash/gt"; + +import { FbFormCondition, FbFormRule } from "~/types"; + +interface Props { + formData: Record; + rule: FbFormRule; +} + +const includesValidator = (filterValue: string, fieldValue: string) => { + if (fieldValue === null) { + return; + } + + return includes(fieldValue, filterValue); +}; + +const startsWithValidator = (filterValue: string, fieldValue: string) => { + if (fieldValue === null) { + return; + } + + return startsWith(fieldValue, filterValue); +}; + +const endsWithValidator = (filterValue: string, fieldValue: string) => { + if (fieldValue === null) { + return; + } + + return endsWith(fieldValue, filterValue); +}; + +const isValidator = (filterValue: string, fieldValue: string | string[]) => { + if (fieldValue === null) { + return; + } + + // This is check for checkboxes. + if (typeof fieldValue === "object") { + return fieldValue.includes(filterValue); + } else { + return eq(fieldValue, filterValue); + } +}; + +const gtValidator = ({ + filterValue, + fieldValue, + equal = false +}: { + filterValue: string; + fieldValue: string; + equal?: boolean; +}) => { + if (fieldValue === null) { + return; + } + + if (!equal) { + return gt(fieldValue, filterValue); + } else { + return gte(fieldValue, filterValue); + } +}; + +const ltValidator = ({ + filterValue, + fieldValue, + equal = false +}: { + filterValue: string; + fieldValue: string; + equal?: boolean; +}) => { + if (fieldValue === null) { + return; + } + + if (!equal) { + return lt(filterValue, filterValue); + } else { + return lte(fieldValue, filterValue); + } +}; + +const timeGtValidator = ({ + filterValue, + fieldValue, + equal = false +}: { + filterValue: string; + fieldValue: string; + equal?: boolean; +}) => { + if (fieldValue === null) { + return; + } + + if (!equal) { + return gt(Date.parse(fieldValue), Date.parse(filterValue)); + } else { + return gte(Date.parse(fieldValue), Date.parse(filterValue)); + } +}; + +const timeLtValidator = ({ + filterValue, + fieldValue, + equal = false +}: { + filterValue: string; + fieldValue: string; + equal?: boolean; +}) => { + if (fieldValue === null) { + return; + } + + if (!equal) { + return lt(Date.parse(fieldValue), Date.parse(filterValue)); + } else { + return lte(Date.parse(fieldValue), Date.parse(filterValue)); + } +}; + +const checkCondition = (condition: FbFormCondition, fieldValue: string) => { + switch (condition.filterType) { + case "contains": + return includesValidator(condition.filterValue, fieldValue); + case "not_contains": + return !includesValidator(condition.filterValue, fieldValue); + case "starts": + return startsWithValidator(condition.filterValue, fieldValue); + case "not_starts": + return !startsWithValidator(condition.filterValue, fieldValue); + case "ends": + return endsWithValidator(condition.filterValue, fieldValue); + case "not_ends": + return !endsWithValidator(condition.filterValue, fieldValue); + case "is": + return isValidator(condition.filterValue, fieldValue); + case "not_is": + return !isValidator(condition.filterValue, fieldValue); + case "selected": + return isValidator(condition.filterValue, fieldValue); + case "not_selected": + return !isValidator(condition.filterValue, fieldValue); + case "gt": + return gtValidator({ filterValue: condition.filterValue, fieldValue }); + case "gte": + return gtValidator({ filterValue: condition.filterValue, fieldValue, equal: true }); + case "lt": + return ltValidator({ filterValue: condition.filterValue, fieldValue }); + case "lte": + return ltValidator({ filterValue: condition.filterValue, fieldValue, equal: true }); + case "time_gt": + return timeGtValidator({ filterValue: condition.filterValue, fieldValue }); + case "time_gte": + return timeGtValidator({ filterValue: condition.filterValue, fieldValue, equal: true }); + case "time_lt": + return timeLtValidator({ filterValue: condition.filterValue, fieldValue }); + case "time_lte": + return timeLtValidator({ filterValue: condition.filterValue, fieldValue, equal: true }); + default: + return false; + } +}; + +export const checkIfConditionsMet = ({ formData, rule }: Props) => { + if (rule.chain === "matchAll") { + let isValid = true; + + rule.conditions.forEach(condition => { + if (!checkCondition(condition, formData?.[condition.fieldName])) { + isValid = false; + return; + } + }); + + return isValid; + } else { + let isValid = false; + + rule.conditions.forEach(condition => { + if (checkCondition(condition, formData?.[condition.fieldName])) { + isValid = true; + return; + } + }); + + return isValid; + } +}; + +interface GetNextStepIndexProps { + formData: Record; + rules: FbFormRule[]; +} + +export default ({ formData, rules }: GetNextStepIndexProps) => { + let nextStepIndex = ""; + rules.forEach(rule => { + if (checkIfConditionsMet({ formData, rule })) { + nextStepIndex = rule.action; + return; + } + }); + + return nextStepIndex; +}; diff --git a/packages/app-form-builder/src/components/Form/functions/index.ts b/packages/app-form-builder/src/components/Form/functions/index.ts index 0812222fcca..e8fd490fd17 100644 --- a/packages/app-form-builder/src/components/Form/functions/index.ts +++ b/packages/app-form-builder/src/components/Form/functions/index.ts @@ -3,3 +3,4 @@ export { default as createFormSubmission } from "./createFormSubmission"; export { default as handleFormTriggers } from "./handleFormTriggers"; export { default as reCaptchaEnabled } from "./reCaptchaEnabled"; export { default as termsOfServiceEnabled } from "./termsOfServiceEnabled"; +export { default as getNextStepIndex } from "./getNextStepIndex"; diff --git a/packages/app-form-builder/src/components/Form/graphql.ts b/packages/app-form-builder/src/components/Form/graphql.ts index 568e3656d89..9a65f38789a 100644 --- a/packages/app-form-builder/src/components/Form/graphql.ts +++ b/packages/app-form-builder/src/components/Form/graphql.ts @@ -28,6 +28,19 @@ export const DATA_FIELDS = ` steps { title layout + rules { + title + action + chain + id + conditions { + id + fieldName + filterType + filterValue + } + isValid + } } triggers settings { diff --git a/packages/app-form-builder/src/hooks/useFormDragAndDrop.ts b/packages/app-form-builder/src/hooks/useFormDragAndDrop.ts new file mode 100644 index 00000000000..2013c8858d7 --- /dev/null +++ b/packages/app-form-builder/src/hooks/useFormDragAndDrop.ts @@ -0,0 +1,139 @@ +import { useCallback } from "react"; +import { useFormEditor } from "~/admin/components/FormEditor"; +import { DragObjectWithFieldInfo } from "~/admin/components/FormEditor/Droppable"; +import { DropTarget, DropSource, DropDestination, FbFormModelField } from "~/types"; + +interface UseFormDragParams { + editField: (field: FbFormModelField | null) => void; + setDropDestination: (desitnation: DropDestination | null) => void; +} + +interface HadleDropParams { + target: DropTarget; + source: DropSource; + destination: DropDestination; +} + +interface CreateCustomFieldParams { + data: FbFormModelField; + dropDestination: DropDestination; +} + +export interface ComposeHadleDropParams { + item: DragObjectWithFieldInfo; + destination: DropDestination; +} + +export const useFormDragAndDrop = (params: UseFormDragParams) => { + const { data, moveRow, moveField, getFieldPlugin, insertField } = useFormEditor(); + + const { editField, setDropDestination } = params; + + const handleDrop = useCallback( + (params: HadleDropParams) => { + const { target, source, destination } = params; + + if (target.name === "custom") { + /** + * We can cast because field is empty in the start + */ + editField({} as FbFormModelField); + setDropDestination(destination); + return; + } + + if (target.type === "row") { + // Reorder rows. + // Reorder logic is different depending on the source and destination position. + // "source" is a container from which we move row. + // "destination" is a container in which we move row. + moveRow(source.position.row, destination.position.row, source, destination); + return; + } + + if (source.position) { + if (source.position.index === null) { + console.log("Tried to move Form Field but its position index is null."); + console.log(source); + return; + } + + const sourceContainer = + source.containerType === "conditionGroup" + ? data.fields.find(f => f._id === source.containerId)?.settings + : data.steps.find(step => step.id === source.containerId); + + const fieldId = sourceContainer?.layout[source.position.row][source.position.index]; + + if (!fieldId) { + console.log("Missing data when moving field."); + return; + } + + moveField({ field: fieldId, target, source, destination }); + return; + } + + // Find field plugin which handles the dropped field type "name". + const plugin = getFieldPlugin({ name: target.name }); + if (!plugin) { + return; + } + insertField({ + data: plugin.field.createField(), + target, + destination + }); + }, + [data] + ); + + const composeHandleDropParams = (params: ComposeHadleDropParams) => { + const { item, destination } = params; + + // We don't want to drop steps inside of steps. + if (item.ui === "step") { + return undefined; + } + + handleDrop({ + target: { + type: item.ui, + id: item.id, + name: item.name + }, + source: { + containerId: item.container?.id, + containerType: item.container?.type, + position: item.pos + }, + destination + }); + + return undefined; + }; + + const createCustomField = (params: CreateCustomFieldParams) => { + const { data, dropDestination } = params; + + insertField({ + data, + target: { + id: data._id, + type: "field", + name: data.name + }, + destination: { + containerType: dropDestination.containerType, + containerId: dropDestination.containerId, + position: dropDestination.position + } + }); + }; + + return { + handleDrop, + composeHandleDropParams, + createCustomField + }; +}; diff --git a/packages/app-form-builder/src/types.ts b/packages/app-form-builder/src/types.ts index 30015bba7dd..3d2f4041ce2 100644 --- a/packages/app-form-builder/src/types.ts +++ b/packages/app-form-builder/src/types.ts @@ -11,6 +11,51 @@ import { import { ApolloClient } from "apollo-client"; import { SecurityPermission } from "@webiny/app-security/types"; +export interface DropTarget { + /* + Contains info about the Element that we are dragging. + */ + type: "field" | "row" | "conditionGroup" | "step"; + /* + Property "id" is optional, + because when we move row it does not have an id. + */ + id?: string; + name: string; +} + +export interface DropSource { + /* + Contains info about the Container from which we are dragging an element or elements. + containerId and containerType could be undefined in case we are creating a custom field. + */ + containerId?: string; + containerType?: "step" | "conditionGroup"; + position: { + row: number; + /* + Property "index" can be null in case we move row. + */ + index: number | null; + }; +} + +export interface DropDestination { + /* + Contains info about the Container, + in which we are dropping an element or elements. + */ + containerId: string; + containerType: "step" | "conditionGroup"; + position: { + row: number; + /* + Property "index" can be null in case we move row. + */ + index: number | null; + }; +} + export interface FbErrorResponse { message: string; code?: string | null; @@ -110,6 +155,50 @@ export interface FbFormStep { id: string; title: string; layout: FbFormModelFieldsLayout; + rules: FbFormRule[]; + index: number; +} + +export type FbFormRule = { + action: string; + chain: string; + id: string; + title: string; + conditions: FbFormCondition[]; + isValid: boolean; +}; + +export type FbFormCondition = { + id: string; + fieldName: string; + filterType: string; + filterValue: string; +}; + +export interface MoveStepParams { + target: { + containerId: string; + position: { + row: number; + index: number | null; + }; + }; + destination: { + containerId: string; + }; +} + +export interface MoveStepParams { + target: { + containerId: string; + position: { + row: number; + index: number | null; + }; + }; + destination: { + containerId: string; + }; } export type FbBuilderFieldPlugin = Plugin & { @@ -193,7 +282,7 @@ export interface FbFormRenderModel extends Omit { } export interface FbFormModelField { - _id?: string; + _id: string; type: string; name: string; fieldId: FieldIdType; @@ -300,6 +389,8 @@ export type FormRenderPropsType> = { getDefaultValues: () => { [key: string]: any }; goToNextStep: () => void; goToPreviousStep: () => void; + validateStepConditions: (formData: Record, stepIndex: number) => void; + setFormState: (formData: Record) => void; isLastStep: boolean; isFirstStep: boolean; isMultiStepForm: boolean; diff --git a/packages/app-page-builder-elements/src/renderers/form/FormRender.tsx b/packages/app-page-builder-elements/src/renderers/form/FormRender.tsx index 4b2bf478743..e84c79a3e9e 100644 --- a/packages/app-page-builder-elements/src/renderers/form/FormRender.tsx +++ b/packages/app-page-builder-elements/src/renderers/form/FormRender.tsx @@ -5,9 +5,10 @@ import { handleFormTriggers, reCaptchaEnabled, termsOfServiceEnabled, - onFormMounted + onFormMounted, + getNextStepIndex } from "./FormRender/functions"; - +import { checkIfConditionsMet } from "./FormRender/functions/getNextStepIndex"; import { FormLayoutComponent as FormLayoutComponentType, FormData, @@ -17,10 +18,10 @@ import { FormSubmissionResponse, FormLayoutComponentProps, CreateFormParams, - FormDataFieldsLayout, FormSubmissionFieldValues, CreateFormParamsFormLayoutComponent, - CreateFormParamsValidator + CreateFormParamsValidator, + FormRule } from "./types"; interface FieldValidator { @@ -37,25 +38,44 @@ const FormRender: React.FC = props => { const { formData, createFormParams } = props; const { preview = false, formLayoutComponents = [] } = createFormParams; const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [formState, setFormState] = useState(); + + // We need to add index to every step so we can properly, + // add or remove step from array of steps based on step rules. + formData.steps = formData.steps.map((formStep, index) => ({ + ...formStep, + index + })); + + const [modifiedSteps, setModifiedSteps] = useState(formData.steps); // Check if the form is a multi step. const isMultiStepForm = formData.steps.length > 1; const goToNextStep = () => { - setCurrentStepIndex(prevStep => (prevStep += 1)); + setCurrentStepIndex(prevStep => { + const nextStep = (prevStep += 1); + validateStepConditions(formState, nextStep); + return nextStep; + }); }; const goToPreviousStep = () => { setCurrentStepIndex(prevStep => (prevStep -= 1)); }; + const resolvedSteps = useMemo(() => { + return modifiedSteps || formData.steps; + }, [formData.steps, modifiedSteps]); + const isFirstStep = currentStepIndex === 0; - const isLastStep = currentStepIndex === formData.steps.length - 1; + const isLastStep = currentStepIndex === resolvedSteps.length - 1; + // We need this check in case we deleted last step and at the same time we were previewing it. const currentStep = - formData.steps[currentStepIndex] === undefined - ? formData.steps[formData.steps.length - 1] - : formData.steps[currentStepIndex]; + resolvedSteps[currentStepIndex] === undefined + ? resolvedSteps[resolvedSteps.length - 1] + : resolvedSteps[currentStepIndex]; const fieldValidators = useMemo(() => { let validators: CreateFormParamsValidator[] = []; @@ -105,8 +125,63 @@ const FormRender: React.FC = props => { return fields.find(field => field.fieldId === id) || null; }; + const validateStepConditions = (formData: Record, stepIndex: number) => { + const currentStep = resolvedSteps[stepIndex]; + + const nextStepIndex = getNextStepIndex({ + formData, + rules: currentStep.rules + }); + + if (nextStepIndex === "submit") { + setModifiedSteps([...modifiedSteps.slice(0, stepIndex + 1)]); + } else if (nextStepIndex !== "") { + setModifiedSteps([ + ...modifiedSteps.slice(0, stepIndex + 1), + ...steps.slice(+nextStepIndex) + ]); + } else { + setModifiedSteps([ + ...modifiedSteps.slice(0, stepIndex + 1), + ...steps.slice(currentStep.index + 1) + ]); + } + }; + const getFields = (stepIndex = 0): FormRenderComponentDataField[][] => { - const fieldLayout = structuredClone(steps[stepIndex].layout) as FormDataFieldsLayout; + const stepFields = + resolvedSteps[stepIndex] === undefined + ? resolvedSteps[resolvedSteps.length - 1] + : resolvedSteps[stepIndex]; + const fieldLayout = structuredClone(stepFields.layout.filter(Boolean)); + + // Here we are adding condition group fields into step layout. + fieldLayout.forEach((row, fieldIndex) => { + row.forEach(fieldId => { + const field = getFieldById(fieldId); + if (!field) { + return; + } + + if (field.settings.rules !== undefined) { + field.settings?.rules.forEach((rule: FormRule) => { + if (checkIfConditionsMet({ formData: formState, rule })) { + if (rule.action === "show") { + fieldLayout.splice(fieldIndex, 1, ...field.settings.layout); + } else { + fieldLayout.splice(fieldIndex, field.settings.layout.length, [ + field._id + ]); + } + } else { + if (field.settings.defaultBehaviour === "show") { + fieldLayout.splice(fieldIndex, 1, ...field.settings.layout); + } + } + }); + } + }); + }); return fieldLayout.map(row => { return row.map(id => { @@ -222,6 +297,8 @@ const FormRender: React.FC = props => { submit, goToNextStep, goToPreviousStep, + validateStepConditions, + setFormState, isFirstStep, isLastStep, isMultiStepForm, diff --git a/packages/app-page-builder-elements/src/renderers/form/FormRender/functions/getNextStepIndex.ts b/packages/app-page-builder-elements/src/renderers/form/FormRender/functions/getNextStepIndex.ts new file mode 100644 index 00000000000..0300db69fcf --- /dev/null +++ b/packages/app-page-builder-elements/src/renderers/form/FormRender/functions/getNextStepIndex.ts @@ -0,0 +1,218 @@ +import includes from "lodash/includes"; +import endsWith from "lodash/endsWith"; +import startsWith from "lodash/startsWith"; +import eq from "lodash/eq"; +import lte from "lodash/lte"; +import lt from "lodash/lt"; +import gte from "lodash/gte"; +import gt from "lodash/gt"; + +import { FbFormRule, FbFormCondition } from "~/types"; + +interface Props { + formData: Record; + rule: FbFormRule; +} + +const includesValidator = (filterValue: string, fieldValue: string) => { + if (fieldValue === null) { + return; + } + + return includes(fieldValue, filterValue); +}; + +const startsWithValidator = (filterValue: string, fieldValue: string) => { + if (fieldValue === null) { + return; + } + + return startsWith(fieldValue, filterValue); +}; + +const endsWithValidator = (filterValue: string, fieldValue: string) => { + if (fieldValue === null) { + return; + } + + return endsWith(fieldValue, filterValue); +}; + +const isValidator = (filterValue: string, fieldValue: string | string[]) => { + if (fieldValue === null) { + return; + } + + // This is check for checkboxes. + if (typeof fieldValue === "object") { + return fieldValue.includes(filterValue); + } else { + return eq(fieldValue, filterValue); + } +}; + +const gtValidator = ({ + filterValue, + fieldValue, + equal = false +}: { + filterValue: string; + fieldValue: string; + equal?: boolean; +}) => { + if (fieldValue === null) { + return; + } + + if (!equal) { + return gt(fieldValue, filterValue); + } else { + return gte(fieldValue, filterValue); + } +}; + +const ltValidator = ({ + filterValue, + fieldValue, + equal = false +}: { + filterValue: string; + fieldValue: string; + equal?: boolean; +}) => { + if (fieldValue === null) { + return; + } + + if (!equal) { + return lt(filterValue, filterValue); + } else { + return lte(fieldValue, filterValue); + } +}; + +const timeGtValidator = ({ + filterValue, + fieldValue, + equal = false +}: { + filterValue: string; + fieldValue: string; + equal?: boolean; +}) => { + if (fieldValue === null) { + return; + } + + if (!equal) { + return gt(Date.parse(fieldValue), Date.parse(filterValue)); + } else { + return gte(Date.parse(fieldValue), Date.parse(filterValue)); + } +}; + +const timeLtValidator = ({ + filterValue, + fieldValue, + equal = false +}: { + filterValue: string; + fieldValue: string; + equal?: boolean; +}) => { + if (fieldValue === null) { + return; + } + + if (!equal) { + return lt(Date.parse(fieldValue), Date.parse(filterValue)); + } else { + return lte(Date.parse(fieldValue), Date.parse(filterValue)); + } +}; + +const checkCondition = (condition: FbFormCondition, fieldValue: string) => { + switch (condition.filterType) { + case "contains": + return includesValidator(condition.filterValue, fieldValue); + case "not_contains": + return !includesValidator(condition.filterValue, fieldValue); + case "starts": + return startsWithValidator(condition.filterValue, fieldValue); + case "not_starts": + return !startsWithValidator(condition.filterValue, fieldValue); + case "ends": + return endsWithValidator(condition.filterValue, fieldValue); + case "not_ends": + return !endsWithValidator(condition.filterValue, fieldValue); + case "is": + return isValidator(condition.filterValue, fieldValue); + case "not_is": + return !isValidator(condition.filterValue, fieldValue); + case "selected": + return isValidator(condition.filterValue, fieldValue); + case "not_selected": + return !isValidator(condition.filterValue, fieldValue); + case "gt": + return gtValidator({ filterValue: condition.filterValue, fieldValue }); + case "gte": + return gtValidator({ filterValue: condition.filterValue, fieldValue, equal: true }); + case "lt": + return ltValidator({ filterValue: condition.filterValue, fieldValue }); + case "lte": + return ltValidator({ filterValue: condition.filterValue, fieldValue, equal: true }); + case "time_gt": + return timeGtValidator({ filterValue: condition.filterValue, fieldValue }); + case "time_gte": + return timeGtValidator({ filterValue: condition.filterValue, fieldValue, equal: true }); + case "time_lt": + return timeLtValidator({ filterValue: condition.filterValue, fieldValue }); + case "time_lte": + return timeLtValidator({ filterValue: condition.filterValue, fieldValue, equal: true }); + default: + return false; + } +}; + +export const checkIfConditionsMet = ({ formData, rule }: Props) => { + if (rule.chain === "matchAll") { + let isValid = true; + + rule.conditions.forEach(condition => { + if (!checkCondition(condition, formData?.[condition.fieldName])) { + isValid = false; + return; + } + }); + + return isValid; + } else { + let isValid = false; + + rule.conditions.forEach(condition => { + if (checkCondition(condition, formData?.[condition.fieldName])) { + isValid = true; + return; + } + }); + + return isValid; + } +}; + +interface GetNextStepIndexProps { + formData: Record; + rules: FbFormRule[]; +} + +export default ({ formData, rules }: GetNextStepIndexProps) => { + let nextStepIndex = ""; + rules.forEach(rule => { + if (checkIfConditionsMet({ formData, rule })) { + nextStepIndex = rule.action; + return; + } + }); + + return nextStepIndex; +}; diff --git a/packages/app-page-builder-elements/src/renderers/form/FormRender/functions/index.ts b/packages/app-page-builder-elements/src/renderers/form/FormRender/functions/index.ts index 0812222fcca..e8fd490fd17 100644 --- a/packages/app-page-builder-elements/src/renderers/form/FormRender/functions/index.ts +++ b/packages/app-page-builder-elements/src/renderers/form/FormRender/functions/index.ts @@ -3,3 +3,4 @@ export { default as createFormSubmission } from "./createFormSubmission"; export { default as handleFormTriggers } from "./handleFormTriggers"; export { default as reCaptchaEnabled } from "./reCaptchaEnabled"; export { default as termsOfServiceEnabled } from "./termsOfServiceEnabled"; +export { default as getNextStepIndex } from "./getNextStepIndex"; diff --git a/packages/app-page-builder-elements/src/renderers/form/dataLoaders/graphql.ts b/packages/app-page-builder-elements/src/renderers/form/dataLoaders/graphql.ts index 384e2580c9f..b7ad14e0d8b 100644 --- a/packages/app-page-builder-elements/src/renderers/form/dataLoaders/graphql.ts +++ b/packages/app-page-builder-elements/src/renderers/form/dataLoaders/graphql.ts @@ -26,6 +26,19 @@ export const GET_PUBLISHED_FORM = /* GraphQL */ ` steps { title layout + rules { + title + action + chain + id + conditions { + id + fieldName + filterType + filterValue + } + isValid + } } triggers settings { diff --git a/packages/app-page-builder-elements/src/renderers/form/types.ts b/packages/app-page-builder-elements/src/renderers/form/types.ts index d7b5fc03ad5..2cc7a5e5384 100644 --- a/packages/app-page-builder-elements/src/renderers/form/types.ts +++ b/packages/app-page-builder-elements/src/renderers/form/types.ts @@ -11,7 +11,7 @@ export interface FormDataFieldValidator { } export interface FormDataField { - _id?: string; + _id: string; type: string; name: string; fieldId: FieldIdType; @@ -47,6 +47,24 @@ export interface FormDataStep { id: string; title: string; layout: string[][]; + rules: FormRule[]; + index: number; +} + +export interface FormRule { + action: string; + chain: string; + id: string; + title: string; + conditions: FormCondition[]; + isValid: boolean; +} + +export interface FormCondition { + id: string; + fieldName: string; + filterType: string; + filterValue: string; } export interface FormData { @@ -87,6 +105,8 @@ export type FormLayoutComponentProps = { getDefaultValues: () => { [key: string]: any }; goToNextStep: () => void; goToPreviousStep: () => void; + validateStepConditions: (formData: Record, stepIndex: number) => void; + setFormState: (formData: Record) => void; isLastStep: boolean; isFirstStep: boolean; isMultiStepForm: boolean; diff --git a/packages/app-page-builder-elements/src/types.ts b/packages/app-page-builder-elements/src/types.ts index b2539c8a9eb..f40be7147f9 100644 --- a/packages/app-page-builder-elements/src/types.ts +++ b/packages/app-page-builder-elements/src/types.ts @@ -143,6 +143,22 @@ export type ElementStylesModifier = (args: { export type LinkComponent = React.ComponentType>; +export type FbFormRule = { + action: string; + chain: string; + id: string; + title: string; + conditions: FbFormCondition[]; + isValid: boolean; +}; + +export type FbFormCondition = { + id: string; + fieldName: string; + filterType: string; + filterValue: string; +}; + declare global { // eslint-disable-next-line namespace JSX { From 04bc3c734bd58091ebb1a47f1d66f670dcc75f2b Mon Sep 17 00:00:00 2001 From: Vitalii Nobis Date: Mon, 4 Dec 2023 14:35:25 +0000 Subject: [PATCH 2/7] fix: fixed issue with form preview, fixed an array of available fields for condition groups --- .../Tabs/EditTab/ConditionGroup.tsx | 7 +++++- .../Tabs/EditTab/EditFieldDialog/RulesTab.tsx | 23 ++++++++++++++----- .../Tabs/EditTab/FormStep/FormStep.tsx | 4 ++-- .../FormEditor/Tabs/EditTab/Styled.ts | 2 +- .../src/components/Form/FormRender.tsx | 14 +++++++---- .../app-page-builder-elements/package.json | 3 ++- yarn.lock | 1 + 7 files changed, 39 insertions(+), 15 deletions(-) diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/ConditionGroup.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/ConditionGroup.tsx index 196ddc7d18a..3f9acef0b15 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/ConditionGroup.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/ConditionGroup.tsx @@ -7,7 +7,7 @@ import { Accordion } from "@webiny/ui/Accordion"; import { AccordionItem } from "@webiny/ui/Accordion"; import { Center, Vertical, Horizontal } from "../../DropZone"; import Field from "./Field"; -import { RowContainer, rowHandle, Row, fieldContainer } from "./Styled"; +import { RowContainer, rowHandle, Row, fieldContainer, RulesTag } from "./Styled"; import Draggable from "../../Draggable"; import { Icon } from "@webiny/ui/Icon"; import { ReactComponent as HandleIcon } from "../../../../icons/round-drag_indicator-24px.svg"; @@ -72,6 +72,11 @@ const ConditionalGroupField: React.FC = props => { open={true} actions={ + {conditionGroupField.settings.rules?.length ? ( + {"Rules Attached"} + ) : ( + <> + )} } onClick={() => onEdit(conditionGroupField)} diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab.tsx index 6c354392cab..ff3a7172e1a 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab.tsx @@ -25,13 +25,23 @@ import { useFormEditor } from "~/admin/components/FormEditor/Context"; import { RuleActionSelect } from "./RuleActionSelect"; import { SelectDefaultBehaviour } from "./DefaultBehaviour"; import { FbFormModelField, FbFormModel, FbFormRule, FbFormCondition } from "~/types"; +import { getAvailableFields } from "../FormStep/EditFormStepDialog/helpers"; -const getFields = (id: string, formData: FbFormModel) => { - const filedIds = formData.steps.map(step => step.layout).flat(2); - const currentFieldIndex = filedIds.indexOf(id); - const availableFiledIds = filedIds.slice(0, currentFieldIndex); +const getCondtionFields = (id: string, formData: FbFormModel) => { + const availableFields: Array = []; - return availableFiledIds.map(id => formData.fields.find(field => field._id === id) || null); + formData.steps.forEach(step => { + const stepLayout = step.layout.flat(2); + + if (stepLayout.includes(id)) { + const fields = getAvailableFields({ step, formData }).filter( + field => field?.type !== "condition-group" + ); + availableFields.push(...fields); + } + }); + + return availableFields; }; interface RulesTabProps { @@ -43,7 +53,8 @@ export const RulesTab = ({ field, form }: RulesTabProps) => { const { Bind } = form; const { data: formData } = useFormEditor(); - const fields = field._id ? getFields(field._id, formData) : []; + const fields = field._id ? getCondtionFields(field._id, formData) : []; + const areRulesInValid = field?.settings?.rules?.some( (rule: FbFormRule) => rule.isValid === false ); diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStep.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStep.tsx index da25c5b79e2..44db13d0fba 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStep.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStep.tsx @@ -15,7 +15,7 @@ import { StyledAccordion, StyledAccordionItem, conditionGroupContainer, - StepRulesTag + RulesTag } from "../Styled"; import { Icon } from "@webiny/ui/Icon"; @@ -94,7 +94,7 @@ export const FormStep = ({ actions={ {formStep.rules.length ? ( - {"Rules Attached"} + {"Rules Attached"} ) : ( <> )} diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts index 979d3558484..afeb022d923 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts @@ -19,7 +19,7 @@ export const StyledAccordionItem = styled(AccordionItem)` } `; -export const StepRulesTag = styled.div<{ isValid: boolean }>` +export const RulesTag = styled.div<{ isValid: boolean }>` display: inline-block; padding: 5px 20px 7px 20px; background-color: white; diff --git a/packages/app-form-builder/src/components/Form/FormRender.tsx b/packages/app-form-builder/src/components/Form/FormRender.tsx index 8418a4141d1..fff07d3fb4a 100644 --- a/packages/app-form-builder/src/components/Form/FormRender.tsx +++ b/packages/app-form-builder/src/components/Form/FormRender.tsx @@ -55,6 +55,13 @@ const FormRender: React.FC = props => { setLayoutRenderKey(new Date().getTime().toString()); }, []); + // We need to add index to every step so we can properly, + // add or remove step from array of steps based on step rules. + data.steps = data.steps.map((formStep, index) => ({ + ...formStep, + index + })); + useEffect((): void => { if (!data.id) { return; @@ -74,7 +81,7 @@ const FormRender: React.FC = props => { useEffect(() => { setCurrentStepIndex(0); setModifiedSteps(data.steps); - }, [data.steps, data.fields.length]); + }, [data.steps.length, data.fields.length]); const reCaptchaResponseToken = useRef(""); const termsOfServiceAccepted = useRef(false); @@ -96,12 +103,13 @@ const FormRender: React.FC = props => { }; const formData: FbFormModel = cloneDeep(data); + const { fields, settings, steps } = formData; const resolvedSteps = useMemo(() => { return modifiedSteps || steps; }, [steps, modifiedSteps]); - console.log("resolvedSteps", resolvedSteps); + // Check if the form is a multi step. const isMultiStepForm = formData.steps.length > 1; @@ -130,8 +138,6 @@ const FormRender: React.FC = props => { rules: currentStep.rules }); - console.log("nextStepIndex", nextStepIndex); - if (nextStepIndex === "submit") { setModifiedSteps([...modifiedSteps.slice(0, stepIndex + 1)]); } else if (nextStepIndex !== "") { diff --git a/packages/app-page-builder-elements/package.json b/packages/app-page-builder-elements/package.json index ac91db25474..1b69e085ee4 100644 --- a/packages/app-page-builder-elements/package.json +++ b/packages/app-page-builder-elements/package.json @@ -19,7 +19,8 @@ "@emotion/styled": "^11.10.6", "@webiny/lexical-editor": "0.0.0", "@webiny/theme": "0.0.0", - "facepaint": "^1.2.1" + "facepaint": "^1.2.1", + "lodash": "^4.17.21" }, "peerDependencies": { "@editorjs/editorjs": "^2.20.1", diff --git a/yarn.lock b/yarn.lock index f632a2818f9..8eeb11b6e8f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15939,6 +15939,7 @@ __metadata: "@webiny/theme": 0.0.0 execa: ^5.0.0 facepaint: ^1.2.1 + lodash: ^4.17.21 rimraf: ^3.0.2 ttypescript: ^1.5.12 typescript: 4.7.4 From 793955b076ee6ca5ac15c50107f7c3fa55e74808 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis Date: Fri, 22 Dec 2023 14:09:03 +0000 Subject: [PATCH 3/7] fix: refactored condition group field --- .../Tabs/EditTab/ConditionGroup.tsx | 303 ------------------ .../FormEditor/Tabs/EditTab/EditTabStep.tsx | 2 +- .../Tabs/EditTab/EditTabStepRow.tsx | 4 +- .../ConditionGroupField/ConditionGroup.tsx | 95 ++++++ .../ConditionGroupField/ConditionGroupRow.tsx | 105 ++++++ .../ConditionGroupRowField.tsx | 112 +++++++ .../ConditionGroupWithFields.tsx | 27 ++ .../EmptyConditionGroup.tsx | 34 ++ .../ConditionGroupField/useConditionGroup.ts} | 113 +++---- .../EditFormStepDialog/EditFormStepDialog.tsx | 11 +- .../FormStepWithFields/FormStepRowField.tsx | 28 +- .../Tabs/EditTab/FormStep/useFormStep.ts | 6 +- .../src/components/Form/FormRender.tsx | 33 +- 13 files changed, 463 insertions(+), 410 deletions(-) delete mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/ConditionGroup.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroup.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroupRow.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroupRowField.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroupWithFields.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/EmptyConditionGroup.tsx rename packages/app-form-builder/src/{hooks/useFormDragAndDrop.ts => admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/useConditionGroup.ts} (53%) diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/ConditionGroup.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/ConditionGroup.tsx deleted file mode 100644 index 3f9acef0b15..00000000000 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/ConditionGroup.tsx +++ /dev/null @@ -1,303 +0,0 @@ -import React from "react"; -import { ReactComponent as EditIcon } from "../../icons/edit.svg"; -import { ReactComponent as DeleteIcon } from "../../icons/delete.svg"; -import { useFormEditor } from "../../Context"; -import { FbFormModelField, FbFormStep } from "~/types"; -import { Accordion } from "@webiny/ui/Accordion"; -import { AccordionItem } from "@webiny/ui/Accordion"; -import { Center, Vertical, Horizontal } from "../../DropZone"; -import Field from "./Field"; -import { RowContainer, rowHandle, Row, fieldContainer, RulesTag } from "./Styled"; -import Draggable from "../../Draggable"; -import { Icon } from "@webiny/ui/Icon"; -import { ReactComponent as HandleIcon } from "../../../../icons/round-drag_indicator-24px.svg"; -import { ComposeHadleDropParams } from "~/hooks/useFormDragAndDrop"; -interface FieldProps { - field: FbFormModelField; - onEdit: (field: FbFormModelField) => void; - onDelete: ({ - field, - containerId, - containerType - }: { - field: FbFormModelField; - containerId: string; - containerType?: "conditionGroup" | "step"; - }) => void; - onDrop: (params: ComposeHadleDropParams) => undefined; - targetStepId: string; - formStep: FbFormStep; - deleteConditionGroup: ({ - formStep, - conditionGroup - }: { - formStep: FbFormStep; - conditionGroup: FbFormModelField; - }) => void; -} - -const ConditionalGroupField: React.FC = props => { - const { - field: conditionGroupField, - onEdit, - onDrop, - deleteConditionGroup, - onDelete, - formStep - } = props; - const { getField } = useFormEditor(); - - const getFields = () => { - return (conditionGroupField?.settings?.layout || []).map((row: any) => { - return row - .map((id: any) => { - return getField({ - _id: id - }); - }) - .filter(Boolean) as FbFormModelField[]; - }); - }; - - const fields = getFields().map((fields: any) => - fields - .filter((field: any) => field._id !== conditionGroupField._id) - .filter((field: any) => field.length !== 0) - ) as FbFormModelField[][]; - - return ( - - - {conditionGroupField.settings.rules?.length ? ( - {"Rules Attached"} - ) : ( - <> - )} - } - onClick={() => onEdit(conditionGroupField)} - /> - } - onClick={() => { - deleteConditionGroup({ - formStep, - conditionGroup: conditionGroupField - }); - }} - /> - - } - > - {fields.length === 0 ? ( -
{ - onDrop({ - item, - destination: { - containerId: conditionGroupField._id, - containerType: "conditionGroup", - position: { - row: 0, - index: 0 - } - } - }); - return undefined; - }} - > - {`Drop your first field here`} -
- ) : ( - fields.map((row, index) => { - return ( - - {({ drag, isDragging }) => ( - -
- } /> -
- { - onDrop({ - item, - destination: { - containerId: conditionGroupField._id, - containerType: "conditionGroup", - position: { - row: index, - index: null - } - } - }); - return undefined; - }} - isVisible={target => { - const isVisible = - target.ui !== "step" && - target.name !== "conditionGroup"; - return isVisible; - }} - /> - - {row.map((field, fieldIndex) => ( - - {({ drag }) => ( -
- { - onDrop({ - item, - destination: { - containerId: - conditionGroupField._id, - containerType: - "conditionGroup", - position: { - row: index, - index: fieldIndex - } - } - }); - return undefined; - }} - isVisible={target => { - const isVisible = - target.ui !== "step" && - target.ui !== "row" && - target.name !== - "conditionGroup"; - return isVisible; - }} - /> - { - onDelete({ - field, - containerId: - conditionGroupField._id || - "", - containerType: - "conditionGroup" - }); - }} - /> - - {/* Field end */} - {fieldIndex === row.length - 1 && ( - { - const condition = - row.length < 4 || - target.pos.row === - index; - const isVisible = - target.ui === "field" && - target.name !== - "conditionGroup"; - return ( - isVisible && condition - ); - }} - onDrop={item => { - onDrop({ - item, - destination: { - containerId: - conditionGroupField._id, - containerType: - "conditionGroup", - position: { - row: index, - index: - fieldIndex + - 1 - } - } - }); - return undefined; - }} - /> - )} -
- )} -
- ))} -
- {/* Row end */} - {index === fields.length - 1 && ( - { - onDrop({ - item, - destination: { - containerId: conditionGroupField._id, - containerType: "conditionGroup", - position: { - row: index + 1, - index: null - } - } - }); - return undefined; - }} - isVisible={target => { - const isVisible = - target.ui !== "step" && - target.name !== "conditionGroup"; - return isVisible; - }} - /> - )} -
- )} -
- ); - }) - )} - - - ); -}; - -export default ConditionalGroupField; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditTabStep.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditTabStep.tsx index 3ce90de9e64..5b15c2fafaf 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditTabStep.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditTabStep.tsx @@ -41,7 +41,7 @@ const StyledRowContainer = styled(RowContainer)<{ isDragging: boolean }>` `; interface EditTabStepProps { - setIsEditStep: (params: { isOpened: boolean; id: string }) => void; + setIsEditStep: (params: { isOpened: boolean; step: FbFormStep }) => void; formStep: FbFormStep; index: number; } diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditTabStepRow.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditTabStepRow.tsx index 222f2719f59..1e0da4f51d1 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditTabStepRow.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditTabStepRow.tsx @@ -7,7 +7,7 @@ import { useFormEditor } from "~/admin/components/FormEditor/Context"; interface EditTabStepRowProps { dragRef: ConnectDragSource; - setIsEditStep: (params: { isOpened: boolean; id: string }) => void; + setIsEditStep: (params: { isOpened: boolean; step: FbFormStep }) => void; formStep: FbFormStep; index: number; } @@ -31,7 +31,7 @@ export const EditTabStepRow = ({ onEdit={() => { setIsEditStep({ isOpened: true, - id: formStep.id + step: formStep }); }} deleteStepDisabled={data.steps.length <= 1} diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroup.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroup.tsx new file mode 100644 index 00000000000..3562511d481 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroup.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import { ReactComponent as EditIcon } from "~/admin/icons/edit.svg"; +import { ReactComponent as DeleteIcon } from "~/admin/icons/delete.svg"; +import { useFormEditor } from "../../../../Context"; +import { ContainerType, FbFormModelField, FbFormStep } from "~/types"; +import { Accordion } from "@webiny/ui/Accordion"; +import { AccordionItem } from "@webiny/ui/Accordion"; +import { RulesTag } from "../../Styled"; +import { EmptyConditionGroup } from "./EmptyConditionGroup"; +import { ConditionGroupWithFields } from "./ConditionGroupWithFields"; + +interface DeleteConditionGroupParams { + formStep: FbFormStep; + conditionGroup: FbFormModelField; +} + +interface OnDeleteParams { + field: FbFormModelField; + containerId: string; + containerType?: ContainerType; +} + +interface FieldProps { + field: FbFormModelField; + onEdit: (field: FbFormModelField) => void; + onDelete: (params: OnDeleteParams) => void; + targetStepId: string; + formStep: FbFormStep; + deleteConditionGroup: (params: DeleteConditionGroupParams) => void; +} + +const ConditionalGroupField = (props: FieldProps) => { + const { field: conditionGroupField, onEdit, deleteConditionGroup, formStep } = props; + const { getField } = useFormEditor(); + + const getFields = () => { + return (conditionGroupField?.settings?.layout || []).map((row: any) => { + return row + .map((id: any) => { + return getField({ + _id: id + }); + }) + .filter(Boolean) as FbFormModelField[]; + }); + }; + + const fields = getFields().map((fields: any) => + fields + .filter((field: any) => field._id !== conditionGroupField._id) + .filter((field: any) => field.length !== 0) + ) as FbFormModelField[][]; + + return ( + + + {conditionGroupField.settings.rules?.length ? ( + {"Rules Attached"} + ) : ( + <> + )} + } + onClick={() => onEdit(conditionGroupField)} + /> + } + onClick={() => { + deleteConditionGroup({ + formStep, + conditionGroup: conditionGroupField + }); + }} + /> +
+ } + > + {fields.length === 0 ? ( + + ) : ( + + )} + + + ); +}; + +export default ConditionalGroupField; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroupRow.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroupRow.tsx new file mode 100644 index 00000000000..bb5ca661012 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroupRow.tsx @@ -0,0 +1,105 @@ +import React, { useCallback, useMemo } from "react"; +import { FbFormModelField } from "~/types"; +import { useConditionGroup } from "./useConditionGroup"; +import { DragObjectWithFieldInfo } from "~/admin/components/FormEditor/Droppable"; +import Draggable, { BeginDragProps } from "~/admin/components/FormEditor/Draggable"; +import { Row, RowContainer, RowHandle } from "../../Styled"; +import { Icon } from "@webiny/ui/Icon"; +import { ReactComponent as HandleIcon } from "~/admin/components/FormEditor/icons/round-drag_indicator-24px.svg"; +import { Horizontal } from "~/admin/components/FormEditor/DropZone"; +import { ConditionGroupRowField } from "./ConditionGroupRowField"; + +export interface ConditionalGroupRowProps { + conditionGroup: FbFormModelField; + row: FbFormModelField[]; + rowIndex: number; + isLastRow: boolean; +} + +export const ConditionalGroupRow = (props: ConditionalGroupRowProps) => { + const { conditionGroup, row, rowIndex, isLastRow } = props; + + const { handleDrop } = useConditionGroup(); + + const rowBeginDragParams: BeginDragProps = useMemo(() => { + return { + ui: "row", + pos: { row: rowIndex }, + container: { + type: "conditionGroup", + id: conditionGroup._id + } + }; + }, [rowIndex, conditionGroup]); + + const onRowHorizontalZoneDrop = useCallback( + (item: DragObjectWithFieldInfo) => { + handleDrop({ + item, + conditionGroup, + destinationPosition: { + row: rowIndex, + index: null + } + }); + + return undefined; + }, + [handleDrop, conditionGroup, rowIndex] + ); + + const onLastRowHorizontalZoneDrop = useCallback( + (item: DragObjectWithFieldInfo) => { + handleDrop({ + item, + conditionGroup, + destinationPosition: { + row: rowIndex + 1, + index: null + } + }); + + return undefined; + }, + [handleDrop, conditionGroup, rowIndex] + ); + + return ( + + {({ drag, isDragging }) => ( + + + } /> + + item.ui !== "step" && item.ui !== "conditionGroup"} + /> + + {/* Row start - includes field drop zones and fields */} + + {row.map((field, fieldIndex) => ( + + ))} + + + {/* Row end */} + {isLastRow && ( + item.ui !== "step" && item.ui !== "conditionGroup"} + /> + )} + + )} + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroupRowField.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroupRowField.tsx new file mode 100644 index 00000000000..8e9c4891fcd --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroupRowField.tsx @@ -0,0 +1,112 @@ +import React, { useMemo, useCallback } from "react"; +import Draggable, { BeginDragProps } from "~/admin/components/FormEditor/Draggable"; +import { DragObjectWithFieldInfo } from "~/admin/components/FormEditor/Droppable"; +import { FbFormModelField } from "~/types"; +import { useConditionGroup } from "./useConditionGroup"; +import { FieldContainer, FieldHandle } from "../../Styled"; +import { Vertical } from "~/admin/components/FormEditor/DropZone"; +import Field from "../../Field"; +import { useFormEditor } from "~/admin/components/FormEditor/Context"; + +export interface ConditionGroupRowFieldProps { + conditionGroup: FbFormModelField; + row: FbFormModelField[]; + rowIndex: number; + field: FbFormModelField; + fieldIndex: number; +} + +export const ConditionGroupRowField = (props: ConditionGroupRowFieldProps) => { + const { conditionGroup, row, rowIndex, field, fieldIndex } = props; + + const { handleDrop, editField } = useConditionGroup(); + const { deleteField } = useFormEditor(); + + const beginFieldDragParams: BeginDragProps = useMemo(() => { + return { + ui: "field", + name: field.name, + id: field._id, + pos: { + row: rowIndex, + index: fieldIndex + }, + container: { + type: "conditionGroup", + id: conditionGroup._id + } + }; + }, [field, fieldIndex, conditionGroup, rowIndex]); + + const onFieldVerticalZoneDrop = useCallback( + (item: DragObjectWithFieldInfo) => { + handleDrop({ + item, + conditionGroup, + destinationPosition: { + row: rowIndex, + index: fieldIndex + } + }); + + return undefined; + }, + [handleDrop, conditionGroup, rowIndex, fieldIndex] + ); + + const onLastFieldVerticalZoneDrop = useCallback( + (item: DragObjectWithFieldInfo) => { + handleDrop({ + item, + conditionGroup, + destinationPosition: { + row: rowIndex, + index: fieldIndex + 1 + } + }); + + return undefined; + }, + [handleDrop, conditionGroup, rowIndex, fieldIndex] + ); + + const onDeleteField = useCallback(() => { + deleteField({ + field, + containerId: conditionGroup._id || "", + containerType: "conditionGroup" + }); + }, [field, conditionGroup]); + + const isLastField = fieldIndex === row.length - 1; + + return ( + + {({ drag }) => ( + + + item.ui === "field" && (row.length < 4 || item?.pos?.row === rowIndex) + } + /> + + + onDeleteField()} /> + + + {isLastField && ( + + item.ui === "field" && + (row.length < 4 || item?.pos?.row === rowIndex) + } + onDrop={onLastFieldVerticalZoneDrop} + /> + )} + + )} + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroupWithFields.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroupWithFields.tsx new file mode 100644 index 00000000000..a62041d6a61 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroupWithFields.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { FbFormModelField } from "~/types"; +import { ConditionalGroupRow } from "./ConditionGroupRow"; + +export interface ConditionGroupWithFieldsProps { + fields: FbFormModelField[][]; + conditionGroup: FbFormModelField; +} + +export const ConditionGroupWithFields = ({ + fields, + conditionGroup +}: ConditionGroupWithFieldsProps) => { + return ( + + {fields.map((row, rowIndex) => ( + + ))} + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/EmptyConditionGroup.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/EmptyConditionGroup.tsx new file mode 100644 index 00000000000..0312d15ab73 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/EmptyConditionGroup.tsx @@ -0,0 +1,34 @@ +import React, { useCallback } from "react"; + +import { useConditionGroup } from "./useConditionGroup"; + +import { FbFormModelField } from "~/types"; +import { Center } from "~/admin/components/FormEditor/DropZone"; +import { DragObjectWithFieldInfo } from "~/admin/components/FormEditor/Droppable"; + +interface EmptyConditionGroupProps { + conditionGroupField: FbFormModelField; +} + +export const EmptyConditionGroup = (props: EmptyConditionGroupProps) => { + const { conditionGroupField } = props; + const { handleDrop } = useConditionGroup(); + + const onFieldVerticalZoneDrop = useCallback( + (item: DragObjectWithFieldInfo) => { + handleDrop({ + item, + conditionGroup: conditionGroupField, + destinationPosition: { + row: 0, + index: 0 + } + }); + + return undefined; + }, + [handleDrop, conditionGroupField] + ); + + return
{`Drop your first field here`}
; +}; diff --git a/packages/app-form-builder/src/hooks/useFormDragAndDrop.ts b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/useConditionGroup.ts similarity index 53% rename from packages/app-form-builder/src/hooks/useFormDragAndDrop.ts rename to packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/useConditionGroup.ts index 2013c8858d7..f40df00b1ff 100644 --- a/packages/app-form-builder/src/hooks/useFormDragAndDrop.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/useConditionGroup.ts @@ -1,42 +1,46 @@ -import { useCallback } from "react"; -import { useFormEditor } from "~/admin/components/FormEditor"; +import { useCallback, useContext } from "react"; +import { useFormEditor } from "~/admin/components/FormEditor/Context"; import { DragObjectWithFieldInfo } from "~/admin/components/FormEditor/Droppable"; -import { DropTarget, DropSource, DropDestination, FbFormModelField } from "~/types"; +import { DropDestination, DropPosition, DropSource, DropTarget, FbFormModelField } from "~/types"; +import { FormStepContext } from "../FormStepContext/FormStepContext"; +import { useFormStep } from "../useFormStep"; -interface UseFormDragParams { - editField: (field: FbFormModelField | null) => void; - setDropDestination: (desitnation: DropDestination | null) => void; -} - -interface HadleDropParams { - target: DropTarget; - source: DropSource; - destination: DropDestination; -} - -interface CreateCustomFieldParams { - data: FbFormModelField; - dropDestination: DropDestination; -} - -export interface ComposeHadleDropParams { +interface HandleDropParams { item: DragObjectWithFieldInfo; - destination: DropDestination; + destinationPosition: DropPosition; + conditionGroup: FbFormModelField; } -export const useFormDragAndDrop = (params: UseFormDragParams) => { +export const useConditionGroup = () => { const { data, moveRow, moveField, getFieldPlugin, insertField } = useFormEditor(); + const { editField } = useFormStep(); - const { editField, setDropDestination } = params; + const { setDropDestination } = useContext(FormStepContext); const handleDrop = useCallback( - (params: HadleDropParams) => { - const { target, source, destination } = params; + (params: HandleDropParams) => { + const { item, conditionGroup, destinationPosition } = params; + + const target: DropTarget = { + type: item.ui, + id: item.id, + name: item.name + }; + + const source: DropSource = { + containerId: item?.container?.id, + containerType: item?.container?.type, + position: item.pos + }; + + const destination: DropDestination = { + containerId: conditionGroup._id, + containerType: "conditionGroup", + position: destinationPosition + }; if (target.name === "custom") { - /** - * We can cast because field is empty in the start - */ + // We can cast because field is empty in the start. editField({} as FbFormModelField); setDropDestination(destination); return; @@ -57,19 +61,15 @@ export const useFormDragAndDrop = (params: UseFormDragParams) => { console.log(source); return; } - const sourceContainer = source.containerType === "conditionGroup" ? data.fields.find(f => f._id === source.containerId)?.settings : data.steps.find(step => step.id === source.containerId); - const fieldId = sourceContainer?.layout[source.position.row][source.position.index]; - if (!fieldId) { console.log("Missing data when moving field."); return; } - moveField({ field: fieldId, target, source, destination }); return; } @@ -82,58 +82,17 @@ export const useFormDragAndDrop = (params: UseFormDragParams) => { insertField({ data: plugin.field.createField(), target, + source, destination }); + + return undefined; }, [data] ); - const composeHandleDropParams = (params: ComposeHadleDropParams) => { - const { item, destination } = params; - - // We don't want to drop steps inside of steps. - if (item.ui === "step") { - return undefined; - } - - handleDrop({ - target: { - type: item.ui, - id: item.id, - name: item.name - }, - source: { - containerId: item.container?.id, - containerType: item.container?.type, - position: item.pos - }, - destination - }); - - return undefined; - }; - - const createCustomField = (params: CreateCustomFieldParams) => { - const { data, dropDestination } = params; - - insertField({ - data, - target: { - id: data._id, - type: "field", - name: data.name - }, - destination: { - containerType: dropDestination.containerType, - containerId: dropDestination.containerId, - position: dropDestination.position - } - }); - }; - return { handleDrop, - composeHandleDropParams, - createCustomField + editField }; }; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/EditFormStepDialog.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/EditFormStepDialog.tsx index 841b68668db..ae3a869975b 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/EditFormStepDialog.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/EditFormStepDialog.tsx @@ -11,6 +11,7 @@ import { Tabs, Tab } from "@webiny/ui/Tabs"; import { RulesTab } from "./RulesTab"; import { FbFormModel, FbFormStep, FbFormRule } from "~/types"; +import { UpdateStepParams } from "~/admin/components/FormEditor/Context/useFormEditorFactory"; const EditStepDialog = styled(BaseDialog)` font-size: 1.4rem; @@ -53,10 +54,7 @@ export interface DialogProps { }; stepTitle: string; setEditStep: (params: { isOpened: boolean; step: FbFormStep }) => void; - updateStep: ( - { title, rules }: { title: string; rules: FbFormRule[] }, - id: string | null - ) => void; + updateStep: (params: UpdateStepParams) => void; formData: FbFormModel; } @@ -79,10 +77,11 @@ export const EditFormStepDialog = ({ const onSubmit: FormOnSubmit = (_, form) => { const data = { title: form.data.title, - rules: form.data.rules + rules: form.data.rules, + id: editStep.step.id }; - updateStep(data, editStep.step.id); + updateStep(data); closeEditStepDialog(); }; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStepWithFields/FormStepRowField.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStepWithFields/FormStepRowField.tsx index bddd4787542..413bd44cb28 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStepWithFields/FormStepRowField.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStepWithFields/FormStepRowField.tsx @@ -8,6 +8,7 @@ import { Vertical } from "~/admin/components/FormEditor/DropZone"; import { useFormStep } from "~/admin/components/FormEditor/Tabs/EditTab/FormStep/useFormStep"; import { DragObjectWithFieldInfo } from "~/admin/components/FormEditor/Droppable"; import { useFormEditor } from "~/admin/components/FormEditor"; +import ConditionalGroupField from "../ConditionGroupField/ConditionGroup"; export interface FormStepFieldRowFieldProps { formStep: FbFormStep; @@ -20,7 +21,7 @@ export interface FormStepFieldRowFieldProps { export const FormStepRowField = (props: FormStepFieldRowFieldProps) => { const { formStep, row, rowIndex, field, fieldIndex } = props; const { handleDrop, editField } = useFormStep(); - const { deleteField } = useFormEditor(); + const { deleteField, deleteConditionGroup } = useFormEditor(); const fieldBeginDragParams: BeginDragProps = useMemo(() => { return { @@ -70,6 +71,10 @@ export const FormStepRowField = (props: FormStepFieldRowFieldProps) => { [handleDrop, formStep, rowIndex, fieldIndex] ); + const onDeleteField = useCallback(() => { + deleteField({ field, containerId: formStep.id }); + }, [field, formStep]); + const isLastField = fieldIndex === row.length - 1; return ( @@ -84,11 +89,22 @@ export const FormStepRowField = (props: FormStepFieldRowFieldProps) => { /> - deleteField(field, formStep.id)} - /> + {field.name === "conditionGroup" ? ( + + ) : ( + onDeleteField()} + /> + )} {isLastField && ( diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/useFormStep.ts b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/useFormStep.ts index 64af9ca74ff..b69cb05b1e2 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/useFormStep.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/useFormStep.ts @@ -89,7 +89,10 @@ export const useFormStep = () => { console.log(source); return; } - const sourceContainer = data.steps.find(step => step.id === source.containerId); + const sourceContainer = + source.containerType === "conditionGroup" + ? data.fields.find(f => f._id === source.containerId)?.settings + : data.steps.find(step => step.id === source.containerId); const fieldId = sourceContainer?.layout[source.position.row][source.position.index]; if (!fieldId) { console.log("Missing data when moving field."); @@ -107,6 +110,7 @@ export const useFormStep = () => { insertField({ data: plugin.field.createField(), target, + source, destination }); diff --git a/packages/app-form-builder/src/components/Form/FormRender.tsx b/packages/app-form-builder/src/components/Form/FormRender.tsx index cb586040675..7f0703c8dd1 100644 --- a/packages/app-form-builder/src/components/Form/FormRender.tsx +++ b/packages/app-form-builder/src/components/Form/FormRender.tsx @@ -77,7 +77,7 @@ const FormRender = (props: FbFormRenderComponentProps) => { // We need this useEffect in case when user has deleted a step and he was on that step on the preview tab, // so it won't trigger an error when we trying to view the step that we have deleted, - // we will simpy change currentStep to the first step. + // we will simply change currentStep to the first step. useEffect(() => { setCurrentStepIndex(0); setModifiedSteps(data.steps); @@ -168,23 +168,28 @@ const FormRender = (props: FbFormRenderComponentProps) => { if (!field) { return; } - if (field.settings.rules !== undefined) { - field.settings?.rules.forEach((rule: FbFormRule) => { - if (checkIfConditionsMet({ formData: formState, rule })) { - if (rule.action === "show") { - fieldLayout.splice(fieldIndex, 1, ...field.settings.layout); + if (field.settings?.rules.length) { + field.settings.rules.forEach((rule: FbFormRule) => { + if (checkIfConditionsMet({ formData: formState, rule })) { + if (rule.action === "show") { + fieldLayout.splice(fieldIndex, 1, ...field.settings.layout); + } else { + fieldLayout.splice(fieldIndex, field.settings.layout.length, [ + field._id + ]); + } } else { - fieldLayout.splice(fieldIndex, field.settings.layout.length, [ - field._id - ]); - } - } else { - if (field.settings.defaultBehaviour === "show") { - fieldLayout.splice(fieldIndex, 1, ...field.settings.layout); + if (field.settings.defaultBehaviour === "show") { + fieldLayout.splice(fieldIndex, 1, ...field.settings.layout); + } } + }); + } else { + if (field.settings.defaultBehaviour === "show") { + fieldLayout.splice(fieldIndex, 1, ...field.settings.layout); } - }); + } } }); }); From 97a1d9cf1820bb48888088dec21d5a09b26103c4 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis Date: Wed, 3 Jan 2024 15:24:41 +0000 Subject: [PATCH 4/7] fix: using @webiny/validation for form step rules instead of lodash --- .../Form/functions/getNextStepIndex.ts | 94 +++++++++++------- .../FormRender/functions/getNextStepIndex.ts | 96 ++++++++++++------- packages/validation/src/index.ts | 4 + .../validation/src/validators/endsWith.ts | 32 +++++++ .../validation/src/validators/startsWith.ts | 31 ++++++ 5 files changed, 192 insertions(+), 65 deletions(-) create mode 100644 packages/validation/src/validators/endsWith.ts create mode 100644 packages/validation/src/validators/startsWith.ts diff --git a/packages/app-form-builder/src/components/Form/functions/getNextStepIndex.ts b/packages/app-form-builder/src/components/Form/functions/getNextStepIndex.ts index d7362d71c19..b8bc6c6baf6 100644 --- a/packages/app-form-builder/src/components/Form/functions/getNextStepIndex.ts +++ b/packages/app-form-builder/src/components/Form/functions/getNextStepIndex.ts @@ -1,11 +1,4 @@ -import includes from "lodash/includes"; -import endsWith from "lodash/endsWith"; -import startsWith from "lodash/startsWith"; -import eq from "lodash/eq"; -import lte from "lodash/lte"; -import lt from "lodash/lt"; -import gte from "lodash/gte"; -import gt from "lodash/gt"; +import { validation } from "@webiny/validation"; import { FbFormCondition, FbFormRule } from "~/types"; @@ -19,7 +12,7 @@ const includesValidator = (filterValue: string, fieldValue: string) => { return; } - return includes(fieldValue, filterValue); + return fieldValue.includes(filterValue); }; const startsWithValidator = (filterValue: string, fieldValue: string) => { @@ -27,7 +20,14 @@ const startsWithValidator = (filterValue: string, fieldValue: string) => { return; } - return startsWith(fieldValue, filterValue); + // Need to use try catch block because without it validation will throw and error, + // so user won't be able to interact with page. + // Same applies to all validation methods below. + try { + return validation.validateSync(fieldValue, `starts:${filterValue}`); + } catch { + return; + } }; const endsWithValidator = (filterValue: string, fieldValue: string) => { @@ -35,7 +35,11 @@ const endsWithValidator = (filterValue: string, fieldValue: string) => { return; } - return endsWith(fieldValue, filterValue); + try { + return validation.validateSync(fieldValue, `ends:${filterValue}`); + } catch { + return; + } }; const isValidator = (filterValue: string, fieldValue: string | string[]) => { @@ -43,11 +47,15 @@ const isValidator = (filterValue: string, fieldValue: string | string[]) => { return; } - // This is check for checkboxes. - if (typeof fieldValue === "object") { - return fieldValue.includes(filterValue); - } else { - return eq(fieldValue, filterValue); + try { + // This is check for checkboxes. + if (typeof fieldValue === "object") { + return fieldValue.includes(filterValue); + } else { + return validation.validateSync(fieldValue, `eq:${filterValue}`); + } + } catch { + return; } }; @@ -64,10 +72,14 @@ const gtValidator = ({ return; } - if (!equal) { - return gt(fieldValue, filterValue); - } else { - return gte(fieldValue, filterValue); + try { + if (!equal) { + return validation.validateSync(fieldValue, `gt:${filterValue}`); + } else { + return validation.validateSync(fieldValue, `gte:${filterValue}`); + } + } catch { + return; } }; @@ -84,10 +96,14 @@ const ltValidator = ({ return; } - if (!equal) { - return lt(filterValue, filterValue); - } else { - return lte(fieldValue, filterValue); + try { + if (!equal) { + return validation.validateSync(fieldValue, `lt:${filterValue}`); + } else { + return validation.validateSync(fieldValue, `lte:${filterValue}`); + } + } catch { + return; } }; @@ -104,10 +120,17 @@ const timeGtValidator = ({ return; } - if (!equal) { - return gt(Date.parse(fieldValue), Date.parse(filterValue)); - } else { - return gte(Date.parse(fieldValue), Date.parse(filterValue)); + try { + if (!equal) { + return validation.validateSync(Date.parse(fieldValue), `gt:${Date.parse(filterValue)}`); + } else { + return validation.validateSync( + Date.parse(fieldValue), + `gte:${Date.parse(filterValue)}` + ); + } + } catch { + return; } }; @@ -124,10 +147,17 @@ const timeLtValidator = ({ return; } - if (!equal) { - return lt(Date.parse(fieldValue), Date.parse(filterValue)); - } else { - return lte(Date.parse(fieldValue), Date.parse(filterValue)); + try { + if (!equal) { + return validation.validateSync(Date.parse(fieldValue), `lt:${Date.parse(filterValue)}`); + } else { + return validation.validateSync( + Date.parse(fieldValue), + `lte:${Date.parse(filterValue)}` + ); + } + } catch { + return; } }; diff --git a/packages/app-page-builder-elements/src/renderers/form/FormRender/functions/getNextStepIndex.ts b/packages/app-page-builder-elements/src/renderers/form/FormRender/functions/getNextStepIndex.ts index 0300db69fcf..b8bc6c6baf6 100644 --- a/packages/app-page-builder-elements/src/renderers/form/FormRender/functions/getNextStepIndex.ts +++ b/packages/app-page-builder-elements/src/renderers/form/FormRender/functions/getNextStepIndex.ts @@ -1,13 +1,6 @@ -import includes from "lodash/includes"; -import endsWith from "lodash/endsWith"; -import startsWith from "lodash/startsWith"; -import eq from "lodash/eq"; -import lte from "lodash/lte"; -import lt from "lodash/lt"; -import gte from "lodash/gte"; -import gt from "lodash/gt"; +import { validation } from "@webiny/validation"; -import { FbFormRule, FbFormCondition } from "~/types"; +import { FbFormCondition, FbFormRule } from "~/types"; interface Props { formData: Record; @@ -19,7 +12,7 @@ const includesValidator = (filterValue: string, fieldValue: string) => { return; } - return includes(fieldValue, filterValue); + return fieldValue.includes(filterValue); }; const startsWithValidator = (filterValue: string, fieldValue: string) => { @@ -27,7 +20,14 @@ const startsWithValidator = (filterValue: string, fieldValue: string) => { return; } - return startsWith(fieldValue, filterValue); + // Need to use try catch block because without it validation will throw and error, + // so user won't be able to interact with page. + // Same applies to all validation methods below. + try { + return validation.validateSync(fieldValue, `starts:${filterValue}`); + } catch { + return; + } }; const endsWithValidator = (filterValue: string, fieldValue: string) => { @@ -35,7 +35,11 @@ const endsWithValidator = (filterValue: string, fieldValue: string) => { return; } - return endsWith(fieldValue, filterValue); + try { + return validation.validateSync(fieldValue, `ends:${filterValue}`); + } catch { + return; + } }; const isValidator = (filterValue: string, fieldValue: string | string[]) => { @@ -43,11 +47,15 @@ const isValidator = (filterValue: string, fieldValue: string | string[]) => { return; } - // This is check for checkboxes. - if (typeof fieldValue === "object") { - return fieldValue.includes(filterValue); - } else { - return eq(fieldValue, filterValue); + try { + // This is check for checkboxes. + if (typeof fieldValue === "object") { + return fieldValue.includes(filterValue); + } else { + return validation.validateSync(fieldValue, `eq:${filterValue}`); + } + } catch { + return; } }; @@ -64,10 +72,14 @@ const gtValidator = ({ return; } - if (!equal) { - return gt(fieldValue, filterValue); - } else { - return gte(fieldValue, filterValue); + try { + if (!equal) { + return validation.validateSync(fieldValue, `gt:${filterValue}`); + } else { + return validation.validateSync(fieldValue, `gte:${filterValue}`); + } + } catch { + return; } }; @@ -84,10 +96,14 @@ const ltValidator = ({ return; } - if (!equal) { - return lt(filterValue, filterValue); - } else { - return lte(fieldValue, filterValue); + try { + if (!equal) { + return validation.validateSync(fieldValue, `lt:${filterValue}`); + } else { + return validation.validateSync(fieldValue, `lte:${filterValue}`); + } + } catch { + return; } }; @@ -104,10 +120,17 @@ const timeGtValidator = ({ return; } - if (!equal) { - return gt(Date.parse(fieldValue), Date.parse(filterValue)); - } else { - return gte(Date.parse(fieldValue), Date.parse(filterValue)); + try { + if (!equal) { + return validation.validateSync(Date.parse(fieldValue), `gt:${Date.parse(filterValue)}`); + } else { + return validation.validateSync( + Date.parse(fieldValue), + `gte:${Date.parse(filterValue)}` + ); + } + } catch { + return; } }; @@ -124,10 +147,17 @@ const timeLtValidator = ({ return; } - if (!equal) { - return lt(Date.parse(fieldValue), Date.parse(filterValue)); - } else { - return lte(Date.parse(fieldValue), Date.parse(filterValue)); + try { + if (!equal) { + return validation.validateSync(Date.parse(fieldValue), `lt:${Date.parse(filterValue)}`); + } else { + return validation.validateSync( + Date.parse(fieldValue), + `lte:${Date.parse(filterValue)}` + ); + } + } catch { + return; } }; diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 17779198405..42dd8b034d1 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -23,6 +23,8 @@ import dateLte from "./validators/dateLte"; import timeGte from "./validators/timeGte"; import timeLte from "./validators/timeLte"; import slug from "./validators/slug"; +import startsWith from "./validators/startsWith"; +import endsWith from "./validators/endsWith"; const validation = new Validation(); validation.setValidator("creditCard", creditCard); @@ -48,5 +50,7 @@ validation.setValidator("dateLte", dateLte); validation.setValidator("timeGte", timeGte); validation.setValidator("timeLte", timeLte); validation.setValidator("slug", slug); +validation.setValidator("starts", startsWith); +validation.setValidator("ends", endsWith); export { validation, Validation, ValidationError }; diff --git a/packages/validation/src/validators/endsWith.ts b/packages/validation/src/validators/endsWith.ts new file mode 100644 index 00000000000..fb1896a7b95 --- /dev/null +++ b/packages/validation/src/validators/endsWith.ts @@ -0,0 +1,32 @@ +import ValidationError from "~/validationError"; + +/** + * @name ends + * @description Ends With validator. This validator checks if the given value ends with specific word or character. + * @param {any} value This is the value that will be validated. + * @param {Array} params This is the value to validate against. It is passed as a validator parameter: `ends:valueToCompareWith` + * @throws {ValidationError} + * @example + * import { validation } from '@webiny/validation'; + * validation.validate('another email', 'ends:email').then(() => { + * // Valid + * }).catch(e => { + * // Invalid + * }); + */ +export default (value: any, params?: string[]) => { + if (!value || !params) { + return; + } + value = value + ""; + + const endOfTheString = value.slice(value.length - params[0].length); + + // Intentionally put '==' instead of '===' because passed parameter for this validator is always sent inside a string (eg. "ends:test"). + // eslint-disable-next-line + if (endOfTheString == params[0]) { + return; + } + + throw new ValidationError("Value must end with " + params[0] + "."); +}; diff --git a/packages/validation/src/validators/startsWith.ts b/packages/validation/src/validators/startsWith.ts new file mode 100644 index 00000000000..f4facfa0551 --- /dev/null +++ b/packages/validation/src/validators/startsWith.ts @@ -0,0 +1,31 @@ +import ValidationError from "~/validationError"; + +/** + * @name starts + * @description Starts With validator. This validator checks if the given value starts with specific word or character. + * @param {any} value This is the value that will be validated. + * @param {Array} params This is the value to validate against. It is passed as a validator parameter: `starts:valueToCompareWith` + * @throws {ValidationError} + * @example + * import { validation } from '@webiny/validation'; + * validation.validate('another email', 'starts:another).then(() => { + * // Valid + * }).catch(e => { + * // Invalid + * }); + */ +export default (value: any, params?: string[]) => { + if (!value || !params) { + return; + } + value = value + ""; + const startOfString = value.slice(0, params[0].length); + + // Intentionally put '==' instead of '===' because passed parameter for this validator is always sent inside a string (eg. "starts:test"). + // eslint-disable-next-line + if (startOfString == params[0]) { + return; + } + + throw new ValidationError("Value must start with " + params[0] + "."); +}; From b7e4ba46213f6a74b1e1c763c16222d9679d2076 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis Date: Wed, 3 Jan 2024 15:44:30 +0000 Subject: [PATCH 5/7] fix: added @webiny/validation package for page-builder deps. --- packages/app-page-builder-elements/package.json | 1 + packages/app-page-builder-elements/tsconfig.build.json | 3 ++- packages/app-page-builder-elements/tsconfig.json | 6 ++++-- yarn.lock | 1 + 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/app-page-builder-elements/package.json b/packages/app-page-builder-elements/package.json index 1b69e085ee4..c31fe172bec 100644 --- a/packages/app-page-builder-elements/package.json +++ b/packages/app-page-builder-elements/package.json @@ -19,6 +19,7 @@ "@emotion/styled": "^11.10.6", "@webiny/lexical-editor": "0.0.0", "@webiny/theme": "0.0.0", + "@webiny/validation": "0.0.0", "facepaint": "^1.2.1", "lodash": "^4.17.21" }, diff --git a/packages/app-page-builder-elements/tsconfig.build.json b/packages/app-page-builder-elements/tsconfig.build.json index 58a9bc11a38..500a685d91f 100644 --- a/packages/app-page-builder-elements/tsconfig.build.json +++ b/packages/app-page-builder-elements/tsconfig.build.json @@ -3,7 +3,8 @@ "include": ["src"], "references": [ { "path": "../lexical-editor/tsconfig.build.json" }, - { "path": "../theme/tsconfig.build.json" } + { "path": "../theme/tsconfig.build.json" }, + { "path": "../validation/tsconfig.build.json" } ], "compilerOptions": { "rootDir": "./src", diff --git a/packages/app-page-builder-elements/tsconfig.json b/packages/app-page-builder-elements/tsconfig.json index 33e3a51328c..ba1c716d308 100644 --- a/packages/app-page-builder-elements/tsconfig.json +++ b/packages/app-page-builder-elements/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.json", "include": ["src", "__tests__"], - "references": [{ "path": "../lexical-editor" }, { "path": "../theme" }], + "references": [{ "path": "../lexical-editor" }, { "path": "../theme" }, { "path": "../validation" }], "compilerOptions": { "rootDirs": ["./src", "./__tests__"], "outDir": "./dist", @@ -12,7 +12,9 @@ "@webiny/lexical-editor/*": ["../lexical-editor/src/*"], "@webiny/lexical-editor": ["../lexical-editor/src"], "@webiny/theme/*": ["../theme/src/*"], - "@webiny/theme": ["../theme/src"] + "@webiny/theme": ["../theme/src"], + "@webiny/validation/*": ["../validation/src/*"], + "@webiny/validation": ["../validation/src"] }, "baseUrl": "." } diff --git a/yarn.lock b/yarn.lock index 1ff297d4132..9f12699bb6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16127,6 +16127,7 @@ __metadata: "@webiny/lexical-editor": 0.0.0 "@webiny/project-utils": 0.0.0 "@webiny/theme": 0.0.0 + "@webiny/validation": 0.0.0 execa: ^5.0.0 facepaint: ^1.2.1 lodash: ^4.17.21 From 4cbc675fa10813738e8329b959ed9f24301d5201 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis Date: Fri, 12 Jan 2024 09:26:48 +0000 Subject: [PATCH 6/7] fix: improved ui for rules tabs, refactored validation for rules --- .../src/plugins/graphql/form.ts | 18 +- packages/api-form-builder/src/types.ts | 9 +- .../components/FormEditor/Context/graphql.ts | 14 +- .../Tabs/EditTab/EditFieldDialog.tsx | 25 +- .../EditFieldDialog/DefaultBehaviour.tsx | 6 +- .../EditFieldDialog/RuleActionSelect.tsx | 29 +- .../EditFieldDialog/RulesConditions.tsx | 167 ++++++---- .../Tabs/EditTab/EditFieldDialog/RulesTab.tsx | 288 ------------------ .../EditTab/EditFieldDialog/RulesTab/Rule.tsx | 50 +++ .../EditFieldDialog/RulesTab/Rules.tsx | 137 +++++++++ .../EditFieldDialog/RulesTab/RulesTab.tsx | 87 ++++++ .../ConditionGroupField/ConditionGroup.tsx | 10 +- .../EditFormStepDialog/EditFormStepDialog.tsx | 5 +- .../EditFormStepDialog/RuleCondition.tsx | 81 +++-- .../FormStep/EditFormStepDialog/RulesTab.tsx | 263 ---------------- .../RulesTab/AddRuleCondition.tsx | 36 +++ .../EditFormStepDialog/RulesTab/Rule.tsx | 55 ++++ .../RulesTab/RuleAction.tsx | 40 +++ .../RulesTab/RuleCondition.tsx | 33 ++ .../EditFormStepDialog/RulesTab/Rules.tsx | 140 +++++++++ .../EditFormStepDialog/RulesTab/RulesTab.tsx | 54 ++++ ...eActionSelect.tsx => SelectRuleAction.tsx} | 47 ++- .../fieldsValidationConditions.ts | 4 + .../FormStepWithFields/FormStepRowField.tsx | 2 +- .../FormEditor/Tabs/EditTab/Styled.ts | 50 +-- .../src/components/Form/FormRender.tsx | 23 +- .../Form/functions/getNextStepIndex.ts | 97 ++---- .../src/components/Form/functions/index.ts | 2 + .../Form/functions/onFormDataChange.ts | 8 + .../components/Form/functions/usePrevious.ts | 11 + .../src/components/Form/graphql.ts | 7 +- packages/app-form-builder/src/types.ts | 9 +- .../src/validators/endsWith.ts | 9 + .../app-form-builder/src/validators/gt.ts | 11 + .../src/validators/includes.ts | 7 + .../app-form-builder/src/validators/index.ts | 8 + .../app-form-builder/src/validators/is.ts | 11 + .../app-form-builder/src/validators/lt.ts | 11 + .../src/validators/startsWith.ts | 9 + .../app-page-builder-elements/package.json | 4 +- .../src/renderers/form/FormRender.tsx | 51 +++- .../FormRender/functions/getNextStepIndex.ts | 96 ++---- .../src/renderers/form/dataLoaders/graphql.ts | 7 +- .../src/renderers/form/types.ts | 9 +- .../app-page-builder-elements/src/types.ts | 9 +- .../src/validators/endsWith.ts | 9 + .../src/validators/gt.ts | 11 + .../src/validators/includes.ts | 7 + .../src/validators/index copy.ts | 8 + .../src/validators/index.ts | 8 + .../src/validators/is.ts | 11 + .../src/validators/lt.ts | 11 + .../src/validators/startsWith.ts | 9 + .../tsconfig.build.json | 3 +- .../app-page-builder-elements/tsconfig.json | 6 +- yarn.lock | 2 - 56 files changed, 1224 insertions(+), 910 deletions(-) delete mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rule.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rules.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/RulesTab.tsx delete mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/AddRuleCondition.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/Rule.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RuleAction.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RuleCondition.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/Rules.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RulesTab.tsx rename packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/{RuleActionSelect.tsx => SelectRuleAction.tsx} (60%) create mode 100644 packages/app-form-builder/src/components/Form/functions/onFormDataChange.ts create mode 100644 packages/app-form-builder/src/components/Form/functions/usePrevious.ts create mode 100644 packages/app-form-builder/src/validators/endsWith.ts create mode 100644 packages/app-form-builder/src/validators/gt.ts create mode 100644 packages/app-form-builder/src/validators/includes.ts create mode 100644 packages/app-form-builder/src/validators/index.ts create mode 100644 packages/app-form-builder/src/validators/is.ts create mode 100644 packages/app-form-builder/src/validators/lt.ts create mode 100644 packages/app-form-builder/src/validators/startsWith.ts create mode 100644 packages/app-page-builder-elements/src/validators/endsWith.ts create mode 100644 packages/app-page-builder-elements/src/validators/gt.ts create mode 100644 packages/app-page-builder-elements/src/validators/includes.ts create mode 100644 packages/app-page-builder-elements/src/validators/index copy.ts create mode 100644 packages/app-page-builder-elements/src/validators/index.ts create mode 100644 packages/app-page-builder-elements/src/validators/is.ts create mode 100644 packages/app-page-builder-elements/src/validators/lt.ts create mode 100644 packages/app-page-builder-elements/src/validators/startsWith.ts diff --git a/packages/api-form-builder/src/plugins/graphql/form.ts b/packages/api-form-builder/src/plugins/graphql/form.ts index 3932f63471e..bc95a018235 100644 --- a/packages/api-form-builder/src/plugins/graphql/form.ts +++ b/packages/api-form-builder/src/plugins/graphql/form.ts @@ -62,13 +62,18 @@ const plugin: GraphQLSchemaPlugin = { input FbFormRuleInput { title: String - action: String - chain: String + action: FbFormRuleActionInput + matchAll: Boolean id: String conditions: [FbFormConditionInput] isValid: Boolean } + input FbFormRuleActionInput { + type: String + value: String + } + input FbFormConditionInput { id: String fieldName: String @@ -101,13 +106,18 @@ const plugin: GraphQLSchemaPlugin = { type FbFormRuleType { title: String - action: String - chain: String + action: FbFormRuleActionType + matchAll: Boolean id: String conditions: [FbFormConditionType] isValid: Boolean } + type FbFormRuleActionType { + type: String + value: String + } + type FbFormConditionType { id: String fieldName: String diff --git a/packages/api-form-builder/src/types.ts b/packages/api-form-builder/src/types.ts index fe8cd245066..f2d99ef2947 100644 --- a/packages/api-form-builder/src/types.ts +++ b/packages/api-form-builder/src/types.ts @@ -22,9 +22,14 @@ interface FbFormStep { rules: FbFormRule[]; } +export type FbFormRuleAction = { + type: string; + value: string; +}; + export type FbFormRule = { - action: string; - chain: string; + action: FbFormRuleAction; + matchAll: boolean; id: string; title: string; conditions: FbFormCondition[]; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/graphql.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/graphql.ts index ddec4e99878..1db765d4d95 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Context/graphql.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/graphql.ts @@ -84,8 +84,11 @@ export const GET_FORM = gql` layout rules { title - action - chain + action { + type + value + } + matchAll id conditions { id @@ -139,8 +142,11 @@ export const UPDATE_REVISION = gql` layout rules { title - action - chain + action { + type + value + } + matchAll id conditions { id diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog.tsx index 22596b1e43f..f74b8afcf8e 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog.tsx @@ -1,9 +1,8 @@ import React, { useState, useEffect, useCallback } from "react"; import cloneDeep from "lodash/cloneDeep"; -import { css } from "emotion"; import styled from "@emotion/styled"; import { - Dialog, + Dialog as BaseDialog, DialogContent, DialogTitle, DialogCancel, @@ -20,15 +19,21 @@ import { i18n } from "@webiny/app/i18n"; const t = i18n.namespace("FormEditor.EditFieldDialog"); import { useFormEditor } from "../../Context"; import { FbBuilderFieldPlugin, FbFormModelField } from "~/types"; -import { RulesTab } from "./EditFieldDialog/RulesTab"; +import { RulesTab } from "./EditFieldDialog/RulesTab/RulesTab"; -const dialogBody = css({ +const DialogBody = styled(DialogContent)({ "&.webiny-ui-dialog__content": { - width: 875, - height: 450 + width: 975, + maxWidth: 975 } }); +const Dialog = styled(BaseDialog)` + & .mdc-dialog__surface { + max-width: 975px; + } +`; + const FbFormModelFieldList = styled("div")({ display: "flex", justifyContent: "center", @@ -117,7 +122,7 @@ const EditFieldDialog = ({ field, onSubmit, ...props }: EditFieldDialogProps) =>
{form => ( <> - + @@ -133,7 +138,7 @@ const EditFieldDialog = ({ field, onSubmit, ...props }: EditFieldDialogProps) => )} - + default: render = ( <> - + {plugins .byType("form-editor-field-type") @@ -185,7 +190,7 @@ const EditFieldDialog = ({ field, onSubmit, ...props }: EditFieldDialogProps) => /> ))} - + {t`Cancel`} diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/DefaultBehaviour.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/DefaultBehaviour.tsx index 39d9a0c1edf..3c58a7503f9 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/DefaultBehaviour.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/DefaultBehaviour.tsx @@ -4,7 +4,7 @@ import { AccordionItem } from "@webiny/ui/Accordion"; import { Select } from "@webiny/ui/Select"; import { - StyledAccordion, + AccordionWithShadow, DefaultBehaviourWrapper } from "~/admin/components/FormEditor/Tabs/EditTab/Styled"; @@ -26,7 +26,7 @@ const defaultBehaviour = [ export const SelectDefaultBehaviour: React.FC = ({ defaultBehaviourValue, onChange }) => { return ( - + By default if no rule is met @@ -44,6 +44,6 @@ export const SelectDefaultBehaviour: React.FC = ({ defaultBehaviourValue, - + ); }; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RuleActionSelect.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RuleActionSelect.tsx index 496e84a101f..c269b6dbd2a 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RuleActionSelect.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RuleActionSelect.tsx @@ -1,6 +1,7 @@ -import React from "react"; +import React, { useCallback } from "react"; import { Select } from "@webiny/ui/Select"; import styled from "@emotion/styled"; +import { FbFormRule } from "~/types"; const RuleAction = styled("div")` display: flex; @@ -21,25 +22,35 @@ const RuleAction = styled("div")` `; const ActionSelect = styled(Select)` - margin-left: 35px; margin-right: 15px; - width: 250px; `; interface Props { - value: string; - onChange: (value: string) => void; + rule: FbFormRule; + onChange: (params: FbFormRule) => void; } -export const RuleActionSelect: React.FC = ({ value, onChange }) => { +export const RuleActionSelect = ({ rule, onChange }: Props) => { + const onChangeAction = useCallback( + (value: string) => { + return onChange({ + ...rule, + action: { + type: "", + value + } + }); + }, + [rule.action.value, onChange] + ); + return ( - Then diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesConditions.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesConditions.tsx index 4883cb43891..b8bc7671884 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesConditions.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesConditions.tsx @@ -1,4 +1,5 @@ -import React from "react"; +import React, { useCallback } from "react"; +import { mdbid } from "@webiny/utils"; import styled from "@emotion/styled"; import { Select } from "@webiny/ui/Select"; @@ -7,6 +8,9 @@ import { IconButton } from "@webiny/ui/Button"; import { fieldConditionOptions } from "~/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/fieldsValidationConditions"; import { renderConditionValueController } from "~/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/renderConditionValueController"; +import { AddConditionButton } from "~/admin/components/FormEditor/Tabs/EditTab/Styled"; + +import { ReactComponent as AddIcon } from "@material-design-icons/svg/outlined/add_circle_outline.svg"; import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete_outline.svg"; import cloneDeep from "lodash/cloneDeep"; @@ -15,79 +19,118 @@ import { FbFormModelField, FbFormCondition, FbFormRule } from "~/types"; const SelectFieldWrapper = styled.div` display: flex; + justify-content: space-between; + align-items: center; margin: 15px 0; & > span { font-size: 22px; } `; -const CondtionsWrapper = styled.div` - display: flex; - margin-left: 20px; +const FieldSelect = styled(Select)` + flex-basis: 35%; `; const SelectCondition = styled(Select)` - margin-right: 15px; - margin-left: 63px; - width: 250px; + flex-basis: 20%; `; const ConditionValue = styled.div` - width: 397px; + flex-basis: 35%; `; -const FieldSelect = styled(Select)` - margin-left: 70px; +const ConditionsChain = styled.div` + text-align: center; + font-size: 12px; + margin-top: 10px; `; +interface AddConditionProps { + rule: FbFormRule; + onChange: (params: FbFormRule) => void; +} + +export const AddCondition = ({ rule, onChange }: AddConditionProps) => { + const onAddCondition = useCallback(() => { + return onChange({ + ...rule, + conditions: [ + ...(rule.conditions || []), + { + fieldName: "", + filterType: "", + filterValue: "", + id: mdbid() + } + ] + }); + }, [rule, onChange]); + + return ( + + } /> + + ); +}; + interface Props { rule: FbFormRule; condition: FbFormCondition; fields: (FbFormModelField | null)[]; - rulesValue: Array; + rules: Array; conditionIndex: number; - onChangeRule: (params: FbFormRule) => void; - deleteCondition: () => void; + onChange: (params: FbFormRule) => void; } -export const RuleConditions: React.FC = ({ - rulesValue, +export const RuleConditions = ({ + rules, fields, condition, rule, conditionIndex, - onChangeRule, - deleteCondition -}) => { + onChange +}: Props) => { const fieldType = fields.find(field => field?.fieldId === condition?.fieldName)?.type || ""; - const onChange = (property: string, value: string) => { - const ruleIndex = findIndex(rulesValue, { id: rule.id }); - const rules = cloneDeep(rulesValue); - const conditions = cloneDeep(rules[ruleIndex].conditions || []); + const handleCondition = useCallback( + (property: string, value: string) => { + const ruleIndex = findIndex(rules, { id: rule.id }); + const conditions = cloneDeep(rules[ruleIndex].conditions || []); - conditions[conditionIndex] = { - ...rules[ruleIndex].conditions[conditionIndex], - [property]: value - }; + conditions[conditionIndex] = { + ...rules[ruleIndex].conditions[conditionIndex], + [property]: value + }; - rules[ruleIndex].conditions = conditions; + rules[ruleIndex].conditions = conditions; - onChangeRule({ + return onChange({ + ...rule, + conditions + }); + }, + [condition, rule, onChange] + ); + + const onDeleteCondition = useCallback(() => { + return onChange({ ...rule, - conditions + conditions: (rule.conditions as FbFormCondition[]).filter( + ruleValueCondition => ruleValueCondition.id !== condition.id + ) }); - }; + }, [condition, rule, onChange]); + + const showAddConditionButton = condition.id === rule.conditions[rule.conditions.length - 1].id; return ( <> - If onChange("fieldName", value)} + onChange={value => handleCondition("fieldName", value)} > {fields.map((field: any) => ( ))} - deleteCondition()} />} /> + handleCondition("filterType", val)} + value={condition.filterType} + > + {fieldConditionOptions + .find(filter => filter.type === fieldType) + ?.options.map(option => ( + + ))} + + {/* This field depends on selected field type */} + + {renderConditionValueController({ + condition, + fields, + handleOnChange: handleCondition + })} + + } /> - {!condition.fieldName ? ( - <> - ) : ( - - onChange("filterType", val)} - value={condition.filterType} - > - {fieldConditionOptions - .find(filter => filter.type === fieldType) - ?.options.map(option => ( - - ))} - - {/* This field depends on selected field type */} - - {renderConditionValueController({ - condition, - fields, - handleOnChange: onChange - })} - - - )} + + {rule.conditions.length > 1 ? (rule.matchAll ? "AND" : "OR") : null} + + {showAddConditionButton && } ); }; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab.tsx deleted file mode 100644 index ff3a7172e1a..00000000000 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab.tsx +++ /dev/null @@ -1,288 +0,0 @@ -import React from "react"; - -import { FormRenderPropParams } from "@webiny/form"; -import { Icon } from "@webiny/ui/Icon"; -import { AccordionItem } from "@webiny/ui/Accordion"; -import { Alert } from "@webiny/ui/Alert"; -import { mdbid } from "@webiny/utils"; - -import { ReactComponent as InfoIcon } from "@material-design-icons/svg/outlined/info.svg"; -import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete_outline.svg"; - -import { RuleConditions } from "./RulesConditions"; -import { conditionChainOptions } from "~/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/fieldsValidationConditions"; -import { - RulesTabWrapper, - AddRuleButtonWrapper, - RuleButtonDescription, - StyledAccordion, - ConditionSetupWrapper, - AddRuleButton, - AddConditionButton, - ConditionsChainSelect -} from "~/admin/components/FormEditor/Tabs/EditTab/Styled"; -import { useFormEditor } from "~/admin/components/FormEditor/Context"; -import { RuleActionSelect } from "./RuleActionSelect"; -import { SelectDefaultBehaviour } from "./DefaultBehaviour"; -import { FbFormModelField, FbFormModel, FbFormRule, FbFormCondition } from "~/types"; -import { getAvailableFields } from "../FormStep/EditFormStepDialog/helpers"; - -const getCondtionFields = (id: string, formData: FbFormModel) => { - const availableFields: Array = []; - - formData.steps.forEach(step => { - const stepLayout = step.layout.flat(2); - - if (stepLayout.includes(id)) { - const fields = getAvailableFields({ step, formData }).filter( - field => field?.type !== "condition-group" - ); - availableFields.push(...fields); - } - }); - - return availableFields; -}; - -interface RulesTabProps { - field: FbFormModelField; - form: FormRenderPropParams; -} - -export const RulesTab = ({ field, form }: RulesTabProps) => { - const { Bind } = form; - - const { data: formData } = useFormEditor(); - const fields = field._id ? getCondtionFields(field._id, formData) : []; - - const areRulesInValid = field?.settings?.rules?.some( - (rule: FbFormRule) => rule.isValid === false - ); - - return ( - - {areRulesInValid && ( - - - At the moment one or more of your rules are broken. To correct the state - please check your rules and ensure they are referencing fields that still - exists and are place inside the current or one of the previous steps. - - - )} - - {({ value: defaultBehaviourValue, onChange: onChangeDefaultBehaviour }) => ( - - )} - - - {({ value: rulesValue, onChange: onChangeRules }) => ( - <> - {rulesValue && - (rulesValue as FbFormRule[]).map((rule, ruleIndex) => ( - - - } - onClick={() => - onChangeRules( - (rulesValue as FbFormRule[]).filter( - rulesValueItem => - rulesValueItem.id !== rule.id - ) - ) - } - /> - - } - > - - {({ value: ruleValue, onChange: onChangeRule }) => ( - <> - {rule.conditions.length === 0 ? ( - - onChangeRule({ - ...ruleValue, - conditions: [ - ...(ruleValue.conditions || - []), - { - fieldName: "", - filterType: "", - filterValue: "", - id: mdbid() - } - ] - }) - } - > - + Add Condition - - ) : ( - <> - {ruleValue.conditions.map( - ( - condition: FbFormCondition, - conditionIndex: number - ) => { - return ( - - - onChangeRule({ - ...ruleValue, - conditions: - ( - ruleValue.conditions as FbFormCondition[] - ).filter( - ruleValueCondition => - ruleValueCondition.id !== - condition.id - ) - }) - } - /> - {condition.id === - ruleValue - .conditions[ - ruleValue - .conditions - .length - 1 - ].id && ( - <> - - onChangeRule( - { - ...ruleValue, - conditions: - [ - ...(ruleValue.conditions || - []), - { - fieldName: - "", - filterType: - "", - filterValue: - "", - id: mdbid() - } - ] - } - ) - } - > - + Add - Condition - - - onChangeRule( - { - ...ruleValue, - chain: val - } - ) - } - > - {conditionChainOptions.map( - chainOption => ( - - ) - )} - - - onChangeRule( - { - ...ruleValue, - action: val - } - ) - } - /> - - )} - - ); - } - )} - - )} - - )} - - - - ))} - - { - onChangeRules([ - ...(rulesValue || []), - { - title: "Rule", - id: mdbid(), - conditions: [], - action: "hide", - isValid: true, - chain: "matchAny" - } - ]); - }} - > - + Add Rule - - - } /> - Click here to learn how field rules work - - - - )} - - - ); -}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rule.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rule.tsx new file mode 100644 index 00000000000..657e95c19d4 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rule.tsx @@ -0,0 +1,50 @@ +import React from "react"; + +import { BindComponent } from "@webiny/form/types"; + +import { RuleConditions, AddCondition } from "../RulesConditions"; +import { ConditionSetupWrapper } from "~/admin/components/FormEditor/Tabs/EditTab/Styled"; +import { FbFormRule, FbFormModelField } from "~/types"; +import { RuleActionSelect } from "../RuleActionSelect"; + +interface RuleProps { + bind: BindComponent; + rules: FbFormRule[]; + ruleIndex: number; + fields: (FbFormModelField | null)[]; +} + +interface BindProps { + value: FbFormRule; + onChange: (params: FbFormRule) => void; +} + +export const Rule = ({ ruleIndex, bind: Bind, fields, rules }: RuleProps) => { + return ( + + {({ value: rule, onChange }: BindProps) => ( + <> + {!rule.conditions.length ? ( + + ) : ( + <> + {rule.conditions.map((condition, conditionIndex) => ( + + + + ))} + + + )} + + )} + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rules.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rules.tsx new file mode 100644 index 00000000000..0f17266e142 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rules.tsx @@ -0,0 +1,137 @@ +import React, { useCallback } from "react"; + +import { mdbid } from "@webiny/utils"; +import { Icon } from "@webiny/ui/Icon"; +import { BindComponent } from "@webiny/form"; +import { AccordionItem } from "@webiny/ui/Accordion"; +import { Switch } from "@webiny/ui/Switch"; +import { ReactComponent as InfoIcon } from "@material-design-icons/svg/outlined/info.svg"; +import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete_outline.svg"; + +import { Rule } from "./Rule"; + +import { + AccordionWithShadow, + StyledAddRuleButton, + AddRuleButtonWrapper, + RuleButtonDescription +} from "~/admin/components/FormEditor/Tabs/EditTab/Styled"; + +import { FbFormModelField, FbFormRule } from "~/types"; + +interface RulesAccordionProps { + children: React.ReactNode; + rules: FbFormRule[]; + rule: FbFormRule; + ruleIndex: number; + onChange: (value: FbFormRule[]) => void; +} + +const RulesAccordion = ({ children, rules, rule, ruleIndex, onChange }: RulesAccordionProps) => { + const onDeleteRule = useCallback(() => { + return onChange(rules.filter(rulesValueItem => rulesValueItem.id !== rule.id)); + }, [rule, onChange]); + + const onChangeConditionChain = useCallback( + (matchAll: boolean) => { + rules[ruleIndex] = { + ...rules[ruleIndex], + matchAll + }; + return onChange([...rules]); + }, + [rule, onChange] + ); + + return ( + + + + } + /> + } onClick={onDeleteRule} /> + + } + > + {children} + + + ); +}; + +interface AddRuleButtonProps { + rules: FbFormRule[]; + onChange: (param: FbFormRule[]) => void; +} + +const AddRuleButton = ({ rules, onChange }: AddRuleButtonProps) => { + const onAddRule = useCallback(() => { + return onChange([ + ...(rules || []), + { + title: "Rule", + id: mdbid(), + conditions: [], + action: { + type: "", + value: "hide" + }, + isValid: true, + matchAll: false + } + ]); + }, [rules, onChange]); + + return ( + + + Add Rule + + } /> + Click here to learn how field rules work + + + ); +}; + +interface RulesProps { + bind: BindComponent; + fields: (FbFormModelField | null)[]; +} + +interface BindProps { + value: FbFormRule[]; + onChange: (params: FbFormRule[]) => void; +} + +export const Rules = ({ bind: Bind, fields }: RulesProps) => { + return ( + + {({ value: rules, onChange }: BindProps) => ( + <> + {rules.map((rule, ruleIndex) => ( + + + + ))} + + + )} + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/RulesTab.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/RulesTab.tsx new file mode 100644 index 00000000000..06de4e0dc7e --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/RulesTab.tsx @@ -0,0 +1,87 @@ +import React from "react"; + +import { Alert } from "@webiny/ui/Alert"; +import { BindComponent, FormRenderPropParams } from "@webiny/form"; + +import { getAvailableFields } from "~/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/helpers"; +import { useFormEditor } from "~/admin/components/FormEditor/Context"; +import { RulesTabWrapper } from "~/admin/components/FormEditor/Tabs/EditTab/Styled"; + +import { SelectDefaultBehaviour } from "../DefaultBehaviour"; + +import { FbFormRule, FbFormModelField, FbFormModel } from "~/types"; +import { Rules } from "./Rules"; + +interface GetConditionFieldParams { + id: string; + formData: FbFormModel; +} + +const getConditionField = ({ id, formData }: GetConditionFieldParams) => { + const availableFields: Array = []; + + formData.steps.forEach(step => { + const stepLayout = step.layout.flat(2); + + if (stepLayout.includes(id)) { + const fields = getAvailableFields({ step, formData }).filter( + field => field?.type !== "condition-group" + ); + availableFields.push(...fields); + } + }); + + return availableFields; +}; + +interface RuleBrokenAlertProps { + field: FbFormModelField; +} + +const RulesBrokenAlert = ({ field }: RuleBrokenAlertProps) => { + const rulesBroken = field?.settings?.rules?.some((rule: FbFormRule) => rule.isValid === false); + + return rulesBroken !== undefined && rulesBroken === true ? ( + + + At the moment one or more of your rules are broken. To correct the state please + check your rules and ensure they are referencing fields that still exists and are + place inside the current or one of the previous steps. + + + ) : null; +}; + +interface ConditionGroupDefaultBehaviorProps { + bind: BindComponent; +} + +const ConditionGroupDefaultBehavior = ({ bind: Bind }: ConditionGroupDefaultBehaviorProps) => { + return ( + + {({ value, onChange }) => ( + + )} + + ); +}; + +interface RulesTabProps { + field: FbFormModelField; + form: FormRenderPropParams; +} + +export const RulesTab = ({ field, form }: RulesTabProps) => { + const { Bind } = form; + + const { data: formData } = useFormEditor(); + const fields = field._id ? getConditionField({ id: field._id, formData }) : []; + + return ( + + + + + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroup.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroup.tsx index 3562511d481..aac5267c669 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroup.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroup.tsx @@ -34,9 +34,9 @@ const ConditionalGroupField = (props: FieldProps) => { const { getField } = useFormEditor(); const getFields = () => { - return (conditionGroupField?.settings?.layout || []).map((row: any) => { + return (conditionGroupField?.settings?.layout || []).map((row: string[]) => { return row - .map((id: any) => { + .map((id: string) => { return getField({ _id: id }); @@ -45,10 +45,8 @@ const ConditionalGroupField = (props: FieldProps) => { }); }; - const fields = getFields().map((fields: any) => - fields - .filter((field: any) => field._id !== conditionGroupField._id) - .filter((field: any) => field.length !== 0) + const fields = getFields().map((fields: FbFormModelField[]) => + fields.filter((field: FbFormModelField) => field._id !== conditionGroupField._id) ) as FbFormModelField[][]; return ( diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/EditFormStepDialog.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/EditFormStepDialog.tsx index ae3a869975b..bd2e5c67853 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/EditFormStepDialog.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/EditFormStepDialog.tsx @@ -8,7 +8,7 @@ import { Input } from "@webiny/ui/Input"; import { ButtonPrimary, ButtonSecondary } from "@webiny/ui/Button"; import { Tabs, Tab } from "@webiny/ui/Tabs"; -import { RulesTab } from "./RulesTab"; +import { RulesTab } from "./RulesTab/RulesTab"; import { FbFormModel, FbFormStep, FbFormRule } from "~/types"; import { UpdateStepParams } from "~/admin/components/FormEditor/Context/useFormEditorFactory"; @@ -18,7 +18,8 @@ const EditStepDialog = styled(BaseDialog)` color: #fff; font-weight: 600; & .mdc-dialog__surface { - width: 875px; + width: 975px; + max-width: 975px; } `; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleCondition.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleCondition.tsx index 2375ceb1568..fa6ce18637c 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleCondition.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleCondition.tsx @@ -11,32 +11,33 @@ import { updateRuleConditions } from "./updateRuleConditions"; const SelectFieldWrapper = styled.div` display: flex; + justify-content: space-between; + align-items: center; margin: 15px 0; & > span { font-size: 22px; } `; -const CondtionsWrapper = styled.div` - display: flex; - margin-left: 20px; +const FieldSelect = styled(Select)` + flex-basis: 35%; `; const SelectCondition = styled(Select)` - margin-right: 15px; - margin-left: 63px; - width: 250px; + flex-basis: 20%; `; const ConditionValue = styled.div` - width: 397px; + flex-basis: 35%; `; -const FieldSelect = styled(Select)` - margin-left: 70px; +const ConditionsChain = styled.div` + text-align: center; + font-size: 12px; + margin-top: 10px; `; -interface Params { +export interface RuleConditionProps { condition: FbFormCondition; rule: FbFormRule; fields: (FbFormModelField | null)[]; @@ -45,7 +46,7 @@ interface Params { onDelete: () => void; } -export const RuleCondition: React.FC = params => { +export const RuleCondition = (params: RuleConditionProps) => { const { condition, rule, fields, conditionIndex, onChange, onDelete } = params; const fieldType = fields.find(field => field?.fieldId === condition.fieldName)?.type || ""; @@ -66,10 +67,9 @@ export const RuleCondition: React.FC = params => { return ( <> - If handleOnChange("fieldName", value)} > @@ -79,36 +79,33 @@ export const RuleCondition: React.FC = params => { ))} + handleOnChange("filterType", value)} + > + {fieldConditionOptions + .find(filter => filter.type === fieldType) + ?.options.map((option, index) => ( + + ))} + + {/* This field depends on selected field type */} + + {renderConditionValueController({ + condition, + fields, + handleOnChange + })} + } /> - {!condition.fieldName ? ( - <> - ) : ( - - handleOnChange("filterType", value)} - > - {fieldConditionOptions - .find(filter => filter.type === fieldType) - ?.options.map((option, index) => ( - - ))} - - {/* This field depends on selected field type */} - - {renderConditionValueController({ - condition, - fields, - handleOnChange - })} - - - )} + + {rule.conditions.length > 1 ? (rule.matchAll ? "AND" : "OR") : null} + ); }; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab.tsx deleted file mode 100644 index 29a7e65194e..00000000000 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import React from "react"; -import { Icon } from "@webiny/ui/Icon"; -import { AccordionItem } from "@webiny/ui/Accordion"; -import { Alert } from "@webiny/ui/Alert"; -import { mdbid } from "@webiny/utils"; - -import { ReactComponent as InfoIcon } from "@material-design-icons/svg/outlined/info.svg"; -import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete_outline.svg"; - -import { RuleActionSelect } from "./RuleActionSelect"; -import { conditionChainOptions } from "./fieldsValidationConditions"; -import { getAvailableFields } from "./helpers"; -import { - RulesTabWrapper, - AddRuleButtonWrapper, - RuleButtonDescription, - StyledAccordion, - ConditionSetupWrapper, - AddRuleButton, - AddConditionButton, - ConditionsChainSelect -} from "../../Styled"; - -import { FbFormStep, FbFormModel, FbFormRule } from "~/types"; -import { BindComponent } from "@webiny/form/types"; -import { RuleCondition } from "./RuleCondition"; - -interface RulesTabProps { - bind: BindComponent; - step: FbFormStep; - formData: FbFormModel; -} - -export const RulesTab = ({ bind: Bind, step, formData }: RulesTabProps) => { - const fields = getAvailableFields({ step, formData }); - - const areRulesBroken = step.rules?.some(rule => rule.isValid === false); - const isCurrentStepLast = - formData.steps.findIndex(steps => steps.id === step.id) === formData.steps.length - 1; - - const rulesDisabledMessage = "You cannot add rules to the last step!"; - - // We also check whether last step has rules, - // if yes then we most block ability to add new rules and conditions. - if (isCurrentStepLast && !step?.rules?.length) { - return ( - -

{rulesDisabledMessage}

-
- ); - } - - return ( - - {areRulesBroken !== undefined && areRulesBroken === true && ( - - - At the moment one or more of your rules are broken. To correct the state - please check your rules and ensure they are referencing fields that still - exists and are place inside the current or one of the previous steps. - - - )} - - {({ value: stepRules, onChange: onChangeRules }) => ( - <> - {stepRules && - (stepRules as FbFormRule[]).map((rule, ruleIndex) => ( - - - } - onClick={() => - onChangeRules( - (stepRules as FbFormRule[]).filter( - rulesValueItem => - rulesValueItem.id !== rule.id - ) - ) - } - /> - - } - > - - {({ - value: stepRule, - onChange: onChangeRule - }: { - value: FbFormRule; - onChange: (params: any) => void; - }) => ( - <> - {stepRule.conditions.length === 0 ? ( - { - onChangeRule({ - ...stepRule, - conditions: [ - ...(stepRule.conditions || - []), - { - fieldName: "", - filterType: "", - filterValue: "", - id: mdbid() - } - ] - }); - }} - disabled={isCurrentStepLast} - > - + Add Condition - - ) : ( - stepRule.conditions.map( - (condition, conditionIndex) => ( - - { - onChangeRule({ - ...stepRule, - conditions: - stepRule.conditions.filter( - ruleCondition => - ruleCondition.id !== - condition.id - ) - }); - }} - /> - {condition.id === - stepRule.conditions[ - stepRule.conditions - .length - 1 - ].id && ( - <> - { - onChangeRule({ - ...stepRule, - conditions: - [ - ...(stepRule.conditions || - []), - { - fieldName: - "", - filterType: - "", - filterValue: - "", - id: mdbid() - } - ] - }); - }} - > - + Add Condition - - { - onChangeRule({ - ...stepRule, - chain: val - }); - }} - > - {conditionChainOptions.map( - ( - chainOption, - index - ) => ( - - ) - )} - - { - onChangeRule({ - ...stepRule, - action: val - }); - }} - /> - - )} - - ) - ) - )} - - )} - - - - ))} - - { - onChangeRules([ - ...(stepRules || []), - { - title: "Rule", - id: mdbid(), - conditions: [], - action: "hide", - isValid: true, - chain: "matchAny" - } - ]); - }} - > - + Add Rule - - - } /> - Click here to learn how step rules work - - - - )} - - - ); -}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/AddRuleCondition.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/AddRuleCondition.tsx new file mode 100644 index 00000000000..619f425204a --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/AddRuleCondition.tsx @@ -0,0 +1,36 @@ +import React, { useCallback } from "react"; +import { mdbid } from "@webiny/utils"; + +import { AddConditionButton } from "~/admin/components/FormEditor/Tabs/EditTab/Styled"; +import { IconButton } from "@webiny/ui/Button"; +import { ReactComponent as AddIcon } from "@material-design-icons/svg/outlined/add_circle_outline.svg"; + +import { FbFormRule } from "~/types"; + +interface EmptyRuleProps { + rule: FbFormRule; + onChange: (value: FbFormRule) => void; +} + +export const AddRuleCondition = ({ rule, onChange }: EmptyRuleProps) => { + const onCreateCondition = useCallback(() => { + return onChange({ + ...rule, + conditions: [ + ...(rule.conditions || []), + { + fieldName: "", + filterType: "", + filterValue: "", + id: mdbid() + } + ] + }); + }, [onChange, rule]); + + return ( + + } /> + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/Rule.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/Rule.tsx new file mode 100644 index 00000000000..6c66d9c9b61 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/Rule.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { BindComponent } from "@webiny/form/types"; + +import { AddRuleCondition } from "./AddRuleCondition"; +import { RuleConditionWrapper } from "./RuleCondition"; +import { RuleAction } from "./RuleAction"; + +import { FbFormModelField, FbFormRule, FbFormStep } from "~/types"; + +interface RuleProps { + bind: BindComponent; + ruleIndex: number; + steps: FbFormStep[]; + currentStep: FbFormStep; + fields: (FbFormModelField | null)[]; +} + +interface BindParams { + value: FbFormRule; + onChange: (value: FbFormRule) => void; +} + +export const Rule = ({ bind: Bind, ruleIndex, steps, currentStep, fields }: RuleProps) => { + return ( + + {({ value: rule, onChange }: BindParams) => ( + <> + {rule.conditions.length === 0 ? ( + + ) : ( + <> + {rule.conditions.map((condition, conditionIndex) => ( + + ))} + + + )} + + )} + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RuleAction.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RuleAction.tsx new file mode 100644 index 00000000000..311802db4f9 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RuleAction.tsx @@ -0,0 +1,40 @@ +import React, { useCallback } from "react"; + +import { FbFormRule, FbFormStep, FbFormRuleAction } from "~/types"; +import { SelectRuleAction } from "../SelectRuleAction"; + +interface RuleActionSelectProps { + rule: FbFormRule; + steps: FbFormStep[]; + currentStep: FbFormStep; + ruleIndex: number; + onChange: (params: FbFormRule) => void; +} + +export const RuleAction = ({ + rule, + ruleIndex, + steps, + currentStep, + onChange +}: RuleActionSelectProps) => { + const onChangeAction = useCallback( + (action: FbFormRuleAction) => { + return onChange({ + ...rule, + action + }); + }, + [rule, onChange, currentStep] + ); + + return ( + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RuleCondition.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RuleCondition.tsx new file mode 100644 index 00000000000..1ad554869b0 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RuleCondition.tsx @@ -0,0 +1,33 @@ +import React, { useCallback } from "react"; + +import { RuleCondition, RuleConditionProps } from "../RuleCondition"; +import { AddRuleCondition } from "./AddRuleCondition"; + +export const RuleConditionWrapper = ({ + onChange, + rule, + condition, + ...rest +}: Omit) => { + const onDeleteCondition = useCallback(() => { + return onChange({ + ...rule, + conditions: rule.conditions.filter(ruleCondition => ruleCondition.id !== condition.id) + }); + }, [onChange, rule, condition]); + + const showAddConditionButton = condition.id === rule.conditions[rule.conditions.length - 1].id; + + return ( + <> + + {showAddConditionButton && } + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/Rules.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/Rules.tsx new file mode 100644 index 00000000000..ea3f0618a2b --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/Rules.tsx @@ -0,0 +1,140 @@ +import React, { useCallback } from "react"; + +import { Rule } from "./Rule"; + +import { mdbid } from "@webiny/utils"; +import { BindComponent } from "@webiny/form/types"; +import { AccordionItem } from "@webiny/ui/Accordion"; +import { Switch } from "@webiny/ui/Switch"; + +import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete_outline.svg"; + +import { FbFormModelField, FbFormRule, FbFormStep } from "~/types"; +import { + StyledAddRuleButton, + AddRuleButtonWrapper, + AccordionWithShadow +} from "~/admin/components/FormEditor/Tabs/EditTab/Styled"; + +type OnChangeRulesHandler = (value: FbFormRule[]) => void; + +interface RulesProps { + bind: BindComponent; + steps: FbFormStep[]; + currentStep: FbFormStep; + fields: (FbFormModelField | null)[]; +} + +interface BindParams { + value: FbFormRule[]; + onChange: OnChangeRulesHandler; +} + +interface RulesAccordionProps { + children: React.ReactElement; + rules: FbFormRule[]; + rule: FbFormRule; + ruleIndex: number; + onChange: OnChangeRulesHandler; +} + +const RulesAccordion = ({ children, rule, rules, ruleIndex, onChange }: RulesAccordionProps) => { + const onDeleteRule = useCallback(() => { + return onChange(rules.filter(rulesValueItem => rulesValueItem.id !== rule.id)); + }, [rule, onChange]); + + const onChangeConditionChain = useCallback( + (matchAll: boolean) => { + rules[ruleIndex] = { + ...rules[ruleIndex], + matchAll + }; + return onChange([...rules]); + }, + [rule, onChange] + ); + + return ( + + + + } + /> + } onClick={onDeleteRule} /> + + } + > + {children} + + + ); +}; + +interface AddRuleButtonProps { + rules: FbFormRule[]; + onChange: (param: FbFormRule[]) => void; +} + +const AddRuleButton = ({ rules, onChange }: AddRuleButtonProps) => { + const onAddRule = useCallback(() => { + return onChange([ + ...(rules || []), + { + title: "Rule", + id: mdbid(), + conditions: [], + action: { + type: "", + value: "" + }, + isValid: true, + matchAll: false + } + ]); + }, [rules, onChange]); + + return ( + + + Add Rule + + ); +}; + +export const Rules = ({ bind: Bind, steps, currentStep, fields }: RulesProps) => { + return ( + + {({ value: rules, onChange }: BindParams) => ( + <> + {rules.map((rule, ruleIndex) => ( + + + + ))} + + + )} + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RulesTab.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RulesTab.tsx new file mode 100644 index 00000000000..d16435f101c --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RulesTab.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { Alert } from "@webiny/ui/Alert"; +import { getAvailableFields } from "../helpers"; +import { RulesTabWrapper } from "~/admin/components/FormEditor/Tabs/EditTab/Styled"; + +import { FbFormStep, FbFormModel, FbFormRule } from "~/types"; +import { BindComponent } from "@webiny/form/types"; +import { Rules } from "./Rules"; + +interface RulesTabProps { + bind: BindComponent; + step: FbFormStep; + formData: FbFormModel; +} + +const RulesBrokenAlert = ({ rules }: { rules: FbFormRule[] }) => { + const rulesBroken = rules.some(rule => rule.isValid === false); + + return rulesBroken !== undefined && rulesBroken === true ? ( + + + At the moment one or more of your rules are broken. To correct the state please + check your rules and ensure they are referencing fields that still exists and are + place inside the current or one of the previous steps. + + + ) : null; +}; + +export const RulesTab = ({ bind: Bind, step, formData }: RulesTabProps) => { + const fields = getAvailableFields({ step, formData }); + + const isCurrentStepLast = + formData.steps.findIndex(steps => steps.id === step.id) === formData.steps.length - 1; + + const rulesDisabledMessage = "You cannot add rules to the last step!"; + + // We also check whether last step has rules, + // if yes then we most block ability to add new rules and conditions. + if (isCurrentStepLast && !step?.rules?.length) { + return ( + +

{rulesDisabledMessage}

+
+ ); + } + + return ( + + + + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleActionSelect.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/SelectRuleAction.tsx similarity index 60% rename from packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleActionSelect.tsx rename to packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/SelectRuleAction.tsx index 8f9e6609be9..e0cf1d51c23 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleActionSelect.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/SelectRuleAction.tsx @@ -1,8 +1,9 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { Select } from "@webiny/ui/Select"; import styled from "@emotion/styled"; import { ruleActionOptions } from "./fieldsValidationConditions"; -import { FbFormStep, FbFormRule } from "~/types"; +import { FbFormStep, FbFormRule, FbFormRuleAction } from "~/types"; +import { Input } from "@webiny/ui/Input"; const RuleAction = styled("div")` display: flex; @@ -23,7 +24,6 @@ const RuleAction = styled("div")` `; const ActionSelect = styled(Select)` - margin-left: 35px; margin-right: 15px; width: 250px; `; @@ -37,28 +37,39 @@ interface Props { steps: FbFormStep[]; currentStep: FbFormStep; ruleIndex: number; - onChangeAction: (value: string) => void; + onChange: (action: FbFormRuleAction) => void; } -export const RuleActionSelect: React.FC = ({ rule, steps, currentStep, onChangeAction }) => { - const defaultActionValue = rule.action === "submit" ? "submit" : "goToStep"; - const [ruleAction, setRuleAction] = useState(defaultActionValue); +export const SelectRuleAction = ({ rule, steps, currentStep, onChange }: Props) => { + const [ruleAction, setRuleAction] = useState(rule.action.type); // We can only select steps that are below current step. const availableSteps = steps.slice(steps.findIndex(step => step.id === currentStep.id) + 1); useEffect(() => { if (ruleAction === "submit") { - onChangeAction("submit"); + onChange({ + type: "submit", + value: "" + }); } }, [ruleAction]); + const onChangeAction = useCallback( + (actionValue: string) => { + return onChange({ + type: ruleAction, + value: actionValue + }); + }, + [ruleAction, rule.action.value] + ); + return ( - Then setRuleAction(val)} > @@ -70,9 +81,9 @@ export const RuleActionSelect: React.FC = ({ rule, steps, currentStep, on {ruleAction === "goToStep" && ( {availableSteps.map((step, index) => ( @@ -82,6 +93,14 @@ export const RuleActionSelect: React.FC = ({ rule, steps, currentStep, on ))} )} + {ruleAction === "submitAndRedirect" && ( + + )} ); }; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/fieldsValidationConditions.ts b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/fieldsValidationConditions.ts index 3226a723341..40b9065fa7c 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/fieldsValidationConditions.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/fieldsValidationConditions.ts @@ -96,5 +96,9 @@ export const ruleActionOptions = [ { value: "submit", label: "Submit" + }, + { + value: "submitAndRedirect", + label: "Submit & Redirect" } ]; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStepWithFields/FormStepRowField.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStepWithFields/FormStepRowField.tsx index 413bd44cb28..0d0fcbfeabe 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStepWithFields/FormStepRowField.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStepWithFields/FormStepRowField.tsx @@ -80,7 +80,7 @@ export const FormStepRowField = (props: FormStepFieldRowFieldProps) => { return ( {({ drag }) => ( - + diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts index 99647f1aed1..0f2ba93776a 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts @@ -4,7 +4,7 @@ import { ButtonSecondary } from "@webiny/ui/Button"; import { Select } from "@webiny/ui/Select"; export const StyledAccordion = styled(Accordion)<{ margingap?: string }>` - background: var(--mdc-theme-background); + background: white; box-shadow: none; & > ul { padding: 0 0 0 0 !important; @@ -12,6 +12,14 @@ export const StyledAccordion = styled(Accordion)<{ margingap?: string }>` ${props => `margin-top: ${props.margingap}`} `; +export const AccordionWithShadow = styled(Accordion)<{ margingap?: string }>` + background: ${props => props.theme.styles.colors["color6"]}; + & > ul { + padding: 0 0 0 0 !important; + } + ${props => `margin-top: ${props.margingap}`} +`; + export const StyledAccordionItem = styled(AccordionItem)` & .webiny-ui-accordion-item__content { background: white; @@ -60,13 +68,15 @@ export const RuleButtonDescription = styled.div` export const ConditionSetupWrapper = styled.div``; -export const AddRuleButton = styled(ButtonSecondary)` +export const StyledAddRuleButton = styled(ButtonSecondary)` width: 150px; `; -export const AddConditionButton = styled(ButtonSecondary)` - border: none; - margin: 10px 0 10px 80px; +export const AddConditionButton = styled("div")` + width: 100%; + display: flex; + justify-content: center; + align-items: center; padding: 0; `; @@ -142,22 +152,22 @@ export const ConditionGroupContainer = styled.div({ } }); -export const FieldContainer = styled.div({ - position: "relative", - flex: "1 100%", - backgroundColor: "var(--mdc-theme-background)", - padding: "0 15px", - margin: 10, - borderRadius: 2, - border: "1px solid var(--mdc-theme-on-background)", - transition: "box-shadow 225ms", - color: "var(--mdc-theme-on-surface)", - cursor: "grab", - "&:hover": { - boxShadow: - "var(--mdc-theme-on-background) 1px 1px 1px, var(--mdc-theme-on-background) 1px 1px 2px" +export const FieldContainer = styled.div<{ noPadding?: boolean }>` + padding: ${props => (props.noPadding ? "0" : "0 15px")}; + position: relative; + flex: 1 100%; + background-color: var(--mdc-theme-background); + margin: 10px; + border-radius: 2px; + border: 1px solid var(--mdc-theme-on-background); + transition: box-shadow 225ms; + color: var(--mdc-theme-on-surface); + cursor: grab; + & :hover { + box-shadow: var(--mdc-theme-on-background) 1px 1px 1px, + var(--mdc-theme-on-background) 1px 1px 2px; } -}); +`; export const RowHandle = styled.div({ width: 30, diff --git a/packages/app-form-builder/src/components/Form/FormRender.tsx b/packages/app-form-builder/src/components/Form/FormRender.tsx index 7f0703c8dd1..dd537610fc5 100644 --- a/packages/app-form-builder/src/components/Form/FormRender.tsx +++ b/packages/app-form-builder/src/components/Form/FormRender.tsx @@ -1,6 +1,6 @@ +import React, { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { plugins } from "@webiny/plugins"; import cloneDeep from "lodash/cloneDeep"; -import React, { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { useApolloClient } from "@apollo/react-hooks"; import { createReCaptchaComponent, createTermsOfServiceComponent } from "./components"; import { @@ -9,7 +9,8 @@ import { onFormMounted, reCaptchaEnabled, termsOfServiceEnabled, - getNextStepIndex + getNextStepIndex, + onFormDataChange } from "./functions"; import { checkIfConditionsMet } from "./functions/getNextStepIndex"; @@ -48,7 +49,7 @@ const FormRender = (props: FbFormRenderComponentProps) => { const client = useApolloClient(); const data = props.data || ({} as FbFormModel); const [currentStepIndex, setCurrentStepIndex] = useState(0); - const [formState, setFormState] = useState(); + const [formState, setFormState] = useState>({}); const [layoutRenderKey, setLayoutRenderKey] = useState(new Date().getTime().toString()); const resetLayoutRenderKey = useCallback(() => { @@ -75,13 +76,17 @@ const FormRender = (props: FbFormRenderComponentProps) => { const [modifiedSteps, setModifiedSteps] = useState(data.steps); + // This variable will trigger update of "modifiedSteps", + // when we modify rules of the step. + const shouldUpdateModifiedSteps = onFormDataChange(data); + // We need this useEffect in case when user has deleted a step and he was on that step on the preview tab, // so it won't trigger an error when we trying to view the step that we have deleted, // we will simply change currentStep to the first step. useEffect(() => { setCurrentStepIndex(0); setModifiedSteps(data.steps); - }, [data.steps.length, data.fields.length]); + }, [data.steps.length, data.fields.length, shouldUpdateModifiedSteps]); const reCaptchaResponseToken = useRef(""); const termsOfServiceAccepted = useRef(false); @@ -133,17 +138,17 @@ const FormRender = (props: FbFormRenderComponentProps) => { const validateStepConditions = (formData: Record, stepIndex: number) => { const currentStep = resolvedSteps[stepIndex]; - const nextStepIndex = getNextStepIndex({ + const action = getNextStepIndex({ formData, rules: currentStep.rules }); - if (nextStepIndex === "submit") { + if (action.type === "submit") { setModifiedSteps([...modifiedSteps.slice(0, stepIndex + 1)]); - } else if (nextStepIndex !== "") { + } else if (action.type === "goToStep") { setModifiedSteps([ ...modifiedSteps.slice(0, stepIndex + 1), - ...steps.slice(+nextStepIndex) + ...steps.slice(+action.value) ]); } else { setModifiedSteps([ @@ -172,7 +177,7 @@ const FormRender = (props: FbFormRenderComponentProps) => { if (field.settings?.rules.length) { field.settings.rules.forEach((rule: FbFormRule) => { if (checkIfConditionsMet({ formData: formState, rule })) { - if (rule.action === "show") { + if (rule.action.value === "show") { fieldLayout.splice(fieldIndex, 1, ...field.settings.layout); } else { fieldLayout.splice(fieldIndex, field.settings.layout.length, [ diff --git a/packages/app-form-builder/src/components/Form/functions/getNextStepIndex.ts b/packages/app-form-builder/src/components/Form/functions/getNextStepIndex.ts index b8bc6c6baf6..f8116b4b974 100644 --- a/packages/app-form-builder/src/components/Form/functions/getNextStepIndex.ts +++ b/packages/app-form-builder/src/components/Form/functions/getNextStepIndex.ts @@ -1,6 +1,6 @@ -import { validation } from "@webiny/validation"; +import * as validators from "~/validators"; -import { FbFormCondition, FbFormRule } from "~/types"; +import { FbFormCondition, FbFormRule, FbFormRuleAction } from "~/types"; interface Props { formData: Record; @@ -12,7 +12,7 @@ const includesValidator = (filterValue: string, fieldValue: string) => { return; } - return fieldValue.includes(filterValue); + return validators.includes(fieldValue, filterValue); }; const startsWithValidator = (filterValue: string, fieldValue: string) => { @@ -20,14 +20,7 @@ const startsWithValidator = (filterValue: string, fieldValue: string) => { return; } - // Need to use try catch block because without it validation will throw and error, - // so user won't be able to interact with page. - // Same applies to all validation methods below. - try { - return validation.validateSync(fieldValue, `starts:${filterValue}`); - } catch { - return; - } + return validators.startsWith(fieldValue, filterValue); }; const endsWithValidator = (filterValue: string, fieldValue: string) => { @@ -35,11 +28,7 @@ const endsWithValidator = (filterValue: string, fieldValue: string) => { return; } - try { - return validation.validateSync(fieldValue, `ends:${filterValue}`); - } catch { - return; - } + return validators.endsWith(fieldValue, filterValue); }; const isValidator = (filterValue: string, fieldValue: string | string[]) => { @@ -47,16 +36,7 @@ const isValidator = (filterValue: string, fieldValue: string | string[]) => { return; } - try { - // This is check for checkboxes. - if (typeof fieldValue === "object") { - return fieldValue.includes(filterValue); - } else { - return validation.validateSync(fieldValue, `eq:${filterValue}`); - } - } catch { - return; - } + return validators.is(fieldValue, filterValue); }; const gtValidator = ({ @@ -72,14 +52,10 @@ const gtValidator = ({ return; } - try { - if (!equal) { - return validation.validateSync(fieldValue, `gt:${filterValue}`); - } else { - return validation.validateSync(fieldValue, `gte:${filterValue}`); - } - } catch { - return; + if (!equal) { + return validators.gt(fieldValue, filterValue); + } else { + return validators.gt(fieldValue, filterValue, true); } }; @@ -96,14 +72,10 @@ const ltValidator = ({ return; } - try { - if (!equal) { - return validation.validateSync(fieldValue, `lt:${filterValue}`); - } else { - return validation.validateSync(fieldValue, `lte:${filterValue}`); - } - } catch { - return; + if (!equal) { + return validators.lt(fieldValue, filterValue); + } else { + return validators.lt(fieldValue, filterValue, true); } }; @@ -120,17 +92,10 @@ const timeGtValidator = ({ return; } - try { - if (!equal) { - return validation.validateSync(Date.parse(fieldValue), `gt:${Date.parse(filterValue)}`); - } else { - return validation.validateSync( - Date.parse(fieldValue), - `gte:${Date.parse(filterValue)}` - ); - } - } catch { - return; + if (!equal) { + return validators.gt(Date.parse(fieldValue), Date.parse(filterValue)); + } else { + return validators.gt(Date.parse(fieldValue), Date.parse(filterValue), true); } }; @@ -147,17 +112,10 @@ const timeLtValidator = ({ return; } - try { - if (!equal) { - return validation.validateSync(Date.parse(fieldValue), `lt:${Date.parse(filterValue)}`); - } else { - return validation.validateSync( - Date.parse(fieldValue), - `lte:${Date.parse(filterValue)}` - ); - } - } catch { - return; + if (!equal) { + return validators.lt(Date.parse(fieldValue), Date.parse(filterValue)); + } else { + return validators.lt(Date.parse(fieldValue), Date.parse(filterValue), true); } }; @@ -205,7 +163,7 @@ const checkCondition = (condition: FbFormCondition, fieldValue: string) => { }; export const checkIfConditionsMet = ({ formData, rule }: Props) => { - if (rule.chain === "matchAll") { + if (rule.matchAll) { let isValid = true; rule.conditions.forEach(condition => { @@ -236,13 +194,16 @@ interface GetNextStepIndexProps { } export default ({ formData, rules }: GetNextStepIndexProps) => { - let nextStepIndex = ""; + let action: FbFormRuleAction = { + type: "", + value: "" + }; rules.forEach(rule => { if (checkIfConditionsMet({ formData, rule })) { - nextStepIndex = rule.action; + action = rule.action; return; } }); - return nextStepIndex; + return action; }; diff --git a/packages/app-form-builder/src/components/Form/functions/index.ts b/packages/app-form-builder/src/components/Form/functions/index.ts index e8fd490fd17..b68c448aea5 100644 --- a/packages/app-form-builder/src/components/Form/functions/index.ts +++ b/packages/app-form-builder/src/components/Form/functions/index.ts @@ -4,3 +4,5 @@ export { default as handleFormTriggers } from "./handleFormTriggers"; export { default as reCaptchaEnabled } from "./reCaptchaEnabled"; export { default as termsOfServiceEnabled } from "./termsOfServiceEnabled"; export { default as getNextStepIndex } from "./getNextStepIndex"; +export { default as usePrevious } from "./usePrevious"; +export { default as onFormDataChange } from "./onFormDataChange"; diff --git a/packages/app-form-builder/src/components/Form/functions/onFormDataChange.ts b/packages/app-form-builder/src/components/Form/functions/onFormDataChange.ts new file mode 100644 index 00000000000..caf61fef76c --- /dev/null +++ b/packages/app-form-builder/src/components/Form/functions/onFormDataChange.ts @@ -0,0 +1,8 @@ +import usePrevious from "./usePrevious"; +import isEqual from "lodash/isEqual"; + +export default function (value: T) { + const previousValue = usePrevious(value); + + return !isEqual(previousValue, value); +} diff --git a/packages/app-form-builder/src/components/Form/functions/usePrevious.ts b/packages/app-form-builder/src/components/Form/functions/usePrevious.ts new file mode 100644 index 00000000000..e7adc312f35 --- /dev/null +++ b/packages/app-form-builder/src/components/Form/functions/usePrevious.ts @@ -0,0 +1,11 @@ +import { useEffect, useRef } from "react"; + +export default function usePrevious(value: T): T | undefined { + const ref = useRef(); + + useEffect(() => { + ref.current = value; + }); + + return ref.current; +} diff --git a/packages/app-form-builder/src/components/Form/graphql.ts b/packages/app-form-builder/src/components/Form/graphql.ts index 9a65f38789a..ac839854de8 100644 --- a/packages/app-form-builder/src/components/Form/graphql.ts +++ b/packages/app-form-builder/src/components/Form/graphql.ts @@ -30,8 +30,11 @@ export const DATA_FIELDS = ` layout rules { title - action - chain + action { + type + value + } + matchAll id conditions { id diff --git a/packages/app-form-builder/src/types.ts b/packages/app-form-builder/src/types.ts index f368686f0e3..4f27022eabc 100644 --- a/packages/app-form-builder/src/types.ts +++ b/packages/app-form-builder/src/types.ts @@ -152,9 +152,14 @@ export interface FbFormStep { index: number; } +export type FbFormRuleAction = { + type: string; + value: string; +}; + export type FbFormRule = { - action: string; - chain: string; + action: FbFormRuleAction; + matchAll: boolean; id: string; title: string; conditions: FbFormCondition[]; diff --git a/packages/app-form-builder/src/validators/endsWith.ts b/packages/app-form-builder/src/validators/endsWith.ts new file mode 100644 index 00000000000..49ca8f34cb1 --- /dev/null +++ b/packages/app-form-builder/src/validators/endsWith.ts @@ -0,0 +1,9 @@ +export default (value: string, param: string) => { + if (!value || !param) { + return; + } + + const endOfTheString = value.slice(value.length - param.length); + + return endOfTheString === param; +}; diff --git a/packages/app-form-builder/src/validators/gt.ts b/packages/app-form-builder/src/validators/gt.ts new file mode 100644 index 00000000000..4ac87a9f31a --- /dev/null +++ b/packages/app-form-builder/src/validators/gt.ts @@ -0,0 +1,11 @@ +export default (value: string | number, param: string | number, equal = false) => { + if (!value || !param) { + return false; + } + + if (equal) { + return value >= param; + } else { + return value > param; + } +}; diff --git a/packages/app-form-builder/src/validators/includes.ts b/packages/app-form-builder/src/validators/includes.ts new file mode 100644 index 00000000000..2de844cd8ce --- /dev/null +++ b/packages/app-form-builder/src/validators/includes.ts @@ -0,0 +1,7 @@ +export default (value: string, param: string) => { + if (!value || !param) { + return false; + } + + return value.includes(param); +}; diff --git a/packages/app-form-builder/src/validators/index.ts b/packages/app-form-builder/src/validators/index.ts new file mode 100644 index 00000000000..569c60c2a18 --- /dev/null +++ b/packages/app-form-builder/src/validators/index.ts @@ -0,0 +1,8 @@ +import includes from "./includes"; +import startsWith from "./startsWith"; +import endsWith from "./endsWith"; +import is from "./is"; +import gt from "./gt"; +import lt from "./lt"; + +export { includes, startsWith, endsWith, is, gt, lt }; diff --git a/packages/app-form-builder/src/validators/is.ts b/packages/app-form-builder/src/validators/is.ts new file mode 100644 index 00000000000..28591003023 --- /dev/null +++ b/packages/app-form-builder/src/validators/is.ts @@ -0,0 +1,11 @@ +export default (value: string | string[], param: string) => { + if (!value || !param) { + return false; + } + + if (typeof param === "object") { + return value.includes(param); + } else { + return value === param; + } +}; diff --git a/packages/app-form-builder/src/validators/lt.ts b/packages/app-form-builder/src/validators/lt.ts new file mode 100644 index 00000000000..e6a5c5b668e --- /dev/null +++ b/packages/app-form-builder/src/validators/lt.ts @@ -0,0 +1,11 @@ +export default (value: string | number, param: string | number, equal = false) => { + if (!value || !param) { + return false; + } + + if (equal) { + return value <= param; + } else { + return value < param; + } +}; diff --git a/packages/app-form-builder/src/validators/startsWith.ts b/packages/app-form-builder/src/validators/startsWith.ts new file mode 100644 index 00000000000..9f001a731c3 --- /dev/null +++ b/packages/app-form-builder/src/validators/startsWith.ts @@ -0,0 +1,9 @@ +export default (value: string, param: string) => { + if (!value || !param) { + return; + } + + const startOfString = value.slice(0, param.length); + + return startOfString === param; +}; diff --git a/packages/app-page-builder-elements/package.json b/packages/app-page-builder-elements/package.json index c31fe172bec..ac91db25474 100644 --- a/packages/app-page-builder-elements/package.json +++ b/packages/app-page-builder-elements/package.json @@ -19,9 +19,7 @@ "@emotion/styled": "^11.10.6", "@webiny/lexical-editor": "0.0.0", "@webiny/theme": "0.0.0", - "@webiny/validation": "0.0.0", - "facepaint": "^1.2.1", - "lodash": "^4.17.21" + "facepaint": "^1.2.1" }, "peerDependencies": { "@editorjs/editorjs": "^2.20.1", diff --git a/packages/app-page-builder-elements/src/renderers/form/FormRender.tsx b/packages/app-page-builder-elements/src/renderers/form/FormRender.tsx index 8347afd438f..1db6fa7bb94 100644 --- a/packages/app-page-builder-elements/src/renderers/form/FormRender.tsx +++ b/packages/app-page-builder-elements/src/renderers/form/FormRender.tsx @@ -34,11 +34,20 @@ export interface FormRenderProps { loading: boolean; } +type FormRedirectTrigger = { + redirect: { + url: string; + }; +}; + const FormRender = (props: FormRenderProps) => { const { formData, createFormParams } = props; const { preview = false, formLayoutComponents = [] } = createFormParams; const [currentStepIndex, setCurrentStepIndex] = useState(0); const [formState, setFormState] = useState(); + const [formRedirectTrigger, setFormRedirectTrigger] = useState( + null + ); // We need to add index to every step so we can properly, // add or remove step from array of steps based on step rules. @@ -128,23 +137,33 @@ const FormRender = (props: FormRenderProps) => { const validateStepConditions = (formData: Record, stepIndex: number) => { const currentStep = resolvedSteps[stepIndex]; - const nextStepIndex = getNextStepIndex({ + const action = getNextStepIndex({ formData, rules: currentStep.rules }); - if (nextStepIndex === "submit") { + if (action.type === "submitAndRedirect") { setModifiedSteps([...modifiedSteps.slice(0, stepIndex + 1)]); - } else if (nextStepIndex !== "") { - setModifiedSteps([ - ...modifiedSteps.slice(0, stepIndex + 1), - ...steps.slice(+nextStepIndex) - ]); + setFormRedirectTrigger({ + redirect: { + url: action.value + } + }); } else { - setModifiedSteps([ - ...modifiedSteps.slice(0, stepIndex + 1), - ...steps.slice(currentStep.index + 1) - ]); + setFormRedirectTrigger(null); + if (action.type === "submit") { + setModifiedSteps([...modifiedSteps.slice(0, stepIndex + 1)]); + } else if (action.type === "goToStep") { + setModifiedSteps([ + ...modifiedSteps.slice(0, stepIndex + 1), + ...steps.slice(+action.value) + ]); + } else { + setModifiedSteps([ + ...modifiedSteps.slice(0, stepIndex + 1), + ...steps.slice(currentStep.index + 1) + ]); + } } }; @@ -166,7 +185,7 @@ const FormRender = (props: FormRenderProps) => { if (field.settings.rules !== undefined) { field.settings?.rules.forEach((rule: FormRule) => { if (checkIfConditionsMet({ formData: formState, rule })) { - if (rule.action === "show") { + if (rule.action.value === "show") { fieldLayout.splice(fieldIndex, 1, ...field.settings.layout); } else { fieldLayout.splice(fieldIndex, field.settings.layout.length, [ @@ -241,7 +260,6 @@ const FormRender = (props: FormRenderProps) => { }); return { ...values, ...overrides }; }; - const submit = async ( formSubmissionFieldValues: FormSubmissionFieldValues ): Promise => { @@ -267,6 +285,13 @@ const FormRender = (props: FormRenderProps) => { }; } + if (formRedirectTrigger) { + props.formData.triggers = { + ...props.formData.triggers, + ...formRedirectTrigger + }; + } + const formSubmission = await createFormSubmission({ props, formSubmissionFieldValues, diff --git a/packages/app-page-builder-elements/src/renderers/form/FormRender/functions/getNextStepIndex.ts b/packages/app-page-builder-elements/src/renderers/form/FormRender/functions/getNextStepIndex.ts index b8bc6c6baf6..82c5dd668de 100644 --- a/packages/app-page-builder-elements/src/renderers/form/FormRender/functions/getNextStepIndex.ts +++ b/packages/app-page-builder-elements/src/renderers/form/FormRender/functions/getNextStepIndex.ts @@ -1,5 +1,4 @@ -import { validation } from "@webiny/validation"; - +import * as validators from "~/validators"; import { FbFormCondition, FbFormRule } from "~/types"; interface Props { @@ -12,7 +11,7 @@ const includesValidator = (filterValue: string, fieldValue: string) => { return; } - return fieldValue.includes(filterValue); + return validators.includes(fieldValue, filterValue); }; const startsWithValidator = (filterValue: string, fieldValue: string) => { @@ -20,14 +19,7 @@ const startsWithValidator = (filterValue: string, fieldValue: string) => { return; } - // Need to use try catch block because without it validation will throw and error, - // so user won't be able to interact with page. - // Same applies to all validation methods below. - try { - return validation.validateSync(fieldValue, `starts:${filterValue}`); - } catch { - return; - } + return validators.startsWith(fieldValue, filterValue); }; const endsWithValidator = (filterValue: string, fieldValue: string) => { @@ -35,11 +27,7 @@ const endsWithValidator = (filterValue: string, fieldValue: string) => { return; } - try { - return validation.validateSync(fieldValue, `ends:${filterValue}`); - } catch { - return; - } + return validators.endsWith(fieldValue, filterValue); }; const isValidator = (filterValue: string, fieldValue: string | string[]) => { @@ -47,16 +35,7 @@ const isValidator = (filterValue: string, fieldValue: string | string[]) => { return; } - try { - // This is check for checkboxes. - if (typeof fieldValue === "object") { - return fieldValue.includes(filterValue); - } else { - return validation.validateSync(fieldValue, `eq:${filterValue}`); - } - } catch { - return; - } + return validators.is(fieldValue, filterValue); }; const gtValidator = ({ @@ -72,14 +51,10 @@ const gtValidator = ({ return; } - try { - if (!equal) { - return validation.validateSync(fieldValue, `gt:${filterValue}`); - } else { - return validation.validateSync(fieldValue, `gte:${filterValue}`); - } - } catch { - return; + if (!equal) { + return validators.gt(fieldValue, filterValue); + } else { + return validators.gt(fieldValue, filterValue, true); } }; @@ -96,14 +71,10 @@ const ltValidator = ({ return; } - try { - if (!equal) { - return validation.validateSync(fieldValue, `lt:${filterValue}`); - } else { - return validation.validateSync(fieldValue, `lte:${filterValue}`); - } - } catch { - return; + if (!equal) { + return validators.lt(fieldValue, filterValue); + } else { + return validators.lt(fieldValue, filterValue, true); } }; @@ -120,17 +91,10 @@ const timeGtValidator = ({ return; } - try { - if (!equal) { - return validation.validateSync(Date.parse(fieldValue), `gt:${Date.parse(filterValue)}`); - } else { - return validation.validateSync( - Date.parse(fieldValue), - `gte:${Date.parse(filterValue)}` - ); - } - } catch { - return; + if (!equal) { + return validators.gt(Date.parse(fieldValue), Date.parse(filterValue)); + } else { + return validators.gt(Date.parse(fieldValue), Date.parse(filterValue), true); } }; @@ -147,17 +111,10 @@ const timeLtValidator = ({ return; } - try { - if (!equal) { - return validation.validateSync(Date.parse(fieldValue), `lt:${Date.parse(filterValue)}`); - } else { - return validation.validateSync( - Date.parse(fieldValue), - `lte:${Date.parse(filterValue)}` - ); - } - } catch { - return; + if (!equal) { + return validators.lt(Date.parse(fieldValue), Date.parse(filterValue)); + } else { + return validators.lt(Date.parse(fieldValue), Date.parse(filterValue), true); } }; @@ -205,7 +162,7 @@ const checkCondition = (condition: FbFormCondition, fieldValue: string) => { }; export const checkIfConditionsMet = ({ formData, rule }: Props) => { - if (rule.chain === "matchAll") { + if (rule.matchAll) { let isValid = true; rule.conditions.forEach(condition => { @@ -236,13 +193,16 @@ interface GetNextStepIndexProps { } export default ({ formData, rules }: GetNextStepIndexProps) => { - let nextStepIndex = ""; + let action = { + type: "", + value: "" + }; rules.forEach(rule => { if (checkIfConditionsMet({ formData, rule })) { - nextStepIndex = rule.action; + action = rule.action; return; } }); - return nextStepIndex; + return action; }; diff --git a/packages/app-page-builder-elements/src/renderers/form/dataLoaders/graphql.ts b/packages/app-page-builder-elements/src/renderers/form/dataLoaders/graphql.ts index b7ad14e0d8b..bfc6669edd3 100644 --- a/packages/app-page-builder-elements/src/renderers/form/dataLoaders/graphql.ts +++ b/packages/app-page-builder-elements/src/renderers/form/dataLoaders/graphql.ts @@ -28,8 +28,11 @@ export const GET_PUBLISHED_FORM = /* GraphQL */ ` layout rules { title - action - chain + action { + type + value + } + matchAll id conditions { id diff --git a/packages/app-page-builder-elements/src/renderers/form/types.ts b/packages/app-page-builder-elements/src/renderers/form/types.ts index 75e0006ffa1..208b2892279 100644 --- a/packages/app-page-builder-elements/src/renderers/form/types.ts +++ b/packages/app-page-builder-elements/src/renderers/form/types.ts @@ -51,9 +51,14 @@ export interface FormDataStep { index: number; } +export type FbFormRuleAction = { + type: string; + value: string; +}; + export interface FormRule { - action: string; - chain: string; + action: FbFormRuleAction; + matchAll: boolean; id: string; title: string; conditions: FormCondition[]; diff --git a/packages/app-page-builder-elements/src/types.ts b/packages/app-page-builder-elements/src/types.ts index 3fab41f2489..173d8fd0deb 100644 --- a/packages/app-page-builder-elements/src/types.ts +++ b/packages/app-page-builder-elements/src/types.ts @@ -146,9 +146,14 @@ export type ElementStylesModifier = (args: { export type LinkComponent = React.ComponentType>; +export type FbFormRuleAction = { + type: string; + value: string; +}; + export type FbFormRule = { - action: string; - chain: string; + action: FbFormRuleAction; + matchAll: boolean; id: string; title: string; conditions: FbFormCondition[]; diff --git a/packages/app-page-builder-elements/src/validators/endsWith.ts b/packages/app-page-builder-elements/src/validators/endsWith.ts new file mode 100644 index 00000000000..49ca8f34cb1 --- /dev/null +++ b/packages/app-page-builder-elements/src/validators/endsWith.ts @@ -0,0 +1,9 @@ +export default (value: string, param: string) => { + if (!value || !param) { + return; + } + + const endOfTheString = value.slice(value.length - param.length); + + return endOfTheString === param; +}; diff --git a/packages/app-page-builder-elements/src/validators/gt.ts b/packages/app-page-builder-elements/src/validators/gt.ts new file mode 100644 index 00000000000..4ac87a9f31a --- /dev/null +++ b/packages/app-page-builder-elements/src/validators/gt.ts @@ -0,0 +1,11 @@ +export default (value: string | number, param: string | number, equal = false) => { + if (!value || !param) { + return false; + } + + if (equal) { + return value >= param; + } else { + return value > param; + } +}; diff --git a/packages/app-page-builder-elements/src/validators/includes.ts b/packages/app-page-builder-elements/src/validators/includes.ts new file mode 100644 index 00000000000..2de844cd8ce --- /dev/null +++ b/packages/app-page-builder-elements/src/validators/includes.ts @@ -0,0 +1,7 @@ +export default (value: string, param: string) => { + if (!value || !param) { + return false; + } + + return value.includes(param); +}; diff --git a/packages/app-page-builder-elements/src/validators/index copy.ts b/packages/app-page-builder-elements/src/validators/index copy.ts new file mode 100644 index 00000000000..569c60c2a18 --- /dev/null +++ b/packages/app-page-builder-elements/src/validators/index copy.ts @@ -0,0 +1,8 @@ +import includes from "./includes"; +import startsWith from "./startsWith"; +import endsWith from "./endsWith"; +import is from "./is"; +import gt from "./gt"; +import lt from "./lt"; + +export { includes, startsWith, endsWith, is, gt, lt }; diff --git a/packages/app-page-builder-elements/src/validators/index.ts b/packages/app-page-builder-elements/src/validators/index.ts new file mode 100644 index 00000000000..569c60c2a18 --- /dev/null +++ b/packages/app-page-builder-elements/src/validators/index.ts @@ -0,0 +1,8 @@ +import includes from "./includes"; +import startsWith from "./startsWith"; +import endsWith from "./endsWith"; +import is from "./is"; +import gt from "./gt"; +import lt from "./lt"; + +export { includes, startsWith, endsWith, is, gt, lt }; diff --git a/packages/app-page-builder-elements/src/validators/is.ts b/packages/app-page-builder-elements/src/validators/is.ts new file mode 100644 index 00000000000..28591003023 --- /dev/null +++ b/packages/app-page-builder-elements/src/validators/is.ts @@ -0,0 +1,11 @@ +export default (value: string | string[], param: string) => { + if (!value || !param) { + return false; + } + + if (typeof param === "object") { + return value.includes(param); + } else { + return value === param; + } +}; diff --git a/packages/app-page-builder-elements/src/validators/lt.ts b/packages/app-page-builder-elements/src/validators/lt.ts new file mode 100644 index 00000000000..e6a5c5b668e --- /dev/null +++ b/packages/app-page-builder-elements/src/validators/lt.ts @@ -0,0 +1,11 @@ +export default (value: string | number, param: string | number, equal = false) => { + if (!value || !param) { + return false; + } + + if (equal) { + return value <= param; + } else { + return value < param; + } +}; diff --git a/packages/app-page-builder-elements/src/validators/startsWith.ts b/packages/app-page-builder-elements/src/validators/startsWith.ts new file mode 100644 index 00000000000..9f001a731c3 --- /dev/null +++ b/packages/app-page-builder-elements/src/validators/startsWith.ts @@ -0,0 +1,9 @@ +export default (value: string, param: string) => { + if (!value || !param) { + return; + } + + const startOfString = value.slice(0, param.length); + + return startOfString === param; +}; diff --git a/packages/app-page-builder-elements/tsconfig.build.json b/packages/app-page-builder-elements/tsconfig.build.json index 500a685d91f..58a9bc11a38 100644 --- a/packages/app-page-builder-elements/tsconfig.build.json +++ b/packages/app-page-builder-elements/tsconfig.build.json @@ -3,8 +3,7 @@ "include": ["src"], "references": [ { "path": "../lexical-editor/tsconfig.build.json" }, - { "path": "../theme/tsconfig.build.json" }, - { "path": "../validation/tsconfig.build.json" } + { "path": "../theme/tsconfig.build.json" } ], "compilerOptions": { "rootDir": "./src", diff --git a/packages/app-page-builder-elements/tsconfig.json b/packages/app-page-builder-elements/tsconfig.json index ba1c716d308..33e3a51328c 100644 --- a/packages/app-page-builder-elements/tsconfig.json +++ b/packages/app-page-builder-elements/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.json", "include": ["src", "__tests__"], - "references": [{ "path": "../lexical-editor" }, { "path": "../theme" }, { "path": "../validation" }], + "references": [{ "path": "../lexical-editor" }, { "path": "../theme" }], "compilerOptions": { "rootDirs": ["./src", "./__tests__"], "outDir": "./dist", @@ -12,9 +12,7 @@ "@webiny/lexical-editor/*": ["../lexical-editor/src/*"], "@webiny/lexical-editor": ["../lexical-editor/src"], "@webiny/theme/*": ["../theme/src/*"], - "@webiny/theme": ["../theme/src"], - "@webiny/validation/*": ["../validation/src/*"], - "@webiny/validation": ["../validation/src"] + "@webiny/theme": ["../theme/src"] }, "baseUrl": "." } diff --git a/yarn.lock b/yarn.lock index abcbaf9705f..7322b4cd35b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16997,10 +16997,8 @@ __metadata: "@webiny/lexical-editor": 0.0.0 "@webiny/project-utils": 0.0.0 "@webiny/theme": 0.0.0 - "@webiny/validation": 0.0.0 execa: ^5.0.0 facepaint: ^1.2.1 - lodash: ^4.17.21 rimraf: ^3.0.2 ttypescript: ^1.5.12 typescript: 4.7.4 From 3a6a7dd15ed92c6f5ee0175571c3df19e3be4c93 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis Date: Fri, 12 Jan 2024 09:46:19 +0000 Subject: [PATCH 7/7] fix: removed unused styled component, changed format of the comments --- .../Context/functions/handleDeleteConditionGroup.ts | 6 ++---- .../FormEditor/Context/functions/handleMoveRow.ts | 12 +++++------- .../Tabs/EditTab/EditFieldDialog/RulesTab/Rule.tsx | 5 ++--- .../components/FormEditor/Tabs/EditTab/Styled.ts | 2 -- 4 files changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleDeleteConditionGroup.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleDeleteConditionGroup.ts index b575ac3aa2b..b89f11932d5 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleDeleteConditionGroup.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleDeleteConditionGroup.ts @@ -9,10 +9,8 @@ interface DeleteConditionGroupParams { conditionGroup: FbFormModelField; conditionGroupFields: FbFormModelField[]; } -/* - When we delete condition group we also need to delete fields inside of it, - because those fields belong directly (they are being stored in the setting of the condition group) to the condition group and not the step. -*/ +// When we delete condition group we also need to delete fields inside of it, +// because those fields belong directly (they are being stored in the setting of the condition group) to the condition group and not the step. export default (params: DeleteConditionGroupParams) => { const { data, formStep, stepFields, conditionGroup, conditionGroupFields } = params; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveRow.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveRow.ts index 5d82f01d501..7e87c26b594 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveRow.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveRow.ts @@ -10,13 +10,11 @@ interface HandleMoveRowParams { sourceRow: number; destinationRow: number; } -/* - The difference between moving row between steps, step and condition group or between two condition groups: - * When we move row between steps we are going to change property "layout" of those steps. - * When we move row between step and condition group we are going to change property "layout" of step and the property "layout" of the Condition Group. - * When we move row between condition groups we are going to change property "layout" of those condition groups and we don't need information about steps in which those, - those condition groups are being stored, because we are not affecting layout of steps in this case. -*/ +// The difference between moving row between steps, step and condition group or between two condition groups: +// * When we move row between steps we are going to change property "layout" of those steps. +// * When we move row between step and condition group we are going to change property "layout" of step and the property "layout" of the Condition Group. +// * When we move row between condition groups we are going to change property "layout" of those condition groups and we don't need information about steps in which those, +// those condition groups are being stored, because we are not affecting layout of steps in this case. export default ({ data, sourceRow, diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rule.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rule.tsx index 657e95c19d4..aa9f5abf0fb 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rule.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rule.tsx @@ -3,7 +3,6 @@ import React from "react"; import { BindComponent } from "@webiny/form/types"; import { RuleConditions, AddCondition } from "../RulesConditions"; -import { ConditionSetupWrapper } from "~/admin/components/FormEditor/Tabs/EditTab/Styled"; import { FbFormRule, FbFormModelField } from "~/types"; import { RuleActionSelect } from "../RuleActionSelect"; @@ -29,7 +28,7 @@ export const Rule = ({ ruleIndex, bind: Bind, fields, rules }: RuleProps) => { ) : ( <> {rule.conditions.map((condition, conditionIndex) => ( - +
{ rules={rules} onChange={onChange} /> - +
))} diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts index 0f2ba93776a..aefb8a17a49 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts @@ -66,8 +66,6 @@ export const RuleButtonDescription = styled.div` } `; -export const ConditionSetupWrapper = styled.div``; - export const StyledAddRuleButton = styled(ButtonSecondary)` width: 150px; `;