Categories
JavaScript

Useful New Features in ES2016 and 2017

ES2015 introduced lots of features, like arrow functions, class syntax for object creation and inheritance, let and const, and many other new features. In the next few years, more great features will be introduced into the language and the standard library. ES2016 was a minor revision with new features, such as introducing the includes function to arrays and the exponential operator. ES2017 introduced more features like Object.values and Object.entries and string functions like padStart and padEnd, and async and await. These are handy features that bring even more convenience to JavaScript developers, making JavaScript app development even easier.


ES2016 Features

Array.includes

Array.includes checks if an item exists in an array. It takes a number or string, which the function can compare as the first argument and the index to search from as the second argument. The second argument can be positive or negative.

const array = [1,2,3];  
const includesTwo = array.includes(2); // returns true

We can also search the array starting with a negative index, then the function search beginning from the computed index, which is the length of the array plus the second argument. So if we write:

array.includes(2, -2);

Then the includes function will search starting with index 3 +(-2). This means that array.includes(2, -2); should return true. If the absolute number of what you pass in is bigger than the length, then the whole array is searched.

The includes function was also added to TypedArray types like Int8Array , or Uint8Array .

Exponential operator

The exponential operator is another new feature of ES2016. The exponential operator is denoted by **. It’s the new syntax for computing exponential values, which is the alternative to the Math.pow function. For example, to compute 2 to the power of 3, we can write,

2**3

which is 8. Note that in JavaScript, it’s impossible to write ambiguous expressions when we combine the exponential operator with unary operators like -, so something like -2**2 is invalid in JavaScript. However, -(2**2) is valid since it’s unambiguous in that we know that the value is -4 since the exponential is in parentheses and the negative sign is outside.

We can force the base of the exponentiation expression to be a negative number by writing:

(-2)**2

So this means the base has to be in parentheses.


ES2017 Features

Object.values

The Object.values lets us get an array with the values of an object in an array. The values are returned in the same order as it is provided by the for...in loop, except that Object.values do not return values that are in the prototype chain.

For example, we can write:

const obj = { a: 1, b: 2 };  
console.log(Object.values(obj)); // [1, 2]  
  
const obj = { 0: 'd', 1: 'e', 2: 'f' };  
console.log(Object.values(obj)); // ['d', 'e', 'f']  
  
const obj2 = { 100: 'd', 2: 'e', 7: 'f' };  
console.log(Object.values(obj2)); // ['e', 'f', 'd']  
  
console.log(Object.values('abc')); // ['a', 'b', 'c']

As we can see, Object.values works with objects and strings. If the keys are numbers, then they are returned in with the keys in increasing order, like in the obj2 example above. For strings, it returns an array of the individual characters of the string.

Object.entries()

The Object.entries function returns an array with each entry being the key-value pairs in their individual arrays. The entries are returned in the same order as it is provided by the for...in loop, except that Object.values do not return values that are in the prototype chain. As with any object, we can sort the array to get the entries in the order we want with the sort function.

For example, we can write:

const obj = { foo: 1, bar: 2 };  
console.log(Object.entries(obj)); // [ ['foo', 1], ['bar', 2] ]  
  
const obj = { 0: 'x', 1: 'y', 2: 'z' };  
console.log(Object.entries(obj)); // [ ['0', 'x'], ['1', 'y'], ['2', 'z'] ]  
  
const obj2 = { 100: 'x', 2: 'y', 7: 'z' };  
console.log(Object.entries(obj2)); // [ ['2', 'x'], ['7', 'y'], ['100', 'z'] ]  
  
console.log(Object.entries('abc')); // [ ['0', 'a'], ['1', 'b'], ['2', 'c'] ]  
  
console.log(Object.entries(100)); // [ ]  
  
const obj = { a: 1, b: 2, c: 3};  
for (const [key, value] of Object.entries(obj)) {  
  console.log(`${key} ${value}`); // "a 1", "b 2", "c 3"  
}  
  
Object.entries(obj).forEach(([key, value]) => {  
  console.log(`${key} ${value}`); // "a 1", "b 2", "c 3"  
});

In the above examples, we can see that Object.entries return an array of key-value pairs with each entry as an array with the key being the first element and the value being the second element. If the keys are integers, then they are sorted in ascending numerical order. So Object.entries(obj2) returns [ ['2', 'x'], ['7', 'y'], ['100', 'z'] ]. It also works with strings with the individual characters return with the index of the characters as the key, so the key is the index of the string, which is the first element of each entry, and the individual character is the value of the string, which is returned as the second character. For objects that have no properties, like numbers and booleans, Object.entries returns an empty array.

The array returned by Object.entries can be converted to a Map object. It can be used like in the following example:

const obj = { a: 1, b: 2 };   
const map = new Map(Object.entries(obj));  
console.log(map); // Map { a: 1, b: 2 }

String.padStart()

The padStart() function adds a string the number of times before a string until it reaches the length that you specify. The function takes two parameters. The first is the target length of your string. If the target length is less than the length of your string, then the string is returned as-is. The second parameter is the string that you want to add the padding with. It’s an optional parameter and it defaults to ' ' is nothing is specified.

For example, we can write the following:

'def'.padStart(10);         // "       def"  
'def'.padStart(10, "123");  // "1231231def"  
'def'.padStart(6,"123465"); // "abcdef"  
'def'.padStart(8, "0");     // "00000def"  
'def'.padStart(1);          // "def"

Note that each string is filled up to the target length with the string in the second argument. The whole string in the second argument may not always be included. Only the part that lets the function fill the string up to the target length is included.

String.padEnd()

The padEnd() function adds a string the number of times after a string until it reaches the length that you specify. The function takes 2 parameters. The first is the target length of your string. If the target length is less than the length of your string, then the string is returned as is. The second parameter is the string that you want to add the padding with. It’s an optional parameter, and it defaults to ' ' is nothing is specified.

For example, we can write the following:

'def'.padEnd(10);         // "def       "  
'def'.padEnd(10, "123");  // "def1231231"  
'def'.padEnd(6,"123465"); // "defabc"  
'def'.padEnd(8, "0");     // "def00000"  
'def'.padEnd(1);          // "def"

Object.getOwnPropertyDescriptors()

The Object.getOwnPropertyDescriptors() function returns all the property descriptors of an object.

For example, we can use it like the following code:

const obj = {  
  a: 1  
};const descriptors = Object.getOwnPropertyDescriptors(obj);

The descriptors object should have:

{  
  a: {  
    configurable: true,  
    enumerable: true,  
    value: 1,  
    writable: true  
  }  
}

Async and Await

With async and await, we can shorten promise code. Before async and await, we have to use the then function, we make to put callback functions as an argument of all of our then functions. This makes the code long is we have lots of promises. Instead, we can use the async and await syntax to replace the then and its associated callbacks as follows. For example, we have the code to make requests to the back end with the Fetch API:

const APIURL = "http://localhost:3000";
const subscribe = async data => {  
  const response = await fetch(`${APIURL}/subscribers`, {  
    method: "POST",  
    mode: "cors",  
    cache: "no-cache",  
    headers: {  
      "Content-Type": "application/json"  
    },  
    body: JSON.stringify(data)  
  });  
  return response.json();  
};

window.onload = () => {  
  nameForm.method = "post";  
  nameForm.target = "_blank";  
  nameForm.action = "";  
  nameForm.addEventListener("submit", async e => {  
    e.preventDefault();  
    const firstName = document.getElementById("firstName").value;  
    const lastName = document.getElementById("lastName").value;  
    const email = document.getElementById("email").value;  
    let errors = [];  
    if (!firstName) {  
      errors.push("First name is required.");  
    } 

    if (!lastName) {  
      errors.push("Last name is required.");  
    } 

    if (!email) {  
      errors.push("Email is required.");  
    } 

    if (!/\[^@\]+@\[^\.\]+\..+/.test(email)) {  
      errors.push("Email is invalid.");  
    } 

    if (errors.length > 0) {  
      alert(errors.join(" "));  
      return;  
    }  
    try {  
      const response = await subscribe({  
        firstName,  
        lastName,  
        email  
      });  
      alert(`${response.firstName} ${response.lastName} has subscribed`);  
    } 
    catch (error) {  
      alert(error.toString());  
    }  
  });  
};

We replaced the then and callbacks with await. Then we can assign the resolved values of each promise as variables. Note that if we use await for our promise code then we have to put async in the function signature as we did in the above example. To catch errors, instead of chaining the catch function in the end, we use the catch clause to do it instead. Also, instead of chaining the finally function at the bottom to run code when a promise ends, we use the finally clause after the catch clause to do that instead.

In the code above, we got the resolved value of the promise assigned to a variable instead of getting the value in the callback of the then function, like in the const response = await subscribe({...}) line above.

async functions always return promises and cannot return anything else like any other function that uses promises. In the example above, we used the promised based Fetch API and async and await syntax, we showed that we can chain promises in a much shorter way than with the then function with callbacks passed in as an argument.

ES2016 and 2017 gave us convenient features that enhance the already great syntactic sugar and functions that were added to ES2015, which was a significant improvement over its predecessor. These two versions of JavaScript make JavaScript development even better with new Object, string, and Array methods and shorthand for chaining promises.

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 Nodejs React

How To Build A Chat App With React, Socket.io, And Express

WebSockets is a great technology for adding real time communication to your apps. It works by allowing apps to send events to another app, passing data along with it. This means that users can see new data on their screen without manually retrieving new data, allowing better interactivity and making the user experience easier for the user. HTTP also has a lot of overhead with sending data that not all apps need like headers, this increases the latency of the communication between apps.

Socket.io is a library that uses both WebSockets and HTTP requests to allow apps to send and receive data between each other. Sending data between apps is almost instant. It works by allow apps to emit events to other apps and the apps receiving the events can handle them the way they like. It also provides namespacing and chat rooms to segregate traffic.

One the best uses of WebSockets and Socket.io is a chat app. Chat apps requires real time communication since messages are sent and received all the time. If we use HTTP requests, we would have to make lots of requests repeatedly to do something similar. It will be very slow and taxing on computing and networking resources if we send requests all the time to get new messages.

In this article, we will build a chat app that allows you to join multiple chat rooms and send messages with different chat handles. Chat handle is the username you use for joining the chat. We will use React for front end, and Express for back end. Socket.io client will be used on the front end and Socket.io server will be used on the back end.

To start we make an empty folder for our project and then inside the folder we make a folder called backend for our back end project. Then we go into the backend folder and run the Express Generator to generate the initial code for the back end app. To do this, run npx express-generator . Then in the same folder, run npm install to install the packages. We will need to add more packages to our back end app. We need Babel to use the latest JavaScript features, including the import syntax for importing modules, which is not yet supported by the latest versions of Node.js. We also need the CORS package to allow front end to communicate with back end. Sequelize is needed for manipulate our database, which we will use for storing chat room and chat message data. Sequelize is a popular ORM for Node.js. We also need the dotenv package to let us retrieve our database credentials from environment variables. Postgres will be our database system of choice to store the data.

We run npm i @babel/cli @babel/core @babel/node @babel/preset-env cors dotenv pg pg-hstore sequelize sequelize-cli socket.io to install the packages. After installing the packages, we will run npx sequelize-cli init in the same folder to add the code needed to use Sequelize for creating models and migrations.

Now we need to configure Babel so that we can run our app with the latest JavaScript syntax. First, create a file called .babelrc in the backend folder and add:

{  
    "presets": [  
        "@babel/preset-env"  
    ]  
}

Next we replace the scripts section of package.json with:

"scripts": {  
    "start": "nodemon --exec npm run babel-node --  ./bin/www",  
    "babel-node": "babel-node"  
},

Note that we also have to install nodemon by running npm i -g nodemon so that the app will restart whenever file changes, making it easier for us to develop the app. Now if we run npm start , we should be able to run with the latest JavaScript features in our app.

Next we have to change config.json created by running npx sequelize init . Rename config.json to config.js and replace the existing code with:

require("dotenv").config();  
const dbHost = process.env.DB_HOST;  
const dbName = process.env.DB_NAME;  
const dbUsername = process.env.DB_USERNAME;  
const dbPassword = process.env.DB_PASSWORD;  
const dbPort = process.env.DB_PORT || 5432;

module.exports = {  
  development: {  
    username: dbUsername,  
    password: dbPassword,  
    database: dbName,  
    host: dbHost,  
    port: dbPort,  
    dialect: "postgres",  
  },  
  test: {  
    username: dbUsername,  
    password: dbPassword,  
    database: "chat_app_test",  
    host: dbHost,  
    port: dbPort,  
    dialect: "postgres",  
  },  
  production: {  
    use_env_variable: "DATABASE_URL",  
    username: dbUsername,  
    password: dbPassword,  
    database: dbName,  
    host: dbHost,  
    port: dbPort,  
    dialect: "postgres",  
  },  
};

This is allow us to read the database credentials from our .env located in the backend folder, which should look something like this:

DB_HOST='localhost'  
DB_NAME='chat_app_development'  
DB_USERNAME='postgres'  
DB_PASSWORD='postgres'

Now that we have our database connection configured, we can make some models and migrations. Run npx sequelize model:generate --name ChatRoom --attributes name:string to create the ChatRooms table with the name column and the ChatRoom model in our code along with the associated migration. Next we make the migration and model for storing the messages. Run npx sequelize model:generate --name ChatRoomMessages --attributes author:string,message:text,chatRoomId:integer . Note that in both commands, we use singular word for the model name. There should also be no spaces after the comma in the column definitions.

Next we add a unique constraint to the name column of the ChatRooms table. Create a new migration by running npx sequelize-cli migration:create add-unique-constraint-for-chatroom-name to make an empty migration. Then in there, put:

"use strict";

module.exports = {  
  up: (queryInterface, Sequelize) => {  
    return queryInterface.addConstraint("ChatRooms", ["name"], {  
      type: "unique",  
      name: "unique_name",  
    });  
  }, 

  down: (queryInterface, Sequelize) => {  
    return queryInterface.removeConstraint("ChatRooms", "unique_name");  
  },  
};

After all that is done, we run npx sequelize-cli db:migrate to run the migrations.

Next in bin/www , we add the code for sending and receiving events with Socket.io. Replace the existing code with:

#!/usr/bin/env node
/**
 * Module dependencies.
 */
const app = require("../app");
const debug = require("debug")("backend:server");
const http = require("http");
const models = require("../models");
/**
 * Get port from environment and store in Express.
 */
const port = normalizePort(process.env.PORT || "3000");
app.set("port", port);
/**
 * Create HTTP server.
 */
const server = http.createServer(app);
const io = require("socket.io")(server);
io.on("connection", socket => {
  socket.on("join", async room => {
    socket.join(room);
    io.emit("roomJoined", room);
  });
  socket.on("message", async data => {
    const { chatRoomName, author, message } = data;
    const chatRoom = await models.ChatRoom.findAll({
      where: { name: chatRoomName },
    });
    const chatRoomId = chatRoom[0].id;
    const chatMessage = await models.ChatMessage.create({
      chatRoomId,
      author,
      message: message,
    });
    io.emit("newMessage", chatMessage);
  });
});
/**
 * Listen on provided port, on all network interfaces.
 */
server.listen(port);
server.on("error", onError);
server.on("listening", onListening);
/**
 * Normalize a port into a number, string, or false.
 */
function normalizePort(val) {
  const port = parseInt(val, 10);
  if (isNaN(port)) {
    // named pipe
    return val;
  }
  if (port >= 0) {
    // port number
    return port;
  }
  return false;
}
/**
 * Event listener for HTTP server "error" event.
 */
function onError(error) {
  if (error.syscall !== "listen") {
    throw error;
  }
  const bind = typeof port === "string" ? "Pipe " + port : "Port " + port;
// handle specific listen errors with friendly messages
  switch (error.code) {
    case "EACCES":
      console.error(bind + " requires elevated privileges");
      process.exit(1);
      break;
    case "EADDRINUSE":
      console.error(bind + " is already in use");
      process.exit(1);
      break;
    default:
      throw error;
  }
}
/**
 * Event listener for HTTP server "listening" event.
 */
function onListening() {
  const addr = server.address();
  const bind = typeof addr === "string" ? "pipe " + addr : "port " + addr.port;
  debug("Listening on " + bind);
}

so that the app will listen to connect from clients, and let the join rooms when the join event is received. We process messages received with the message event in this block of code:

socket.on("message", async data => {  
    const { chatRoomName, author, message } = data;  
    const chatRoom = await models.ChatRoom.findAll({  
      where: { name: chatRoomName },  
    });  
    const chatRoomId = chatRoom\[0\].id;  
    const chatMessage = await models.ChatMessage.create({  
      chatRoomId,  
      author,  
      message: message,  
    });  
    io.emit("newMessage", chatMessage);  
  });

and emit a newMessage event once the message sent with the message event is saved by getting the chat room ID and saving everything to the ChatMessages table.

In our models, we have to create a has many relationship between the ChatRooms and ChatMessages table by changing our model code. In chatmessage.js , we put:

'use strict';
module.exports = (sequelize, DataTypes) => {
  const ChatMessage = sequelize.define('ChatMessage', {
    chatRoomId: DataTypes.INTEGER,
    author: DataTypes.STRING,
    message: DataTypes.TEXT
  }, {});
  ChatMessage.associate = function(models) {
    // associations can be defined here
    ChatMessage.belongsTo(models.ChatRoom, {
      foreignKey: 'chatRoomId',
      targetKey: 'id'
    });
  };
  return ChatMessage;
};

to make the ChatMessages table belong to the ChatRooms table.

In ChatRoom.js , we put:

"use strict";  
module.exports = (sequelize, DataTypes) => {  
  const ChatRoom = sequelize.define(  
    "ChatRoom",  
    {  
      name: DataTypes.STRING,  
    },  
    {}  
  );  
  ChatRoom.associate = function(models) {  
    // associations can be defined here  
    ChatRoom.hasMany(models.ChatMessage, {  
      foreignKey: "chatRoomId",  
      sourceKey: "id",  
    });  
  };  
  return ChatRoom;  
};

so that we make each ChatRoom have many ChatMessages .

Next we need to add some routes to our back end for getting and setting chat rooms, and getting messages messages. Create a new file called chatRoom.js in the routes folder and add:

const express = require("express");
const models = require("../models");
const router = express.Router();
/* GET users listing. */
router.get("/chatrooms", async (req, res, next) => {
  const chatRooms = await models.ChatRoom.findAll();
  res.send(chatRooms);
});
router.post("/chatroom", async (req, res, next) => {
  const room = req.body.room;
  const chatRooms = await models.ChatRoom.findAll({
    where: { name: room },
  });
  const chatRoom = chatRooms[0];
  if (!chatRoom) {
    await models.ChatRoom.create({ name: room });
  }
  res.send(chatRooms);
});
router.get("/chatroom/messages/:chatRoomName", async (req, res, next) => {
  try {
    const chatRoomName = req.params.chatRoomName;
    const chatRooms = await models.ChatRoom.findAll({
      where: {
        name: chatRoomName,
      },
    });
    const chatRoomId = chatRooms[0].id;
    const messages = await models.ChatMessage.findAll({
      where: {
        chatRoomId,
      },
    });
    res.send(messages);
  } catch (error) {
    res.send([]);
  }
});
module.exports = router;

The /chatrooms route get all the chat rooms from the database. The chatroom POST route adds a new chat room if it does not yet exist by looking up any existing one by name. The /chatroom/messages/:chatRoomName route gets the messages for a given chat room by chat room name.

Finally in app.js , we replace the existing code with:

var createError = require("http-errors");
var express = require("express");
var path = require("path");
var cookieParser = require("cookie-parser");
var logger = require("morgan");
var indexRouter = require("./routes/index");
var chatRoomRouter = require("./routes/chatRoom");
var app = express();
const cors = require("cors");

// view engine setup
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "jade");
app.use(logger("dev"));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, "public")));
app.use(cors());
app.use("/", indexRouter);
app.use("/chatroom", chatRoomRouter);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
  next(createError(404));
});

// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get("env") === "development" ? err : {};
// render the error page
  res.status(err.status || 500);
  res.render("error");
});

module.exports = app;

and add our chat room routes by adding:

app.use("/chatroom", chatRoomRouter);

Now that back end is done, we can build our front end. Go to the project’s root folder and run npx create-react-app frontend . This create the initial code for front end with the packages installed. Next we need to install some packages ourselves. Run npm i axios bootstrap formik react-bootstrap react-router-dom socket.io-client yup to install our Axios HTTP client, Bootstrap for styling, React Router for routing URLs to our pages, and Formik and Yup for easy form data handling and validation respectively.

After we installed our packages, we can write some code. All files we change are in the src folder except when the path is mentioned explicitly. First, in App.js , we change the existing code to the following:

import React from "react";  
import { Router, Route, Link } from "react-router-dom";  
import HomePage from "./HomePage";  
import TopBar from "./TopBar";  
import { createBrowserHistory as createHistory } from "history";  
import "./App.css";  
import ChatRoomPage from "./ChatRoomPage";  
const history = createHistory();function App() { return (  
    <div className="App">  
      <Router history={history}>  
        <TopBar />  
        <Route path="/" exact component={HomePage} />  
        <Route path="/chatroom" exact component={ChatRoomPage} />  
      </Router>  
    </div>  
  );  
}

export default App;

To define our routes and include the top bar in our app, which will build later. Then in App.css , replace the existing code with:

.App {  
  margin: 0 auto;  
}

Next create a new page called ChatRoomPage.js and add the following:

import React from "react";
import { useEffect, useState } from "react";
import { Formik } from "formik";
import Form from "react-bootstrap/Form";
import Col from "react-bootstrap/Col";
import Button from "react-bootstrap/Button";
import * as yup from "yup";
import io from "socket.io-client";
import "./ChatRoomPage.css";
import { getChatRoomMessages, getChatRooms } from "./requests";
const SOCKET_IO_URL = "http://localhost:3000";
const socket = io(SOCKET_IO_URL);
const getChatData = () => {
  return JSON.parse(localStorage.getItem("chatData"));
};
const schema = yup.object({
  message: yup.string().required("Message is required"),
});
function ChatRoomPage() {
  const [initialized, setInitialized] = useState(false);
  const [messages, setMessages] = useState([]);
  const [rooms, setRooms] = useState([]);
  const handleSubmit = async evt => {
    const isValid = await schema.validate(evt);
    if (!isValid) {
      return;
    }
    const data = Object.assign({}, evt);
    data.chatRoomName = getChatData().chatRoomName;
    data.author = getChatData().handle;
    data.message = evt.message;
    socket.emit("message", data);
  };
  const connectToRoom = () => {
    socket.on("connect", data => {
      socket.emit("join", getChatData().chatRoomName);
    });
    socket.on("newMessage", data => {
      getMessages();
    });
    setInitialized(true);
  };
  const getMessages = async () => {
    const response = await getChatRoomMessages(getChatData().chatRoomName);
    setMessages(response.data);
    setInitialized(true);
  };
  const getRooms = async () => {
    const response = await getChatRooms();
    setRooms(response.data);
    setInitialized(true);
  };
  useEffect(() => {
   if (!initialized) {
      getMessages();
      connectToRoom();
      getRooms();
    }
  });
  return (
    <div className="chat-room-page">
      <h1>
        Chat Room: {getChatData().chatRoomName}. Chat Handle:{" "}
        {getChatData().handle}
      </h1>
      <div className="chat-box">
        {messages.map((m, i) => {
          return (
            <div className="col-12" key={i}>
              <div className="row">
                <div className="col-2">{m.author}</div>
                <div className="col">{m.message}</div>
                <div className="col-3">{m.createdAt}</div>
              </div>
            </div>
          );
        })}
      </div>
      <Formik validationSchema={schema} onSubmit={handleSubmit}>
        {({
          handleSubmit,
          handleChange,
          handleBlur,
          values,
          touched,
          isInvalid,
          errors,
        }) => (
          <Form noValidate onSubmit={handleSubmit}>
            <Form.Row>
              <Form.Group as={Col} md="12" controlId="handle">
                <Form.Label>Message</Form.Label>
                <Form.Control
                  type="text"
                  name="message"
                  placeholder="Message"
                  value={values.message || ""}
                  onChange={handleChange}
                  isInvalid={touched.message && errors.message}
                />
                <Form.Control.Feedback type="invalid">
                  {errors.message}
                </Form.Control.Feedback>
              </Form.Group>
            </Form.Row>
            <Button type="submit" style={{ marginRight: "10px" }}>
              Send
            </Button>
          </Form>
        )}
      </Formik>
    </div>
  );
}
export default ChatRoomPage;

This contains our main chat room code. The user will see the content of this page after going through the home page where they will fill out their chat handle and chat room name. First we connect to our Socket.io server by running const socket = io(SOCKET_IO_URL); Then, we connect to the given chat room name , which we stored in local storage in the connectToRoom function. The function will have the handler for the connect event, which is executed after the connect event is received. Once the event is received, the the client emits the join event by running socket.emit(“join”, getChatData().chatRoomName); , which sends the join event with our chat room name. Once the join event is received by the server. It will call the socket.join function in its event handler. Whenever the user submits a message the handleSubmit function is called, which emits the message event to our Socket.io server. Once the message is delivered to the server, it will save the message to the database and then emit the newMessage event back to the front end. The front end will then get the latest messages using the route we defined in back end using an HTTP request.

Note that we send the chat data to the server via Socket.io instead of HTTP requests, so that all users in the chat room will get the same data right away since the newMessage event will be broadcasted to all clients.

We create a file called ChatRoom.css , then in the file, add:

.chat-room-page {
  width: 90vw;
  margin: 0 auto;
}
.chat-box {
  height: calc(100vh - 300px);
  overflow-y: scroll;
}

Next we create the home page, which is the first page that the users sees when the user first opens the app. It is where the user will enter their chat handle and the name of the chat room. Create a file called HomePage.js and add:

import React from "react";
import { useEffect, useState } from "react";
import { Formik } from "formik";
import Form from "react-bootstrap/Form";
import Col from "react-bootstrap/Col";
import Button from "react-bootstrap/Button";
import * as yup from "yup";
import { Redirect } from "react-router";
import "./HomePage.css";
import { joinRoom } from "./requests";
const schema = yup.object({
  handle: yup.string().required("Handle is required"),
  chatRoomName: yup.string().required("Chat room is required"),
});
function HomePage() {
  const [redirect, setRedirect] = useState(false);
  const handleSubmit = async evt => {
    const isValid = await schema.validate(evt);
    if (!isValid) {
      return;
    }
    localStorage.setItem("chatData", JSON.stringify(evt));
    await joinRoom(evt.chatRoomName);
    setRedirect(true);
  };
  if (redirect) {
    return <Redirect to="/chatroom" />;
  }
  return (
    <div className="home-page">
      <h1>Join Chat</h1>
      <Formik
        validationSchema={schema}
        onSubmit={handleSubmit}
        initialValues={JSON.parse(localStorage.getItem("chatData") || "{}")}
      >
        {({
          handleSubmit,
          handleChange,
          handleBlur,
          values,
          touched,
          isInvalid,
          errors,
        }) => (
          <Form noValidate onSubmit={handleSubmit}>
            <Form.Row>
              <Form.Group as={Col} md="12" controlId="handle">
                <Form.Label>Handle</Form.Label>
                <Form.Control
                  type="text"
                  name="handle"
                  placeholder="Handle"
                  value={values.handle || ""}
                  onChange={handleChange}
                  isInvalid={touched.handle && errors.handle}
                />
                <Form.Control.Feedback type="invalid">
                  {errors.firstName}
                </Form.Control.Feedback>
              </Form.Group>
              <Form.Group as={Col} md="12" controlId="chatRoomName">
                <Form.Label>Chat Room Name</Form.Label>
                <Form.Control
                  type="text"
                  name="chatRoomName"
                  placeholder="Chat Room Name"
                  value={values.chatRoomName || ""}
                  onChange={handleChange}
                  isInvalid={touched.chatRoomName && errors.chatRoomName}
                />
                <Form.Control.Feedback type="invalid">
                  {errors.chatRoomName}
                </Form.Control.Feedback>
              </Form.Group>
            </Form.Row>
            <Button type="submit" style={{ marginRight: "10px" }}>
              Join
            </Button>
          </Form>
        )}
      </Formik>
    </div>
  );
}
export default HomePage;

Once the user enters the data into the form, it will be checked if they are filled in and once they are, a request will be sent to back end to add the chat room if it is not there. We also save the filled in data to local storage and redirect the user to the chat room page, where they will connect to the chat room with the name that they entered.

Both forms are built with React Bootstrap’s Form component.

Next we create a file called HomePage.css and add:

.home-page {  
    width: 90vw;  
    margin: 0 auto;  
}

to add some margins to our page.

Then we create a file called requests.js in the src folder to add the code for making the requests to our server for manipulating chat rooms and getting chat messages. In the file, add the following code:

const APIURL = "http://localhost:3000";  
const axios = require("axios");  
export const getChatRooms = () => axios.get(`${APIURL}/chatroom/chatrooms`);
export const getChatRoomMessages = chatRoomName =>  
  axios.get(`${APIURL}/chatroom/chatroom/messages/${chatRoomName}`);
export const joinRoom = room =>  
  axios.post(`${APIURL}/chatroom/chatroom`, { room });

Finally, in we create the top bar. Create a file called TopBar.js and add:

import React from "react";
import Navbar from "react-bootstrap/Navbar";
import Nav from "react-bootstrap/Nav";
import { withRouter } from "react-router-dom";
function TopBar({ location }) {
  const { pathname } = location;
  return (
    <Navbar bg="primary" expand="lg" variant="dark">
      <Navbar.Brand href="#home">Chat Room App</Navbar.Brand>
      <Navbar.Toggle aria-controls="basic-navbar-nav" />
      <Navbar.Collapse id="basic-navbar-nav">
        <Nav className="mr-auto">
          <Nav.Link href="/" active={pathname == "/"}>
            Join Another Chat Room
          </Nav.Link>
        </Nav>
      </Navbar.Collapse>
    </Navbar>
  );
}
export default withRouter(TopBar);

We create the top bar using the Navbar widget provided by React Bootstrap with a link to the home page. We wrap the component with the withRouter function so that we get the location object from React Router.

Categories
JavaScript React

Use React Refs to Manipulate the DOM and Konva to Add Graphics

React is a flexible framework that provides structured UI code while allowing the flexibility to manipulate DOM elements directly. All React components can be accessed by their refs. A React ref provides access to the underlying HTML DOM element that you can manipulate directly.

To use refs, we use the React.createRef function the useRef hook to create a ref object and then assign it to a variable. he variable is set as the value of the ref prop.

For example, we define a ref with:

const inputRef = React.createRef();

Then in the input element, we add the following:

<input type="text" ref={inputRef} />

Then we can access the input element by adding:

inputRef.current

This provides access to the HTML element, and we can utilize native DOM functionality. In our app, we will use the useRef hook, and you will see how to implement that below.

What we are building

In this article, we will build a whiteboard app that allows users to add shapes, texts, lines, and images to a whiteboard. In addition, users can undo their work and erase stuff from the screen.

We use the Konva library to let us add the shapes/text and erase them. The Konva library abstracts the hard work of adding items to the canvas. It allows you to add many shapes by simply writing a few lines of code. There are also React bindings for Konva, which abstracts some functionality even further for React. However, the feature set of React Konva is rather limited, so in order to meet the requirements of most apps, React Konva should be used as a companion to Konva.

We also want to allow users to move and transform your shapes easily, which you would have to write yourself if you wanted to do it in the HTML Canvas API.

Konva works by creating a stage and a layer in the stage which will allow you to add the lines, shapes, and text that you want.

Getting started

To start, we will create a React app with the Create React App command line program. Run npx create-react-app whiteboard-app to create the initial files for our app. Next, we need to add some packages. We want to use Bootstrap for styling, in addition to the Konva packages, and helper package for creating unique IDs for our shapes, lines, and text. We also need React Router for routing.

To install the libraries, we run:

npm i bootstrap react-bootstrap konva react-konva react-router-dom use-image uuid

use-image is a package to convert image URLs into image objects that can be displayed on canvas. The UUID package generates unique IDs for our shapes.

With the packages installed, we can start writing code. First we start with the entry point of our app, which is App.js. Replace the existing code of the file with:

import React from "react";  
import { Router, Route, Link } from "react-router-dom";  
import HomePage from "./HomePage";  
import TopBar from "./TopBar";  
import { createBrowserHistory as createHistory } from "history";  
import "./App.css";  
const history = createHistory();
function App() {  
  return (  
    <div className="App">  
      <Router history={history}>  
        <TopBar />  
        <Route path="/" exact component={HomePage} />  
      </Router>  
    </div>  
  );  
}

export default App;

All we added is a top navigation bar and our only route which is the home page.

Next we add the code for the shapes. React Konva has libraries for common shapes like rectangles and circles. We first start with a circle. In the src
folder, create a file called Circle.js and add:

import React from "react";  
import { Circle, Transformer } from "react-konva";
const Circ = ({ shapeProps, isSelected, onSelect, onChange }) => {  
  const shapeRef = React.useRef();  
  const trRef = React.useRef();
  React.useEffect(() => {  
    if (isSelected) {  
      trRef.current.setNode(shapeRef.current);  
      trRef.current.getLayer().batchDraw();  
    }  
  }, [isSelected]);

  return (  
    <React.Fragment>  
      <Circle  
        onClick={onSelect}  
        ref={shapeRef}  
        {...shapeProps}  
        draggable  
        onDragEnd={e => {  
          onChange({  
            ...shapeProps,  
            x: e.target.x(),  
            y: e.target.y(),  
          });  
        }}  
        onTransformEnd={e => {  
          // transformer is changing scale  
          const node = shapeRef.current;  
          const scaleX = node.scaleX();  
          const scaleY = node.scaleY();  
          node.scaleX(1);  
          node.scaleY(1);  
          onChange({  
            ...shapeProps,  
            x: node.x(),  
            y: node.y(),  
            width: node.width() * scaleX,  
            height: node.height() * scaleY,  
          });  
        }}  
      />  
      {isSelected && <Transformer ref={trRef} />}  
    </React.Fragment>  
  );  
};

export default Circ;

This code returns the Circle shape which can be added onto the canvas at will. In the React.useEffect’s callback function, we detect if the shape is selected and then draw a handle for the shape so that it can be resized and moved.

In this file, we added refs to the Circle component so that we can access it in the useEffect callback. The setNode function takes an HTML DOM element.

The component in the return statement is the main code for the Circle . We have an onClick handler that gets the ID of the selected shape. The draggable prop makes the Circle draggable. onDragEnd handles the event when the user stops dragging. The position is updated there. onTransformEnd scales the shape as the user drags the handles that are available. The width and height are changed as the handles are dragged. {isSelected && <Transformer ref={trRef} />} create the Transformer object, which is a Konva object that lets you change the size of a shape when you select.

Next we add a component for the image. Create a file called Image.js in the src folder and add the following:

import React from "react";  
import { Image, Transformer } from "react-konva";  
import useImage from "use-image";
const Img = ({ shapeProps, isSelected, onSelect, onChange, imageUrl }) => {  
  const shapeRef = React.useRef();  
  const trRef = React.useRef();  
  const [image] = useImage(imageUrl); 

  React.useEffect(() => {  
    if (isSelected) {  
      trRef.current.setNode(shapeRef.current);  
      trRef.current.getLayer().batchDraw();  
    }  
  }, [isSelected]); 
  return (  
    <React.Fragment>  
      <Image  
        onClick={onSelect}  
        image={image}  
        ref={shapeRef}  
        draggable  
        onDragEnd={e => {  
          onChange({  
            ...shapeProps,  
            x: e.target.x(),  
            y: e.target.y(),  
          });  
        }}  
        onTransformEnd={e => {  
          const node = shapeRef.current;  
          const scaleX = node.scaleX();  
          const scaleY = node.scaleY();  
          onChange({  
            ...shapeProps,  
            x: node.x(),  
            y: node.y(),  
            width: node.width() * scaleX,  
            height: node.height() * scaleY,  
          });  
        }}  
      />  
      {isSelected && <Transformer ref={trRef} />}  
    </React.Fragment>  
  );  
};

export default Img;

This is very similar to the Circle component except we have the useImage function provided by the use-image library to convert the given imageUrl prop to an image that is displayed on the canvas.

In this file, we added refs to the Image component so that we can access it in the useEffect callback. The setNode function takes an HTML DOM element.

Next we create a free drawing line. Create a file called line.js in the src folder and add:

import Konva from "konva";

export const addLine = (stage, layer, mode = "brush") => {  
  let isPaint = false;  
  let lastLine; stage.on("mousedown touchstart", function(e) {  
    isPaint = true;  
    let pos = stage.getPointerPosition();  
    lastLine = new Konva.Line({  
      stroke: mode == "brush" ? "red" : "white",  
      strokeWidth: mode == "brush" ? 5 : 20,  
      globalCompositeOperation:  
        mode === "brush" ? "source-over" : "destination-out",  
      points: [pos.x, pos.y],  
      draggable: mode == "brush",  
    });  
    layer.add(lastLine);  
  }); 

  stage.on("mouseup touchend", function() {  
    isPaint = false;  
  }); 

  stage.on("mousemove touchmove", function() {  
    if (!isPaint) {  
      return;  
    } const pos = stage.getPointerPosition();  
    let newPoints = lastLine.points().concat(\[pos.x, pos.y\]);  
    lastLine.points(newPoints);  
    layer.batchDraw();  
  });  
};

In this file, we use plain Konva since React Konva does not have a convenient way to make free drawing a line where a user drags the mouse. When the mousedown and touchstart is triggered, we set the color of the line depending on what the mode is. When it is brush, we draw a red line. If it’s erase we draw a thick white line so that users can draw it over their content, letting users erase their changes.

When the mousemove and touchend events are triggered, we set isPaint to false so we stop drawing the line. When the mousemove and touchmove events are triggered, we add dots along the way to draw the line in the direction the user wants when the user moves the mouse when clicking or touching the touchscreen.

stage and layer are the Konva Stage and Layer objects which we pass in when the addLine function is called.

Next we create the Rectangle component for drawing free form rectangles. In the src folder, create a file called Rectangle.js and add:

import React from "react";  
import { Rect, Transformer } from "react-konva";
const Rectangle = ({ shapeProps, isSelected, onSelect, onChange }) => {  
  const shapeRef = React.useRef();  
  const trRef = React.useRef(); 
  React.useEffect(() => {  
    if (isSelected) {  
      // we need to attach transformer manually  
      trRef.current.setNode(shapeRef.current);  
      trRef.current.getLayer().batchDraw();  
    }  
  }, [isSelected]); 

  return (  
    <React.Fragment>  
      <Rect  
        onClick={onSelect}  
        ref={shapeRef}  
        {...shapeProps}  
        draggable  
        onDragEnd={e => {  
          onChange({  
            ...shapeProps,  
            x: e.target.x(),  
            y: e.target.y(),  
          });  
        }}  
        onTransformEnd={e => {  
          // transformer is changing scale  
          const node = shapeRef.current;  
          const scaleX = node.scaleX();  
          const scaleY = node.scaleY();  
          node.scaleX(1);  
          node.scaleY(1);  
          onChange({  
            ...shapeProps,  
            x: node.x(),  
            y: node.y(),  
            width: node.width() * scaleX,  
            height: node.height() * scaleY,  
          });  
        }}  
      />  
      {isSelected && <Transformer ref={trRef} />}  
    </React.Fragment>  
  );  
};export default Rectangle;

This component is similar to Circle component. We have the drag handles to move and resize the rectangle by adding the onDragEnd and onTransformEnd callbacks, change the x and y coordinates in the onDragEnd handler, and change the width and height in the onTransformEnd event callback.

The Transformer component is added if the shape is selected so that users can move or resize the shape with the handles when selected.

Similar to the Circle component, we added refs to the Rectangle component so that we can access it in the useEffect callback. The setNode function takes an HTML DOM element.

Next we add a text field component to let users can add text to the whiteboard. Create a file called textNode.js and add the following:

import Konva from "konva";  
const uuidv1 = require("uuid/v1");
export const addTextNode = (stage, layer) => {  
  const id = uuidv1();  
  const textNode = new Konva.Text({  
    text: "type here",  
    x: 50,  
    y: 80,  
    fontSize: 20,  
    draggable: true,  
    width: 200,  
    id,  
  }); 

  layer.add(textNode); let tr = new Konva.Transformer({  
    node: textNode,  
    enabledAnchors: ["middle-left", "middle-right"],  
    // set minimum width of text  
    boundBoxFunc: function(oldBox, newBox) {  
      newBox.width = Math.max(30, newBox.width);  
      return newBox;  
    },  
  }); 

  stage.on("click", function(e) {  
    if (!this.clickStartShape) {  
      return;  
    }  
    if (e.target._id == this.clickStartShape._id) {  
      layer.add(tr);  
      tr.attachTo(e.target);  
      layer.draw();  
    } else {  
      tr.detach();  
      layer.draw();  
    }  
  }); 

  textNode.on("transform", function() {  
    // reset scale, so only with is changing by transformer  
    textNode.setAttrs({  
      width: textNode.width() \* textNode.scaleX(),  
      scaleX: 1,  
    });  
  }); 

  layer.add(tr); layer.draw(); textNode.on("dblclick", () => {  
    // hide text node and transformer:  
    textNode.hide();  
    tr.hide();  
    layer.draw(); 
    // create textarea over canvas with absolute position  
    // first we need to find position for textarea  
    // how to find it?
    // at first lets find position of text node relative to the stage:  
    let textPosition = textNode.absolutePosition();
    // then lets find position of stage container on the page:  
    let stageBox = stage.container().getBoundingClientRect();
    // so position of textarea will be the sum of positions above:  
    let areaPosition = {  
      x: stageBox.left + textPosition.x,  
      y: stageBox.top + textPosition.y,  
    };
    // create textarea and style it  
    let textarea = document.createElement("textarea");  
    document.body.appendChild(textarea);
    // apply many styles to match text on canvas as close as possible  
    // remember that text rendering on canvas and on the textarea can be different  
    // and sometimes it is hard to make it 100% the same. But we will try...  
    textarea.value = textNode.text();  
    textarea.style.position = "absolute";  
    textarea.style.top = areaPosition.y + "px";  
    textarea.style.left = areaPosition.x + "px";  
    textarea.style.width = textNode.width() - textNode.padding() * 2 + "px";  
    textarea.style.height =  
      textNode.height() - textNode.padding() * 2 + 5 + "px";  
    textarea.style.fontSize = textNode.fontSize() + "px";  
    textarea.style.border = "none";  
    textarea.style.padding = "0px";  
    textarea.style.margin = "0px";  
    textarea.style.overflow = "hidden";  
    textarea.style.background = "none";  
    textarea.style.outline = "none";  
    textarea.style.resize = "none";  
    textarea.style.lineHeight = textNode.lineHeight();  
    textarea.style.fontFamily = textNode.fontFamily();  
    textarea.style.transformOrigin = "left top";  
    textarea.style.textAlign = textNode.align();  
    textarea.style.color = textNode.fill();  
    let rotation = textNode.rotation();  
    let transform = "";  
    if (rotation) {  
      transform += "rotateZ(" + rotation + "deg)";  
    } let px = 0;  
    let isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1;  
    if (isFirefox) {  
      px += 2 + Math.round(textNode.fontSize() / 20);  
    }  
    transform += "translateY(-" + px + "px)"; textarea.style.transform = transform;  
    textarea.style.height = "auto";  
    // after browsers resized it we can set actual value  
    textarea.style.height = textarea.scrollHeight + 3 + "px"; textarea.focus(); 

    function removeTextarea() {  
      textarea.parentNode.removeChild(textarea);  
      window.removeEventListener("click", handleOutsideClick);  
      textNode.show();  
      tr.show();  
      tr.forceUpdate();  
      layer.draw();  
    } 

    function setTextareaWidth(newWidth) {  
      if (!newWidth) {  
        // set width for placeholder  
        newWidth = textNode.placeholder.length * textNode.fontSize();  
      }  
      // some extra fixes on different browsers  
      let isSafari = /^((?!chrome|android).)\*safari/i.test(navigator.userAgent);  
      let isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1;  
      if (isSafari || isFirefox) {  
        newWidth = Math.ceil(newWidth);  
      } let isEdge = document.documentMode || /Edge/.test(navigator.userAgent);  
      if (isEdge) {  
        newWidth += 1;  
      }  
      textarea.style.width = newWidth + "px";  
    } 

    textarea.addEventListener("keydown", function(e) {  
      // hide on enter  
      // but don't hide on shift + enter  
      if (e.keyCode === 13 && !e.shiftKey) {  
        textNode.text(textarea.value);  
        removeTextarea();  
      }  
      // on esc do not set value back to node  
      if (e.keyCode === 27) {  
        removeTextarea();  
      }  
    }); 

    textarea.addEventListener("keydown", function(e) {  
      let scale = textNode.getAbsoluteScale().x;  
      setTextareaWidth(textNode.width() * scale);  
      textarea.style.height = "auto";  
      textarea.style.height =  
        textarea.scrollHeight + textNode.fontSize() + "px";  
    }); 

    function handleOutsideClick(e) {  
      if (e.target !== textarea) {  
        removeTextarea();  
      }  
    }  
    setTimeout(() => {  
      window.addEventListener("click", handleOutsideClick);  
    });  
  });  
  return id;  
};

We add a text area, and then we handle the events created by the text area. When the user clicks the text area, a box will with handles will be displayed to let the user move the text area around the canvas. This is what the click handler for the stage is doing. It finds the text area by ID and then attaches a KonvaTransformer to it, adding the box with handles.

We have a transform handler for the textNode text area to resize the text area when the user drags the handles. We have a double click handler to let users enter text when they double click. Most of the code is for styling the text box as close to the canvas as possible so that it will blend into the canvas. Otherwise, it will look strange. We also let users rotate the text area by applying CSS for rotating the text area as the user drags the handles.

In the keydown event handler, we change the size of the text area as the user types to make sure it displays all the text without scrolling.

When the user clicks outside the text area, the box with handles will disappear, letting the user select other items.

The home page is where we put everything together. Create a new file called HomePage.js in the src folder and add:

import React, { useState, useRef } from "react";  
import ButtonGroup from "react-bootstrap/ButtonGroup";  
import Button from "react-bootstrap/Button";  
import "./HomePage.css";  
import { Stage, Layer } from "react-konva";  
import Rectangle from "./Rectangle";  
import Circle from "./Circle";  
import { addLine } from "./line";  
import { addTextNode } from "./textNode";  
import Image from "./Image";  
const uuidv1 = require("uuid/v1");

function HomePage() {  
  const [rectangles, setRectangles] = useState([]);  
  const [circles, setCircles] = useState([]);  
  const [images, setImages] = useState([]);  
  const [selectedId, selectShape] = useState(null);  
  const [shapes, setShapes] = useState([]);  
  const [, updateState] = React.useState();  
  const stageEl = React.createRef();  
  const layerEl = React.createRef();  
  const fileUploadEl = React.createRef(); 
  const getRandomInt = max => {  
    return Math.floor(Math.random() * Math.floor(max));  
  }; 
  const addRectangle = () => {  
    const rect = {  
      x: getRandomInt(100),  
      y: getRandomInt(100),  
      width: 100,  
      height: 100,  
      fill: "red",  
      id: `rect${rectangles.length + 1}`,  
    };  
    const rects = rectangles.concat(\[rect\]);  
    setRectangles(rects);  
    const shs = shapes.concat([`rect${rectangles.length + 1}`]);  
    setShapes(shs);  
  }; 

  const addCircle = () => {  
    const circ = {  
      x: getRandomInt(100),  
      y: getRandomInt(100),  
      width: 100,  
      height: 100,  
      fill: "red",  
      id: `circ${circles.length + 1}`,  
    };  
    const circs = circles.concat([circ]);  
    setCircles(circs);  
    const shs = shapes.concat([`circ${circles.length + 1}`]);  
    setShapes(shs);  
  }; 

  const drawLine = () => {  
    addLine(stageEl.current.getStage(), layerEl.current);  
  }; 

  const eraseLine = () => {  
    addLine(stageEl.current.getStage(), layerEl.current, "erase");  
  }; 

  const drawText = () => {  
    const id = addTextNode(stageEl.current.getStage(), layerEl.current);  
    const shs = shapes.concat([id]);  
    setShapes(shs);  
  }; 
  
  const drawImage = () => {  
    fileUploadEl.current.click();  
  }; 

  const forceUpdate = React.useCallback(() => updateState({}), []); 
  const fileChange = ev => {  
    let file = ev.target.files[0];  
    let reader = new FileReader(); 
    reader.addEventListener(  
      "load",  
      () => {  
        const id = uuidv1();  
        images.push({  
          content: reader.result,  
          id,  
        });  
        setImages(images);  
        fileUploadEl.current.value = null;  
        shapes.push(id);  
        setShapes(shapes);  
        forceUpdate();  
      },  
      false  
    );
    if (file) {  
      reader.readAsDataURL(file);  
    }  
  }; 

  const undo = () => {  
    const lastId = shapes[shapes.length - 1];  
    let index = circles.findIndex(c => c.id == lastId);  
    if (index != -1) {  
      circles.splice(index, 1);  
      setCircles(circles);  
    } 
    index = rectangles.findIndex(r => r.id == lastId);  
    if (index != -1) {  
      rectangles.splice(index, 1);  
      setRectangles(rectangles);  
    } 
    index = images.findIndex(r => r.id == lastId);  
    if (index != -1) {  
      images.splice(index, 1);  
      setImages(images);  
    }  
    shapes.pop();  
    setShapes(shapes);  
    forceUpdate();  
  }; 

  document.addEventListener("keydown", ev => {  
    if (ev.code == "Delete") {  
      let index = circles.findIndex(c => c.id == selectedId);  
      if (index != -1) {  
        circles.splice(index, 1);  
        setCircles(circles);  
      } 
      index = rectangles.findIndex(r => r.id == selectedId);  
      if (index != -1) {  
        rectangles.splice(index, 1);  
        setRectangles(rectangles);  
      } 
      index = images.findIndex(r => r.id == selectedId);  
      if (index != -1) {  
        images.splice(index, 1);  
        setImages(images);  
      }  
      forceUpdate();  
    }  
  }); 

  return (  
    <div className="home-page">  
      <h1>Whiteboard</h1>  
      <ButtonGroup>  
        <Button variant="secondary" onClick={addRectangle}>  
          Rectangle  
        </Button>  
        <Button variant="secondary" onClick={addCircle}>  
          Circle  
        </Button>  
        <Button variant="secondary" onClick={drawLine}>  
          Line  
        </Button>  
        <Button variant="secondary" onClick={eraseLine}>  
          Erase  
        </Button>  
        <Button variant="secondary" onClick={drawText}>  
          Text  
        </Button>  
        <Button variant="secondary" onClick={drawImage}>  
          Image  
        </Button>  
        <Button variant="secondary" onClick={undo}>  
          Undo  
        </Button>  
      </ButtonGroup>  
      <input  
        style={{ display: "none" }}  
        type="file"  
        ref={fileUploadEl}  
        onChange={fileChange}  
      />  
      <Stage  
        width={window.innerWidth * 0.9}  
        height={window.innerHeight - 150}  
        ref={stageEl}  
        onMouseDown={e => {  
          // deselect when clicked on empty area  
          const clickedOnEmpty = e.target === e.target.getStage();  
          if (clickedOnEmpty) {  
            selectShape(null);  
          }  
        }}  
      >  
        <Layer ref={layerEl}>  
          {rectangles.map((rect, i) => {  
            return (  
              <Rectangle  
                key={i}  
                shapeProps={rect}  
                isSelected={rect.id === selectedId}  
                onSelect={() => {  
                  selectShape(rect.id);  
                }}  
                onChange={newAttrs => {  
                  const rects = rectangles.slice();  
                  rects[i] = newAttrs;  
                  setRectangles(rects);  
                }}  
              />  
            );  
          })}  
          {circles.map((circle, i) => {  
            return (  
              <Circle  
                key={i}  
                shapeProps={circle}  
                isSelected={circle.id === selectedId}  
                onSelect={() => {  
                  selectShape(circle.id);  
                }}  
                onChange={newAttrs => {  
                  const circs = circles.slice();  
                  circs\[i\] = newAttrs;  
                  setCircles(circs);  
                }}  
              />  
            );  
          })}  
          {images.map((image, i) => {  
            return (  
              <Image  
                key={i}  
                imageUrl={image.content}  
                isSelected={image.id === selectedId}  
                onSelect={() => {  
                  selectShape(image.id);  
                }}  
                onChange={newAttrs => {  
                  const imgs = images.slice();  
                  imgs[i] = newAttrs;  
                }}  
              />  
            );  
          })}  
        </Layer>  
      </Stage>  
    </div>  
  );  
}

export default HomePage;

This is where we add the buttons to create the shapes. For the shapes provided by React Konva, we create the shapes by adding an object to the array for the shape and then map them to the shape with the properties specified by the object.

For example, to add a rectangle, we create an object, add it to the array by pushing the object and then calling setRectangles and then map them to the actual Rectangle component when we render the canvas. We pass in the onSelect handler so that the user can click on the shape and get the ID of the selected shape. The onChange handler lets us update the properties of an existing shape and then update the corresponding array for the shapes.

Every React Konva shape we add should be inside the Layer component. They provide the place for the shapes to reside. The Stage component provides a place for the Layer to be in. We set the ref prop for the Stage and Layer components so that we can access it directly.

In the call to the addLine function, we get the refs for the Stage and Layer components to get the reference to the Konva Stage and Layer instances so that we can use them in the addLine function. Note that to get the Konva Stage object, we have to call setStage after the the current attribute.

In the Stage component, we have a onMouseDown handler to deselect all shapes when the click is outside all the shapes.

To the undo a change, we keep track of all the shapes with the shapes array and then when the Undo button is clicked, then the last shape is removed from the array and also the corresponding shapes array. For example, if the undo removes a rectangle, then it will be removed from the shapes array and the rectangle array. The shapes array is an array of IDs of all the shapes.

To build the image upload feature, we add an input element which we don’t show, and we use the ref to write a function to let the user click on the hidden file input. Once the file input is clicked the user can choose files and the image is read with the FileReader object into base64, which will be converted to an image displayed on the canvas with the use-image library.

Similarly for letting users delete shapes when a shape is selected with the delete key, we add a key down handler. In the key down handler function, when a delete key event is triggered then the handler will find the shapes in the arrays by ID and delete it. It will also delete it from the shapes array. We defined the forceUpdate function so that the canvas will be updated even when there is DOM manipulation done without going through React. The keydown handler is added by using document.addEventListener which is not React code, so we need to call forceUpdate to re-render according to the new states.

Finally, to finish it off, we add the top bar. Create a file called TopBar.js in the src folder and add:

import React from "react";  
import Navbar from "react-bootstrap/Navbar";  
import Nav from "react-bootstrap/Nav";  
import NavDropdown from "react-bootstrap/NavDropdown";  
import "./TopBar.css";  
import { withRouter } from "react-router-dom";
function TopBar({ location }) {  
  const { pathname } = location; 
  return (  
    <Navbar bg="primary" expand="lg" variant="dark">  
      <Navbar.Brand href="#home">React Canvas App</Navbar.Brand>  
      <Navbar.Toggle aria-controls="basic-navbar-nav" />  
      <Navbar.Collapse id="basic-navbar-nav">  
        <Nav className="mr-auto">  
          <Nav.Link href="/" active={pathname == "/"}>  
            Home  
          </Nav.Link>  
        </Nav>  
      </Navbar.Collapse>  
    </Navbar>  
  );  
}

export default withRouter(TopBar);

The Navbar component is provided by React Boostrap.

After all the work is done, we get a whiteboard that we can draw on.