DEV Community

Cover image for How to build a complete form with Vue.js
Tero Auralinna
Tero Auralinna

Posted on • Originally published at auralinna.blog on

How to build a complete form with Vue.js

Lately I have experimented with Vue.js JavaScript Framework. In this post I will show how to build an almost functional form with Vue.js. Almost functional means that the front end will be ready but actual data handling in the back end won't be implemented.

Forms are essential part of almost every web application so I wanted to see how to build a basic form with the following specs.

Specification

I set following specs for my form project.

  • Form contains different field types.
  • Textarea field must have a maximum length counter.
  • There must be an inline validation for form fields.
  • Summary of validation errors must be shown on submit if validation fails.
  • Data will be sent via Ajax on submit and loading indicator is visible until HTTP request is finished.
  • Server-side errors will be returned and handled in the same way as errors on a client-side.
  • Double-clicks must be prevented by disabling the submit button when sending form.
  • Thanks page will be shown after form is successfully submitted.

Prerequisites

Vue project is generated with the Webpack template.

Project needs few dependencies for functionalities which are not part of the Vue.js core:

Project uses Bootstrap 4.1.0 library for the form HTML markup. Polyfill.io is used to provide polyfills for older browsers.

This post focuses on the front end. Back end is handled by Mocky REST API. Mocky enables the one to mock HTTP responses to test REST APIs. Of course you could use any local mock server but I decided to use Mocky because it's also accessible from the GitHub page.

Getting started

You can download the whole project from my GitHub repository. Live demo is also available.

$ git clone https://github.com/teroauralinna/vue-demo-form.git
Enter fullscreen mode Exit fullscreen mode

Setup project

Install dependencies

$ npm install
Enter fullscreen mode Exit fullscreen mode

Run project

Serve dev version from http://localhost:8080

$ npm run dev
Enter fullscreen mode Exit fullscreen mode

Show me the code!

App setup

./index.html

Bootstrap is added from the CDN to simplify things a little bit.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title>Vue.js Demo Form</title>
    <link rel="stylesheet" 
      href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css" 
      integrity="sha384-9gVQ4dYFwwWSjIDZnLEWnxCjeSWFphJiwGPXr1jddIhOegiu1FwO5qRGvFXOdJZ4" 
      crossorigin="anonymous">
  </head>
  <body>
    <div id="app"></div>
    <!-- built files will be auto injected -->
    <script src="https://cdn.polyfill.io/v2/polyfill.min.js"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

./src/resources/translations.js

This file includes all translation strings that our app uses. For a bigger app I would use translation file per language. Even though this project doesn't have multiple languages it's easier to handle form validation errors with translation strings.

export default {
  en: {
    form: {
      firstName: "First name",
      lastName: "Last name",
      email: "Email",
      terms: "I accept the terms and conditions",
      type: "Select subscription type",
      additionalInfo: "Additional info",
      submitted: "The form is submitted!",
      sentInfo: "Here is the info you sent:",
      return: "Return to the form",
      submit: "Submit",
      submitting: "Submitting",
      charactersLeft: "You have {charCount} character left. | You have {charCount} characters left.",
      types: {
        free: "Free trial subscription",
        starter: "Starter subscription (50 € / month)",
        enterprise: "Enterprise subscription (250 € / month)"
      }
    },
    error: {
      invalidFields: "Following fields have an invalid or a missing value:",
      general: "An error happened during submit.",
      generalMessage: "Form sending failed due to technical problems. Try again later.",
      fieldRequired: "{field} is required.",
      fieldInvalid: "{field} is invalid or missing.",
      fieldMaxLength: "{field} maximum characters exceeded."
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

./config/prod.env.js

In the config file you can change the API endpoint. There is one for success and one for failure. With the failure response you can see how the back end responds when there are errors.

'use strict'
module.exports = {
  NODE_ENV: '"production"',
  FORM_API_URL: '"https://www.mocky.io/v2/5adb5a8c2900002b003e3df1"', // Success
  //FORM_API_URL: '"https://www.mocky.io/v2/5ade0bf2300000272b4b29b9"', // Failure
}
Enter fullscreen mode Exit fullscreen mode

Success response from https://www.mocky.io/v2/5adb5a8c2900002b003e3df1:

{
  "success": true,
  "errors": []
}
Enter fullscreen mode Exit fullscreen mode

Failure response from https://www.mocky.io/v2/5ade0bf2300000272b4b29b9:

{
  "success": false,
  "errors": [
    {
      "field": "firstName",
      "message": null
    },
    {
      "field": "lastName",
      "message": null
    },
    {
      "field": "email",
      "message": "Email is not valid email address."
    },
    {
      "field": "additionalInfo",
      "message": "Max. 1000 characters."
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

./src/main.js

All dependencies and general configurations are inserted to the main.js file.

import Vue from 'vue'
import VueI18n from 'vue-i18n'
import Vuelidate from 'vuelidate';
import App from './App.vue'
import translations from "./resources/translations";

Vue.use(VueI18n);
Vue.use(Vuelidate);

Vue.config.formApiUrl = process.env.FORM_API_URL;

const i18n = new VueI18n({
  locale: 'en',
  fallbackLocale: 'en',
  messages: translations
})

new Vue({
  el: '#app',
  i18n,
  render: h => h(App)
})
Enter fullscreen mode Exit fullscreen mode

./src/App.vue

App.vue is the container for the app. The form component is imported here.

<template>
  <div id="app" class="container">
    <div class="row justify-content-center">
      <div class="col-xs-12 col-sm-10 col-md-6">
        <h1 class="text-center">{{title}}</h1>
        <app-form></app-form>
      </div>
    </div>
  </div>
</template>

<script>
import Vue from 'vue';
import Form from './components/form/Form.vue';

export default {
  name: 'app',
  data () {
    return {
      title: 'Vue.js Demo Form'
    }
  },
  components: {
    appForm: Form
  }
}
</script>

<style lang="scss">
  h1 {
    margin-bottom: 30px;
  }

  #app {
    padding: 20px;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

The form component

The form component is separated into several files: Form.scss, Form.js and Form.vue. The latter holds the template and requires SCSS and JS files.

./src/components/form/Form.scss

pre {
    white-space: pre-line;
}

form {
    background: #efefef;
    padding: 2rem 2rem 1rem;
}
Enter fullscreen mode Exit fullscreen mode

./src/components/form/Form.js

The Form.js contains all logic which controls the form.

import { required, email, maxLength } from 'vuelidate/lib/validators';
import axios from 'axios';
import Vue from 'vue';

export default {
  name: 'app-form',
  data() {
    return {
      isSubmitted: false,
      isError: false,
      errorHeader: 'error.invalidFields',
      errors: [],
      types: this.getTypes(),
      submitting: false,
      form: {
        firstName: '',
        lastName: '',
        email: '',
        terms: false,
        type: null,
        additionalInfo: ''
      }
    }
  },
  methods: {
    submit() {
      this.$v.$touch();
      if (!this.$v.$error) {
        this.sendFormData();
      } else {
        this.validationError();
      }
    },
    enableSubmitLoader() {
      this.submitting = true;
    },
    disableSubmitLoader() {
      this.submitting = false;
    },
    sendFormData() {
      this.enableSubmitLoader();
      axios.post(Vue.config.formApiUrl, this.form).then(response => {
        this.submitSuccess(response);
        this.disableSubmitLoader();
      }).catch(error => {
        this.submitError(error);
        this.disableSubmitLoader();
      });
    },
    submitSuccess(response) {
      if (response.data.success) {
        this.isSubmitted = true;
        this.isError = false;
      } else {
        this.errorHeader = 'error.invalidFields';
        this.errors = response.data.errors;
        this.isError = true;
      }
    },
    submitError(error) {
      this.errorHeader = 'error.general';
      this.errors = [{'field': null, 'message': 'error.generalMessage'}];
      this.isError = true;
    },
    validationError() {
      this.errorHeader = 'error.invalidFields';
      this.errors = this.getErrors();
      this.isError = true;
    },
    isErrorField(field) {
      try {
        if (this.getValidationField(field).$error) {
          return true;
        }
      } catch (error) {}

      return this.errors.some(el => el.field === field);
    },
    getErrors() {
      let errors = [];
      for (const field of Object.keys(this.form)) {
        try {
          if (this.getValidationField(field).$error) {
            errors.push({'field': field, 'message': null});
          }
        } catch (error) {}
      }
      return errors;
    },
    getFieldClasses(field) {
      return { 'is-invalid': this.isErrorField(field) }
    },
    getCharactersLeft(field) {
      try {
        return this.getValidationField(field).$params.maxLength.max - this.form[field].length;
      } catch (error) {
        return 0;
      }
    },
    getTypes() {
      return [{
        value: 'free', 
        label: 'form.types.free'
      }, {
        value: 'starter', 
        label: 'form.types.starter'
      }, {
        value: 'enterprise', 
        label: 'form.types.enterprise'
      }];
    },
    getValidationField(field) {
      if (this.$v.form[field]) {
        return this.$v.form[field];
      }
      throw Error('No validation for field ' + field);
    },
    onFieldBlur(field) {
      try {
        this.getValidationField(field).$touch();
        if (this.getValidationField(field).$error) {
          if (!this.errors.some(el => el.field === field)) {
            this.errors.push({'field': field, 'message': null});
          }
        } else {
          this.errors = this.errors.filter(el => el.field !== field);
        }
      } catch (error) {}
    },
    reload() {
      window.location = '';
    }
  },
  validations: {
    form: {
      email: { required, email },
      firstName: { required },
      lastName: { required },
      type: { required },
      terms: { required },
      additionalInfo: { maxLength: maxLength(1000) }
    }
  },
  watch: {
    errors() {
      this.isError = this.errors.length > 0 ? true : false;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

./src/components/form/Form.vue

This is the main component which contains the HTML template, data bindings and includes JS and SCSS files.

<template>
  <div>
    <form v-if="!isSubmitted" @submit.prevent="submit" novalidate>
      <div class="form-group">
        <label for="firstName">{{ $t('form.firstName') }} *</label>
        <input type="text" class="form-control" id="firstName" v-model.lazy.trim="form.firstName" @blur="onFieldBlur('firstName')" v-bind:class="getFieldClasses('firstName')">
        <div v-if="isErrorField('firstName')" class="invalid-feedback">{{ $t('error.fieldRequired', { field: $t('form.firstName') }) }}</div>
      </div>
      <div class="form-group">
        <label for="lastName">{{ $t('form.lastName') }} *</label>
        <input type="text" class="form-control" id="lastName" v-model.lazy.trim="form.lastName" @blur="onFieldBlur('lastName')" v-bind:class="getFieldClasses('lastName')">
        <div v-if="isErrorField('lastName')" class="invalid-feedback">{{ $t('error.fieldRequired', { field: $t('form.lastName') }) }}</div>
      </div>
      <div class="form-group">
        <label for="email">{{ $t('form.email') }} *</label>
        <input type="email" class="form-control" id="email" v-model.lazy.trim="form.email" @blur="onFieldBlur('email')" v-bind:class="getFieldClasses('email')">
        <div v-if="isErrorField('email')" class="invalid-feedback">{{ $t('error.fieldInvalid', { field: $t('form.email') }) }}</div>
      </div>
      <div class="form-group">
        <label for="type">{{ $t('form.type') }} *</label>
        <select id="type" class="form-control" v-model="form.type" @blur="onFieldBlur('type')" v-bind:class="getFieldClasses('type')">
            <option v-for="type in types" v-bind:key="type.value" v-bind:value="type.value">{{ $t(type.label) }}</option>
        </select>
        <div v-if="isErrorField('type')" class="invalid-feedback">{{ $t('form.type') }}</div>
      </div>
      <div class="form-group">
        <label for="additionalInfo">{{ $t('form.additionalInfo') }}</label>
        <textarea 
          type="additionalInfo" 
          class="form-control" 
          id="additionalInfo" 
          v-model.trim="form.additionalInfo" 
          v-bind:class="getFieldClasses('additionalInfo')" 
          v-bind:maxlength="$v.form['additionalInfo'].$params.maxLength.max" 
          @blur="onFieldBlur('additionalInfo')">
        </textarea>
        <small class="text-muted form-text">{{ $tc('form.charactersLeft', getCharactersLeft('additionalInfo'), { charCount: getCharactersLeft('additionalInfo') }) }}</small>
        <div v-if="isErrorField('additionalInfo')" class="invalid-feedback">{{ $t('error.fieldMaxLength', { field: $t('form.additionalInfo') }) }}</div>
      </div>
      <div class="form-group">
        <div class="form-check">
          <input type="checkbox" class="form-check-input" id="terms" v-model.lazy.trim="form.terms" @change="onFieldBlur('terms')" v-bind:class="getFieldClasses('terms')">
          <label class="form-check-label" for="terms">{{ $t('form.terms') }} *</label>
        </div>
      </div>
      <div class="alert alert-danger" v-if="isError">
        <p class="mb-0">
          <strong>{{ $t(errorHeader) }}</strong>
        </p>
        <ul class="mb-0 pl-3" v-if="errors.length > 0">
          <li v-for="error in errors" v-bind:key="error.field">
            <span v-if="error.field">{{ $t('form.'+error.field) }}<span v-if="error.message">: {{ $t(error.message) }}</span></span>
            <span v-else-if="error.message">{{ $t(error.message) }}</span>
          </li>
        </ul>
      </div>
      <div class="form-group">
        <button type="submit" class="btn btn-primary" :disabled="submitting">
          <span v-if="submitting">{{ $t('form.submitting' ) }} <img src="../../assets/loader.svg" /></span>
          <span v-else>{{ $t('form.submit' ) }}</span>
        </button>
      </div>
    </form>
    <div v-else>
      <div class="alert alert-success">
        <strong>{{ $t('form.submitted' ) }}</strong>
      </div>
      <div class="alert alert-info">
        <p><strong>{{ $t('form.sentInfo' ) }}</strong></p>
        <pre>
            {{form}}
        </pre>
      </div>
      <p class="text-center">
        <a href="#" class="btn btn-secondary" @click.prevent="reload()">{{ $t('form.return' ) }}</a>
      </p>
    </div>
  </div>
</template>

<script src="./Form.js"></script>
<style src="./Form.scss" lang="scss" scoped></style>
Enter fullscreen mode Exit fullscreen mode

Further development ideas

For a real project I would consider refactoring common code from the Form.js into reusable services. I also would create a component per field type to avoid code duplication and for reusing fields in other forms. Another thing which tempts me is to create a model which represents the whole form. This model would include properties per form field like type, label, validation rules etc. Then forms could be generated dynamically based on the form model. Well this might be a topic for an upcoming blog post.

Top comments (0)