Categories
JavaScript Vue

How to Add Geolocation to a Vue.js App

Many apps want provide an experience based on the user’s location. This is where the HTML Geolocation API comes in. You can use it easily to get the location of the current device.

To get the location of the device in the browser using plain JavaScript, we write:

if (navigator.geolocation) {  
  navigator.geolocation.getCurrentPosition(getPosition);  
}

function getPosition(position) {  
  console.log(position.coords.latitude, position.coords.longitude);  
}

As you can see, getting the latitude and longitude is very easy.

We can also easily add geolocation to any Vue.js app. The vue-browser-geolocation package is a great add-on for adding geolocation capabilities to your app. It provides a promise-based API for getting the location of the device, so we can easily use async and await to get the location with this package.

Getting Started

In this article, we will build an app to get the list of Canadian politicians in your local area if you are in Canada from the Represent Civic Information API.

We will use the vue-browser-geolocation package to get the location, and then use the API to get the list of items from the API. We will get the list of politicians from the local area, the constituent boundaries from the local area, and the list of representatives from the given legislative body selected from a list.

To start, we run the Vue CLI to create the project. Run npx @vue/cli create politicians-app to create the app. In the wizard, we choose ‘Manually select features’, then choose to include Babel, and Vue Router.

Next we install some packages we need to build the app. We will use Axios for making HTTP requests, BootstrapVue for styling, Vue Avatar for showing the avatar picture of the politician, and vue-browser-geolocation to get the location of the device. Run npm i axios bootstrap-vue vue-avatar vue-browser-geolocation to install all the packages.

Making API Requests

Now we can start writing the app. We start by creating a mixin for making HTTP requests that we use in our components. Create a mixins folder in the src folder and create requestsMixin.js in the mixins folder. Then we add the following to the file:

const axios = require("axios");  
const APIURL = "https://represent.opennorth.ca";

export const requestsMixin = {  
  methods: {  
    getRepresentatives(lat, lng) {  
      return axios.get(`${APIURL}/representatives/?point=${lat},${lng}`);  
    }, 

    getBoundaries(lat, lng) {  
      return axios.get(`${APIURL}/boundaries/?contains=${lat},${lng}`);  
    }, 

    getRepresentativeSetsRepresentatives(set, lat, lng) {  
      return axios.get(  
        `${APIURL}/representatives/${set}/?point=${lat},${lng}`  
      );  
    }, 

    getRepresentativeSets() {  
      return axios.get(`${APIURL}/representative-sets/?offset=0&limit=200`);  
    }  
  }  
};

We use the Represent Civic Information API to get the representatives and boundaries by location, and also get the list of legislative bodies in Canada. The full list of endpoints is at https://represent.opennorth.ca/api/#representativeset.

Getting Geolocation

Next create the pages for display the data. In the views folder, create Boundaries.vue and add:

<template>  
  <div class="page">  
    <h1 class="text-center">Your Constituent Boundary</h1>  
    <template v-if="noLocation">  
      <h2 class="text-center">Enable geolocation to see list of boundaries</h2>  
    </template>  
    <template v-if="!noLocation">  
      <b-card v-for="(b, i) in boundaries" :key="i" class="card">  
        <b-card-title>  
          <h2>{{b.name}}</h2>  
        </b-card-title> <b-card-text>  
          <p>  
            <b>Boundary Set Name:</b>  
            {{b.boundary_set_name}}  
          </p>  
        </b-card-text>  
      </b-card>  
    </template>  
  </div>  
</template>

<script>  
import { requestsMixin } from "@/mixins/requestsMixin";

export default {  
  name: "boundaries",  
  mixins: [requestsMixin],  
  data() {  
    return {  
      boundaries: [],  
      noLocation: true  
    };  
  },  
  beforeMount() {  
    this.getBounds();  
  },  
  methods: {  
    async getBounds() {  
      try {  
        const coordinates = await this.$getLocation({  
          enableHighAccuracy: true  
        });  
        const { lat, lng } = coordinates;  
        const response = await this.getBoundaries(lat, lng);  
        this.boundaries = response.data.objects;  
        this.noLocation = false;  
      } catch (error) {  
        this.noLocation = true;  
      }  
    }  
  }  
};  
</script>

In this page, we get the constituency boundaries for the location that the user’s device is currently in with the vue-browser-geolocation package. this.$getLocation in the getBounds function is provided by the package. The promise resolves to an object with the latitude and longitude when geolocation is enabled by the user. We wrap the code with a try...catch in case that it is disabled. If geolocation is disabled, we show a message to let the user know that they have to enable geolocation to see data.

If geolocation is enabled, then this.getBoundaries will be called. The function is provided by the requestsMixin that we created earlier. We included it with the mixin property of this component. The items are displayed in BootstrapVue cards.

Next we replace the code in Home.vue with the following:

<template>  
  <div class="page">  
    <h1 class="text-center">Your Local Representatives</h1>  
    <template v-if="noLocation">  
      <h2 class="text-center">Enable geolocation to see list of representatives</h2>  
    </template>  
    <template v-if="!noLocation">  
      <b-card v-for="(r, i) in reps" :key="i" class="card">  
        <b-card-title>  
          <h2>  
            <avatar :src="r.photo_url" :inline="true"></avatar>  
            <span class="title">{{r.name}}</span>  
          </h2>  
        </b-card-title> <b-card-text>  
          <h5>Offices:</h5>  
          <div v-for="(o,i) in r.offices" :key="i">  
            <p>  
              <b>Address:</b>  
              {{o.postal}}  
            </p>  
            <p>  
              <b>Telephone:</b>  
              {{o.tel}}  
            </p>  
            <p>  
              <b>Type:</b>  
              {{o.type}}  
            </p>  
          </div>  
          <p>  
            <b>Party:</b>  
            {{r.party_name}}  
          </p>  
        </b-card-text> <b-button :href="r.url" variant="primary" target="_blank">Go to Source</b-button>  
      </b-card>  
    </template>  
  </div>  
</template>

<script>  
import { requestsMixin } from "@/mixins/requestsMixin";  
import Avatar from "vue-avatar";

export default {  
  name: "home",  
  mixins: [requestsMixin],  
  data() {  
    return {  
      reps: [],  
      noLocation: true  
    };  
  },  
  components: {  
    Avatar  
  },

  beforeMount() {  
    this.getAllRepresentatives();  
  },  
  methods: {  
    async getAllRepresentatives() {  
      try {  
        const coordinates = await this.$getLocation({  
          enableHighAccuracy: true  
        });  
        const { lat, lng } = coordinates;  
        const response = await this.getRepresentatives(lat, lng);  
        this.reps = response.data.objects;  
        this.noLocation = false;  
      } catch (error) {  
        this.noLocation = true;  
      }  
    }  
  }  
};  
</script>

We use the same this.$geoLocation function as Boundaries.vue. In this page, we get the list of representatives for the location that the user’s device is currently in. The geolocation feature is the same as Boundaries.vue. We run the getAllRepresentatives function in beforeMount so that it loads when the page loads.

The items from the API are displayed in the BootstrapVue cards. We display the picture in an avatar with the vue-avatar package.

Next we create Representatives.vue in the views folder. We add the following code to the file:

<template>  
  <div class="page">  
    <h1 class="text-center">Representative Sets</h1>  
    <template v-if="noLocation">  
      <h2 class="text-center">Enable geolocation to see list of representatives</h2>  
    </template>  
    <template v-if="!noLocation">  
      <b-form>  
        <b-form-group label="Representative Set" label-for="repSet">  
          <b-form-select  
            name="repSet"  
            v-model="form.repSet"  
            :options="repSets"  
            required  
            @change="getRepSetReps()"  
          ></b-form-select>  
        </b-form-group>  
      </b-form>  
      <b-card v-for="(r, i) in reps" :key="i" class="card">  
        <b-card-title>  
          <h2>  
            <avatar :src="r.photo_url" :inline="true"></avatar>  
            <span class="title">{{r.name}}</span>  
          </h2>  
        </b-card-title> <b-card-text>  
          <h5>Info:</h5>  
          <p>  
            <b>Elected Office:</b>  
            {{r.office}}  
          </p>  
          <p>  
            <b>District Name:</b>  
            {{r.district_name}}  
          </p>  
          <p>  
            <b>Party:</b>  
            {{r.party_name || 'None'}}  
          </p>  
          <h5>Offices:</h5>  
          <div v-for="(o,i) in r.offices" :key="i">  
            <p>  
              <b>Address:</b>  
              {{o.postal}}  
            </p>  
            <p>  
              <b>Telephone:</b>  
              {{o.tel}}  
            </p>  
            <p>  
              <b>Type:</b>  
              {{o.type}}  
            </p>  
          </div>  
        </b-card-text>  
      </b-card>  
    </template>  
  </div>  
</template>

<script>  
import { requestsMixin } from "@/mixins/requestsMixin";  
import Avatar from "vue-avatar";

export default {  
  name: "boundaries",  
  mixins: [requestsMixin],  
  data() {  
    return {  
      repSets: [],  
      reps: [],  
      form: {  
        repSet: "strathcona-county-council"  
      },  
      noLocation: true,  
      coordinates: {}  
    };  
  },  
  components: {  
    Avatar  
  },  
  beforeMount() {  
    this.getRepSets();  
    this.getLocation();  
  },  
  methods: {  
    async getRepSets() {  
      const response = await this.getRepresentativeSets();  
      this.repSets = response.data.objects.map(s => {  
        const [part1, part2, value] = s.url.split("/");  
        return {  
          text: s.name,  
          value  
        };  
      });  
    }, 

    async getLocation() {  
      try {  
        const coordinates = await this.$getLocation({  
          enableHighAccuracy: true  
        });  
        this.coordinates = coordinates;  
        this.noLocation = false;  
      } catch (error) {  
        this.noLocation = true;  
      }  
    }, 

    async getRepSetReps() {  
      const { lat, lng } = this.coordinates;  
      const response = await this.getRepresentativeSetsRepresentatives(  
        this.form.repSet,  
        lat,  
        lng  
      );  
      this.reps = response.data.objects;  
    }  
  }  
};  
</script>

When the page loads, we call this.getRepSets and this.getLocation to populate the dropdown with the legislative bodies in Canada and get the location respectively. We get the items in the this.getRepSetReps function with the coordinates provided when the dropdown selection is changed and when geolocation API enabled. We only display the dropdown when geolocation is enabled so that we won’t let the user select anything without it being enabled.

The items are also displayed in BootstrapVue cards in this page.

Then we change the existing code in App.vue to:

<template>  
  <div>  
    <b-navbar toggleable="lg" type="dark" variant="info">  
      <b-navbar-brand href="#">Canadian Politicians App</b-navbar-brand><b-navbar-toggle target="nav-collapse"></b-navbar-toggle><b-collapse id="nav-collapse" is-nav>  
        <b-navbar-nav>  
          <b-nav-item to="/" :active="path == '/'">Home</b-nav-item>  
          <b-nav-item to="/boundaries" :active="path == '/boundaries'">Boundaries</b-nav-item>  
          <b-nav-item to="/representatives" :active="path == '/representatives'">Representatives</b-nav-item>  
        </b-navbar-nav>  
      </b-collapse>  
    </b-navbar>  
    <router-view />  
  </div>  
</template><script>  
export default {  
  data() {  
    return {  
      path: this.$route && this.$route.path  
    };  
  },  
  watch: {  
    $route(route) {  
      this.path = route.path;  
    }  
  }  
};  
</script>

<style>  
.page {  
  padding: 20px;  
  margin: 0 auto;  
}

.card,  
form {  
  max-width: 800px;  
  margin: 0 auto;  
}

.title {  
  position: relative;  
  top: -13px;  
  left: 10px;  
}  
</style>

We add the BootstrapVue toolbar and the links to each page. In the top bar, we set the active prop for the links so that we highlight the link of the current page that is displayed. In the scripts section, we watch the $route object provided by Vue Router for the current path of the app and assign it to this.path so that we can use it to set the active prop.

In the style block, we add padding and margin to the pages with the page class. We set the form and card widths so that they won’t be too wide, and we add the title class so the titles in the cards align with the avatars.

In main.js, we replace the existing code with:

import Vue from 'vue'  
import App from './App.vue'  
import router from './router'  
import store from './store'  
import VueGeolocation from 'vue-browser-geolocation';  
import BootstrapVue from 'bootstrap-vue'  
import 'bootstrap/dist/css/bootstrap.css'  
import 'bootstrap-vue/dist/bootstrap-vue.css'Vue.use(BootstrapVue)  
Vue.use(VueGeolocation);Vue.config.productionTip = falsenew Vue({  
  router,  
  store,  
  render: h => h(App)  
}).$mount('#app')

This adds BootstrapVue library and styles to our app, along with the vue-browser-geolocation package.

In router.js we replace the existing code with:

import Vue from "vue";  
import Router from "vue-router";  
import Home from "./views/Home.vue";  
import Boundaries from "./views/Boundaries.vue";  
import Representatives from "./views/Representatives.vue";

Vue.use(Router);export default new Router({  
  mode: "history",  
  base: process.env.BASE_URL,  
  routes: [  
    {  
      path: "/",  
      name: "home",  
      component: Home  
    },  
    {  
      path: "/boundaries",  
      name: "boundaries",  
      component: Boundaries  
    },  
    {  
      path: "/representatives",  
      name: "representatives",  
      component: Representatives  
    }  
  ]  
});

Now users can go the packages we linked to in the top bar, and also by entering the URLs directly.

Now we run the app by running npm run serve .

Categories
Vue

How to Use VeeValidate 3 for Form Validation

VeeValidate 3 completely changed how form validation it’s done compared to the previous version. While previous versions add form validation rules straight in the input, VeeValidate 3 wraps the component provided by it around the input to provide form validation for the component. We wrap ValidationProvider component around an input to add form validation capabilities to the input.

The built in rules are now included with you start the app. They are all registered one by one in the entry point of the app instead of just calling Vue.use on the library in order to use the rules.

Form validation errors are passed into the input from the scoped slot which is available when you add an input inside ValidationProvider . For example, if we have:

<ValidationProvider name="email" rules="required">
  <div slot-scope="{ errors }">
    <input v-model="name">
    <p>{{ errors[0] }}</p>
  </div>
</ValidationProvider>

we get errors from the ValidationProvider in the code above.

With this arrangement, specifying custom rules is easier than in version 2 as you will see in the app we will build below.

In this article, we will build a simple expense tracker with a form to add the description, amount and date of the expense and a table to display the data. We will also add buttons to let user delete expenses. In addition, there will be a page showing a line chart of the expenses sorted by date.

The back end will be built with Koa to keep it simple. We will use the latest version of Vue.js with the latest version of BootstrapVue to build the UI. Vee-Validate will be used for form validation and Vue-Chartjs will be used for the line chart.

Back End

To start, we build a simple back end to store the expenses. Create a project folder then create a backend folder to store the back end code.Now we can build the back end, we run npm init and answer the questions by entering the default values. Then we install our own packages. Koa comes with nothing, so we need to install a request body parser, a router, CORS add-on to enable cross domain requests with front end and add libraries for database.

To install all these packages, run npm i @babel/cli @babel/core @babel/node @babel/preset-env @koa/cors koa-bodyparser koa-router sequelize sqlite3 . We need the Babel packages for running with the latest JavaScript features. Sequelize and SQLite are the ORM and database that we will use respectively. The Koa packages are for enabling CORS, parsing JSON request body, and enable routing respectively.

Next run:

npx sequelize-cli init

to create database boilerplate code.

Then we run:

npx sequelize-cli --name Expense --attributes description:string,amount:float,date:date

to create a Expense table with the fields and data types listed in the attributes option.

After that is done, run npx sequelize-cli db:migrate to create the database.

Next create app.js in the root of the backend folder and add:

const Koa = require("koa");
const cors = require("@koa/cors");
const Router = require("koa-router");
const models = require("./models");
const bodyParser = require("koa-bodyparser");

const app = new Koa();
app.use(bodyParser());
app.use(cors());
const router = new Router();

router.get("/expenses", async (ctx, next) => {
  const Expenses = await models.Expense.findAll();
  ctx.body = Expenses;
});

router.post("/expenses", async (ctx, next) => {
  const Expense = await models.Expense.create(ctx.request.body);
  ctx.body = Expense;
});

router.delete("/expenses/:id", async (ctx, next) => {
  const id = ctx.params.id;
  await models.Expense.destroy({ where: { id } });
  ctx.body = {};
});

app.use(router.routes()).use(router.allowedMethods());

app.listen(3000);

This is the file with all the logic for our app. We use the Sequelize model we created by importing the models module that is created by running sequelize-cli init .

Then we enable CORS by adding app.use(cors()); JSON request body parsing is enabled by adding app.use(bodyParser()); . We add a router by adding: const router = new Router(); .

In the GET expense route, we get all the expenses. The POST is for adding a Contact. The PUT route is used for updating an existing expense by looking it up by ID. And the DELETE route is for deleting a expense by ID.

Now the back end is done. It is that simple.

Front End

To start building the front end, we add a frontend folder in the project’s root folder and then go into the frontend folder and run:

npx @vue/cli create .

When we run the wizard, we will manually select the options, and choose to include Vuex and Vue Router and use SCSS, and NPM for package management.

Next we install some packages. We will use Axios for making requests, Moment for manipulating dates, BootstrapVue for styling, Vee-Validate for form validation and Vue-Chartjs for displaying our chart.

To install everything, we run:

npm i axios bootstrap-vue chartjs vue-chartjs vee-validate moment

With all the packages installed, we can start writing code.

In the src folder, create a charts folder and create a file called ExpenseChart.vue inside it. In the file, add:

<script>
import { Line } from "vue-chartjs";

export default {
  extends: Line,
  props: ["chartdata", "options"],
  mounted() {
    this.renderChart(this.chartdata, this.options);
  },
  watch: {
    chartdata() {
      this.renderChart(this.chartdata, this.options);
    },
    options() {
      this.renderChart(this.chartdata, this.options);
    }
  }
};
</script>

<style>
</style>

We specify that this component accepts the chartdata and options props. We call this.renderChart in both the mounted and watch blocks so that the chart will be updated whenever the props change or when this component first loads.

Next we create a filter to format dates. Create a filters folder in the src folder and inside it, create date.js . In the file, we add:

import * as moment from "moment";

export const dateFilter = value => {
  return moment(value).format("YYYY-MM-DD");
};

to take in a date and the return it formatted in the YYYY-MM-DD format.

Then we create a mixin for the HTTP request code. Create a mixins folder in the src folder and in it, create a requestsMixin.js and add:

const APIURL = "[http://localhost:3000](http://localhost:3000)";
const axios = require("axios");

export const requestsMixin = {
  methods: {
    getExpenses() {
      return axios.get(`${APIURL}/expenses`);
    },

addExpense(data) {
      return axios.post(`${APIURL}/expenses`, data);
    },

deleteExpense(id) {
      return axios.delete(`${APIURL}/expenses/${id}`);
    }
  }
};

This allows us to call these functions in any component when the mixin is included in the component.

Next in the views folder, we create our components. Create a Graph.vue file in the views folder and add:

<template>
  <div class="about">
    <h1 class="text-center">Expense Chart</h1>
    <expense-chart :chartdata="chartData" :options="options"></expense-chart>
  </div>
</template>

<script>
import { requestsMixin } from "../mixins/requestsMixin";
import * as moment from "moment";

export default {
  name: "home",
  mixins: [requestsMixin],
  data() {
    return {
      chartData: {},
      options: { responsive: true, maintainAspectRatio: false }
    };
  },
  beforeMount() {
    this.getAllExpenses();
  },

methods: {
    async getAllExpenses() {
      const response = await this.getExpenses();
      const sortedData = response.data.sort(
        (a, b) => +moment(a.date).toDate() - +moment(b.date).toDate()
      );
      const dates = Array.from(
        new Set(sortedData.map(d => moment(d.date).format("YYYY-MM-DD")))
      );
      const expensesByDate = {};
      dates.forEach(d => {
        expensesByDate[d] = 0;
      });

dates.forEach(d => {
        const data = sortedData.filter(
          sd => moment(sd.date).format("YYYY-MM-DD") == d
        );
        expensesByDate[d] += +data
          .map(a => +a.amount)
          .reduce((a, b) => {
            return a + b;
          });
      });

this.chartData = {
        labels: dates,
        datasets: [
          {
            label: "Expenses",
            backgroundColor: "#f87979",
            data: Object.keys(expensesByDate).map(d => expensesByDate[d])
          }
        ]
      };
    }
  }
};
</script>

We display the ExpenseChart that we created earlier here. The data is populated by getting them from back end. The this.getExpenses function is from the requestMixin that we included in this file. To generate the chartData , we sort the data by date, and then set the dates as the labels, and in the datasets , we have the data for the line, which is the amount of the expense. We add up all the expenses for each day and convert it into an array with:

const dates = Array.from(
        new Set(sortedData.map(d => moment(d.date).format("YYYY-MM-DD")))
      );
const expensesByDate = {};
dates.forEach(d => {
  expensesByDate[d] = 0;
});

dates.forEach(d => {
  const data = sortedData.filter(
    sd => moment(sd.date).format("YYYY-MM-DD") == d
  );
  expensesByDate[d] += +data
    .map(a => +a.amount)
    .reduce((a, b) => {
      return a + b;
    });
});

In the code above, we convert the expenses into a dictionary with the date as the key and the total expenses as the value.

We make the chart responsive by passing in { responsive: true, maintainAspectRatio: false } to the options prop.

Next we replace the existing code in HomePage.vue with:

<template>
  <div class="page">
    <ValidationObserver ref="observer" v-slot="{ invalid }">
      <b-form [@submit](http://twitter.com/submit "Twitter profile for @submit").prevent="onSubmit" novalidate>
        <b-form-group label="Description">
          <ValidationProvider name="description" rules="required" v-slot="{ errors }">
            <b-form-input
              :state="errors.length == 0"
              v-model="form.description"
              type="text"
              required
              placeholder="Description"
              name="description"
            ></b-form-input>
            <b-form-invalid-feedback :state="errors.length == 0">Description is required</b-form-invalid-feedback>
          </ValidationProvider>
        </b-form-group>
        <b-form-group label="Amount">
          <ValidationProvider name="amount" rules="required|min_value:0" v-slot="{ errors }">
            <b-form-input
              :state="errors.length == 0"
              v-model="form.amount"
              type="text"
              required
              placeholder="Amount"
            ></b-form-input>
            <b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
          </ValidationProvider>
        </b-form-group>
        <b-form-group label="Date">
          <ValidationProvider name="amount" rules="date" v-slot="{ errors }">
            <b-form-input
              :state="errors.length == 0"
              v-model="form.date"
              type="text"
              required
              placeholder="Date (YYYY/MM/DD)"
              name="date"
            ></b-form-input>
            <b-form-invalid-feedback :state="errors.length == 0">{{errors[0]}}</b-form-invalid-feedback>
          </ValidationProvider>
        </b-form-group>
        <b-button type="submit">Add</b-button>
      </b-form>
    </ValidationObserver>

    <b-table-simple responsive>
      <b-thead>
        <b-tr>
          <b-th sticky-column>Description</b-th>
          <b-th>Amount</b-th>
          <b-th>Date</b-th>
          <b-th>Delete</b-th>
        </b-tr>
      </b-thead>
      <b-tbody>
        <b-tr v-for="e in expenses" :key="e.id">
          <b-th sticky-column>{{e.description}}</b-th>
          <b-td>${{e.amount}}</b-td>
          <b-td>{{e.date | formatDate}}</b-td>
          <b-td>
            <b-button [@click](http://twitter.com/click "Twitter profile for @click")="deleteSingleExpense(e.id)">Delete</b-button>
          </b-td>
        </b-tr>
      </b-tbody>
    </b-table-simple>
  </div>
</template>

<script>
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
import { requestsMixin } from "../mixins/requestsMixin";

export default {
  name: "home",
  mixins: [requestsMixin],
  data() {
    return {
      form: {}
    };
  },
  beforeMount() {
    this.getAllExpenses();
  },
  computed: {
    expenses() {
      return this.$store.state.expenses;
    }
  },
  methods: {
    async onSubmit() {
      const isValid = await this.$refs.observer.validate();
      if (!isValid) {
        return;
      }
      await this.addExpense(this.form);
      await this.getAllExpenses();
    },

  async getAllExpenses() {
      const response = await this.getExpenses();
      this.$store.commit("setExpenses", response.data);
    },

  async deleteSingleExpense(id) {
      const response = await this.deleteExpense(id);
      await this.getAllExpenses();
    }
  }
};
</script>

We use the form and table from BootstrapVue to build the form and table. For form validation, we wrap ValidationProvider around each b-form-input to get form validation. The form validation are rules are registered in main.js so we can use them here.

We add :state=”errors.length == 0" in each b-form-input so that we get the right validation message displayed and styled properly for each input. The errors object has the form validation error messages for each input. We also need to specify the name prop in ValidationProvider and b-form-input so that form validation rules are applied to the input inside the ValidationProvider . We put the form inside the ValidationObserver component here to let us validate the whole form. With Vee-Validate, we get the this.$refs.observer.validate() function when we use ValidationObserver like we did in the code above. It returns a promise that resolves to true if the form is valid and false otherwise. So if it resolves to false, we don’t run the rest of the function’s code.

this.$store is provided by Vuex. We call commit on it to store the values into the Vuex store. We get the latest values from the store in the computed block.

Next in App.vue , replace the existing code with:

<template>
  <div id="app">
    <b-navbar toggleable="lg" type="dark" variant="info">
      <b-navbar-brand href="#">Expense Tracker</b-navbar-brand>

<b-navbar-toggle target="nav-collapse"></b-navbar-toggle>

<b-collapse id="nav-collapse" is-nav>
        <b-navbar-nav>
          <b-nav-item to="/" :active="path  == '/'">Home</b-nav-item>
          <b-nav-item to="/graph" :active="path  == '/graph'">Graph</b-nav-item>
        </b-navbar-nav>
      </b-collapse>
    </b-navbar>
    <router-view />
  </div>
</template>

<script>
export default {
  beforeMount() {
    window.Chart.defaults.global.defaultFontFamily = `
  -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
  "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
  sans-serif`;
  },
  data() {
    return {
      path: this.$route && this.$route.path
    };
  },
  watch: {
    $route(route) {
      this.path = route.path;
    }
  }
};
</script>

<style lang="scss">
.page {
  padding: 20px;
}
</style>

In this file, we add the BootstrapVue b-navbar to display a top bar. We watch for URL changes so that we can set the correct link to be active. In the data block, we set the initial route, so that we get the correct link highlighted when the app first loads.

Next in main.js , we replace the existing code with:

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import BootstrapVue from "bootstrap-vue";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required, min_value } from "vee-validate/dist/rules";
import { dateFilter } from "./filters/date";
import ExpenseChart from "./charts/ExpenseChart";extend("required", required);
extend("min_value", min_value);
extend("date", {
  validate: value =>
    /^(19|20)dd[/]([1-9]|0[1-9]|1[012])[/]([1-9]|0[1-9]|[12][0-9]|3[01])$/.test(
      value
    ),
  message: "Date must be in YYYY/MM/DD format"
});Vue.component("expense-chart", ExpenseChart);
Vue.filter("formatDate", dateFilter);
Vue.use(BootstrapVue);
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
Vue.config.productionTip = false;new Vue({
  router,
  store,
  render: h => h(App)
}).$mount("#app");

The validation rules we used in Home.vue are added here. Note that we can have custom validation rules like the date rule. It is easy to define custom rules with VeeValidate 3. We also register the ValidationProvider and ExpenseChart components so that we can use them in our app.

We register the ValidationObserver component here to let us validate the whole form in HomePage.vue .

Also, we add the form validation rules from Vee-Validate that we want to use here. We added the built in required and min_value rules that we used in HomePage.vue here and defined the date rule below the first 2 extend calls. The date rule specifies that we check the value for the YYYY/MM/DD format and also specified the error message if validation error is found.

In router.js , we replace the existing code with:

import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'
import Graph from './views/Graph.vue'

Vue.use(Router)

export default new Router({
  mode: 'history',
  base: process.env.BASE_URL,
  routes: [
    {
      path: '/',
      name: 'home',
      component: Home
    },
    {
      path: '/graph',
      name: 'graph',
      component: Graph
    }
  ]
})

to include the Graph page that we created.

In store.js , we replace the code with:

import Vue from "vue";
import Vuex from "vuex";

Vue.use(Vuex);

export default new Vuex.Store({
  state: {
    expenses: []
  },
  mutations: {
    setExpenses(state, payload) {
      state.expenses = payload;
    }
  },
  actions: {}
});

so that we can keep the expense data in the store whenever it’s obtained.

Finally in index.html , we change the existing code to:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width,initial-scale=1.0" />
    <link rel="icon" href="<%= BASE_URL %>favicon.ico" />
    <title>Expense Tracker</title>
  </head>
  <body>
    <noscript>
      <strong
        >We're sorry but frontend doesn't work properly without JavaScript
        enabled. Please enable it to continue.</strong
      >
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>

to change the title. We already imported the Bootstrap styles in App.vue so we don’t have to include it here.

After writing all that code, we can run our app. Before running anything, install nodemon by running npm i -g nodemon so that we don’t have to restart back end ourselves when files change.

Then run back end by running npm start in the backend folder and npm run servein the frontend folder.

Categories
JavaScript Vue

How To Use Vuetify in Vue.js

There is great support for Material Design in Vue.js. One of the libraries available for Vue.js is Vuetify. It is easy to incorporate into your Vue.js app and the result is appealing to the users’ eyes.

In this piece, we will build an app that displays data from the New York Times API. You can register for an API key at https://developer.nytimes.com/. After that, we can start building the app.

To start building the app, we have to install the Vue CLI. We do this by running:

npm install -g @vue/cli

Node.js 8.9 or later is required for Vue CLI to run. I did not have success getting the Vue CLI to run with the Windows version of Node.js. Ubuntu had no problem running Vue CLI for me.

Then, we run:

vue create vuetify-nyt-app

To create the project folder and create the files. In the wizard, instead of using the default options, we choose ‘Manually select features’. We select Babel, Router, and Vuex from the list of options by pressing space on each. If they are green, it means they’re selected.

Now we need to install a few libraries. We need to install an HTTP client, a library for formatting dates, one for generating GET query strings from objects, and another one for form validation.

Also, we need to install the Vue Material library itself. We do this by running:

npm i axios moment querystring vee-validate

axios is our HTTP client, moment is for manipulating dates, querystring is for generating query strings from objects, and vee-validate is an add-on package for Vue.js to do validation.

Then, we have to add the boilerplate for vuetify. We do this by running vue add vuetify. This adds the library and the references to it in our app, in their appropriate locations in our code.

Now that we have all the libraries installed, we can start building our app.

First, we create some components. In the views folder, we create Home.vue and Search.vue. Those are the code files for our pages. Then, create a mixins folder and create a file, called nytMixin.js.

Mixins are code snippets that can be incorporated directly into Vue.js components and used as if they are directly in the component. Then, we add some filters.

Filters are Vue.js code that map from one thing to another. We create a filters folder and add capitalize.js and formatDate.js.

In the components folder, we create a file, called SearchResults.vue. The components folder contains Vue.js components that aren’t pages.

To make passing data between components easier and more organized, we use Vuex for state management. As we selected Vuex when we ran vue create, we should have a store.js in our project folder. If not, create it.

In store.js, we put:

import Vue from 'vue'  
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({  
  state: {  
    searchResults: []  
  },  
  mutations: {  
    setSearchResults(state, payload) {  
      state.searchResults = payload;  
    }  
  },  
  actions: {}  
})

The state object is where the state is stored. The mutations object is where we can manipulate our state.

When we call this.$store.commit(“setSearchResults”, searchResults) in our code, given that searchResults is defined, state.searchResults will be set to searchResults.

We can then get the result by using this.$store.state.searchResults.

We need to add some boilerplate code to our app. First, we add our filter. In capitalize.js, we put:

export const capitalize = (str) => {  
    if (typeof str == 'string') {  
        if (str == 'realestate') {  
            return 'Real Estate';  
        }  
        if (str == 'sundayreview') {  
            return 'Sunday Review';  
        }
        if (str == 'tmagazine') {  
            return 'T Magazine';  
        }  
        return `${str[0].toUpperCase()}${str.slice(1)}`;  
    }  
}

This allows us to map capitalize our New York Times section names, listed in the New York Times developer pages. Then, in formatDate.js, we put:

import * as moment from 'moment';
export const formatDate = (date) => {  
    if (date) {  
        return moment(date).format('YYYY-MM-DD hh:mm A');  
    }  
}

To format our dates into a human-readable format.

In main.js, we put:

import Vue from 'vue'  
import App from './App.vue'  
import router from './router'  
import store from './store'  
import { formatDate } from './filters/formatDate';  
import { capitalize } from './filters/capitalize';  
import VeeValidate from 'vee-validate';  
import Vuetify from 'vuetify/lib'  
import vuetify from './plugins/vuetify';  
import '@mdi/font/css/materialdesignicons.css'
Vue.config.productionTip = false;
Vue.use(VeeValidate);  
Vue.use(Vuetify);  
Vue.filter('formatDate', formatDate);  
Vue.filter('capitalize', capitalize);
new Vue({  
  router,  
  store,  
  vuetify,  
  render: h => h(App)  
}).$mount('#app')

Notice that, in the file above, we have to register the libraries we use with Vue.js by calling Vue.use on them, so they can be used in our app templates.

We call Vue.filter on our filter functions so we can use them in our templates by adding a pipe and the filter name to the right of our variable.

Then, in router.js, we put:

import Vue from 'vue'  
import Router from 'vue-router'  
import Home from './views/Home.vue';  
import Search from './views/Search.vue';
Vue.use(Router)
export default new Router({  
  mode: 'history',  
  base: process.env.BASE_URL,  
  routes: [  
    {  
      path: '/',  
      name: 'home',  
      component: Home  
    },  
    {  
      path: '/search',  
      name: 'search',  
      component: Search  
    }  
  ]  
})

So that we can go to the pages when we enter the URLs listed.

mode: ‘history’ means that we won’t have a hash sign between the base URL and our routes.

If we deploy our app, we need to configure our web server so that all requests will be redirected to index.html so we won’t have errors when we reload the app.

For example, in Apache, we do:

<IfModule mod_rewrite.c>  
  RewriteEngine On  
  RewriteBase /  
  RewriteRule ^index\.html$ - [L]  
  RewriteCond %{REQUEST_FILENAME} !-f  
  RewriteCond %{REQUEST_FILENAME} !-d  
  RewriteRule . /index.html [L]  
</IfModule>

And, in NGINX, we put:

location / {  
  try_files $uri $uri/ /index.html;  
}

See your web server’s documentation for info on how to do the same thing in your web server.

Now, we write the code for our components. In SearchResult.vue, we put:

<template>  
  <v-container>  
    <v-card v-for="s in searchResults" :key="s.id" class="mx-auto">  
      <v-card-title>{{s.headline.main}}</v-card-title><v-list-item>  
        <v-list-item-content>Date: {{s.pub_date | formatDate}}</v-list-item-content>  
      </v-list-item>  
      <v-list-item>  
        <v-list-item-content>  
          <a :href="s.web_url">Link</a>  
        </v-list-item-content>  
      </v-list-item>  
      <v-list-item v-if="s.byline.original">  
        <v-list-item-content>{{s.byline.original}}</v-list-item-content>  
      </v-list-item>  
      <v-list-item>  
        <v-list-item-content>{{s.lead_paragraph}}</v-list-item-content>  
      </v-list-item>  
      <v-list-item>  
        <v-list-item-content>{{s.snippet}}</v-list-item-content>  
      </v-list-item>  
    </v-card>  
  </v-container>  
</template>

<script>  
export default {  
  computed: {  
    searchResults() {  
      return this.$store.state.searchResults;  
    }  
  }  
};  
</script>

<style scoped>  
.title {  
  margin: 0 15px !important;  
}

#search-results {  
  margin: 0 auto;  
  width: 95vw;  
}  
</style>

This is where get our search results from the Vuex store and display them.

We return this.$store.state.searchResults in a function in the computed property in our app so the search results will be automatically refreshed when the store’s searchResults state is updated.

md-card is a card widget for displaying data in a box. v-for is for looping the array entries and displaying everything. md-list is a list widget for displaying items in a list, neatly on the page. {{s.pub_date | formatDate}} is where our formatDate filter is applied.

Next, we write our mixin. We will add code for our HTTP calls in our mixin.

In nytMixin.js, we put:

const axios = require('axios');  
const querystring = require('querystring');  
const apiUrl = 'https://api.nytimes.com/svc';  
const apikey = 'your api key';
export const nytMixin = {  
    methods: {  
        getArticles(section) {  
            return axios.get(`${apiUrl}/topstories/v2/${section}.json?api-key=${apikey}`);  
        },
        searchArticles(data) {  
            let params = Object.assign({}, data);  
            params['api-key'] = apikey;  
            Object.keys(params).forEach(key => {  
                if (!params\[key]) {  
                    delete params[key];  
                }  
            })  
            const queryString = querystring.stringify(params);  
            return axios.get(`${apiUrl}/search/v2/articlesearch.json?${queryString}`);  
        }  
    }  
}

We return the promises for HTTP requests to get articles in each function. In the searchArticles function, we message the object that we pass in into a query string that we pass into our request.

Make sure you put your API key into your app into the apiKey constant and delete anything that is undefined, with:

Object.keys(params).forEach(key => {  
  if (!params[key]) {  
     delete params[key];  
  }  
})

In Home.vue, we put:

<template>  
  <div>  
    <div class="text-center" id="header">  
      <h1>{{selectedSection | capitalize}}</h1>  
      <v-spacer></v-spacer>  
      <v-menu offset-y>  
        <template v-slot:activator="{ on }">  
          <v-btn color="primary" dark v-on="on">Sections</v-btn>  
        </template>  
        <v-list>  
          <v-list-item v-for="(s, index) in sections" :key="index"@click="selectSection(s)">  
            <v-list-item-title>{{ s | capitalize}}</v-list-item-title>  
          </v-list-item>  
        </v-list>  
      </v-menu>  
      <v-spacer></v-spacer>  
      <v-spacer></v-spacer>  
    </div>  
    <v-spacer></v-spacer>  
    <v-card v-for="a in articles" :key="a.id" class="mx-auto">  
      <v-card-title>{{a.title}}</v-card-title>  
      <v-card-text>  
        <v-list-item>  
          <v-list-item-content>Date: {{a.published_date | formatDate}}</v-list-item-content>  
        </v-list-item>  
        <v-list-item>  
          <v-list-item-content>  
            <a :href="a.url">Link</a>  
          </v-list-item-content>  
        </v-list-item>  
        <v-list-item v-if="a.byline">  
          <v-list-item-content>{{a.byline}}</v-list-item-content>  
        </v-list-item>  
        <v-list-item>  
          <v-list-item-content>{{a.abstract}}</v-list-item-content>  
        </v-list-item>  
        <v-list-item>  
          <v-list-item-content>  
            <img  
              v-if="a.multimedia[a.multimedia.length - 1]"  
              :src="a.multimedia[a.multimedia.length - 1].url"  
              :alt="a.multimedia[a.multimedia.length - 1].caption"  
              class="image"  
            />  
          </v-list-item-content>  
        </v-list-item>  
      </v-card-text>  
    </v-card>  
  </div>  
</template>

<script>  
import { nytMixin } from "../mixins/nytMixin";

export default {  
  name: "home",  
  mixins: [nytMixin],  
  computed: {},
  data() {  
    return {  
      selectedSection: "home",  
      articles: [],  
      sections: `arts, automobiles, books, business, fashion, food, health,  
    home, insider, magazine, movies, national, nyregion, obituaries,  
    opinion, politics, realestate, science, sports, sundayreview,  
    technology, theater, tmagazine, travel, upshot, world\`  
        .replace(/ /g, "")  
        .split(",")  
    };  
  },
  beforeMount() {  
    this.getNewsArticles(this.selectedSection);  
  },
  methods: {  
    async getNewsArticles(section) {  
      const response = await this.getArticles(section);  
      this.articles = response.data.results;  
    },selectSection(section) {  
      this.selectedSection = section;  
      this.getNewsArticles(section);  
    }  
  }  
};  
</script>

<style scoped>  
.image {  
  width: 100%;  
}

.title {  
  color: rgba(0, 0, 0, 0.87) !important;  
  margin: 0 15px !important;  
}

.md-card {  
  width: 95vw;  
  margin: 0 auto;  
}

#header {  
  margin-bottom: 10px;  
}  
</style>

This page component is where we get the articles for the selected section, defaulting to the home section. We also have a menu to select the section we want to see, by adding:

<v-menu offset-y>  
   <template v-slot:activator="{ on }">  
     <v-btn color="primary" dark v-on="on">Sections</v-btn>  
   </template>  
   <v-list>  
      <v-list-item v-for="(s, index) in sections" :key="index" @click="selectSection(s)">  
        <v-list-item-title>{{ s | capitalize}}</v-list-item-title>  
      </v-list-item>  
   </v-list>  
</v-menu>

Notice that we use the async and await keywords in our promises code, instead of using then.

It is much shorter and the functionality between then, await, and async is equivalent. However, it is not supported in Internet Explorer. In the beforeMount block, we run the this.getNewsArticles to get the articles as the page loads.

Note that the Vuetify library uses the slots of the features of Vue.js extensively. The elements with nesting, like the v-slot prop, are in:

<v-menu offset-y>  
  <template v-slot:activator="{ on }">  
     <v-btn color="primary" dark v-on="on">Sections</v-btn>  
  </template>  
  <v-list>  
    <v-list-item v-for="(s, index) in sections" :key="index" @click="selectSection(s)">  
       <v-list-item-title>{{ s | capitalize}}</v-list-item-title>  
     </v-list-item>  
  </v-list>  
</v-menu>

See the Vue.js guide for details.

In Search.vue, we put:

<template>  
  <div>  
    <form>  
      <v-text-field  
        v-model="searchData.keyword"  
        v-validate="'required'"  
        :error-messages="errors.collect('keyword')"  
        label="Keyword"  
        data-vv-name="keyword"  
        required  
      ></v-text-field> <v-menu  
        ref="menu"  
        v-model="toggleBeginDate"  
        :close-on-content-click="false"  
        transition="scale-transition"  
        offset-y  
        full-width  
        min-width="290px"  
      >  
        <template v-slot:activator="{ on }">  
          <v-text-field  
            v-model="searchData.beginDate"  
            label="Begin Date"  
            prepend-icon="event"  
            readonly  
            v-on="on"  
          ></v-text-field>  
        </template>  
        <v-date-picker  
          v-model="searchData.beginDate"  
          no-title  
          scrollable  
          :max="new Date().toISOString()"  
        >  
          <v-spacer></v-spacer>  
          <v-btn text color="primary" @click="toggleBeginDate = false">Cancel</v-btn>  
          <v-btn  
            text  
            color="primary"  
           @click="$refs.menu.save(searchData.beginDate); toggleBeginDate = false"  
          >OK</v-btn>  
        </v-date-picker>  
      </v-menu> <v-menu  
        ref="menu"  
        v-model="toggleEndDate"  
        :close-on-content-click="false"  
        transition="scale-transition"  
        offset-y  
        full-width  
        min-width="290px"  
      >  
        <template v-slot:activator="{ on }">  
          <v-text-field  
            v-model="searchData.endDate"  
            label="End Date"  
            prepend-icon="event"  
            readonly  
            v-on="on"  
          ></v-text-field>  
        </template>  
        <v-date-picker  
          v-model="searchData.endDate"  
          no-title  
          scrollable  
          :max="new Date().toISOString()"  
        >  
          <v-spacer></v-spacer>  
          <v-btn text color="primary" @click="toggleEndDate = false">Cancel</v-btn>  
          <v-btn  
            text  
            color="primary"  
            @click="$refs.menu.save(searchData.endDate); toggleEndDate = false"  
          >OK</v-btn>  
        </v-date-picker>  
      </v-menu> 
      <v-select  
        v-model="searchData.sort"  
        :items="sortChoices"  
        label="Sort By"  
        data-vv-name="sort"  
        item-value="value"  
        item-text="name"  
      >  
        <template slot="selection" slot-scope="{ item }">{{ item.name }}</template>  
        <template slot="item" slot-scope="{ item }">{{ item.name }}</template>  
      </v-select>
      <v-btn class="mr-4" type="submit" @click="search">Search</v-btn>  
    </form>  
    <SearchResults />  
  </div>  
</template>

<script>  
import { nytMixin } from "../mixins/nytMixin";  
import SearchResults from "@/components/SearchResults.vue";  
import * as moment from "moment";  
import { capitalize } from "@/filters/capitalize";export default {  
  name: "search",  
  mixins: [nytMixin],  
  components: {  
    SearchResults  
  },  
  computed: {  
    isFormDirty() {  
      return Object.keys(this.fields).some(key => this.fields[key].dirty);  
    }  
  },  
  data: () => {  
    return {  
      searchData: {  
        sort: "newest"  
      },  
      disabledDates: date => {  
        return +date >= +new Date();  
      },  
      sortChoices: [  
        {  
          value: "newest",  
          name: "Newest"  
        },  
        {  
          value: "oldest",  
          name: "Oldest"  
        },  
        {  
          value: "relevance",  
          name: "Relevance"  
        }  
      ],  
      toggleBeginDate: false,  
      toggleEndDate: false  
    };  
  },  
  methods: {  
    async search(evt) {  
      evt.preventDefault();  
      if (!this.isFormDirty || this.errors.items.length > 0) {  
        return;  
      }  
      const data = {  
        q: this.searchData.keyword,  
        begin_date: moment(this.searchData.beginDate).format("YYYYMMDD"),  
        end_date: moment(this.searchData.endDate).format("YYYYMMDD"),  
        sort: this.searchData.sort  
      };  
      const response = await this.searchArticles(data);  
      this.$store.commit("setSearchResults", response.data.response.docs);  
    }  
  }  
};  
</script>

This is where we have a form to search for articles. We also have two date-pickers to label users to set the start and end dates. We only restrict the dates to today and earlier so that the search query makes sense.

In this block:

<v-text-field  
  v-model="searchData.keyword"  
  v-validate="'required'"  
  :error-messages="errors.collect('keyword')"  
  label="Keyword"  
  data-vv-name="keyword"  
  required  
></v-text-field>

We use vee-validate to check if the required search keyword field is filled in. If it’s not, it’ll display an error message and prevent the query from proceeding.

We also nested our SearchResults component into the Search page component, by including:

components: {  
  SearchResults  
}

Between the script tag and <SearchResults /> in the template.

Finally, we add our top bar and menu by putting the following in App.vue:

<template>  
  <v-app>  
    <v-navigation-drawer v-model="drawer" app>  
      <v-list nav dense>  
        <v-list-item-group v-model="group" active-class="deep-purple--text text--accent-4">  
          <v-list-item>  
            <v-list-item-title>New Yourk Times Vuetify App</v-list-item-title>  
          </v-list-item><v-list-item>  
            <v-list-item-title>  
              <router-link to="/">Home</router-link>  
            </v-list-item-title>  
          </v-list-item><v-list-item>  
            <v-list-item-title>  
              <router-link to="/search">Search</router-link>  
            </v-list-item-title>  
          </v-list-item>  
        </v-list-item-group>  
      </v-list>  
    </v-navigation-drawer><v-app-bar app>  
      <v-toolbar-title class="headline text-uppercase">  
        <v-app-bar-nav-icon @click.stop="drawer = !drawer"></v-app-bar-nav-icon>  
        <span>New York Times Vuetify App</span>  
      </v-toolbar-title>  
      <v-spacer></v-spacer>  
    </v-app-bar><v-content>  
      <v-container fluid>  
        <router-view />  
      </v-container>  
    </v-content>  
  </v-app>  
</template>

<script>  
export default {  
  name: "app",  
  data: () => {  
    return {  
      showNavigation: false,  
      drawer: false,  
      group: null  
    };  
  }  
};  
</script>

<style>  
.center {  
  text-align: center;  
}

form {  
  width: 95vw;  
  margin: 0 auto;  
}

.md-toolbar.md-theme-default {  
  background: #009688 !important;  
  height: 60px;  
}

.md-title,  
.md-toolbar.md-theme-default .md-icon {  
  color: #fff !important;  
}  
</style>

If you want a top bar with a left navigation drawer, you have to follow the code structure above precisely.

Categories
JavaScript Vue

Form Validation with Vee-Validate

Vue.js is a great framework for building front end web apps. It uses a component-based architecture which makes organizing code easy. It allows you to use the latest features JavaScript has to offer which means writing code to build your apps is easy than ever. It has a lot of add-ons like routing and flux store that you can add when you scaffold your app.

However, one thing that is missing is form validation. This means that we have to find our own form validation library to do form validation or write the form validation code ourselves.

If we choose to use a library to do form validation, Vee-Validate is a great choice plugging directly into Vue.js form code to do form validation. Vee-Validate primary adds code to Vue.js component templates to enable form validation for Vue.js forms. It has form validation rules for many kinds of inputs. Therefore, it is a great choice for validating Vue.js forms.

In this story, we will build an address book app with Vue.js that uses Vee-Validate to validate our inputs. The form allows us to add and edit our contacts; also, we can get and delete contacts.

To build our app, first, we need to quickly set up a back end. To do this, we use a Node.js package called JSON Server to run our back end. Find the package’s documentation here.

Once this is running, it provides us with routes for us to save our contact entries from front end.

To install the package, run:

npm install -g json-server

We will run this later so we can save our contacts.

Now we can start building our app. To do this, install the Vue CLI by running:

npm install -g @vue/cli

Then create the app by running:

vue create vee-validate-address-book-app

vee-validate-address-book-app is our app name. When running the wizard, be sure you choose to include Vuex and Vue Router as we will use it later. Next, we have to install some libraries. We need an HTTP client, a Material Design library for making our app look good, and the Vee-Validate library.

To do this, run npm i axios vee-validate vue-material . Axios is our HTTP client for communicating to back end. Vue Material is our Material Design library.

Next, we create our components that we nest in our page components. To do this, create a components folder in our project folder and create a file within it called ContactForm.vue .

In this file, we put:

<template>
  <div class="contact-form">
    <div class="center">
      <h1>{{editing ? 'Edit': 'Add'}} Contact</h1>
    </div>
    <form novalidate class="md-layout" @submit="save">
      <md-field :class="{ 'md-invalid': errors.has('firstName') }">
        <label for="firstName">First Name</label>
        <md-input
          name="firstName"
          v-model="contact.firstName"
          v-validate="'required'"
          :disabled="sending"
        />
        <span class="md-error" v-if="errors.has('firstName')">First Name is required.</span>
      </md-field>
      <br />
      <md-field :class="{ 'md-invalid': errors.has('lastName') }">
        <label for="lastName">Last Name</label>
        <md-input
          name="lastName"
          v-model="contact.lastName"
          :disabled="sending"
          v-validate="'required'"
        />
        <span class="md-error" v-if="errors.has('lastName')">Last Name is required.</span>
      </md-field>
      <br />
      <md-field :class="{ 'md-invalid': errors.has('addressLineOne') }">
        <label for="addressLineOne">Address Line 1</label>
        <md-input
          name="addressLineOne"
          v-model="contact.addressLineOne"
          :disabled="sending"
          v-validate="'required'"
        />
        <span class="md-error" v-if="errors.has('addressLineOne')">Address line 1 is required.</span>
      </md-field>
      <br />
      <md-field :class="{ 'md-invalid': errors.has('addressLineTwo') }">
        <label for="addressLineTwo">Address Line 2</label>
        <md-input name="addressLineTwo" v-model="contact.addressLineTwo" :disabled="sending" />
        <span class="md-error" v-if="errors.has('addressLineTwo')">Address line 2 is required</span>
      </md-field>
      <br />
      <md-field :class="{ 'md-invalid': errors.has('city') }">
        <label for="city">City</label>
        <md-input name="city" v-model="contact.city" :disabled="sending" v-validate="'required'" />
        <span class="md-error" v-if="errors.has('city')">City is required.</span>
      </md-field>
      <br />
      <md-field :class="{ 'md-invalid': errors.has('country') }">
        <label for="country">Country</label>
        <md-select
          name="country"
          v-model="contact.country"
          md-dense
          :disabled="sending"
          v-validate.continues="'required'"
        >
          <md-option :value="c" :key="c" v-for="c in countries">{{c}}</md-option>
        </md-select>
        <span class="md-error" v-if="errors.firstByRule('country', 'required')">Country is required.</span>
      </md-field>
      <br />
      <md-field :class="{ 'md-invalid': errors.has('postalCode') }">
        <label for="postalCode">Postal Code</label>
        <md-input
          name="postalCode"
          v-model="contact.postalCode"
          :disabled="sending"
          v-validate="{ required: true, regex: getPostalCodeRegex() }"
        />
        <span
          class="md-error"
          v-if="errors.firstByRule('postalCode', 'required')"
        >Postal Code is required.</span>
        <span
          class="md-error"
          v-if="errors.firstByRule('postalCode', 'regex')"
        >Postal Code is invalid.</span>
      </md-field>
      <br />
      <md-field :class="{ 'md-invalid': errors.has('phone') }">
        <label for="phone">Phone</label>
        <md-input
          name="phone"
          v-model="contact.phone"
          :disabled="sending"
          v-validate="{ required: true, regex: getPhoneRegex() }"
        />
        <span class="md-error" v-if="errors.firstByRule('phone', 'required')">Phone is required.</span>
        <span class="md-error" v-if="errors.firstByRule('phone', 'regex')">Phone is invalid.</span>
      </md-field>
      <br />
      <md-field :class="{ 'md-invalid': errors.has('gender') }">
        <label for="gender">Gender</label>
        <md-select
          name="gender"
          v-model="contact.gender"
          md-dense
          :disabled="sending"
          v-validate.continues="'required'"
        >
          <md-option value="male">Male</md-option>
          <md-option value="female">Female</md-option>
        </md-select>
        <span class="md-error" v-if="errors.firstByRule('gender', 'required')">Gender is required.</span>
      </md-field>
      <br />
      <md-field :class="{ 'md-invalid': errors.has('age') }">
        <label for="age">Age</label>
        <md-input
          type="number"
          id="age"
          name="age"
          autocomplete="age"
          v-model="contact.age"
          :disabled="sending"
          v-validate="'required|between:0,200'"
        />
        <span class="md-error" v-if="errors.firstByRule('age', 'required')">Age is required.</span>
        <span class="md-error" v-if="errors.firstByRule('age', 'between')">Age must be 0 and 200.</span>
      </md-field>
      <br />
      <md-field :class="{ 'md-invalid': errors.has('email') }">
        <label for="email">Email</label>
        <md-input
          type="email"
          name="email"
          autocomplete="email"
          v-model="contact.email"
          :disabled="sending"
          v-validate="'required|email'"
        />
        <span class="md-error" v-if="errors.firstByRule('email', 'required')">Email is required.</span>
        <span class="md-error" v-if="errors.firstByRule('email', 'email')">Email is invalid.</span>
      </md-field>
<md-progress-bar md-mode="indeterminate" v-if="sending" />
<md-button type="submit" class="md-raised">{{editing ? 'Edit':'Create'}} Contact</md-button>
    </form>
  </div>
</template>

<script>
import { COUNTRIES } from "@/helpers/exports";
import { contactMixin } from "@/mixins/contactMixin";
export default {
  name: "ContactForm",
  mixins: [contactMixin],
  props: {
    editing: Boolean,
    contactId: Number
  },
  computed: {
    isFormDirty() {
      return Object.keys(this.fields).some(key => this.fields[key].dirty);
    },
    contacts() {
      return this.$store.state.contacts;
    }
  },
  data() {
    return {
      sending: false,
      contact: {},
      countries: COUNTRIES.map(c => c.name)
    };
  },
  beforeMount() {
    this.contact = this.contacts.find(c => c.id == this.contactId) || {};
  },
  methods: {
    async save(evt) {
      evt.preventDefault();
      try {
        const result = await this.$validator.validateAll();
        if (!result) {
          return;
        }
        if (this.editing) {
          await this.updateContact(this.contact, this.contactId);
          await this.getAllContacts();
          this.$emit("contactSaved");
        } else {
          await this.addContact(this.contact);
          await this.getAllContacts();
          this.$router.push("/");
        }
      } catch (ex) {
        console.log(ex);
      }
    },
    async getAllContacts() {
      try {
        const response = await this.getContacts();
        this.$store.commit("setContacts", response.data);
      } catch (ex) {
        console.log(ex);
      }
    },
    getPostalCodeRegex() {
      if (this.contact.country == "United States") {
        return /^[0-9]{5}(?:-[0-9]{4})?$/;
      } else if (this.contact.country == "Canada") {
        return /^[A-Za-z]\d[A-Za-z][ -]?\d[A-Za-z]\d$/;
      }
      return /./;
    },
    getPhoneRegex() {
      if (["United States", "Canada"].includes(this.contact.country)) {
        return /^[2-9]\d{2}[2-9]\d{2}\d{4}$/;
      }
      return /./;
    }
  }
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
.contact-form {
  margin: 0 auto;
  width: 90%;
}
</style>

In the file above, we have the contact form for adding and updating contacts in our address book — where Vee-Validate is used the most. Notice that in most input controls within the form tag, we have the v-validate prop.

This is where we specify what kind of input the control accepts.

required means that the form field is required.

regex means we validate against a specified regular expression.

This allows for custom form validation where there are no built-in rules for Vee-Validate available, or when you need to validate the field differently, depending on the value of another field.

For example for a phone number, we have this function:

getPhoneRegex() {  
  if (["United States", "Canada"].includes(this.contact.country)){  
    return /^\[2-9\]\\d{2}\[2-9\]\\d{2}\\d{4}$/;  
  }  
  return /./;  
}

It allows us to validate the number to see if it matches the North American telephone format when we enter United States or Canada. Otherwise, we let people enter whatever they want.

Similarly, for postal code, we have:

getPostalCodeRegex() {  
  if (this.contact.country == "United States") {  
    return /^[0-9]{5}(?:-\[0-9\]{4})?$/;  
  } 
  else if (this.contact.country == "Canada") {  
    return /^[A-Za-z]\d\[A-Za-z]\[ -]?\d\[A-Za-z]\d$/;  
  }  
  return /./;  
}

This allows us to check for U.S. and Canadian postal codes.

To display errors, we can check if errors exist for a form field, then display them. For example, for a first name, we have:

<span class="md-error" v-if="errors.has('firstName')">First Name is required.</span>

errors.has(‘firstName’) checks if the first name field meets the specified validation criteria. Since we’re checking if it’s filled in, there is only one possible error, so we can just display the only error when errors.has(‘firstName’) returns true .

For something more complex like a phone number, we have:

<span class="md-error" v-if="errors.firstByRule('phone', 'required')">Phone is required.</span>
<span class="md-error" v-if="errors.firstByRule('phone', 'regex')">Phone is invalid.</span>

This allows us to check for each validation rule separately. For the phone number field, we have to check if it’s filled in and if what’s filled in has a valid format. The errors.firstByRule function allows us to do that.

errors.firstByRule(‘phone’, ‘required’) returns true if the field is not filled in and false otherwise.

errors.firstByRule(‘phone’, ‘regex’) returns true is the phone number format is filled in incorrectly and false otherwise.

Vee-Validate provides a this.field object to your component. So we can check if fields are dirty, meaning whether they have been manipulated or not, by adding:

Object.keys(this.fields).some(key => this.fields\[key\].dirty)

Each property is a form field and each property of the this.fields object has a dirty property, so we can check whether fields are manipulated or not.

In the save function of the methods object, we have:

async save(evt) {  
  evt.preventDefault();  
  try {  
    const result = await this.$validator.validateAll();  
    if (!result) {  
      return;  
    }  
    if (this.editing) {  
      await this.updateContact(this.contact, this.contactId);  
      await this.getAllContacts();  
      this.$emit("contactSaved");  
    } 
    else {  
      await this.addContact(this.contact);  
      await this.getAllContacts();  
      this.$router.push("/");  
    }  
  } 
  catch (ex) {  
    console.log(ex);  
  }  
},

We need evt.preventDefault() to stop the form from submitting the normal way, i.e. without calling the Ajax code below.

this.$validator.validateAll() validates the form.

this.$validator is an object provided by Vee-Validate.

It returns a promise, so we need the function to be async, and we need await before the function call.

If result is falsy, the form validation failed, so we run return to stop the rest of the function from executing. Finally, if form fields are all valid, we can submit. Since this form is used for both adding and editing contacts, we have to check which action we’re doing. If we edit, then we call await this.updateContact(this.contact, this.contactId); to update our contact. Otherwise, we add contact so we call await this.addContact(this.contact);

In either case, we call await this.getAllContacts(); to refresh our contacts and put them in the store. If we are adding, then we will redirect to the home page at the end by calling this.$router.push(“/”); . this.updateContact , this.addContact , and this.getAllContacts are all from our contactMixin which we will write shortly.

Next, we write some helper code.

Create a folder called helpers and make a file within it called export.js — put in the following:

export const COUNTRIES = [  
    { "name": "Afghanistan", "code": "AF" },  
    { "name": "Aland Islands", "code": "AX" },  
    { "name": "Albania", "code": "AL" },  
    { "name": "Algeria", "code": "DZ" },  
    { "name": "American Samoa", "code": "AS" },  
    { "name": "AndorrA", "code": "AD" },  
    { "name": "Angola", "code": "AO" },  
    { "name": "Anguilla", "code": "AI" },  
    { "name": "Antarctica", "code": "AQ" },  
    { "name": "Antigua and Barbuda", "code": "AG" },  
    { "name": "Argentina", "code": "AR" },  
    { "name": "Armenia", "code": "AM" },  
    { "name": "Aruba", "code": "AW" },  
    { "name": "Australia", "code": "AU" },  
    { "name": "Austria", "code": "AT" },  
    { "name": "Azerbaijan", "code": "AZ" },  
    { "name": "Bahamas", "code": "BS" },  
    { "name": "Bahrain", "code": "BH" },  
    { "name": "Bangladesh", "code": "BD" },  
    { "name": "Barbados", "code": "BB" },  
    { "name": "Belarus", "code": "BY" },  
    { "name": "Belgium", "code": "BE" },  
    { "name": "Belize", "code": "BZ" },  
    { "name": "Benin", "code": "BJ" },  
    { "name": "Bermuda", "code": "BM" },  
    { "name": "Bhutan", "code": "BT" },  
    { "name": "Bolivia", "code": "BO" },  
    { "name": "Bosnia and Herzegovina", "code": "BA" },  
    { "name": "Botswana", "code": "BW" },  
    { "name": "Bouvet Island", "code": "BV" },  
    { "name": "Brazil", "code": "BR" },  
    { "name": "British Indian Ocean Territory", "code": "IO" },  
    { "name": "Brunei Darussalam", "code": "BN" },  
    { "name": "Bulgaria", "code": "BG" },  
    { "name": "Burkina Faso", "code": "BF" },  
    { "name": "Burundi", "code": "BI" },  
    { "name": "Cambodia", "code": "KH" },  
    { "name": "Cameroon", "code": "CM" },  
    { "name": "Canada", "code": "CA" },  
    { "name": "Cape Verde", "code": "CV" },  
    { "name": "Cayman Islands", "code": "KY" },  
    { "name": "Central African Republic", "code": "CF" },  
    { "name": "Chad", "code": "TD" },  
    { "name": "Chile", "code": "CL" },  
    { "name": "China", "code": "CN" },  
    { "name": "Christmas Island", "code": "CX" },  
    { "name": "Cocos (Keeling) Islands", "code": "CC" },  
    { "name": "Colombia", "code": "CO" },  
    { "name": "Comoros", "code": "KM" },  
    { "name": "Congo", "code": "CG" },  
    { "name": "Congo, The Democratic Republic of the", "code": "CD" },  
    { "name": "Cook Islands", "code": "CK" },  
    { "name": "Costa Rica", "code": "CR" },  
    {  
        "name": "Cote D\\"Ivoire", "code": "CI"  
    },  
    { "name": "Croatia", "code": "HR" },  
    { "name": "Cuba", "code": "CU" },  
    { "name": "Cyprus", "code": "CY" },  
    { "name": "Czech Republic", "code": "CZ" },  
    { "name": "Denmark", "code": "DK" },  
    { "name": "Djibouti", "code": "DJ" },  
    { "name": "Dominica", "code": "DM" },  
    { "name": "Dominican Republic", "code": "DO" },  
    { "name": "Ecuador", "code": "EC" },  
    { "name": "Egypt", "code": "EG" },  
    { "name": "El Salvador", "code": "SV" },  
    { "name": "Equatorial Guinea", "code": "GQ" },  
    { "name": "Eritrea", "code": "ER" },  
    { "name": "Estonia", "code": "EE" },  
    { "name": "Ethiopia", "code": "ET" },  
    { "name": "Falkland Islands (Malvinas)", "code": "FK" },  
    { "name": "Faroe Islands", "code": "FO" },  
    { "name": "Fiji", "code": "FJ" },  
    { "name": "Finland", "code": "FI" },  
    { "name": "France", "code": "FR" },  
    { "name": "French Guiana", "code": "GF" },  
    { "name": "French Polynesia", "code": "PF" },  
    { "name": "French Southern Territories", "code": "TF" },  
    { "name": "Gabon", "code": "GA" },  
    { "name": "Gambia", "code": "GM" },  
    { "name": "Georgia", "code": "GE" },  
    { "name": "Germany", "code": "DE" },  
    { "name": "Ghana", "code": "GH" },  
    { "name": "Gibraltar", "code": "GI" },  
    { "name": "Greece", "code": "GR" },  
    { "name": "Greenland", "code": "GL" },  
    { "name": "Grenada", "code": "GD" },  
    { "name": "Guadeloupe", "code": "GP" },  
    { "name": "Guam", "code": "GU" },  
    { "name": "Guatemala", "code": "GT" },  
    { "name": "Guernsey", "code": "GG" },  
    { "name": "Guinea", "code": "GN" },  
    { "name": "Guinea-Bissau", "code": "GW" },  
    { "name": "Guyana", "code": "GY" },  
    { "name": "Haiti", "code": "HT" },  
    { "name": "Heard Island and Mcdonald Islands", "code": "HM" },  
    { "name": "Holy See (Vatican City State)", "code": "VA" },  
    { "name": "Honduras", "code": "HN" },  
    { "name": "Hong Kong", "code": "HK" },  
    { "name": "Hungary", "code": "HU" },  
    { "name": "Iceland", "code": "IS" },  
    { "name": "India", "code": "IN" },  
    { "name": "Indonesia", "code": "ID" },  
    { "name": "Iran, Islamic Republic Of", "code": "IR" },  
    { "name": "Iraq", "code": "IQ" },  
    { "name": "Ireland", "code": "IE" },  
    { "name": "Isle of Man", "code": "IM" },  
    { "name": "Israel", "code": "IL" },  
    { "name": "Italy", "code": "IT" },  
    { "name": "Jamaica", "code": "JM" },  
    { "name": "Japan", "code": "JP" },  
    { "name": "Jersey", "code": "JE" },  
    { "name": "Jordan", "code": "JO" },  
    { "name": "Kazakhstan", "code": "KZ" },  
    { "name": "Kenya", "code": "KE" },  
    { "name": "Kiribati", "code": "KI" },  
    {  
        "name": "Korea, Democratic People\"s Republic of", "code": "KP"  
    },  
    { "name": "Korea, Republic of", "code": "KR" },  
    { "name": "Kuwait", "code": "KW" },  
    { "name": "Kyrgyzstan", "code": "KG" },  
    {  
        "name": "Lao People\"s Democratic Republic", "code": "LA"  
    },  
    { "name": "Latvia", "code": "LV" },  
    { "name": "Lebanon", "code": "LB" },  
    { "name": "Lesotho", "code": "LS" },  
    { "name": "Liberia", "code": "LR" },  
    { "name": "Libyan Arab Jamahiriya", "code": "LY" },  
    { "name": "Liechtenstein", "code": "LI" },  
    { "name": "Lithuania", "code": "LT" },  
    { "name": "Luxembourg", "code": "LU" },  
    { "name": "Macao", "code": "MO" },  
    { "name": "Macedonia, The Former Yugoslav Republic of", "code": "MK" },  
    { "name": "Madagascar", "code": "MG" },  
    { "name": "Malawi", "code": "MW" },  
    { "name": "Malaysia", "code": "MY" },  
    { "name": "Maldives", "code": "MV" },  
    { "name": "Mali", "code": "ML" },  
    { "name": "Malta", "code": "MT" },  
    { "name": "Marshall Islands", "code": "MH" },  
    { "name": "Martinique", "code": "MQ" },  
    { "name": "Mauritania", "code": "MR" },  
    { "name": "Mauritius", "code": "MU" },  
    { "name": "Mayotte", "code": "YT" },  
    { "name": "Mexico", "code": "MX" },  
    { "name": "Micronesia, Federated States of", "code": "FM" },  
    { "name": "Moldova, Republic of", "code": "MD" },  
    { "name": "Monaco", "code": "MC" },  
    { "name": "Mongolia", "code": "MN" },  
    { "name": "Montenegro", "code": "ME" },  
    { "name": "Montserrat", "code": "MS" },  
    { "name": "Morocco", "code": "MA" },  
    { "name": "Mozambique", "code": "MZ" },  
    { "name": "Myanmar", "code": "MM" },  
    { "name": "Namibia", "code": "NA" },  
    { "name": "Nauru", "code": "NR" },  
    { "name": "Nepal", "code": "NP" },  
    { "name": "Netherlands", "code": "NL" },  
    { "name": "Netherlands Antilles", "code": "AN" },  
    { "name": "New Caledonia", "code": "NC" },  
    { "name": "New Zealand", "code": "NZ" },  
    { "name": "Nicaragua", "code": "NI" },  
    { "name": "Niger", "code": "NE" },  
    { "name": "Nigeria", "code": "NG" },  
    { "name": "Niue", "code": "NU" },  
    { "name": "Norfolk Island", "code": "NF" },  
    { "name": "Northern Mariana Islands", "code": "MP" },  
    { "name": "Norway", "code": "NO" },  
    { "name": "Oman", "code": "OM" },  
    { "name": "Pakistan", "code": "PK" },  
    { "name": "Palau", "code": "PW" },  
    { "name": "Palestinian Territory, Occupied", "code": "PS" },  
    { "name": "Panama", "code": "PA" },  
    { "name": "Papua New Guinea", "code": "PG" },  
    { "name": "Paraguay", "code": "PY" },  
    { "name": "Peru", "code": "PE" },  
    { "name": "Philippines", "code": "PH" },  
    { "name": "Pitcairn", "code": "PN" },  
    { "name": "Poland", "code": "PL" },  
    { "name": "Portugal", "code": "PT" },  
    { "name": "Puerto Rico", "code": "PR" },  
    { "name": "Qatar", "code": "QA" },  
    { "name": "Reunion", "code": "RE" },  
    { "name": "Romania", "code": "RO" },  
    { "name": "Russian Federation", "code": "RU" },  
    { "name": "RWANDA", "code": "RW" },  
    { "name": "Saint Helena", "code": "SH" },  
    { "name": "Saint Kitts and Nevis", "code": "KN" },  
    { "name": "Saint Lucia", "code": "LC" },  
    { "name": "Saint Pierre and Miquelon", "code": "PM" },  
    { "name": "Saint Vincent and the Grenadines", "code": "VC" },  
    { "name": "Samoa", "code": "WS" },  
    { "name": "San Marino", "code": "SM" },  
    { "name": "Sao Tome and Principe", "code": "ST" },  
    { "name": "Saudi Arabia", "code": "SA" },  
    { "name": "Senegal", "code": "SN" },  
    { "name": "Serbia", "code": "RS" },  
    { "name": "Seychelles", "code": "SC" },  
    { "name": "Sierra Leone", "code": "SL" },  
    { "name": "Singapore", "code": "SG" },  
    { "name": "Slovakia", "code": "SK" },  
    { "name": "Slovenia", "code": "SI" },  
    { "name": "Solomon Islands", "code": "SB" },  
    { "name": "Somalia", "code": "SO" },  
    { "name": "South Africa", "code": "ZA" },  
    { "name": "South Georgia and the South Sandwich Islands", "code": "GS" },  
    { "name": "Spain", "code": "ES" },  
    { "name": "Sri Lanka", "code": "LK" },  
    { "name": "Sudan", "code": "SD" },  
    { "name": "Suriname", "code": "SR" },  
    { "name": "Svalbard and Jan Mayen", "code": "SJ" },  
    { "name": "Swaziland", "code": "SZ" },  
    { "name": "Sweden", "code": "SE" },  
    { "name": "Switzerland", "code": "CH" },  
    { "name": "Syrian Arab Republic", "code": "SY" },  
    { "name": "Taiwan, Province of China", "code": "TW" },  
    { "name": "Tajikistan", "code": "TJ" },  
    { "name": "Tanzania, United Republic of", "code": "TZ" },  
    { "name": "Thailand", "code": "TH" },  
    { "name": "Timor-Leste", "code": "TL" },  
    { "name": "Togo", "code": "TG" },  
    { "name": "Tokelau", "code": "TK" },  
    { "name": "Tonga", "code": "TO" },  
    { "name": "Trinidad and Tobago", "code": "TT" },  
    { "name": "Tunisia", "code": "TN" },  
    { "name": "Turkey", "code": "TR" },  
    { "name": "Turkmenistan", "code": "TM" },  
    { "name": "Turks and Caicos Islands", "code": "TC" },  
    { "name": "Tuvalu", "code": "TV" },  
    { "name": "Uganda", "code": "UG" },  
    { "name": "Ukraine", "code": "UA" },  
    { "name": "United Arab Emirates", "code": "AE" },  
    { "name": "United Kingdom", "code": "GB" },  
    { "name": "United States", "code": "US" },  
    { "name": "United States Minor Outlying Islands", "code": "UM" },  
    { "name": "Uruguay", "code": "UY" },  
    { "name": "Uzbekistan", "code": "UZ" },  
    { "name": "Vanuatu", "code": "VU" },  
    { "name": "Venezuela", "code": "VE" },  
    { "name": "Viet Nam", "code": "VN" },  
    { "name": "Virgin Islands, British", "code": "VG" },  
    { "name": "Virgin Islands, U.S.", "code": "VI" },  
    { "name": "Wallis and Futuna", "code": "WF" },  
    { "name": "Western Sahara", "code": "EH" },  
    { "name": "Yemen", "code": "YE" },  
    { "name": "Zambia", "code": "ZM" },  
    { "name": "Zimbabwe", "code": "ZW" }  
]

This provides the countries that we reference in ContactForm.vue .

Next, we add our mixin to manipulate our contacts by communicating with our back end. We make a folder call mixins and create a file called contactMixin.js within it.

In the file, we put:

const axios = require('axios');  
const apiUrl = 'http://localhost:3000';

export const contactMixin = {  
    methods: {  
        getContacts() {  
            return axios.get(`${apiUrl}/contacts`);  
        }, 

        addContact(data) {  
            return axios.post(`${apiUrl}/contacts`, data);  
        }, 

        updateContact(data, id) {  
            return axios.put(`${apiUrl}/contacts/${id}`, data);  
        }, 

        deleteContact(id) {  
            return axios.delete(`${apiUrl}/contacts/${id}`);  
        }  
    }  
}

This will let us include our functions in the methods object of the component object we include or mixin with by putting it in the mixins array of our component object.

Then we add our pages. To do this, create a views folder if it doesn’t already exists and add ContactFormPage.vue .

In there, put:

<template>  
  <div class="about">  
    <ContactForm :edit="false" />  
  </div>  
</template>

<script>  
// @ is an alias to /src  
import ContactForm from "@/components/ContactForm.vue";

export default {  
  name: "ContactFormPage",  
  components: {  
    ContactForm  
  }  
};  
</script>

This just displays the ContactForm component that we created. We set the :edit prop to false so that it’ll add our contact instead of editing.

Next, we add our home page to display a list of contacts. In the views folder, we add a file called Home.vue if it doesn’t already exist. In there we put:

<template>  
  <div class="home">  
    <div class="center">  
      <h1>Address Book Home</h1>  
    </div>  
    <md-table>  
      <md-table-row>  
        <md-table-head md-numeric>ID</md-table-head>  
        <md-table-head>First Name</md-table-head>  
        <md-table-head>Last Name</md-table-head>  
        <md-table-head>Address Line 1</md-table-head>  
        <md-table-head>Address Line 2</md-table-head>  
        <md-table-head>City</md-table-head>  
        <md-table-head>Country</md-table-head>  
        <md-table-head>Postal Code</md-table-head>  
        <md-table-head>Gender</md-table-head>  
        <md-table-head>Age</md-table-head>  
        <md-table-head>Email</md-table-head>  
        <md-table-head></md-table-head>  
        <md-table-head></md-table-head>  
      </md-table-row><md-table-row v-for="c in contacts" :key="c.id">  
        <md-table-cell md-numeric>{{c.id}}</md-table-cell>  
        <md-table-cell>{{c.firstName}}</md-table-cell>  
        <md-table-cell>{{c.lastName}}</md-table-cell>  
        <md-table-cell>{{c.addressLineOne}}</md-table-cell>  
        <md-table-cell>{{c.addressLineTwo}}</md-table-cell>  
        <md-table-cell>{{c.city}}</md-table-cell>  
        <md-table-cell>{{c.country}}</md-table-cell>  
        <md-table-cell>{{c.postalCode}}</md-table-cell>  
        <md-table-cell>{{c.gender}}</md-table-cell>  
        <md-table-cell md-numeric>{{c.age}}</md-table-cell>  
        <md-table-cell>{{c.email}}</md-table-cell>  
        <md-table-cell>  
          <md-button class="md-primary" @click="selectedContactId = c.id; showDialog = true">Edit</md-button>  
        </md-table-cell>  
        <md-table-cell>  
          <md-button class="md-accent" @click="removeContact(c.id)">Delete</md-button>  
        </md-table-cell>  
      </md-table-row>  
    </md-table><md-dialog :md-active.sync="showDialog">  
      <md-dialog-content>  
        <ContactForm  
          :editing="true"  
          :contactId="selectedContactId"  
          @contactSaved="selectedContactId = undefined; showDialog = false"  
        />  
      </md-dialog-content>  
    </md-dialog>  
  </div>  
</template>

<script>  
import { contactMixin } from "@/mixins/contactMixin";  
import ContactForm from "@/components/ContactForm.vue";

export default {  
  name: "HomePage",  
  mixins: [contactMixin],  
  components: {  
    ContactForm  
  },  
  props: {  
    editing: Boolean,  
    id: Number  
  },  
  computed: {  
    contacts() {  
      return this.$store.state.contacts;  
    }  
  },  
  data() {  
    return {  
      showDialog: false,  
      selectedContactId: undefined  
    };  
  }, 

  beforeMount() {  
    this.getAllContacts();  
  }, 

  methods: {  
    async getAllContacts() {  
      try {  
        const response = await this.getContacts();  
        this.$store.commit("setContacts", response.data);  
      } catch (ex) {  
        console.log(ex);  
      }  
    }, 

    async removeContact(id) {  
      try {  
        await this.deleteContact(id);  
        await this.getAllContacts();  
      } catch (ex) {  
        console.log(ex);  
      }  
    }  
  }  
};  
</script>

<style scoped>  
.md-dialog-container {  
  padding: 20px;  
}

.md-content.md-table.md-theme-default {  
  width: 95%;  
  margin: 0 auto;  
}  
</style>

We get our contacts during page load by call the this.getAllContacts function in the beforeMount function. Notice that we have this.getContacts function from our mixin. Mixins allows us to reuse code.

Code in our mixinx cannot have the same name as the functions in our methods objects in our components because mixin functions hooks straight into our methods, since we exported an object with methods field in our Mixin code.

In App.vue , we add our menu and top bar by putting the following:

<template>  
  <div id="app">  
    <md-toolbar class="md-accent">  
      <md-button class="md-icon-button" @click="showNavigation = true">  
        <md-icon>menu</md-icon>  
      </md-button>  
      <h3 class="md-title">Vee Validate Address Book App</h3>  
    </md-toolbar>  
    <md-drawer :md-active.sync="showNavigation" md-swipeable>  
      <md-toolbar class="md-transparent" md-elevation="0">  
        <span class="md-title">Vee Validate Address Book App</span>  
      </md-toolbar><md-list>  
        <md-list-item>  
          <router-link to="/">  
            <span class="md-list-item-text">Home</span>  
          </router-link>  
        </md-list-item><md-list-item>  
          <router-link to="/contact">  
            <span class="md-list-item-text">Add Contact</span>  
          </router-link>  
        </md-list-item>  
      </md-list>  
    </md-drawer>
    <router-view />  
  </div>  
</template>

<script>  
export default {  
  name: "app",  
  data: () => {  
    return {  
      showNavigation: false  
    };  
  }  
};  
</script>

<style lang="scss">  
.center {  
  text-align: center;  
}  
</style>

In main.js , we add our boilerplate code to include Vue Material and Vee-Validate in our app:

import Vue from 'vue'  
import App from './App.vue'  
import router from './router'  
import store from './store'  
import VueMaterial from 'vue-material'  
import 'vue-material/dist/vue-material.min.css'  
import VeeValidate from 'vee-validate';Vue.use(VeeValidate);  
Vue.use(VueMaterial);Vue.config.productionTip = falsenew Vue({  
  router,  
  store,  
  render: h => h(App)  
}).$mount('#app')

In router.js , we add our routes so we can see our pages:

import Vue from 'vue'  
import Router from 'vue-router'  
import HomePage from './views/HomePage.vue'  
import ContactFormPage from './views/ContactFormPage.vue'
Vue.use(Router)

export default new Router({  
  mode: 'history',  
  base: process.env.BASE_URL,  
  routes: [  
    {  
      path: '/',  
      name: 'home',  
      component: HomePage  
    },  
    {  
      path: '/contact',  
      name: 'contact',  
      component: ContactFormPage  
    }  
  ]  
})

In store.js , we put:

import Vue from 'vue'  
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({  
  state: {  
    contacts: []  
  },  
  mutations: {  
    setContacts(state, payload) {  
      state.contacts = payload;  
    }  
  },  
  actions: {}  
})

to store our contact in a place where all components can access. The store uses the Vuex library so that we have a this.$store object to call our mutation with the this.$store.commit function and get the latest data from the store via the computed property of our component object, like so:

contacts() {  
  return this.$store.state.contacts;  
}

Finally in index.html , we put:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width,initial-scale=1.0">
  <link rel="icon" href="<%= BASE_URL %>favicon.ico">
  <link rel="stylesheet" href="//fonts.googleapis.com/css?family=Roboto:400,500,700,400italic|Material+Icons">
  <link rel="stylesheet" href="https://unpkg.com/vue-material/dist/vue-material.min.css">
  <link rel="stylesheet" href="https://unpkg.com/vue-material/dist/theme/default.css">
  <title>Address Book App</title>
</head>
<body>
  <noscript>
    <strong>We're sorry but vee-validate-tutorial-app doesn't work properly without JavaScript enabled. Please enable it
      to continue.</strong>
  </noscript>
  <div id="app"></div>
  <!-- built files will be auto injected -->
</body>
</html>

to add the Roboto font and Material icons to our app.

Now we are ready to start our JSON server. Go to our project folder and run json-server — watch db.json to start the server. It will allow us to call these routes without any configuration:

GET    /contacts  
POST   /contacts  
PUT    /contacts/1  
DELETE /contacts/1

These are all the routes we need. Data will be saved to db.json of the folder that we’re in, which should be our app’s project folder.

Categories
JavaScript Vue

Create Web Components with Vue.js

Component-based architecture is the main architecture for front end development today. The World Wide Web Consortium (W3C) has caught up to the present by creating the web components API. It lets developers build custom elements that can be embedded in web pages. The elements can be reused and nested anywhere, allowing for code reuse in any pages or apps.

The custom elements are nested in the shadow DOM, which is rendered separately from the main DOM of a document. This means that they are completely isolated from other parts of the page or app, eliminating the chance of conflict with other parts,

There are also template and slot elements that aren’t rendered on the page, allowing you to reused the things inside in any place.

To create web components without using any framework, you have to register your element by calling CustomElementRegistry.define() and pass in the name of the element you want to define. Then you have to attach the shadow DOM of your custom element by calling Element.attachShawdow() so that your element will be displayed on your page.

This doesn’t include writing the code that you want for your custom elements, which will involve manipulating the shadow DOM of your element. It is going to be frustrating and error-prone if you want to build a complex element.

Vue.js abstracts away the tough parts by letting you build your code into a web component. You write code by importing and including the components in your Vue components instead of globally, and then you can run commands to build your code into one or more web components and test it.

We build the code into a web component with Vue CLI by running:

npm run build -- --target wc --inline-vue --name custom-element-name

The --inline-vue flag includes a copy of view in the built code, --target wc builds the code into a web component, and --name is the name of your element.

In this article, we will build a weather widget web component that displays the weather from the OpenWeatherMap API. We will add a search to let users look up the current weather and forecast from the API.

We will use Vue.js to build the web component. To begin building it, we start with creating the project with Vue CLI. Run npx @vue/cli create weather-widget to create the project. In the wizard, select Babel, SCSS and Vuex.

The OpenWeatherMap API is available at https://openweathermap.org/api. You can register for an API key here. Once you got an API key, create an .env file in the root folder and add VUE_APP_APIKEY as the key and the API key as the value.

Next, we install some packages that we need for building the web component. We need Axios for making HTTP requests, BootstrapVue for styling, and Vee-Validate for form validation. To install them, we run npm i axios bootstrap-vue vee-validate to install them.

With all the packages installed we can start writing our code. Create CurrentWeather.vue in the components folder and add:

<template>  
  <div>  
    <br />  
    <b-list-group v-if="weather.main">  
      <b-list-group-item>Current Temparature: {{weather.main.temp - 273.15}} C</b-list-group-item>  
      <b-list-group-item>High: {{weather.main.temp_max - 273.15}} C</b-list-group-item>  
      <b-list-group-item>Low: {{weather.main.temp_min - 273.15}} C</b-list-group-item>  
      <b-list-group-item>Pressure: {{weather.main.pressure }}mb</b-list-group-item>  
      <b-list-group-item>Humidity: {{weather.main.humidity }}%</b-list-group-item>  
    </b-list-group>  
  </div>  
</template>

<script>  
import { requestsMixin } from "@/mixins/requestsMixin";  
import store from "../store";  
import { BListGroup, BListGroupItem } from "bootstrap-vue";  
import 'bootstrap/dist/css/bootstrap.css'  
import 'bootstrap-vue/dist/bootstrap-vue.css'

export default {  
  store,  
  name: "CurrentWeather",  
  mounted() {},  
  mixins: [requestsMixin],  
  components: {  
    BListGroup,  
    BListGroupItem  
  },  
  computed: {  
    keyword() {  
      return this.$store.state.keyword;  
    }  
  },  
  data() {  
    return {  
      weather: {}  
    };  
  },  
  watch: {  
    async keyword(val) {  
      const response = await this.searchWeather(val);  
      this.weather = response.data;  
    }  
  }  
};  
</script>

<style scoped>  
p {  
  font-size: 20px;  
}  
</style>

This component displays the current weather from the OpenWeatherMap API is the keyword from the Vuex store is updated. We will create the Vuex store later. The this.searchWeather function is from the requestsMixin, which is a Vue mixin that we will create. The computed block gets the keyword from the store via this.$store.state.keyword and return the latest value.

Note that we’re importing all the BootstrapVue components individually here. This is because we aren’t building an app. main.js in our project will not be run, so we cannot register components globally by calling Vue.use. Also, we have to import the store here, so that we have access to the Vuex store in the component.

Next, create Forecast.vue in the same folder and add:

<template>  
  <div>  
    <br />  
    <b-list-group v-for="(l, i) of forecast.list" :key="i">  
      <b-list-group-item>  
        <b>Date: {{l.dt_txt}}</b>  
      </b-list-group-item>  
      <b-list-group-item>Temperature: {{l.main.temp - 273.15}} C</b-list-group-item>  
      <b-list-group-item>High: {{l.main.temp_max - 273.15}} C</b-list-group-item>  
      <b-list-group-item>Low: {{l.main.temp_min }}mb</b-list-group-item>  
      <b-list-group-item>Pressure: {{l.main.pressure }}mb</b-list-group-item>  
    </b-list-group>  
  </div>  
</template>

<script>  
import { requestsMixin } from "@/mixins/requestsMixin";  
import store from "../store";  
import { BListGroup, BListGroupItem } from "bootstrap-vue";  
import 'bootstrap/dist/css/bootstrap.css'  
import 'bootstrap-vue/dist/bootstrap-vue.css'

export default {  
  store,  
  name: "Forecast",  
  mixins: [requestsMixin],  
  components: {  
    BListGroup,  
    BListGroupItem  
  },  
  computed: {  
    keyword() {  
      return this.$store.state.keyword;  
    }  
  },  
  data() {  
    return {  
      forecast: []  
    };  
  },  
  watch: {  
    async keyword(val) {  
      const response = await this.searchForecast(val);  
      this.forecast = response.data;  
    }  
  }  
};  
</script>

<style scoped>  
p {  
  font-size: 20px;  
}  
</style>

It’s very similar to CurrentWeather.vue. The only difference is that we are getting the current weather instead of the weather forecast.

Next, we create a mixins folder in the src folder and add:

const APIURL = "http://api.openweathermap.org";  
const axios = require("axios");
export const requestsMixin = {  
  methods: {  
    searchWeather(loc) {  
      return axios.get(  
        `${APIURL}/data/2.5/weather?q=${loc}&appid=${process.env.VUE_APP_APIKEY}`  
      );  
    },

    searchForecast(loc) {  
      return axios.get(  
        `${APIURL}/data/2.5/forecast?q=${loc}&appid=${process.env.VUE_APP_APIKEY}`  
      );  
    }  
  }  
};

These functions are for getting the current weather and the forecast respectively from the OpenWeatherMap API. process.env.VUE_APP_APIKEY is obtained from our .env file that we created earlier.

Next in App.vue , we replace the existing code with:

<template>  
  <div>  
    <b-navbar toggleable="lg" type="dark" variant="info">  
      <b-navbar-brand href="#">Weather App</b-navbar-brand>  
    </b-navbar>  
    <div class="page">  
      <ValidationObserver ref="observer" v-slot="{ invalid }">  
        <b-form @submit.prevent="onSubmit" novalidate>  
          <b-form-group label="Keyword" label-for="keyword">  
            <ValidationProvider name="keyword" rules="required" v-slot="{ errors }">  
              <b-form-input  
                :state="errors.length == 0"  
                v-model="form.keyword"  
                type="text"  
                required  
                placeholder="Keyword"  
                name="keyword"  
              ></b-form-input>  
              <b-form-invalid-feedback :state="errors.length == 0">Keyword is required</b-form-invalid-feedback>  
            </ValidationProvider>  
          </b-form-group><b-button type="submit" variant="primary">Search</b-button>  
        </b-form>  
      </ValidationObserver><br />
      <b-tabs>  
        <b-tab title="Current Weather">  
          <CurrentWeather />  
        </b-tab>  
        <b-tab title="Forecast">  
          <Forecast />  
        </b-tab>  
      </b-tabs>  
    </div>  
  </div>  
</template>

<script>  
import CurrentWeather from "@/components/CurrentWeather.vue";  
import Forecast from "@/components/Forecast.vue";  
import store from "./store";  
import {  
  BTabs,  
  BTab,  
  BButton,  
  BForm,  
  BFormGroup,  
  BFormInvalidFeedback,  
  BNavbar,  
  BNavbarBrand,  
  BFormInput  
} from "bootstrap-vue";  
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";  
import { required } from "vee-validate/dist/rules";  
extend("required", required);

export default {  
  store,  
  name: "App",  
  components: {  
    CurrentWeather,  
    Forecast,  
    ValidationProvider,  
    ValidationObserver,  
    BTabs,  
    BTab,  
    BButton,  
    BForm,  
    BFormGroup,  
    BFormInvalidFeedback,  
    BNavbar,  
    BNavbarBrand,  
    BFormInput  
  },  
  data() {  
    return {  
      form: {}  
    };  
  },  
  methods: {  
    async onSubmit() {  
      const isValid = await this.$refs.observer.validate();  
      if (!isValid) {  
        return;  
      }  
      localStorage.setItem("keyword", this.form.keyword);  
      this.$store.commit("setKeyword", this.form.keyword);  
    }  
  },  
  beforeMount() {  
    this.form = { keyword: localStorage.getItem("keyword") || "" };  
  },  
  mounted() {  
    this.$store.commit("setKeyword", this.form.keyword);  
  }  
};  
</script>

<style lang="scss">  
@import "./../node_modules/bootstrap/dist/css/bootstrap.css";  
@import "./../node_modules/bootstrap-vue/dist/bootstrap-vue.css";  
.page {  
  padding: 20px;  
}  
</style>

We add the BootstrapVue b-navbar here to add a top bar to show the extension’s name. Below that, we added the form for searching the weather info. Form validation is done by wrapping the form in the ValidationObserver component and wrapping the input in the ValidationProvider component. We provide the rule for validation in the rules prop of ValidationProvider. The rules will be added in main.js later.

The error messages are displayed in the b-form-invalid-feedback component. We get the errors from the scoped slot in ValidationProvider. It’s where we get the errors object from.

When the user submits the number, the onSubmit function is called. This is where the ValidationObserver becomes useful as it provides us with the this.$refs.observer.validate() function to check for form validity.

If isValid resolves to true , then we set the keyword in local storage, and also in the Vuex store by running this.$store.commit(“setKeyword”, this.form.keyword); .

In the beforeMount hook, we set the keyword so that it will be populated when the extension first loads if a keyword was set in local storage. In the mounted hook, we set the keyword in the Vuex store so that the tabs will get the keyword to trigger the search for the weather data.

Like in the previous components, we import and register all the components and the Vuex store in this component, so that we can use the BootstrapVue components here. We also called Vee-Validate’s extend function so that we can use its required form validation rule for checking the input.

In style section of this file, we import the BootstrapVue styles, so that they can be accessed in this and the child components. We also add the page class so that we can add some padding to the page.

Then in store.js , we replace the existing code with:

import Vue from "vue";  
import Vuex from "vuex";

Vue.use(Vuex);

export default new Vuex.Store({  
  state: {  
    keyword: ""  
  },  
  mutations: {  
    setKeyword(state, payload) {  
      state.keyword = payload;  
    }  
  },  
  actions: {}  
});

to add the Vuex store that we referenced in the components. We have the keyword state for storing the search keyword in the store, and the setKeyword mutation function so that we can set the keyword in our components.

Finally, in package.json , we add 2 scripts to the scripts section of the file:

"wc-build": "npm run build -- --target wc --inline-vue --name weather-widget","wc-test": "cd dist && live-server --port=8080 --entry-file=./demo.html"

The wc-build script builds our code into a web component as we described before, and the wc-test runs a local web server so that we can see what the web component looks like when it’s included in a web page. We use the live-server NPM package for serving the file. The --entry-file option specifies that we server demo.html as the home page, which we get when we run npm run wc-build .