ZupIT / beagle

A framework to help implement Server-Driven UI in your apps natively.
https://docs.usebeagle.io
Apache License 2.0
686 stars 90 forks source link

EPIC - Form validation #883

Closed Tiagoperes closed 3 years ago

Tiagoperes commented 4 years ago

Problem

Today we have two default components to create forms:

These two components, the way that they work today, don't support form validation. It is perfectly possible to validade a form with beagle, but it requires custom:components to do so.

We believe form validation to be an essencial part of a front-end framework, and for this reason, we wish to enhance both the SimpleForm and the TextInput so we can support form validation out of the box (without the need for customization).

Also, we don't want to transform the simple form into the old form component. We don't dismiss the idea of creating a less customizable, but more easy to use form, but this would be another component and is not the topic of this epic. What we want to achieve here is to support form validation by modifying the SimpleForm and the TextInput as little as we can.

The TextInput component

First, we need to be able to tell the TextInput it has an error. For this, a new property should be added to the component: error. error is a string and will mostly of the time be calculated via an expression (Bindable<string>). If error is null or an empty string, it means the form has no errors.

example-error

In the example above, the TextInput has its property error resolved to the string "The password must have at least 6 characters."

If we only have the error property, the initial state of a form would be to show errors everywhere. This would happen because, considering all the fields are empty, all validations will fail. Se the example below:

example-full-error

This is not what we want. It is true that all fields have errors, but we shouldn't show them until the user interacts with the field. Most of the times, we want the errors on a field to be showed only after the onBlur effect, i.e. after the input loses focus.

From this behavior, we take that the TextInput should have one more property: showErrors: boolean. This means we can control wether to show or not the error of a field. To implement the behavior of most validations, showErrors would start false for every TextInput and there would be an action in the onBlur of each TextInput to change its showError from false to true.

In summary, the following properties should be added to the component TextInput:

error?: Bindable<string>,
showError?: Bindable<boolean>

The SimpleForm component

To make the validation work, the SimpleForm must do two things:

  1. Prevent form submissions when there are validation errors;
  2. If there are validation errors, show them when the user tries to submit the form.

We need the first one because a form cannot be submitted with errors. The only thing we need to do is: on the onSubmit event, before calling the action onSubmit, we check if there are children where the error property is not empty. If there's at least one child with error, the form is not submitted.

We need the second one, because, if the user tries, for instance, to submit an empty form (initial state), it won't be possible, because there will be validation errors everywhere. Because no TextField has been interacted with yet, in all of them showError will be false. This is a terrible behavior, because the form won't get submitted and the user won't know why. To fix this, we need to transform every showError into true after a failed attempt to submit the form.

To make this possible, we must add a new event to the form: onValidationError, this event is executed every time a form is submitted, but because of a validation error, the onSubmit event is not run. This makes it possible to implement any behavior the user wishes to implement.

In summary, the following property should be added to the component SimpleForm:

onValidationError?: BeagleAction[]

And the following behavior should change:

When the form is submitted, before executing the action provided for onSubmit, we should verify if any child of the form has error different than null or an empty string. If there's at least one child with error, instead of executing onSubmit, onValidationError should be executed.

Example

The example below shows a sign up form where it must be typed a username (required), a password (at least 6 characters) and a password confirmation (must be equal to to the password).

The following image shows the form in a state where every field has an error and showError resolves to true in each one of them:

json-result

The json that renders the view with the behavior above (click the submit button when all fields are empty):

{
  "_beagleComponent_":"beagle:simpleForm",
  "context":{
    "id":"form",
    "value":{
      "data":{
        "username":"",
        "password":"",
        "passwordConfirmation":""
      },
      "showFormErrors": false,
      "showError": {
        "username": false,
        "password": false,
        "passwordConfirmation": false
      }
    }
  },
  "onValidationError": [
    {
      "_beagleAction_":"beagle:setContext",
      "contextId":"form",
      "path":"showFormErrors",
      "value":true
    }
  ],
  "onSubmit":[
    {
      "_beagleAction_":"beagle:alert",
      "message":"form submitted!"
    }
  ],
  "children":[
    {
      "_beagleComponent_":"beagle:container",
      "children":[
        {
          "_beagleComponent_":"beagle:text",
          "styleId":"title",
          "text":"Account"
        },
        {
          "_beagleComponent_":"beagle:container",
          "children":[
            {
              "_beagleComponent_":"beagle:textInput",
              "placeholder":"Username",
              "value":"@{form.data.username}",
              "showError":"@{or(form.showFormErrors, form.showError.username)}",
              "error":"@{condition(isEmpty(form.data.username), 'The username is required.', '')}",
              "onChange":[
                {
                  "_beagleAction_":"beagle:setContext",
                  "contextId":"form",
                  "path":"data.username",
                  "value":"@{onChange.value}"
                }
              ],
              "onBlur":[
                {
                  "_beagleAction_":"beagle:setContext",
                  "contextId":"form",
                  "path":"showError.username",
                  "value":true
                }
              ]
            }
          ]
        },
        {
          "_beagleComponent_":"beagle:container",
          "style":{
            "margin":{
              "left":{
                "value":0,
                "type":"REAL"
              },
              "right":{
                "value":0,
                "type":"REAL"
              },
              "top":{
                "value":30,
                "type":"REAL"
              },
              "bottom":{
                "value":30,
                "type":"REAL"
              }
            },
            "flex":{
              "flexDirection":"ROW",
              "flex":1
            }
          },
          "children":[
            {
              "style":{
                "margin":{
                  "left":{
                    "value":0,
                    "type":"REAL"
                  },
                  "right":{
                    "value":15,
                    "type":"REAL"
                  }
                },
                "flex":{
                  "flex":1
                }
              },
              "_beagleComponent_":"beagle:textInput",
              "placeholder":"Password",
              "value":"@{form.data.password}",
              "showError":"@{or(form.showFormErrors, form.showError.password)}",
              "error":"@{condition(lt(length(form.data.password), 6), 'The password must have at least 6 characters.', '')}",
              "type":"PASSWORD",
              "onChange":[
                {
                  "_beagleAction_":"beagle:setContext",
                  "contextId":"form",
                  "path":"data.password",
                  "value":"@{onChange.value}"
                }
              ],
              "onBlur":[
                {
                  "_beagleAction_":"beagle:setContext",
                  "contextId":"form",
                  "path":"showError.password",
                  "value":true
                }
              ]
            },
            {
              "_beagleComponent_":"beagle:textInput",
              "placeholder":"Confirm your password",
              "value":"@{form.data.passwordConfirmation}",
              "showError":"@{or(form.showFormErrors, form.showError.passwordConfirmation)}",
              "error":"@{condition(eq(form.data.password, form.data.passwordConfirmation), '', 'The password and its confirmation must match.')}",
              "type":"PASSWORD",
              "onChange":[
                {
                  "_beagleAction_":"beagle:setContext",
                  "contextId":"form",
                  "path":"data.passwordConfirmation",
                  "value":"@{onChange.value}"
                }
              ],
              "onBlur":[
                {
                  "_beagleAction_":"beagle:setContext",
                  "contextId":"form",
                  "path":"showError.passwordConfirmation",
                  "value":true
                }
              ]
            }
          ]
        }
      ]
    },
    {
      "_beagleComponent_":"beagle:container",
      "children":[
        {
          "_beagleComponent_":"beagle:button",
          "text":"Previous",
          "disabled":true
        },
        {
          "_beagleComponent_":"beagle:button",
          "text":"Next",
          "onPress":[
            {
              "_beagleAction_":"beagle:submitForm"
            }
          ]
        }
      ]
    }
  ]
}

Final thoughts

This is still more complex than we desire, but it's a first step towards supporting validations natively. Our next step is to create a new component where we can use shortcuts to all these actions and contexts, see an idea below:

{
  "_beagleComponent_": "beagle:smartForm",
  "name": "form",
  "onSubmit": [],
  "children": [
    {
      "_beagleComponent_":"beagle:smartTextInput",
      "placeholder":"Username",
      "model":"username",
      "required":true
    },
   {
      "_beagleComponent_":"beagle:smartTextInput",
      "placeholder":"Password",
      "model":"password",
      "type":"PASSWORD",
      "minLenght":6
    },
    {
      "_beagleComponent_":"beagle:smartTextInput",
      "placeholder":"Confirm your password",
      "model":"passwordConfirmation",
      "type":"PASSWORD",
      "error":"@{condition(eq(form.data.password, form.data.passwordConfirmation), '', 'The password and its confirmation must match.')}"
    }
  ]
}

Attention: this is just an idea for a future epic, it shouldn't be implemented now.

uziasferreirazup commented 3 years ago

@Tiagoperes I adjust your json to use simpleForm and there is some missing close relativs:


         {
  "_beagleComponent_": "beagle:simpleForm",
  "context": {
    "id": "form",
    "value": {
      "data": {
        "username": "",
        "password": "",
        "passwordConfirmation": ""
      },
      "showFormErrors": false,
      "showError": {
        "username": false,
        "password": false,
        "passwordConfirmation": false
      }
    }
  },
  "onValidationError": [
    {
      "_beagleAction_": "beagle:setContext",
      "contextId": "form",
      "path": "showFormErrors",
      "value": true
    }
  ],
  "onSubmit": [
    {
      "_beagleAction_": "beagle:alert",
      "message": "form submitted!"
    }
  ],
  "children": [
    {
      "_beagleComponent_": "beagle:container",
      "children": [
        {
          "_beagleComponent_": "beagle:text",
          "styleId": "title",
          "text": "Account"
        },
        {
          "_beagleComponent_": "beagle:container",
          "children": [
            {
              "_beagleComponent_": "beagle:textInput",
              "placeholder": "Username",
              "value": "@{form.data.username}",
              "showError": "@{or(form.showFormErrors, form.showError.username)}",
              "error": "@{condition(isEmpty(form.data.username), 'The username is required.', '')}",
              "onChange": [
                {
                  "_beagleAction_": "beagle:setContext",
                  "contextId": "form",
                  "path": "data.username",
                  "value": "@{onChange.value}"
                }
              ],
              "onBlur": [
                {
                  "_beagleAction_": "beagle:setContext",
                  "contextId": "form",
                  "path": "showError.username",
                  "value": true
                }
              ]
            }
          ]
        },
        {
          "_beagleComponent_": "beagle:container",
          "children": [
            {
              "_beagleComponent_": "beagle:textInput",
              "placeholder": "Password",
              "value": "@{form.data.password}",
              "showError": "@{or(form.showFormErrors, form.showError.password)}",
              "error": "@{condition(lt(length(form.data.password), 6), 'The password must have at least 6 characters.', '')}",
              "type": "PASSWORD",
              "onChange": [
                {
                  "_beagleAction_": "beagle:setContext",
                  "contextId": "form",
                  "path": "data.password",
                  "value": "@{onChange.value}"
                }
              ],
              "onBlur": [
                {
                  "_beagleAction_": "beagle:setContext",
                  "contextId": "form",
                  "path": "showError.password",
                  "value": true
                }
              ]
            },
            {
              "_beagleComponent_": "beagle:textInput",
              "placeholder": "Confirm your password",
              "value": "@{form.data.passwordConfirmation}",
              "showError": "@{or(form.showFormErrors, form.showError.passwordConfirmation)}",
              "error": "@{condition(eq(form.data.password, form.data.passwordConfirmation), '', 'The password and its confirmation must match.')}",
              "type": "PASSWORD",
              "onChange": [
                {
                  "_beagleAction_": "beagle:setContext",
                  "contextId": "form",
                  "path": "data.passwordConfirmation",
                  "value": "@{onChange.value}"
                }
              ],
              "onBlur": [
                {
                  "_beagleAction_": "beagle:setContext",
                  "contextId": "form",
                  "path": "showError.passwordConfirmation",
                  "value": true
                }
              ]
            }
          ]
        }
      ]
    },
    {
      "_beagleComponent_": "beagle:container",
      "children": [
        {
          "_beagleComponent_": "beagle:button",
          "text": "Previous",
          "disabled": true
        },
        {
          "_beagleComponent_": "beagle:button",
          "text": "Next",
          "onPress": [
            {
              "_beagleAction_": "beagle:submitForm"
            }
          ]
        }
      ]
    }
  ]
}