As part of learning VueJS one of my initial project involved creating a multi-step form or a form wizard in Vue JS.

There are Vue packages available for this but I wanted to do implement it myself with the simplest implementation. Also, there are code snippets and articles available but none of them matched it to be as dynamic as I want my implementation to be and none of them handled the validation.

So here it is, In this article, I write about the implementation details of multi-step form in Vue js, along with Validation.

To start with Let's start with how I want my dynamic template to be like.

    <form-wizard>
        <tab-content title="About You" :selected="true">
            <!--Content of Tab 1 -->
        </tab-content>
        <tab-content title="About your Company">
            <!-- Content of Tab 2 -->
        </tab-content>
        <tab-content title="Finishing Up">
            <!-- Content of Tab 3 -->
        </tab-content>
    </form-wizard>

Here form-wizard is that component that deals with the handling of stepper on the top and navigation buttons on the bottom. It also deals with which tab is currently active.

tab-content the actual form fields and maintains the status of it's active stage.

Let's go into the details of both components.

Form-Wizard Component


<template>
    <div class="card">
        <div class="card-header">
        <ul class="nav nav-pills nav-wizard nav-fill">
            <li @click="selectTab(index)" class="nav-item" :class="tab.isActive ? 'active' : 'in-active'" v-for="(tab, index) in tabs" v-bind:key="`tab-${index}`">
                <a class="nav-link"  href="#">
                        <span class="tabStatus">{{index+1}} </span> 
                        <span class="tabLabel">{{tab.title}}</span>
                </a>
            </li>
        </ul>
        </div>
        <div class="card-body">
        <div class="col-lg-8 mx-auto">
   
            <form>
            <slot></slot>
            </form>

        </div>
        </div>
        <div class="card-footer text-center">
            <div class="btn-group" role="group">
                <button @click="previousTab" :disabled="currentTab === 0" class="btn btn-warning">Previous</button>
                <button @click="nextTab" v-if="currentTab < totalTabs - 1" class="btn btn-primary">Next</button>
                <button @click="onSubmit" v-if="currentTab === totalTabs - 1" class="btn btn-success">Submit</button>
            </div>
        </div>
    </div>
</template>
<script>
import { store } from "./store.js";
export default {
    name: 'form-wizard',
    data(){
        return{
            tabs: [],
            currentTab : 0,
            totalTabs : 0,
            storeState: store.state,
        }
    },
    mounted(){
            this.tabs = this.$children;
            this.totalTabs = this.tabs.length;
            this.currentTab = this.tabs.findIndex((tab) => tab.isActive === true);
            if(this.currentTab === -1 && this.totalTabs > 0){  //Select first tab if none is marked selected
                this.tabs[0].isActive = true;
                this.currentTab = 0;
            }
    },
    methods:{
        previousTab(){
            this._switchTab(this.currentTab - 1);
        },

        nextTab(){

            if(this._validateCurrentTab() === false)
                return;

            this._switchTab(this.currentTab + 1);              
              
        },

        selectTab(index){
            if(index < this.currentTab){
              this._switchTab(index);
            }

            if(this._validateCurrentTab() === false){
                return;
            }

            this._switchTab(index);
            
        },


        onSubmit(){
            if(this._validateCurrentTab() === false)
                return;
            this.$emit('onComplete');
        },

        _switchTab(index){
            //Disable all tabs
            this.tabs.forEach(tab => {
              tab.isActive = false;
            });

            this.currentTab = index;
            this.tabs[index].isActive = true;
        },

        _validateCurrentTab(){
            if(Object.keys(this.storeState.v).length === 0 && this.storeState.v.constructor === Object)
                return true;

            this.storeState.v.$touch();

            if (this.storeState.v.$invalid) {
                return false;
            }

            return true;
        }
    },
    watch:{
       currentTab(){
          store.setCurrentTab(this.currentTab);
       }
    }
    
}

Although this looks complex (As every strange code does), I will try to simplify the details for you.

template : I have defined a basic template that consists of Bootstrap navigation pulls classes to track the status of where we are in the form. div with class card-body will get everything that we define inside our form-wizard component. And below in the card-footer, I have defined three buttons (previous, next and submit) for navigation and submitting the form.

data : data properties are declared to maintain the status of currently Active tab and also to get to know total number of child components.

mounted : Once the whole component is rendered, we get the count of total child components. (This is to know when to show the submit button in the end)

methods : We have defined methods that helps us to navigate between the tabs and also handle submit event and validation.

previousTab() -> This is triggered on the click of the previous button, It makes the previous tab active and hides all other tabs.

nextTab() -> This is triggered on the click of next button, First it does validation via vuelidate package of the current tab and if that passes then It makes the next tab active and hides all other tabs.

onSubmit() -> This is triggered on the click of submit button. First it does validation of all the child Forms and if it passes we are ready to submit our Form.

Tab Component

Tab component is supposed to have the form fields.

Here is how a form field looks like

<template>
    <div v-if="isActive">
        <slot></slot>
    </div>
</template>

<script>
export default {
    name: 'tab-content',
    props:{
        selected: {
            default: false,
        },
        title: {
            type: String,
            required: true
        }
    },
    data(){
        return{
            isActive: false,
        }
    },
    created(){
        this.isActive = this.selected;
    }
}
</script>

There are data properties which handle the title display of the tab and also it accepts a selected prop which signifies which tab will be selected by default.

template: We have defined a basic template that slots in the form details inside a div.

props : name, info and selected property are accepted from attributes.

Using FormWizard and TabContent Component

Here is the example code on how to use both the components to create a multi-step form


<template>
  <form-wizard @onComplete="onComplete">
    <tab-content title="About You" :selected="true">
      <div class="form-group">
        <label for="fullName">Full Name</label>
        <input
          type="text"
          class="form-control"
          :class="hasError('fullName') ? 'is-invalid' : ''"
          placeholder="Enter your name"
          v-model="formData.fullName"
        >
        <div v-if="hasError('fullName')" class="invalid-feedback">
          <div class="error" v-if="!$v.formData.fullName.required">Please provide a valid name.</div>
        </div>
      </div>
      <div class="form-group">
        <label for="email">Your Email</label>
        <input
          type="email"
          class="form-control"
          :class="hasError('email') ? 'is-invalid' : ''"
          placeholder="Enter your email"
          v-model="formData.email"
        >
        <div v-if="hasError('email')" class="invalid-feedback">
          <div class="error" v-if="!$v.formData.email.required">Email field is required</div>
          <div class="error" v-if="!$v.formData.email.email">Should be in email format</div>
        </div>
      </div>
    </tab-content>
    <tab-content title="About your Company">
      <div class="form-group">
        <label for="companyName">Your Company Name</label>
        <input
          type="text"
          class="form-control"
          :class="hasError('companyName') ? 'is-invalid' : ''"
          placeholder="Enter your Company / Organization name"
          v-model="formData.companyName"
        >
        <div v-if="hasError('companyName')" class="invalid-feedback">
          <div
            class="error"
            v-if="!$v.formData.companyName.required"
          >Please provide a valid company name.</div>
        </div>
      </div>
      <div class="form-group">
        <label for="numberOfEmployees">Number of Employees</label>
        <input
          type="text"
          class="form-control"
          :class="hasError('numberOfEmployees') ? 'is-invalid' : ''"
          placeholder="Enter Total Number of Employees"
          v-model="formData.numberOfEmployees"
        >
        <div v-if="hasError('numberOfEmployees')" class="invalid-feedback">
          <div
            class="error"
            v-if="!$v.formData.numberOfEmployees.required"
          >Please provide number of employees in your company.</div>
          <div class="error" v-if="!$v.formData.numberOfEmployees.numeric">Should be a valid value.</div>
        </div>
      </div>
    </tab-content>
    <tab-content title="Finishing Up">
      <div class="form-group">
        <label for="referral">From Where did you hear about us</label>
        <select
          :class="hasError('referral') ? 'is-invalid' : ''"
          class="form-control"
          v-model="formData.referral"
        >
          <option>Newspaper</option>
          <option>Online Ad</option>
          <option>Friend</option>
          <option>Other</option>
        </select>
        <div v-if="hasError('referral')" class="invalid-feedback">
          <div class="error" v-if="!$v.formData.referral.required">Please select on of the fields.</div>
        </div>
      </div>
      <div class="form-group form-check">
        <input
          type="checkbox"
          :class="hasError('terms') ? 'is-invalid' : ''"
          class="form-check-input"
          v-model="formData.terms"
        >
        <label class="form-check-label">I accpet terms & conditions</label>
        <div v-if="hasError('terms')" class="invalid-feedback">
          <div class="error" v-if="!$v.formData.terms.required">Please select terms and conditions.</div>
        </div>
      </div>
    </tab-content>
  </form-wizard>
</template>

<script>
import { FormWizard, TabContent, ValidationHelper } from "vue-step-wizard";
import "vue-step-wizard/dist/vue-step-wizard.css";
import { required } from "vuelidate/lib/validators";
import { email } from "vuelidate/lib/validators";
import { numeric } from "vuelidate/lib/validators";

export default {
  name: "StepFormValidation",
  components: {
    FormWizard,
    TabContent
  },
  mixins: [ValidationHelper],
  data() {
    return {
      formData: {
        fullName: "",
        email: null,
        companyName: null,
        numberOfEmployees: null,
        referral: null,
        terms: false
      },
      validationRules: [
        { fullName: { required }, email: { required, email } },
        { companyName: { required }, numberOfEmployees: { required, numeric } },
        { referral: { required }, terms: { required } }
      ]
    };
  },
  methods: {
    onComplete() {
      alert("Submitting Form ! Rock On");
    }
  }
};
</script>

v-model : is used to bind the element to a property. Form properties are defined in the root Vue instance.

data-vv-scope : this attribute is used to group form fields for validation. So when we click the next button on Step 1 of form only fields marked step1 will be validated.

v-validate: This attribute denotes the validation as per vee-validate rules.

Code:

https://github.com/tushargugnani/vue-step-wizard

Working Demo:

https://codesandbox.io/s/vue-step-wizard-form-with-validation-ghz85?from-embed

Comments