How to build a VueJS frontend for your machine learning prediction input and output

In this tutorial, we build a Vue application for users to interact with our machine learning model. This tutorial assumes you already have a backend hosting a machine learning prediction REST endpoint. To try out the frontend implementation for yourself, you may go to hdbpricer.com

This is part 3 of a 4-part tutorial
1. Building a good prediction model
2. Hosting the model prediction as an API endpoint on Flask
3. Building a simple VueJS frontend for a users to price their HDBs
4. Deploying the entire full stack application to the internet

The Git repository for the implementation can be found here
My hdbpricer app client

Background

Most Machine learning engineers / Data scientists would likely have Python or R as their core programming language and is less likely to delve into front end technologies such as javascript frameworks. Therefore, I believe this tutorial will be helpful for the Machine learning engineers to create a quick intuitive front end application to showcase the value they are delivering.

Getting Started

We will use the Vue CLI to generate a boilerplate.

We will use npm to install the Vue CLI globally.

$ npm install -g @vue/cli@3.7.0

Within the folder directory you are building your app in, run

$ vue create client

You will be prompted to answer some project setting questions. Use the down arrow key to select “Manually select features” and select the features “Babel”, “Router”, and “Linter / Formatter”.

Use history mode for the router. Then, select “ESLint + Airbnb config” for the linter and “Lint on save”. Finally, select the “In package.json” to save our configurations in package.json.

Let’s look at the folder structure.

  1. The node_modules is where your libraries are installed
  2. The public folder contains
    • index.html : After your Vue application is built, it will be injected into this file
    • favicon.ico : The logo that appears on your browser tab when your app is deployed
  3. the src folder contains
    • assets folder : Where we usually save the image files we use in our app
    • components folder : As we are building a single page application with multiple components (Tables, graphs, maps etc.), this is where we store all the seperate components we want to see on our application.
    • router folder : Includes an index.js file that helps to map the different urls to the components of the application.
    • views folder : Some default templates that is generated for homepage and about page for UI components tied to the router.
    • App.vue : The base template of the application where the components will be built upon
    • main.js : The javascript file that will initialise the app. Usually you will use this to add 3rd party components, import plugins etc.
  4. package.json contains the configurations for your application

There are some files such as editorconfig, gitignore, babelconfig, procfile, readme are not crucial in this tutorial.

To understand more about Vue components,

Take a peek at the client/src/components/HelloWorld.vue file. This is a Single File component broken up into three different sections:

  1. template: HTML
  2. script: where the component logic is implemented
  3. style: CSS

To run the app :

$ cd client 
$ npm run serve

You will see the app hosted locally on http://localhost:8080/

Our first component

Install axios to run AJAX calls to the backend app (as seen in the previous tutorial)

$ npm install axios@0.18.0 --save

In the components folder, create a Ping.vue file

Ping.vue

<template>
<!-- eslint-disable max-len -->
  <div class="container">
    <button type="button" class="btn btn-primary">{{ msg }}</button>
  </div>
</template>

<script>
import axios from 'axios';

export default {
  name: 'Ping',
  data() {
    return {
      msg: '',
    };
  },
  methods: {
    getMessage() {
      const path = 'http://localhost:5000/ping';
      axios.get(path)
        .then((res) => {
          this.msg = res.data;
        })
        .catch((error) => {
          // eslint-disable-next-line
          console.error(error);
        });
    },
  },
  created() {
    this.getMessage();
  },
};
</script>

To ensure we are able to reach this page, we will need to add /ping to the router

import Vue from 'vue';
import VueRouter from 'vue-router';
import Ping from '../components/Ping.vue';

Vue.use(VueRouter);

const routes = [
/*
  {
    path: '/',
    name: 'Home',
    component: Home,
  },
*/
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'),
  },
  {
    path: '/ping',
    name: 'Ping',
    component: Ping,
  },
];

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes,
});

export default router;

When you run the Flask backend in another terminal window, you will be able to open localhost:8080/ping and see the message ‘pong!’ from the backend. This is because the Ajax call retrieved the response from the backend, and binded to the Vue Ping component.

Beautifying using Bootstrap

Install

$ npm install bootstrap@4.3.1 --save

Import bootstrap for Vue in main.js

main.js

import 'bootstrap/dist/css/bootstrap.css';
import BootstrapVue from 'bootstrap-vue';
import Vue from 'vue';
import App from './App.vue';
import router from './router';

Vue.use(BootstrapVue);
Vue.config.productionTip = false;

new Vue({
  router,
  render: (h) => h(App),
}).$mount('#app');

You may then use bootstrap in the components you build.

HDB component

As we are building a HDB price predictor, we will create a HDB component here.

HDB.vue

<template>
<!-- eslint-disable max-len -->
<!-- eslint-disable no-mixed-spaces-and-tabs -->
<!-- eslint-disable no-tabs -->
  <div class="container">
    <div class="row">
      <div class="col-sm-12">
        <h1>HDB</h1>
        <hr />
    <alert :message=message v-if="showMessage"></alert>
        <button
          type="button"
          class="btn btn-success btn-sm"
          v-b-modal.HDB-modal
        >
          Price new HDB
        </button>

        <br /><br />
        <table class="table table-striped table-dark">
          <thead>
            <tr>
              <th scope="col">Town</th>
              <th scope="col">Flat type</th>
              <th scope="col">Storey range</th>
              <th scope="col">Floor area (sqm)</th>
              <th scope="col">Lease commence date</th>
              <th scope="col">Resale price</th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="(hdb, index) in hdbs" :key="index">
              <td>{{ hdb.town }}</td>
              <td>{{ hdb.flat_type }}</td>
              <td>{{ hdb.storey_range }}</td>
              <td>{{ hdb.floor_area_sqm }}</td>
              <td>{{ hdb.lease_commence_date }}</td>
              <td>S$ {{ hdb.resale_price }}</td>
            </tr>
          </tbody>
        </table>
      </div>
      <!--
      <div class="col-sm-6">
        <mappy></mappy>
      </div>

      <div class="col-sm-6">
        <rawdata></rawdata>
      </div>
      -->

    </div>
    <b-modal
      ref="priceHDBModal"
      id="HDB-modal"
      title="Price an HDB"
      header-bg-variant="dark"
      body-bg-variant="secondary"
      hide-footer
    >
      <b-form @submit="onSubmit" @reset="onReset" class="w-100">
        <b-form-group
          id="form-town-group"
          label="Town:"
          label-for="form-town-input"
        >
          <b-form-select v-model="priceHDBForm.town" :options="townoptions" required></b-form-select>
        </b-form-group>
        <b-form-group
          id="form-flat_type-group"
          label="Flat type:"
          label-for="form-flat_type-input"
        >
          <b-form-select v-model="priceHDBForm.flat_type" :options="flat_typeoptions" required></b-form-select>
        </b-form-group>
        <b-form-group
          id="form-storey_range-group"
          label="Storey range:"
          label-for="form-storey_range-input"
        >
          <b-form-select v-model="priceHDBForm.storey_range" :options="storey_rangeoptions" required></b-form-select>
        </b-form-group>
        <b-form-group
          id="form-floor_area_sqm-group"
          label="Floor area (sqm):"
          label-for="form-floor_area_sqm-input"
        >
          <b-form-input
            id="form-floor_area_sqm-input"
            type="range"
            v-model="priceHDBForm.floor_area_sqm"
            min="30"
            max="300"
            required
            placeholder="Enter Floor area (sqm)"
          >
          </b-form-input>
          <div class="mt-2">Value: {{ priceHDBForm.floor_area_sqm }}</div>
        </b-form-group>

        <b-form-group
          id="form-lease_commence_date-group"
          label="Lease commence date (Year):"
          label-for="form-lease_commence_date-input"
        >
          <b-form-input
            id="form-lease_commence_date-input"
            type="number"
            v-model="priceHDBForm.lease_commence_date"
            min="1965"
            :max="currentYear"
            required
            placeholder="Enter Lease commence date (Year)"
          >
          </b-form-input>
        </b-form-group>
        <!-- <b-form-group id="form-read-group">
          <b-form-checkbox-group v-model="addBookForm.read" id="form-checks">
            <b-form-checkbox value="true">Read?</b-form-checkbox>
          </b-form-checkbox-group>
        </b-form-group> -->

        <b-button type="submit" variant="primary">Submit</b-button>
        <b-button type="reset" variant="danger">Reset</b-button>
      </b-form>
    </b-modal>

  </div>
</template>
<script>
import axios from 'axios';
import Alert from './Alert.vue';

export default {
  data() {
    return {
      hdbs: [],
      priceHDBForm: {
        town: null,
        flat_type: null,
        storey_range: null,
        floor_area_sqm: 120,
        lease_commence_date: '',
      },
      message: '',
      showMessage: false,
      variants: ['primary', 'secondary', 'success', 'warning', 'danger', 'info', 'light', 'dark'],
      headerBgVariant: 'dark',
      headerTextVariant: 'light',
      bodyBgVariant: 'light',
      bodyTextVariant: 'dark',
      footerBgVariant: 'warning',
      footerTextVariant: 'dark',
      currentYear: new Date().getFullYear(),
      townoptions: [
        { value: null, text: 'Please select a town' },
        { value: 'ANG MO KIO', text: 'ANG MO KIO' }, { value: 'BEDOK', text: 'BEDOK' }, { value: 'BISHAN', text: 'BISHAN' }, { value: 'BUKIT BATOK', text: 'BUKIT BATOK' }, { value: 'BUKIT MERAH', text: 'BUKIT MERAH' }, { value: 'BUKIT PANJANG', text: 'BUKIT PANJANG' }, { value: 'BUKIT TIMAH', text: 'BUKIT TIMAH' }, { value: 'CENTRAL AREA', text: 'CENTRAL AREA' }, { value: 'CHOA CHU KANG', text: 'CHOA CHU KANG' }, { value: 'CLEMENTI', text: 'CLEMENTI' }, { value: 'GEYLANG', text: 'GEYLANG' }, { value: 'HOUGANG', text: 'HOUGANG' }, { value: 'JURONG EAST', text: 'JURONG EAST' }, { value: 'JURONG WEST', text: 'JURONG WEST' }, { value: 'KALLANG/WHAMPOA', text: 'KALLANG/WHAMPOA' }, { value: 'MARINE PARADE', text: 'MARINE PARADE' }, { value: 'PASIR RIS', text: 'PASIR RIS' }, { value: 'PUNGGOL', text: 'PUNGGOL' }, { value: 'QUEENSTOWN', text: 'QUEENSTOWN' }, { value: 'SEMBAWANG', text: 'SEMBAWANG' }, { value: 'SENGKANG', text: 'SENGKANG' }, { value: 'SERANGOON', text: 'SERANGOON' }, { value: 'TAMPINES', text: 'TAMPINES' }, { value: 'TOA PAYOH', text: 'TOA PAYOH' }, { value: 'WOODLANDS', text: 'WOODLANDS' }, { value: 'YISHUN', text: 'YISHUN' },
      ],

      flat_typeoptions: [
        { value: null, text: 'Please select a Flat type' },
        { value: '1 ROOM', text: '1 ROOM' }, { value: '2 ROOM', text: '2 ROOM' }, { value: '3 ROOM', text: '3 ROOM' }, { value: '4 ROOM', text: '4 ROOM' }, { value: '5 ROOM', text: '5 ROOM' }, { value: 'EXECUTIVE', text: 'EXECUTIVE' }, { value: 'MULTI-GENERATION', text: 'MULTI-GENERATION' },
      ],

      storey_rangeoptions: [
        { value: null, text: 'Please select a Storey range' },
        { value: '01 TO 03', text: '01 TO 03' }, { value: '04 TO 06', text: '04 TO 06' }, { value: '07 TO 09', text: '07 TO 09' }, { value: '10 TO 12', text: '10 TO 12' }, { value: '13 TO 15', text: '13 TO 15' }, { value: '16 TO 18', text: '16 TO 18' }, { value: '19 TO 21', text: '19 TO 21' }, { value: '22 TO 24', text: '22 TO 24' }, { value: '25 TO 27', text: '25 TO 27' }, { value: '28 TO 30', text: '28 TO 30' }, { value: '31 TO 33', text: '31 TO 33' }, { value: '34 TO 36', text: '34 TO 36' }, { value: '37 TO 39', text: '37 TO 39' }, { value: '40 TO 42', text: '40 TO 42' }, { value: '43 TO 45', text: '43 TO 45' }, { value: '46 TO 48', text: '46 TO 48' }, { value: '49 TO 51', text: '49 TO 51' },
      ],

    };
  },
  components: {
    alert: Alert,
  },
  methods: {
    getHDBs() {
       const path = "http://localhost:5000/hdbs";
      //const path = '/hdbs';
      axios
        .get(path)
        .then((res) => {
          this.hdbs = res.data.hdbs;
        })
        .catch((error) => {
          // eslint-disable-next-line
          console.error(error);
        });
    },
    priceHDB(payload) {
       const path = "http://localhost:5000/hdbs";
      //const path = '/hdbs';
      axios
        .post(path, payload)
        .then(() => {
          this.getHDBs();
          this.message = 'HDB priced!';
          this.showMessage = true;
        })
        .catch((error) => {
          // eslint-disable-next-line
          console.log(error);
          this.getHDBs();
          this.message = 'This HDB could not be priced';
        });
    },
    initForm() {
      this.priceHDBForm.town = null;
      this.priceHDBForm.flat_type = null;
      this.priceHDBForm.storey_range = null;
      this.priceHDBForm.floor_area_sqm = 100;
      this.priceHDBForm.lease_commence_date = '';
    },
    onSubmit(evt) {
      evt.preventDefault();
      this.$refs.priceHDBModal.hide();
      const payload = {
        town: this.priceHDBForm.town,
        flat_type: this.priceHDBForm.flat_type,
        storey_range: this.priceHDBForm.storey_range,
        floor_area_sqm: this.priceHDBForm.floor_area_sqm,
        lease_commence_date: this.priceHDBForm.lease_commence_date,
      };
      this.priceHDB(payload);
      this.initForm();
    },
    onReset(evt) {
      evt.preventDefault();
      this.$refs.priceHDBModal.hide();
      this.initForm();
    },
  },

  created() {
    this.getHDBs();
    this.showMessage = false;
  },
};
</script>

We will then update the router to reach this component.

import Vue from 'vue';
import VueRouter from 'vue-router';
import Ping from '../components/Ping.vue';
import HDB from '../components/HDB.vue';

Vue.use(VueRouter);

const routes = [
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'),
  },
  {
    path: '/ping',
    name: 'Ping',
    component: Ping,
  },
  {
    path: '/',
    name: 'HDB',
    component: HDB,
  },
];

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes,
});

export default router;

As you can see, what we did was

<template>

  1. Add the HDB component to the root directory of the webapp (http://localhost:8080/)

2. Used bootstrap to define the col size

3. Add an alert message when the HDB is priced

Alert.vue Component

<template>
<!-- eslint-disable max-len -->
<!-- eslint-disable no-mixed-spaces-and-tabs -->
<!-- eslint-disable no-tabs -->
  <div>
    <b-alert variant="success" show dismissible >{{ message }}</b-alert>
    <br>
  </div>
</template>

<script>
export default {
  props: ['message'],

};
</script>

4. Add a button to trigger a form (modal) to price HDB

5. Add a table to list the HDB predictions

6. Add an input form

The form allows the user to submit values such as town, flat type to be submitted to the ML model.

<script>

  1. Import axios library and alert component

2. Export data used in this template

3. Link <alert> to Alert component

4. Method getHDBs() – GET

Load table of predicted HDB prices

5. Method priceHDB(payload) – POST

Send payload of form input to backend

6. Initialise form with empty values

7. Event listener for submit function

8. Reset form values

9. When HDB vue component is created

Conclusion

This app will end up looking like this. However, the map and chart components are not part of this tutorial. Feel free to reach out to me if you would like to know how to build those components.

Initial Vue app inspiration was taken from Michael Herman’s post. Huge credits!

Thank you for reading this tutorial blog post. 🙂

3 thoughts on “How to build a VueJS frontend for your machine learning prediction input and output

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s