Search code examples
vue.jsvalidation

Vue validation without form


I'm trying to validate input fields inside children components but using steps (each component it's a step), the action buttons (back and next) it's in parent component, i'm confused (logically) how's the best way to do that, i don't know if i use a library or the best way is validate inside each component

The active component is based in v-show="step" in each children component I'm using a watch for step to control all actions in the children components and trigger children functions using $refs if is necessary. Form.vue

        <StepGuide class="mb-5" :step="step"></StepGuide>
        <StepOne ref="step1" v-show="step == 1"></StepOne>
        <StepTwo ref="step2" v-show="step == 2"></StepTwo>
        <StepThree ref="step3" v-show="step == 3"></StepThree>
        <StepFour ref="step4" v-show="step == 4"></StepFour>
        <StepFive ref="step5" v-show="step == 5"></StepFive>
        <div class="row justify-content-center">
            <div class="col-sm-3 text-center mt-4 d-flex justify-content-around">
                <button type="button" @click="step--" key="back" v-if="step > 1" class="px-4 py-2 rounded btn-add">Anterior</button>
                <button type="button" @click="step++" key="next" v-if="step < 5" class="px-4 py-2 rounded btn-add">Próximo</button>
            </div>
        </div>

StepOne Component Input Example

     <div class="col-sm-3">
        <label for="customer">Nome do cliente *</label>
        <input
            name="customer_name"
            class="form-control"
            id="customerName" 
            type="text"
            v-model="customer.name"
            
        />
    </div>

Solution

  • If you take a look at the Vuetify source code (it's a UI library) - you will see how they use the provide + inject pattern where the form (a parent component) provides a function which is injected by each form field component which is capable of validating its own content. The form fields call the provided (and injected) function with a boolean argument TRUE to notify the form about their existence (because they might not be a direct parent of the form) and with FALSE to unsubscribe from the form (in beforeUnmount hook).
    The form (through this notification function) collects a list of the form field components and when the form must be validated - it calls the validate() method of each subscribed form field. The form fields on the other hand take care of the validation of their own content - and show appropriate error messages. The form fields are also expected to provide a method reset to clear the validation errors.

    You can start with the following example and extend it for your own use case.

    <template>
      <form v-bind="$attrs" v-on="listeners">
        <slot />
      </form>
    </template>
    
    <script>
    export default
    {
      name: 'FormValidator',
      provide()
      {
        return {
          form: this
        };
      },
      inheritAttrs: false,
      data()
      {
        return {
          fieldList: [],
        };
      },
      computed:
        {
          listeners()
          {
            return {
              ...this.$listeners,
              submit: (evt) =>
              {
                evt.preventDefault();
                this.fieldList.forEach(item =>
                {
                  item.setDirty(true);
                });
                this.$nextTick(() =>
                {
                  this.$emit('submit', evt);
                });
              }
            };
          }
        },
      methods:
        {
          register(field)
          {
            this.fieldList.push(field);
          },
          unregister(field)
          {
            const idx = this.fieldList.indexOf(field);
            if (idx !== -1) this.fieldList.splice(idx, 1);
          },
          valid()
          {
            const fields = this.fieldList;
            return fields.every(item => item.valid);
          },
          reset()
          {
            this.fieldList.forEach(item => item.setDirty(false));
          },
        }
    };
    </script>
    
    <template>
      <div class="form_field">
        <label v-if="label" style="text-align: initial;">{{ label }}</label>
        <div class="form_group" @input="setDirty" @change="setDirty">
          <div v-if="$slots.prepend" class="form_input_prepend">
            <slot name="prepend" />
          </div>
          <slot />
          <div v-if="$slots.append" class="form_input_append">
            <slot name="append" />
          </div>
        </div>
        <div v-if="error && (dirty || force)" class="field_error">{{ error }}</div>
      </div>
    </template>
    
    <script>
    export default
    {
      name: 'FormField',
      inject:
        {
          form:
            {
              default: null
            }
        },
      props:
        {
          label:
            {
              type: String,
              default: ''
            },
          error:
            {
              // the actual validation happens in your own code - you just provide an error message here (if any)
              type: String,
              default: null
            },
          force:
            {
              // force real-time validation, no matter if the value is dirty or not
              type: Boolean,
              default: false
            },
        },
      data()
      {
        return {
          dirty: false,
        };
      },
      computed:
        {
          valid() // eslint-disable-line vue/no-unused-properties
          {
            return !this.error;
          }
        },
      created()
      {
        this.form && this.form.register(this);
      },
      beforeDestroy()
      {
        this.form && this.form.unregister(this);
      },
      methods:
        {
          setDirty(value)
          {
            this.dirty = value;
          }
        }
    };
    </script>
    
    <style lang="scss">
      $field_radius: 4px;
      $input_disabled: grey;
    
      .form_field
      {
        display: flex;
        flex-direction: column;
      }
    
      .form_field + .form_field
      {
        margin-top: 8px;
      }
    
      .form_field > label
      {
        padding-bottom: 4px;
      }
    
      .form_group
      {
        border: 1px solid $dialog_border;
        border-radius: $field_radius;
        display: flex;
        align-items: center;
        background-color: $input_prepend;
        flex-grow: 1;
      }
    
      .form_group:focus-within
      {
        border-color: blue;
      }
    
      .form_group input,
      .form_group select,
      .form_group textarea
      {
        border: none;
        flex: 1 1 auto;
        padding: 0 6px;
        margin: 0;
        min-height: calc(1.5rem + 8px);
        min-width: 48px;
        font-size: 1rem;
        line-height: 1.5;
        border-radius: $field_radius; /* probably should be conditional - based on the absence of prepend/append slot contents */
      }
    
      .form_group select
      {
        background: white;
      }
    
      .form_group textarea
      {
        height: auto;
      }
    
      .form_group input[type="file"]
      {
        padding: 0;
        min-height: 0;
      }
    
      .form_group input:focus,
      .form_group select:focus,
      .form_group textarea:focus
      {
        outline: none;
      }
    
      .form_group input:disabled,
      .form_group select:disabled,
      .form_group textarea:disabled
      {
        background-color: $input_disabled;
      }
    
      .form_group > *:not(.form_input_prepend):not(.form_input_append)
      {
        align-self: stretch;
      }
    
      .form_input_prepend,
      .form_input_append
      {
        /* background-color: $input_prepend; */
        display: flex;
        justify-content: center;
        align-items: center;
      }
    
      .form_input_prepend
      {
        border-top-left-radius: $field_radius;
        border-bottom-left-radius: $field_radius;
      }
    
      .form_input_append
      {
        border-top-right-radius: $field_radius;
        border-bottom-right-radius: $field_radius;
      }
    
      .field_error
      {
        font-family: 'Segoe UI', Tahoma, sans-serif;
        font-size: 85%;
        color: red;
        margin: 3px 0;
      }
    </style>