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 f9ce2c3ab9d..c3fc832a49d 100644 --- a/apps/theme/layouts/forms/DefaultFormLayout/Field.tsx +++ b/apps/theme/layouts/forms/DefaultFormLayout/Field.tsx @@ -33,6 +33,8 @@ export const Field = (props: FieldProps) => { 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..bc95a018235 100644 --- a/packages/api-form-builder/src/plugins/graphql/form.ts +++ b/packages/api-form-builder/src/plugins/graphql/form.ts @@ -57,6 +57,28 @@ const plugin: GraphQLSchemaPlugin = { input FbFormStepInput { title: String layout: [[String]] + rules: [FbFormRuleInput] + } + + input FbFormRuleInput { + title: String + action: FbFormRuleActionInput + matchAll: Boolean + id: String + conditions: [FbFormConditionInput] + isValid: Boolean + } + + input FbFormRuleActionInput { + type: String + value: String + } + + input FbFormConditionInput { + id: String + fieldName: String + filterType: String + filterValue: String } input FbFieldOptionsInput { @@ -79,6 +101,28 @@ const plugin: GraphQLSchemaPlugin = { type FbFormStepType { title: String layout: [[String]] + rules: [FbFormRuleType] + } + + type FbFormRuleType { + title: String + action: FbFormRuleActionType + matchAll: Boolean + id: String + conditions: [FbFormConditionType] + isValid: Boolean + } + + type FbFormRuleActionType { + type: String + value: String + } + + 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..f2d99ef2947 100644 --- a/packages/api-form-builder/src/types.ts +++ b/packages/api-form-builder/src/types.ts @@ -19,8 +19,30 @@ interface FbSubmissionMeta { interface FbFormStep { title: string; layout: string[][]; + rules: FbFormRule[]; } +export type FbFormRuleAction = { + type: string; + value: string; +}; + +export type FbFormRule = { + action: FbFormRuleAction; + matchAll: boolean; + 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..b89f11932d5 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleDeleteConditionGroup.ts @@ -0,0 +1,53 @@ +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 index 7d56aa0ffaa..7c3dabaf223 100644 --- 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 @@ -14,17 +14,18 @@ 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 (Condition Group yet to be implemented). + // 2. When we move field in scope of one Condition Group. moveField({ field, data, target, - destination + 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 (Condition Group yet to be implemented). + // 2. When we move field in scope of two different Condition Groups. moveFieldBetween({ data, field, diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/handleMoveField/getFieldPosition.ts b/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/handleMoveField/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 index 7220b2916cd..9e239a03480 100644 --- 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 @@ -1,4 +1,11 @@ -import { FbFormModel, FbFormModelField, FbFormStep, DropTarget, DropDestination } from "~/types"; +import { + FbFormModel, + FbFormModelField, + FbFormStep, + DropTarget, + DropDestination, + DropSource +} from "~/types"; import getFieldPosition from "./getFieldPosition"; /** @@ -11,15 +18,27 @@ interface MoveField { 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 targetStep = data.steps.find(s => s.id === destination.containerId) as FbFormStep; + const destinationLayout = + destination.containerType === "conditionGroup" + ? data.fields.find(f => f._id === destination.containerId)?.settings + : data.steps.find(step => step.id === destination.containerId); - targetStep.layout = targetStep?.layout.filter(row => row.length > 0); + if (destinationLayout) { + destinationLayout.layout = destinationLayout?.layout.filter( + (row: string[][]) => row.length > 0 + ); + } }; -const moveField = ({ data, field, destination }: MoveField) => { +const moveField = (params: MoveField) => { + const { data, field, destination } = params; const destinationContainerLayout = data.steps.find( step => step.id === destination.containerId ) as FbFormStep; @@ -28,6 +47,46 @@ const moveField = ({ data, field, destination }: MoveField) => { 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 => @@ -36,7 +95,7 @@ const moveField = ({ data, field, destination }: MoveField) => { const existingFieldPosition = getFieldPosition({ field: fieldId, - data: destinationContainerLayout + layout: destinationContainerLayout.layout }); if (existingFieldPosition) { 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 index 2dd07093458..7cddc1af504 100644 --- 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 @@ -1,12 +1,6 @@ -import { - FbFormModel, - FbFormModelField, - FbFormStep, - DropTarget, - DropDestination, - DropSource -} from "~/types"; +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. @@ -23,12 +17,11 @@ interface MoveFieldBetweenParams { const cleanupEmptyRows = (params: MoveFieldBetweenParams): void => { const { data, destination, source } = params; - const sourceContainerLayout = data.steps.find( - step => step.id === source.containerId - ) as FbFormStep; - const destinationContainerLayout = data.steps.find( - step => step.id === destination.containerId - ) as FbFormStep; + + const { + sourceContainer: sourceContainerLayout, + destinationContainer: destinationContainerLayout + } = getContainerLayout({ data, source, destination }); if (sourceContainerLayout) { sourceContainerLayout.layout = sourceContainerLayout?.layout.filter(row => row.length > 0); @@ -38,7 +31,14 @@ const cleanupEmptyRows = (params: MoveFieldBetweenParams): void => { 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; @@ -49,16 +49,14 @@ const moveFieldBetween = (params: MoveFieldBetweenParams) => { return; } - const sourceContainerLayout = data.steps.find( - step => step.id === source.containerId - ) as FbFormStep; - const destinationContainerLayout = data.steps.find( - step => step.id === destination.containerId - ) as FbFormStep; + const { + sourceContainer: sourceContainerLayout, + destinationContainer: destinationContainerLayout + } = getContainerLayout({ data, source, destination }); const existingPosition = getFieldPosition({ field: fieldId, - data: sourceContainerLayout || destinationContainerLayout + layout: sourceContainerLayout.layout || destinationContainerLayout.layout }); if (existingPosition) { 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 1e467c30714..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 @@ -1,4 +1,5 @@ -import { FbFormModel, FbFormStep, DropSource, DropDestination } from "~/types"; +import { FbFormModel, DropSource, DropDestination } from "~/types"; +import { getContainerLayout } from "./index"; import moveRow from "./handleMoveRow/moveRow"; import moveRowBetween from "./handleMoveRow/moveRowBetween"; @@ -9,7 +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. export default ({ data, sourceRow, @@ -18,27 +23,26 @@ export default ({ destination }: HandleMoveRowParams): void => { if (source.containerId === destination.containerId) { - // This condition should cover: - // 1. When we move rows in scope of one Step; - // 2. When we move rows in scope of one Condition Group (Condition Group yet to be implemented). - const sourceContainer = data.steps.find( - step => step.id === source.containerId - ) as FbFormStep; - moveRow({ - sourceRow, - destinationRow, - sourceContainer - }); + // 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: - // 1. When we move rows in scope of two different Steps; - // 2. When we move rows in scope of two different Condition Groups (Condition Group yet to be implemented). - const sourceContainer = data.steps.find( - step => step.id === source.containerId - ) as FbFormStep; - const destinationContainer = data.steps.find( - step => step.id === destination.containerId - ) as FbFormStep; + // 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, 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 715cc3b9ae8..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 @@ -2,3 +2,6 @@ export { default as deleteField } from "./deleteField"; export { default as moveStep } from "./moveStep"; 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/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..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 @@ -82,6 +82,22 @@ export const GET_FORM = gql` steps { title layout + rules { + title + action { + type + value + } + matchAll + id + conditions { + id + fieldName + filterType + filterValue + } + isValid + } } settings ${SETTINGS_FIELDS} triggers @@ -124,6 +140,22 @@ export const UPDATE_REVISION = gql` steps { title layout + rules { + title + action { + type + value + } + matchAll + 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 522651416e1..e0b8ee9dcf4 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 @@ -10,7 +10,14 @@ import { UpdateFormRevisionMutationResponse, UpdateFormRevisionMutationVariables } from "./graphql"; -import { deleteField, moveStep, handleMoveRow, handleMoveField } from "./functions"; +import { + deleteField, + moveStep, + handleMoveRow, + handleMoveField, + validateStepRule, + handleDeleteConditionGroup +} from "./functions"; import moveField from "./functions/handleMoveField/moveField"; import getFieldPosition from "./functions/handleMoveField/getFieldPosition"; import { plugins } from "@webiny/plugins"; @@ -19,6 +26,7 @@ import { FbFormModelField, FieldIdType, FieldLayoutPositionType, + FbFormRule, FbBuilderFieldPlugin, FbFormModel, FbUpdateFormInput, @@ -48,6 +56,8 @@ type State = FormEditorProviderContextState; export interface InsertFieldParams { data: FbFormModelField; target: DropTarget; + // "source" can be undefined if we create a custom field + source?: DropSource; destination: DropDestination; } @@ -58,6 +68,23 @@ export interface MoveFieldParams { destination: DropDestination; } +export interface DeleteFieldParams { + field: FbFormModelField; + containerType?: "conditionGroup" | "step"; + containerId: string; +} + +export interface DeleteConditionGroupParams { + formStep: FbFormStep; + conditionGroup: FbFormModelField; +} + +export interface UpdateStepParams { + id: string | null; + title: string; + rules: FbFormRule[]; +} + export type SaveFormResult = Promise<{ data: FbFormModel | null; error: FbErrorResponse | null }>; export interface FormEditor { @@ -67,13 +94,15 @@ export interface FormEditor { state: State; addStep: () => void; deleteStep: (id: string) => void; - updateStep: (title: string, id: string | null) => void; + updateStep: (params: UpdateStepParams) => void; getForm: (id: string) => Promise<{ data: GetFormQueryResponse }>; saveForm: (data: FbFormModel | null) => SaveFormResult; setData: (setter: SetDataCallable, saveForm?: boolean) => Promise; getFields: () => FbFormModelField[]; getStepFields: (targetStepId: string) => FbFormModelField[][]; getField: (query: Partial>) => FbFormModelField | null; + deleteConditionGroup: (params: DeleteConditionGroupParams) => void; + getConditionGroupLayoutFields: (conditionGroupId: string) => FbFormModelField[][]; getFieldPlugin: ( query: Partial> ) => FbBuilderFieldPlugin | null; @@ -87,11 +116,12 @@ export interface FormEditor { ) => void; moveStep: (params: MoveStepParams) => void; updateField: (field: FbFormModelField) => void; - deleteField: (field: FbFormModelField, targetStepId: string) => void; + deleteField: (params: DeleteFieldParams) => void; getFieldPosition: ( field: FieldIdType | FbFormModelField, data: FbFormStep ) => FieldLayoutPositionType | null; + validateStepRules: (data: FbFormModel) => FbFormModel; } const extractFieldErrors = (error: FbErrorResponse, form: FbFormModel): FormEditorFieldError[] => { @@ -173,9 +203,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 })) }; @@ -196,7 +227,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) { @@ -295,6 +326,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. @@ -349,46 +395,96 @@ export const useFormEditorFactory = ( self.setData(data => { data.steps.push({ id: mdbid(), - title: `New Step`, - layout: [] + title: `Step`, + layout: [], + rules: [], + index: data.steps.length }); - return data; + return { + ...self.validateStepRules(data) + }; }); }, - deleteStep: (targetStepId: string) => { - const stepFields = self.getStepFields(targetStepId).flat(1); + deleteStep: (stepId: string) => { + const stepFields = self.getStepFields(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.getStepFields(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, destination, target }) => { + insertField: ({ data, destination, target, source }) => { const field = cloneDeep(data); field._id = shortid.generate(); @@ -413,7 +509,8 @@ export const useFormEditorFactory = ( data, field, target, - destination + destination, + source }); // We are dropping a new field at the specified index. @@ -433,7 +530,9 @@ export const useFormEditorFactory = ( source, destination }); - return data; + return { + ...self.validateStepRules(data) + }; }); }, moveStep: ({ source, destination }) => { @@ -444,7 +543,9 @@ export const useFormEditorFactory = ( data: data.steps }); - return data; + return { + ...self.validateStepRules(data) + }; }); }, /** @@ -459,7 +560,9 @@ export const useFormEditorFactory = ( source, destination }); - return data; + return { + ...self.validateStepRules(data) + }; }); }, @@ -475,17 +578,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) + }; }); }, @@ -493,7 +628,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/Droppable.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Droppable.tsx index 5ca754881f1..ba7bc9365ea 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Droppable.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Droppable.tsx @@ -24,6 +24,7 @@ export interface IsVisibleCallableParams { type: string; isDragging: boolean; ui: DropTargetType; + name?: string; id?: string; pos: FieldLayoutPositionType; container?: Container; 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 cdd2f962fe8..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,14 +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/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", @@ -57,9 +63,39 @@ const EditFieldDialog = ({ field, onSubmit, ...props }: EditFieldDialogProps) => } 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(); @@ -86,7 +122,7 @@ const EditFieldDialog = ({ field, onSubmit, ...props }: EditFieldDialogProps) => {form => ( <> - + @@ -96,8 +132,13 @@ const EditFieldDialog = ({ field, onSubmit, ...props }: EditFieldDialogProps) => )} + {field?.type === "condition-group" && ( + + + + )} - + default: render = ( <> - + {plugins .byType("form-editor-field-type") @@ -149,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 new file mode 100644 index 00000000000..3c58a7503f9 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/DefaultBehaviour.tsx @@ -0,0 +1,49 @@ +import React from "react"; + +import { AccordionItem } from "@webiny/ui/Accordion"; +import { Select } from "@webiny/ui/Select"; + +import { + AccordionWithShadow, + DefaultBehaviourWrapper +} from "~/admin/components/FormEditor/Tabs/EditTab/Styled"; + +interface Props { + defaultBehaviourValue: string; + onChange: (params: string) => 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..c269b6dbd2a --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RuleActionSelect.tsx @@ -0,0 +1,60 @@ +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; + 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-right: 15px; +`; + +interface Props { + rule: FbFormRule; + onChange: (params: FbFormRule) => void; +} + +export const RuleActionSelect = ({ rule, onChange }: Props) => { + const onChangeAction = useCallback( + (value: string) => { + return onChange({ + ...rule, + action: { + type: "", + value + } + }); + }, + [rule.action.value, onChange] + ); + + return ( + + + + + + + ); +}; 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..b8bc7671884 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesConditions.tsx @@ -0,0 +1,171 @@ +import React, { useCallback } from "react"; +import { mdbid } from "@webiny/utils"; +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 { 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"; +import findIndex from "lodash/findIndex"; +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 FieldSelect = styled(Select)` + flex-basis: 35%; +`; + +const SelectCondition = styled(Select)` + flex-basis: 20%; +`; + +const ConditionValue = styled.div` + flex-basis: 35%; +`; + +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)[]; + rules: Array; + conditionIndex: number; + onChange: (params: FbFormRule) => void; +} + +export const RuleConditions = ({ + rules, + fields, + condition, + rule, + conditionIndex, + onChange +}: Props) => { + const fieldType = fields.find(field => field?.fieldId === condition?.fieldName)?.type || ""; + + 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 + }; + + rules[ruleIndex].conditions = conditions; + + return onChange({ + ...rule, + conditions + }); + }, + [condition, rule, onChange] + ); + + const onDeleteCondition = useCallback(() => { + return onChange({ + ...rule, + 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 ( + <> + + handleCondition("fieldName", value)} + > + {fields.map((field: any) => ( + + ))} + + 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 + })} + + } /> + + + {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/Rule.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rule.tsx new file mode 100644 index 00000000000..aa9f5abf0fb --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rule.tsx @@ -0,0 +1,49 @@ +import React from "react"; + +import { BindComponent } from "@webiny/form/types"; + +import { RuleConditions, AddCondition } from "../RulesConditions"; +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/EditTab.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditTab.tsx index 4fe6ced601a..e823ed7e909 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 @@ -3,7 +3,7 @@ import { EditContainer } from "./Styled"; import { useFormEditor } from "~/admin/components/FormEditor"; import { FbFormStep } from "~/types"; -import { EditFormStepDialog } from "./FormStep/EditFormStepDialog"; +import { EditFormStepDialog } from "./FormStep/EditFormStepDialog/EditFormStepDialog"; import { FieldErrors } from "./FieldErrors"; import { EditTabStep } from "./EditTabStep"; @@ -11,12 +11,15 @@ import { EditTabStep } from "./EditTabStep"; export const EditTab = () => { const { data, errors, updateStep } = useFormEditor(); - const [isEditStep, setIsEditStep] = useState<{ isOpened: boolean; id: string | null }>({ + const [editStep, setIsEditStep] = 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 || ""; return ( @@ -29,12 +32,15 @@ export const EditTab = () => { setIsEditStep={setIsEditStep} /> ))} - + {editStep.isOpened && ( + + )} ); }; 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..aac5267c669 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroup.tsx @@ -0,0 +1,93 @@ +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: string[]) => { + return row + .map((id: string) => { + return getField({ + _id: id + }); + }) + .filter(Boolean) as FbFormModelField[]; + }); + }; + + const fields = getFields().map((fields: FbFormModelField[]) => + fields.filter((field: FbFormModelField) => field._id !== conditionGroupField._id) + ) 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/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/useConditionGroup.ts b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/useConditionGroup.ts new file mode 100644 index 00000000000..f40df00b1ff --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/useConditionGroup.ts @@ -0,0 +1,98 @@ +import { useCallback, useContext } from "react"; +import { useFormEditor } from "~/admin/components/FormEditor/Context"; +import { DragObjectWithFieldInfo } from "~/admin/components/FormEditor/Droppable"; +import { DropDestination, DropPosition, DropSource, DropTarget, FbFormModelField } from "~/types"; +import { FormStepContext } from "../FormStepContext/FormStepContext"; +import { useFormStep } from "../useFormStep"; + +interface HandleDropParams { + item: DragObjectWithFieldInfo; + destinationPosition: DropPosition; + conditionGroup: FbFormModelField; +} + +export const useConditionGroup = () => { + const { data, moveRow, moveField, getFieldPlugin, insertField } = useFormEditor(); + const { editField } = useFormStep(); + + const { setDropDestination } = useContext(FormStepContext); + + const handleDrop = useCallback( + (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. + 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, + source, + destination + }); + + return undefined; + }, + [data] + ); + + return { + handleDrop, + editField + }; +}; 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..bd2e5c67853 --- /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/RulesTab"; + +import { FbFormModel, FbFormStep, FbFormRule } from "~/types"; +import { UpdateStepParams } from "~/admin/components/FormEditor/Context/useFormEditorFactory"; + +const EditStepDialog = styled(BaseDialog)` + font-size: 1.4rem; + color: #fff; + font-weight: 600; + & .mdc-dialog__surface { + width: 975px; + max-width: 975px; + } +`; + +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: (params: UpdateStepParams) => 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, + id: editStep.step.id + }; + + updateStep(data); + 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/RuleCondition.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleCondition.tsx new file mode 100644 index 00000000000..fa6ce18637c --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleCondition.tsx @@ -0,0 +1,111 @@ +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; + justify-content: space-between; + align-items: center; + margin: 15px 0; + & > span { + font-size: 22px; + } +`; + +const FieldSelect = styled(Select)` + flex-basis: 35%; +`; + +const SelectCondition = styled(Select)` + flex-basis: 20%; +`; + +const ConditionValue = styled.div` + flex-basis: 35%; +`; + +const ConditionsChain = styled.div` + text-align: center; + font-size: 12px; + margin-top: 10px; +`; + +export interface RuleConditionProps { + condition: FbFormCondition; + rule: FbFormRule; + fields: (FbFormModelField | null)[]; + conditionIndex: number; + onChange: (rule: FbFormRule) => void; + onDelete: () => void; +} + +export const RuleCondition = (params: RuleConditionProps) => { + 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 ( + <> + + handleOnChange("fieldName", value)} + > + {fields.map((field, index) => ( + + ))} + + 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/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/SelectRuleAction.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/SelectRuleAction.tsx new file mode 100644 index 00000000000..e0cf1d51c23 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/SelectRuleAction.tsx @@ -0,0 +1,106 @@ +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, FbFormRuleAction } from "~/types"; +import { Input } from "@webiny/ui/Input"; + +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-right: 15px; + width: 250px; +`; + +const ActionOptionSelect = styled(Select)` + width: 250px; +`; + +interface Props { + rule: FbFormRule; + steps: FbFormStep[]; + currentStep: FbFormStep; + ruleIndex: number; + onChange: (action: FbFormRuleAction) => void; +} + +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") { + onChange({ + type: "submit", + value: "" + }); + } + }, [ruleAction]); + + const onChangeAction = useCallback( + (actionValue: string) => { + return onChange({ + type: ruleAction, + value: actionValue + }); + }, + [ruleAction, rule.action.value] + ); + + return ( + + setRuleAction(val)} + > + {ruleActionOptions.map((action, index) => ( + + ))} + + {ruleAction === "goToStep" && ( + + {availableSteps.map((step, index) => ( + + ))} + + )} + {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 new file mode 100644 index 00000000000..40b9065fa7c --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/fieldsValidationConditions.ts @@ -0,0 +1,104 @@ +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" + }, + { + value: "submitAndRedirect", + label: "Submit & Redirect" + } +]; 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 db73c0fed22..ee2edca32a2 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,7 +1,7 @@ import React from "react"; import { FbFormModelField, FbFormStep } from "~/types"; -import { RowHandle, StyledAccordion, StyledAccordionItem, Wrapper } from "../Styled"; +import { RowHandle, StyledAccordion, StyledAccordionItem, Wrapper, RulesTag } from "../Styled"; import { Icon } from "@webiny/ui/Icon"; import { AccordionItem } from "@webiny/ui/Accordion"; @@ -13,6 +13,7 @@ import { FormStepWithFields } from "./FormStepWithFields"; import EditFieldDialog from "../EditFieldDialog"; import { useFormStep } from "./useFormStep"; +import { DeleteFieldParams } from "../../../Context/useFormEditorFactory"; export interface FormStepProps { title: string; @@ -22,7 +23,7 @@ export interface FormStepProps { onEdit: () => void; getStepFields: (stepId: string) => FbFormModelField[][]; updateField: (field: FbFormModelField) => void; - deleteField: (field: FbFormModelField, stepId: string) => void; + deleteField: (params: DeleteFieldParams) => void; } export const FormStep = (props: FormStepProps) => { @@ -44,6 +45,11 @@ export const FormStep = (props: FormStepProps) => { open={true} actions={ + {formStep.rules.length ? ( + {"Rules Attached"} + ) : ( + <> + )} } onClick={onEdit} /> } 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..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 @@ -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,12 +71,16 @@ 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 ( {({ drag }) => ( - + @@ -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/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 04c5dfc0b37..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 @@ -1,9 +1,23 @@ 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)` - background: var(--mdc-theme-background); +export const StyledAccordion = styled(Accordion)<{ margingap?: string }>` + background: white; box-shadow: none; + & > ul { + padding: 0 0 0 0 !important; + } + ${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)` @@ -12,7 +26,74 @@ export const StyledAccordionItem = styled(AccordionItem)` } `; -export const EditContainer = styled.div({ +export const RulesTag = 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 StyledAddRuleButton = styled(ButtonSecondary)` + width: 150px; +`; + +export const AddConditionButton = styled("div")` + width: 100%; + display: flex; + justify-content: center; + align-items: center; + 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" }); @@ -54,14 +135,12 @@ export const Row = styled.div({ overflowX: "auto" }); -export const FieldContainer = styled.div({ +export const ConditionGroupContainer = styled.div({ position: "relative", flex: "1 100%", - backgroundColor: "var(--mdc-theme-background)", + backgroundColor: "white", 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", @@ -71,6 +150,23 @@ export const FieldContainer = styled.div({ } }); +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, cursor: "grab", 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 8d306672f92..84a084c5f9c 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 = () => { 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/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/components/Form/FormRender.tsx b/packages/app-form-builder/src/components/Form/FormRender.tsx index ac3ca89e2f6..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 } from "react"; import { useApolloClient } from "@apollo/react-hooks"; import { createReCaptchaComponent, createTermsOfServiceComponent } from "./components"; import { @@ -8,9 +8,13 @@ import { handleFormTriggers, onFormMounted, reCaptchaEnabled, - termsOfServiceEnabled + termsOfServiceEnabled, + getNextStepIndex, + onFormDataChange } from "./functions"; +import { checkIfConditionsMet } from "./functions/getNextStepIndex"; + import { FormRenderPropsType, FbFormRenderComponentProps, @@ -20,7 +24,8 @@ import { FbFormModelField, FormRenderFbFormModelField, FbFormModel, - FbFormLayout + FbFormLayout, + FbFormRule } from "~/types"; import { FbFormLayoutPlugin } from "~/plugins"; @@ -44,12 +49,20 @@ const FormRender = (props: FbFormRenderComponentProps) => { 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(() => { 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; @@ -61,12 +74,19 @@ const FormRender = (props: FbFormRenderComponentProps) => { }); }, [data.id]); + 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 simpy change currentStep to the first step. + // we will simply change currentStep to the first step. useEffect(() => { setCurrentStepIndex(0); - }, [data.steps.length]); + setModifiedSteps(data.steps); + }, [data.steps.length, data.fields.length, shouldUpdateModifiedSteps]); const reCaptchaResponseToken = useRef(""); const termsOfServiceAccepted = useRef(false); @@ -76,7 +96,11 @@ const FormRender = (props: FbFormRenderComponentProps) => { } const goToNextStep = () => { - setCurrentStepIndex(prevStep => (prevStep += 1)); + setCurrentStepIndex(prevStep => { + const nextStep = (prevStep += 1); + validateStepConditions(formState, nextStep); + return nextStep; + }); }; const goToPreviousStep = () => { @@ -84,19 +108,24 @@ const FormRender = (props: FbFormRenderComponentProps) => { }; const formData: FbFormModel = cloneDeep(data); + const { fields, settings, steps } = formData; + const resolvedSteps = useMemo(() => { + return modifiedSteps || steps; + }, [steps, modifiedSteps]); + // 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 +135,70 @@ const FormRender = (props: FbFormRenderComponentProps) => { return fields.find(field => field.fieldId === id) || null; }; + const validateStepConditions = (formData: Record, stepIndex: number) => { + const currentStep = resolvedSteps[stepIndex]; + + const action = getNextStepIndex({ + formData, + rules: currentStep.rules + }); + + 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) + ]); + } + }; + // 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) { + if (field.settings?.rules.length) { + field.settings.rules.forEach((rule: FbFormRule) => { + if (checkIfConditionsMet({ formData: formState, rule })) { + if (rule.action.value === "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); + } + } + }); + } else { + if (field.settings.defaultBehaviour === "show") { + fieldLayout.splice(fieldIndex, 1, ...field.settings.layout); + } + } + } + }); + }); + const validatorPlugins = plugins.byType("fb-form-field-validator"); @@ -251,6 +339,8 @@ const FormRender = (props: FbFormRenderComponentProps) => { 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..f8116b4b974 --- /dev/null +++ b/packages/app-form-builder/src/components/Form/functions/getNextStepIndex.ts @@ -0,0 +1,209 @@ +import * as validators from "~/validators"; + +import { FbFormCondition, FbFormRule, FbFormRuleAction } from "~/types"; + +interface Props { + formData: Record; + rule: FbFormRule; +} + +const includesValidator = (filterValue: string, fieldValue: string) => { + if (fieldValue === null) { + return; + } + + return validators.includes(fieldValue, filterValue); +}; + +const startsWithValidator = (filterValue: string, fieldValue: string) => { + if (fieldValue === null) { + return; + } + + return validators.startsWith(fieldValue, filterValue); +}; + +const endsWithValidator = (filterValue: string, fieldValue: string) => { + if (fieldValue === null) { + return; + } + + return validators.endsWith(fieldValue, filterValue); +}; + +const isValidator = (filterValue: string, fieldValue: string | string[]) => { + if (fieldValue === null) { + return; + } + + return validators.is(fieldValue, filterValue); +}; + +const gtValidator = ({ + filterValue, + fieldValue, + equal = false +}: { + filterValue: string; + fieldValue: string; + equal?: boolean; +}) => { + if (fieldValue === null) { + return; + } + + if (!equal) { + return validators.gt(fieldValue, filterValue); + } else { + return validators.gt(fieldValue, filterValue, true); + } +}; + +const ltValidator = ({ + filterValue, + fieldValue, + equal = false +}: { + filterValue: string; + fieldValue: string; + equal?: boolean; +}) => { + if (fieldValue === null) { + return; + } + + if (!equal) { + return validators.lt(fieldValue, filterValue); + } else { + return validators.lt(fieldValue, filterValue, true); + } +}; + +const timeGtValidator = ({ + filterValue, + fieldValue, + equal = false +}: { + filterValue: string; + fieldValue: string; + equal?: boolean; +}) => { + if (fieldValue === null) { + return; + } + + if (!equal) { + return validators.gt(Date.parse(fieldValue), Date.parse(filterValue)); + } else { + return validators.gt(Date.parse(fieldValue), Date.parse(filterValue), true); + } +}; + +const timeLtValidator = ({ + filterValue, + fieldValue, + equal = false +}: { + filterValue: string; + fieldValue: string; + equal?: boolean; +}) => { + if (fieldValue === null) { + return; + } + + if (!equal) { + return validators.lt(Date.parse(fieldValue), Date.parse(filterValue)); + } else { + return validators.lt(Date.parse(fieldValue), Date.parse(filterValue), true); + } +}; + +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.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 action: FbFormRuleAction = { + type: "", + value: "" + }; + rules.forEach(rule => { + if (checkIfConditionsMet({ formData, rule })) { + action = rule.action; + return; + } + }); + + 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 0812222fcca..b68c448aea5 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,6 @@ 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"; +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 568e3656d89..ac839854de8 100644 --- a/packages/app-form-builder/src/components/Form/graphql.ts +++ b/packages/app-form-builder/src/components/Form/graphql.ts @@ -28,6 +28,22 @@ export const DATA_FIELDS = ` steps { title layout + rules { + title + action { + type + value + } + matchAll + id + conditions { + id + fieldName + filterType + filterValue + } + isValid + } } triggers settings { diff --git a/packages/app-form-builder/src/types.ts b/packages/app-form-builder/src/types.ts index 812d8d16932..4f27022eabc 100644 --- a/packages/app-form-builder/src/types.ts +++ b/packages/app-form-builder/src/types.ts @@ -148,8 +148,31 @@ export interface FbFormStep { id: string; title: string; layout: FbFormModelFieldsLayout; + rules: FbFormRule[]; + index: number; } +export type FbFormRuleAction = { + type: string; + value: string; +}; + +export type FbFormRule = { + action: FbFormRuleAction; + matchAll: boolean; + id: string; + title: string; + conditions: FbFormCondition[]; + isValid: boolean; +}; + +export type FbFormCondition = { + id: string; + fieldName: string; + filterType: string; + filterValue: string; +}; + export interface MoveStepParams { source: Omit; destination: { @@ -345,6 +368,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-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/src/renderers/form/FormRender.tsx b/packages/app-page-builder-elements/src/renderers/form/FormRender.tsx index 93d4d29bd35..1db6fa7bb94 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 { @@ -33,29 +34,57 @@ 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. + 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 +134,73 @@ const FormRender = (props: FormRenderProps) => { return fields.find(field => field.fieldId === id) || null; }; + const validateStepConditions = (formData: Record, stepIndex: number) => { + const currentStep = resolvedSteps[stepIndex]; + + const action = getNextStepIndex({ + formData, + rules: currentStep.rules + }); + + if (action.type === "submitAndRedirect") { + setModifiedSteps([...modifiedSteps.slice(0, stepIndex + 1)]); + setFormRedirectTrigger({ + redirect: { + url: action.value + } + }); + } else { + 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) + ]); + } + } + }; + 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.value === "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 => { @@ -166,7 +260,6 @@ const FormRender = (props: FormRenderProps) => { }); return { ...values, ...overrides }; }; - const submit = async ( formSubmissionFieldValues: FormSubmissionFieldValues ): Promise => { @@ -192,6 +285,13 @@ const FormRender = (props: FormRenderProps) => { }; } + if (formRedirectTrigger) { + props.formData.triggers = { + ...props.formData.triggers, + ...formRedirectTrigger + }; + } + const formSubmission = await createFormSubmission({ props, formSubmissionFieldValues, @@ -222,6 +322,8 @@ const FormRender = (props: FormRenderProps) => { 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..82c5dd668de --- /dev/null +++ b/packages/app-page-builder-elements/src/renderers/form/FormRender/functions/getNextStepIndex.ts @@ -0,0 +1,208 @@ +import * as validators from "~/validators"; +import { FbFormCondition, FbFormRule } from "~/types"; + +interface Props { + formData: Record; + rule: FbFormRule; +} + +const includesValidator = (filterValue: string, fieldValue: string) => { + if (fieldValue === null) { + return; + } + + return validators.includes(fieldValue, filterValue); +}; + +const startsWithValidator = (filterValue: string, fieldValue: string) => { + if (fieldValue === null) { + return; + } + + return validators.startsWith(fieldValue, filterValue); +}; + +const endsWithValidator = (filterValue: string, fieldValue: string) => { + if (fieldValue === null) { + return; + } + + return validators.endsWith(fieldValue, filterValue); +}; + +const isValidator = (filterValue: string, fieldValue: string | string[]) => { + if (fieldValue === null) { + return; + } + + return validators.is(fieldValue, filterValue); +}; + +const gtValidator = ({ + filterValue, + fieldValue, + equal = false +}: { + filterValue: string; + fieldValue: string; + equal?: boolean; +}) => { + if (fieldValue === null) { + return; + } + + if (!equal) { + return validators.gt(fieldValue, filterValue); + } else { + return validators.gt(fieldValue, filterValue, true); + } +}; + +const ltValidator = ({ + filterValue, + fieldValue, + equal = false +}: { + filterValue: string; + fieldValue: string; + equal?: boolean; +}) => { + if (fieldValue === null) { + return; + } + + if (!equal) { + return validators.lt(fieldValue, filterValue); + } else { + return validators.lt(fieldValue, filterValue, true); + } +}; + +const timeGtValidator = ({ + filterValue, + fieldValue, + equal = false +}: { + filterValue: string; + fieldValue: string; + equal?: boolean; +}) => { + if (fieldValue === null) { + return; + } + + if (!equal) { + return validators.gt(Date.parse(fieldValue), Date.parse(filterValue)); + } else { + return validators.gt(Date.parse(fieldValue), Date.parse(filterValue), true); + } +}; + +const timeLtValidator = ({ + filterValue, + fieldValue, + equal = false +}: { + filterValue: string; + fieldValue: string; + equal?: boolean; +}) => { + if (fieldValue === null) { + return; + } + + if (!equal) { + return validators.lt(Date.parse(fieldValue), Date.parse(filterValue)); + } else { + return validators.lt(Date.parse(fieldValue), Date.parse(filterValue), true); + } +}; + +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.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 action = { + type: "", + value: "" + }; + rules.forEach(rule => { + if (checkIfConditionsMet({ formData, rule })) { + action = rule.action; + return; + } + }); + + return action; +}; 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..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 @@ -26,6 +26,22 @@ export const GET_PUBLISHED_FORM = /* GraphQL */ ` steps { title layout + rules { + title + action { + type + value + } + matchAll + 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 6fa597b554b..208b2892279 100644 --- a/packages/app-page-builder-elements/src/renderers/form/types.ts +++ b/packages/app-page-builder-elements/src/renderers/form/types.ts @@ -47,6 +47,29 @@ export interface FormDataStep { id: string; title: string; layout: string[][]; + rules: FormRule[]; + index: number; +} + +export type FbFormRuleAction = { + type: string; + value: string; +}; + +export interface FormRule { + action: FbFormRuleAction; + matchAll: boolean; + id: string; + title: string; + conditions: FormCondition[]; + isValid: boolean; +} + +export interface FormCondition { + id: string; + fieldName: string; + filterType: string; + filterValue: string; } export interface FormData { @@ -87,6 +110,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 cdc30e46dd3..173d8fd0deb 100644 --- a/packages/app-page-builder-elements/src/types.ts +++ b/packages/app-page-builder-elements/src/types.ts @@ -146,6 +146,27 @@ export type ElementStylesModifier = (args: { export type LinkComponent = React.ComponentType>; +export type FbFormRuleAction = { + type: string; + value: string; +}; + +export type FbFormRule = { + action: FbFormRuleAction; + matchAll: boolean; + 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 { 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/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] + "."); +};