Categories
Vue

How to Add Input Mask to Enforce Input Format

Input validation is always a chore to set up. An input mask is a way to enforce the format of the user’s input in a simple way.

Input validation is always a chore to set up. An input mask is a way to enforce the format of the user’s input in a simple way. When an input mask is applied to an input element, only input in a set format can be entered.

For example, if an input has an input mask of for phone may have 3 digits for the area code, followed by a dash, then 3 digits for the prefix, followed by another dash, and then followed by the remaining 4 digits.

There are many JavaScript libraries for adding an input task to input fields. If we are writing a Vue.js app, we can use Vue-InputMask, located at https://github.com/scleriot/vue-inputmask.

In this article, we will build weight tracker that lets users enter date in YYYY-MM-DD format and the user’s weight. Then the output will be shown in a table sorted by reverse chronological order. We will also let user edit and delete the entries.

To start building the project, we will use the Vue CLI. We run npx @vue/cli create weight-tracker to start the wizard. Then we select ‘Manually select features’ and pick Babel, Vuex, and Vuex Router from the list.

Next we install Axios for making HTTP requests, Bootstrap Vue for styling, Vee-Validate for form validation, Vue-Filter-Date-Format for displaying dates, and Vue-Inputmask for the input mask. To install them, we run npm i axios bootstrap-vue vee-validate vue-filter-date-format vue-inputmask .

Once that’s done, we move on to building the form for adding and edit the weight data. To do this, we create a WeightForm.vue file in the components folder and add:

<template>
  <ValidationObserver ref="observer" v-slot="{ invalid }">
    <b-form @submit.prevent="onSubmit" novalidate>
      <b-form-group label="Date">
        <ValidationProvider name="date" rules="required|date" v-slot="{ errors }">
          <b-form-input
            type="text"
            :state="errors.length == 0"
            v-model="form.date"
            required
            placeholder="Date"
            name="date"
            v-mask="'9999-99-99'"
          ></b-form-input>
          <b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

      <b-form-group label="Weight">
        <ValidationProvider
          name="weight"
          rules="required|min_value:0|max_value:9999"
          v-slot="{ errors }"
        >
          <b-form-input
            type="text"
            :state="errors.length == 0"
            v-model="form.weight"
            required
            placeholder="Weight"
            name="weight"
          ></b-form-input>
          <b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

      <b-button type="submit" variant="primary" style="margin-right: 10px">Submit</b-button>
      <b-button type="reset" variant="danger" @click="cancel()">Cancel</b-button>
    </b-form>
  </ValidationObserver>
</template>

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

export default {
  name: "WeightForm",
  mixins: [requestsMixin],
  props: {
    edit: Boolean,
    weight: Object
  },
  data() {
    return {
      form: {}
    };
  },
  methods: {
    async onSubmit() {
      const isValid = await this.$refs.observer.validate();
      if (!isValid) {
        return;
      }
      const offDate = new Date(this.form.date);
      const correctedDate = new Date(
        offDate.getTime() + Math.abs(offDate.getTimezoneOffset() * 60000)
      );

      const params = {
        ...this.form,
        date: correctedDate
      };

      if (this.edit) {
        await this.editWeight(params);
      } else {
        await this.addWeight(params);
      }
      const { data } = await this.getWeights();
      this.$store.commit("setWeights", data);
      this.$emit("saved");
    },
    cancel() {
      this.$emit("cancelled");
    }
  },
  watch: {
    weight: {
      handler(val) {
        this.form = JSON.parse(JSON.stringify(val || {}));
      },
      deep: true,
      immediate: true
    }
  }
};
</script>

In this file, we have a form to let users enter their weights. We use Vee-Validate to validate our inputs. We use the ValidationObserver component to watch for the validity of the form inside the component and ValidationProvider to check for the validation rule of the inputted value of the input inside the component. Inside the ValidationProvider , we have our BootstrapVue input for the text input fields. In the b-form-input components, we add the input mask to the date input with Vue-InputMask. It’s very simple. All we have to do is use the v-mask directive provided by Vue-InputMask like we did in the code. We also add Vee-Validate validation to make sure that users have filled out the date before submitting. In the weight field, we enforce the minimum and maximum value with the help of Vee-Validate as we wrote in the rules.

In the onSubmit function we correct the date bu adding the time zone offset to our date. We only need this because we have a date in YYYY-MM-DD format, according to Stack Overflow https://stackoverflow.com/a/14569783/6384091. After that, we submit the data and get the latest ones and put them in our Vuex store. Then we close the modal by emitting the saved event to the Home.vue component, which we will modify later.

We have the watch block to watch the weight prop, which we will need for editing.

Next we create a mixins folder and add requestsMixin.js into the mixins folder. In the file, we add:

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

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

    addWeight(data) {
      return axios.post(`${APIURL}/weights`, data);
    },

    editWeight(data) {
      return axios.put(`${APIURL}/weights/${data.id}`, data);
    },

    deleteWeight(id) {
      return axios.delete(`${APIURL}/weights/${id}`);
    }
  }
};

These are the functions we use in our components to make HTTP requests to get and save our data.

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

<template>
  <div class="page">
    <h1 class="text-center">Weights</h1>
    <b-button-toolbar class="button-toolbar">
      <b-button @click="openAddModal()" variant="primary">Add Weight</b-button>
    </b-button-toolbar>

    <b-table-simple responsive>
      <b-thead>
        <b-tr>
          <b-th sticky-column>Date</b-th>
          <b-th>Weight</b-th>
          <b-th>Edit</b-th>
          <b-th>Delete</b-th>
        </b-tr>
      </b-thead>
      <b-tbody>
        <b-tr v-for="w in weights" :key="w.id">
          <b-th sticky-column>{{ new Date(w.date) | dateFormat('YYYY-MM-DD') }}</b-th>
          <b-td>{{w.weight}}</b-td>
          <b-td>
            <b-button @click="openEditModal(w)">Edit</b-button>
          </b-td>
          <b-td>
            <b-button @click="deleteOneWeight(w.id)">Delete</b-button>
          </b-td>
        </b-tr>
      </b-tbody>
    </b-table-simple>

    <b-modal id="add-modal" title="Add Weight" hide-footer>
      <WeightForm @saved="closeModal()" @cancelled="closeModal()" :edit="false" />
    </b-modal>

    <b-modal id="edit-modal" title="Edit Weight" hide-footer>
      <WeightForm
        @saved="closeModal()"
        @cancelled="closeModal()"
        :edit="true"
        :weight="selectedWeight"
      />
    </b-modal>
  </div>
</template>

<script>
// @ is an alias to /src
import WeightForm from "@/components/WeightForm.vue";
import { requestsMixin } from "@/mixins/requestsMixin";

export default {
  name: "home",
  components: {
    WeightForm
  },
  mixins: [requestsMixin],
  computed: {
    weights() {
      return this.$store.state.weights.sort(
        (a, b) => +new Date(b.date) - +new Date(a.date)
      );
    }
  },
  beforeMount() {
    this.getAllWeights();
  },
  data() {
    return {
      selectedWeight: {}
    };
  },
  methods: {
    openAddModal() {
      this.$bvModal.show("add-modal");
    },
    openEditModal(weight) {
      this.$bvModal.show("edit-modal");
      this.selectedWeight = weight;
    },
    closeModal() {
      this.$bvModal.hide("add-modal");
      this.$bvModal.hide("edit-modal");
      this.selectedWeight = {};
    },
    async deleteOneWeight(id) {
      await this.deleteWeight(id);
      this.getAllWeights();
    },
    async getAllWeights() {
      const { data } = await this.getWeights();
      this.$store.commit("setWeights", data);
    }
  }
};
</script>

<style scoped>
</style>

We have a table to display the entered data with a BootstrapVue table. In each row, there’s an Edit and Delete button to open the edit modal and pass that data to the WeightForm, and delete the entry respectively.

When the page loads, we get all the entered data with the getAllWeights function called in the beforeMount hook. In the getAllWeights function, we put everything in the Vuex store. Then in here, we get the latest state of the store by putting the this.$store.state.weights in the computed block of the code. In there, we also sort the weight data by reverse chronological order.

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

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

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

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

<script>
export default {
  data() {
    return {
      path: this.$route && this.$route.path
    };
  },
  watch: {
    $route(route) {
      this.path = route.path;
    }
  }
};
</script>

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

button,
.btn.btn-primary {
  margin-right: 10px !important;
}

.button-toolbar {
  margin-bottom: 10px;
}
</style>

to add a Bootstrap navigation bar to the top of our pages, and a router-view to display the routes we define. This style section isn’t scoped so the styles will apply globally. In the .page selector, we add some padding to our pages. We add some padding to the buttons in the remaining style code.

Then in main.js , replace the existing code with:

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import BootstrapVue from "bootstrap-vue";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required, min_value, max_value } from "vee-validate/dist/rules";
import VueFilterDateFormat from "vue-filter-date-format";
const VueInputMask = require("vue-inputmask").default;

Vue.use(VueInputMask);
Vue.use(VueFilterDateFormat);
extend("required", required);
extend("min_value", min_value);
extend("max_value", max_value);
extend("date", {
  validate: value =>
    /([12]d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]d|3[01]))/.test(value),
  message: "Date must be in YYYY-MM-DD format"
});
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
Vue.use(BootstrapVue);

Vue.config.productionTip = false;

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

We added all the libraries we need here, including BootstrapVue JavaScript and CSS, Vee-Validate components along with the validation rules, the Vue-InputMask library, and the Vue-Filter-Date-Format library are adding here for use in our app. The min_value and max_value rules are added for validating the weight, and we made a date rule for validating that the date is in YYYY-MM-DD format.

In router.js we replace the existing code with:

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

Vue.use(Router);

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

to include the home page in our routes so users can see the page.

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

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

Vue.use(Vuex);

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

to add our weights state to the store so we can observer it in the computed block of WeightForm and HomePage components. We have the setWeights function to update the passwords state and we use it in the components by call this.$store.commit(“setWeights”, data); like we did in WeightForm .

Finally, in index.html , we replace the existing code with:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width,initial-scale=1.0" />
    <link rel="icon" href="<%= BASE_URL %>favicon.ico" />
    <title>Weight Tracker</title>
  </head>
  <body>
    <noscript>
      <strong
        >We're sorry but vue-input-mask-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 change the title of our app.

After all the hard work, we can start our app by running npm run serve.

To start the back end, we first install the json-server package by running npm i json-server. Then, go to our project folder and run:

json-server --watch db.json

In db.json, change the text to:

{
  "`weights`": [
]
}

So we have the weights endpoints defined in the requests.js available.

Categories
React

How to Build a Chrome Extension with React

Use React with Hooks to build a Chrome extension that displays the weather by calling the OpenWeaterMap API.

The most popular web browsers, Chrome and Firefox, support extensions. Extensions are small apps built using HTML, CSS, and JavaScript that you can add to your browser to get additional functionality that is not included by default. This makes extending your browser very easy. All a user needs to do is to add browser add-ons from the online stores, like the Chrome Web Store or the Firefox Store.

Browser extensions are just normal HTML apps packaged in a specific way. This means that we can use HTML, CSS, and JavaScript to build our own extensions — this also means we can use frontend JavaScript frameworks like React or Vue to build extensions.

Chrome and Firefox extensions follow the Web Extension API standard.

In this article, we will build a Chrome extension that displays the weather from the OpenWeatherMap API. We will add search to let users look up the current weather and forecast from the API and display it in the extension’s popup box.

The OpenWeatherMap API is available at https://openweathermap.org/api and you will need to register for an API key.

Getting Started

We will use React to build the browser extension. If we create the app with Create React App, we can just build the app as we normally would and then modify the manifest.json in the project to create a valid Chrome extension.

The manifest.json is the additional configuration file needed to tell Chrome how to handle our app as an extension. We will build the app first, and then I will show you what’s needed in manifest.json to make it work.

To start building the app, we run Create React App by running:

npx create-react-app weather-app

This command will create the React project folder with skeleton code that we will build on.

Next we install some packages we need to build our extension. We need Axios for making HTTP requests, Bootstrap for styling, Formik for form value handling, MobX for state management, and Yup for form validation.

We install all of them by running:

npm i axios bootstrap formik mobx mobx-react react-bootstrap yup

Building the App

Now we can build our weather widget Chrome extension. In App.js, replace the existing code with:

import React from "react";
import HomePage from "./HomePage";
import "./App.css";
import { KeywordStore } from "./store";
import Navbar from "react-bootstrap/Navbar";
const keywordStore = new KeywordStore();

function App() {
  return (
    <div className="App">
      <Navbar bg="primary" expand="lg" variant="dark">
        <Navbar.Brand href="#home">Weather App</Navbar.Brand>
      </Navbar>
      <HomePage keywordStore={keywordStore} />
    </div>
  );
}

export default App;

We add the React Boostrap Navbar and our HomePage in the file.

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

html,
body {
  min-width: 500px;
}

.page {
  padding: 20px;
}

We specify the minimum width of our widget when displayed in the browser and add some padding to our page.

Next we create a component to display the current weather. Create a file called CurrentWeather.js in the src folder and add:

import React from "react";
import { observer } from "mobx-react";
import { searchWeather } from "./request";
import ListGroup from "react-bootstrap/ListGroup";

function CurrentWeather({ keywordStore }) {
  const [weather, setWeather] = React.useState({});

  const getWeatherForecast = async keyword => {
    const response = await searchWeather(keyword);
    setWeather(response.data);
  };

  React.useEffect(() => {
    keywordStore.keyword && getWeatherForecast(keywordStore.keyword);
  }, [keywordStore.keyword]);

  return (
    <div>
      {weather.main ? (
        <ListGroup>
          <ListGroup.Item>
            Current Temparature: {weather.main.temp - 273.15} C
          </ListGroup.Item>
          <ListGroup.Item>
            High: {weather.main.temp_max - 273.15} C
          </ListGroup.Item>
          <ListGroup.Item>
            Low: {weather.main.temp_min - 273.15} C
          </ListGroup.Item>
          <ListGroup.Item>Pressure: {weather.main.pressure} </ListGroup.Item>
          <ListGroup.Item>Humidity: {weather.main.humidity}</ListGroup.Item>
        </ListGroup>
      ) : null}
    </div>
  );
}
export default observer(CurrentWeather);

We get the search keyword from the keywordStore (which we will build later in the article) and search for the current weather when keywordStore.keyword is updated by passing it in the array of the second argument of the React.useEffect function.

Once the data is retrieved, we set the data with the setWeather function and display the data in a ListGroup at the bottom of the page.

We wrap observer around our component in the last line so that we get the latest keyword value from keywordStore.

Next we add a component to display the forecast, add Forecast.js in the src folder and add:

import React from "react";
import { observer } from "mobx-react";
import { searchForecast } from "./request";
import ListGroup from "react-bootstrap/ListGroup";
import Card from "react-bootstrap/Card";

function Forecast({ keywordStore }) {
  const [forecast, setForecast] = React.useState({});

  const getWeatherForecast = async keyword => {
    const response = await searchForecast(keyword);
    setForecast(response.data);
  };

  React.useEffect(() => {
    keywordStore.keyword && getWeatherForecast(keywordStore.keyword);
  }, [keywordStore.keyword]);

  return (
    <div>
      {Array.isArray(forecast.list) ? (
        <div>
          {forecast.list.map(l => {
            return (
              <Card body>
                <ListGroup>
                  <ListGroup.Item>Date: {l.dt_txt}</ListGroup.Item>
                  <ListGroup.Item>
                    Temperature: {l.main.temp - 273.15} C
                  </ListGroup.Item>
                  <ListGroup.Item>
                    High: {l.main.temp_max - 273.15} C
                  </ListGroup.Item>
                  <ListGroup.Item>
                    Low: {l.main.temp_min - 273.15} C
                  </ListGroup.Item>
                  <ListGroup.Item>Pressure: {l.main.pressure} C</ListGroup.Item>
                </ListGroup>
              </Card>
            );
          })}
        </div>
      ) : null}
    </div>
  );
}
export default observer(Forecast);

It’s very similar to CurrentWeather.js except that the data is in a list. So we map forecast.list into an array of Cards instead of just rendering it.

Next we create a HomePage.js file in the src folder and add:

import React 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 { observer } from "mobx-react";
import * as yup from "yup";
import Tabs from "react-bootstrap/Tabs";
import Tab from "react-bootstrap/Tab";
import CurrentWeather from "./CurrentWeather";
import Forecast from "./Forecast";

const schema = yup.object({
  keyword: yup.string().required("Keyword is required")
});

function HomePage({ keywordStore }) {
  const [initialized, setInitialized] = React.useState(false);

  const handleSubmit = async evt => {
    const isValid = await schema.validate(evt);
    if (!isValid) {
      return;
    }
    localStorage.setItem("keyword", evt.keyword);
    keywordStore.setKeyword(evt.keyword);
  };

  React.useEffect(() => {
    if (!initialized) {
      keywordStore.setKeyword(localStorage.getItem("keyword") || "");
      setInitialized(true);
    }
  });

  return (
    <div className="page">
      <h1>Weather</h1>
      <Formik
        validationSchema={schema}
        onSubmit={handleSubmit}
        initialValues={{ keyword: localStorage.getItem("keyword") || "" }}
      >
        {({
          handleSubmit,
          handleChange,
          handleBlur,
          values,
          touched,
          isInvalid,
          errors
        }) => (
          <Form noValidate onSubmit={handleSubmit}>
            <Form.Row>
              <Form.Group as={Col} md="12" controlId="keyword">
                <Form.Label>City</Form.Label>
                <Form.Control
                  type="text"
                  name="keyword"
                  placeholder="City"
                  value={values.keyword || ""}
                  onChange={handleChange}
                  isInvalid={touched.keyword && errors.keyword}
                />
                <Form.Control.Feedback type="invalid">
                  {errors.keyword}
                </Form.Control.Feedback>
              </Form.Group>
            </Form.Row>
            <Button type="submit" style={{ marginRight: "10px" }}>
              Search
            </Button>
          </Form>
        )}
      </Formik>
      <br />
      <Tabs defaultActiveKey="weather">
        <Tab eventKey="weather" title="Current Weather">
          <CurrentWeather keywordStore={keywordStore} />
        </Tab>
        <Tab eventKey="forecast" title="Forecast">
          <Forecast keywordStore={keywordStore} />
        </Tab>
      </Tabs>
    </div>
  );
}
export default observer(HomePage);

We have the form that lets the user enter the city of their choice into a search box. When the form is submitted, the evt.keyword value will be set in the store. When the keyword value in the store updates, the search functions in the CurrentWeather and Forecast components will be executed. We also set the keyword in the Local Storage. Using Local Storage also allows us to persist a default value for the user based on the location that they set.

Form validation is done by the Yup library by creating an object for the form validation schema. We make the keyword field a required field.

In the useEffect callback, we load the keyword from Local Storage and set it in the MobX store to trigger the searches.

At the bottom of the component, we have the tabs for displaying the current weather and forecast.

Next create a file called request.js in the src folder and add:

const APIURL = "http://api.openweathermap.org";
const axios = require("axios");

export const searchWeather = loc =>
  axios.get(
    `${APIURL}/data/2.5/weather?q=${loc}&appid=${process.env.REACT_APP_APIKEY}`
  );

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

This the code for the HTTP requests for searching the current weather and the forecasts.

process.env.REACT_API_APIKEY is the OpenWeatherMap API key stored in the .env file of the project’s root folder.

It is stored as:

REACT_APP_APIKEY='OpenWeatherMap API key'

Next create a store.js in the src folder and add:

import { observable, action, decorate } from "mobx";

class KeywordStore {
  keyword = "";

  setKeyword(keyword) {
    this.keyword = keyword;
  }
}

KeywordStore = decorate(KeywordStore, {
  keyword: observable,
  setKeyword: action
});

export { KeywordStore };

This is the MobX store where we store the keyword so that they can be set and accessed easily by our components. We set it in HomePage and get it in CurrentWeather and Forecast components.

Packaging the App for the Browser

To create the Chrome extension, we need to modify manifest.json .

In there, replace the existing code with:

{
  "short_name": "Weather Extension",
  "name": "Weather Extension",
  "icons": {
    "16": "favicon.ico",
    "48": "logo192.png",
    "128": "logo512.png"
  },
  "content_security_policy": "script-src 'self' 'sha256-WDhufSqZOEoWULzS4Nwz11MNyHzZClVYbQ2JSt1vfkw'; object-src 'self'",
  "permissions": [],
  "manifest_version": 2,
  "version": "0.0.1",
  "browser_action": {
    "default_popup": "index.html",
    "default_title": "Weather Widget"
  }
}

Some of these key-values must be set the way they are in this file. manifest_version must be set to 2, and version must be greater than 0.

Also, we need:

"browser_action": {
  "default_popup": "index.html",
  "default_title": "Weather Widget"
}

This shows the pop-up when clicking on the extension’s icon after we install the extension.

Loading the App in Chrome

Next we run npm run build to build the production files. Then go to chrome://extensions/ in Chrome. Toggle on Developer mode and click “Load unpacked”, then select the build folder once the open file dialog comes up.

Categories
React TypeScript

How to Build React Apps with TypeScript with a Practical Example

Overview

TypeScript is a superset of JavaScript that was created to address many common problems — the biggest being that JavaScript variables and objects have all dynamic types. This means that you have no way to know what properties an object has without logging it in a debugger.

This creates a lot of frustration as you have to check each individual object yourself, slowing down development. Without static types, you also cannot have auto-complete in your editor since there is no way to know what are in those objects are with 100% certainty.

Also, you can put any argument into your JavaScript functions, so there is no enforcement to what is passed in. This creates problems when you pass the wrong argument types expected or don’t pass in enough arguments, making those parameters undefined. There is also nothing stopping you from passing in too many arguments.

By being able to detect all these issues at compile-time, TypeScript makes code easier to understand and follow while decreasing the number of bugs sent to production. You don’t have to worry about breaking things when you change code as the compiler will tell you that you got those basic errors.

Create React App projects can be started with TypeScript using the --typscript option. However, we need to install type definitions for some libraries ourselves.

In this article, we will build an address book app using React and TypeScript.

Getting Started

To start, we need to run Create React App to scaffold the app. We run npx create-react-app address-book --typscript to create the app project folder with the initial files. The app will have a home page to display the contacts and let us open a modal to add a contact. There will be a table that displays all the contacts, and Edit and Delete buttons on each row.

The contacts will be stored in a central MobX store, making them easy to access. React Router will be used for routing. Contacts will be saved in the back end by using the JSON server. We will also use Formik and Yup for form management and validation.

To install the libraries we mentioned above, we run npm i axios bootstrap formik react-bootstrap mobx mobx-react react-router-dom yup. Axios is the HTTP client that we use for making HTTP requests to back end. react-router-dom is the package name for the latest version of React Router.

We also need to install type definitions for our libraries by running:

npm i --save-dev @types/react @types/react-router-dom @types/react-bootstrap @types/react-dom @types/yup

In tsconfig.json, we replace the existing code with:

{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react",
    "noImplicitThis": false,
    "experimentalDecorators": true
  },
  "include": [
    "src"
  ]
}

This disables noImplicitThis so we can use bind(this) in our components and add experimentalDecorators to use decorators needed by MobX.

Building the React App

We create some custom types for the code we will write. Create an interfaces.ts file in the src folder and add:

import { ContactsStore } from './store';

export interface Contact {
    firstName: string;
    lastName: string;
    address: string;
    city: string;
    region: string;
    postalCode: string;
    phone: string;
    email: string;
    age: string;
    id: number;
    country: string;
}

export interface HomePageProps {
    contactsStore: ContactsStore
}

export interface ContactFormProps {
    edit?: boolean,
    onSave?: any,
    contact?: Contact,
    onCancelAdd?: any,
    onCancelEdit?: any,
    contactsStore: ContactsStore
}

This will be the types for our data and props.

Now that we have all the libraries installed, we can start building the app. All files will be in the src folder unless mentioned otherwise. First we work on the MobX store. We create a file called store.tsx in the src folder and add the following:

import { observable } from "mobx";
import { Contact } from "./interfaces";

class ContactsStore {
    [@observable](http://twitter.com/observable "Twitter profile for @observable") contacts: Contact[] = [];

    setContacts(contacts: Contact[]) {
        this.contacts = contacts;
    }
}

export { ContactsStore };

This is a simple store that holds the contacts. The contacts array is where we store the contacts for the whole app. The setContacts function lets us set contacts from any component where we pass in this store object to.

This block:

ContactsStore = decorate(ContactsStore, {
  contacts: observable,
  setContacts: action,
});

designates the contacts array in ContactsStore as the entity that can be watched by components for changes. The setContacts is the function that can be used to set the contacts array in the store.

In App.tsx, we replace the existing code with the following:

import React from "react";
import { Router, Route } from "react-router-dom";
import HomePage from "./HomePage";
import { createBrowserHistory as createHistory } from "history";
import Navbar from "react-bootstrap/Navbar";
import Nav from "react-bootstrap/Nav";
import "./App.css";
const history = createHistory();

function App({ contactsStore }: any) {
  return (
    <div className="App">
      <Router history={history}>
        <Navbar bg="primary" expand="lg" variant="dark">
          <Navbar.Brand href="#home">Address Book App</Navbar.Brand>
          <Navbar.Toggle aria-controls="basic-navbar-nav" />
          <Navbar.Collapse id="basic-navbar-nav">
            <Nav className="mr-auto">
              <Nav.Link href="/">Home</Nav.Link>
            </Nav>
          </Navbar.Collapse>
        </Navbar>
        <Route
          path="/"
          exact
          component={(props: any) => (
            <HomePage {...props} contactsStore={contactsStore} />
          )}
        />
      </Router>
    </div>
  );
}

export default App;

We pass the store into any component that needs it, like the HomePage. It will then pass the component to the ContactForm.

This is where we add the navigation bar and show our routes handled by React Router. In App.css, we replace the existing code with:

.App {
  text-align: center;
}

Next we build our contact form. This is the most logic heavy part of our app.

In this ContactForm.tsx, we have the React.SFC<ContactFormProps> annotation for the ContactForm component so we get type checking for our component. The ContactFormProps lets us check the data type of our props.

Create a file called ContactForm.tsx and add:

import React from "react";
import { Formik } from "formik";
import Form from "react-bootstrap/Form";
import Col from "react-bootstrap/Col";
import InputGroup from "react-bootstrap/InputGroup";
import Button from "react-bootstrap/Button";
import * as yup from "yup";
import { COUNTRIES } from "./exports";
import { addContact, editContact, getContacts } from "./requests";
import { FormControl } from "react-bootstrap";
import { Contact, ContactFormProps } from "./interfaces";

const schema = yup.object({
  firstName: yup.string().required("First name is required"),
  lastName: yup.string().required("Last name is required"),
  address: yup.string().required("Address is required"),
  city: yup.string().required("City is required"),
  region: yup.string().required("Region is required"),
  country: yup
    .string()
    .required("Country is required")
    .default("Afghanistan"),
  postalCode: yup
    .string()
    .when("country", {
      is: "United States",
      then: yup
        .string()
        .matches(/^[0-9]{5}(?:-[0-9]{4})?$/, "Invalid postal code")
    })
    .when("country", {
      is: "Canada",
      then: yup
        .string()
        .matches(/^[A-Za-z]d[A-Za-z][ -]?d[A-Za-z]d$/, "Invalid postal code")
    })
    .required(),
  phone: yup
    .string()
    .when("country", {
      is: country => ["United States", "Canada"].includes(country),
      then: yup
        .string()
        .matches(/^[2-9]d{2}[2-9]d{2}d{4}$/, "Invalid phone nunber")
    })
    .required(),
  email: yup
    .string()
    .email("Invalid email")
    .required("Email is required"),
  age: yup
    .number()
    .required("Age is required")
    .min(0, "Minimum age is 0")
    .max(200, "Maximum age is 200")
});

const ContactForm: React.SFC<ContactFormProps> = ({
  edit,
  onSave,
  contact,
  onCancelAdd,
  onCancelEdit,
  contactsStore
}: ContactFormProps) => {
  const handleSubmit = async (evt: Contact) => {
    const isValid = await schema.validate(evt);
    if (!isValid) {
      return;
    }
    if (!edit) {
      await addContact(evt);
    } else {
      await editContact(evt);
    }
    const response = await getContacts();
    contactsStore.setContacts(response.data);
    onSave();
  };

  return (
    <>
      <Formik
        validationSchema={schema}
        onSubmit={handleSubmit}
        initialValues={(contact || {}) as any}
      >
        {({
          handleSubmit,
          handleChange,
          handleBlur,
          values,
          touched,
          isInvalid,
          errors
        }: any) => (
          <Form noValidate onSubmit={handleSubmit}>
            <Form.Row>
              <Form.Group as={Col} md="12" controlId="firstName">
                <Form.Label>First name</Form.Label>
                <Form.Control
                  type="text"
                  name="firstName"
                  placeholder="First Name"
                  value={values.firstName || ""}
                  onChange={handleChange}
                  isInvalid={touched.firstName && errors.firstName}
                />
                <Form.Control.Feedback type="invalid">
                  {errors.firstName}
                </Form.Control.Feedback>
              </Form.Group>
              <Form.Group as={Col} md="12" controlId="lastName">
                <Form.Label>Last name</Form.Label>
                <Form.Control
                  type="text"
                  name="lastName"
                  placeholder="Last Name"
                  value={values.lastName || ""}
                  onChange={handleChange}
                  isInvalid={touched.firstName && errors.lastName}
                />

                <Form.Control.Feedback type="invalid">
                  {errors.lastName}
                </Form.Control.Feedback>
              </Form.Group>
              <Form.Group as={Col} md="12" controlId="address">
                <Form.Label>Address</Form.Label>
                <InputGroup>
                  <Form.Control
                    type="text"
                    placeholder="Address"
                    aria-describedby="inputGroupPrepend"
                    name="address"
                    value={values.address || ""}
                    onChange={handleChange}
                    isInvalid={touched.address && errors.address}
                  />
                  <Form.Control.Feedback type="invalid">
                    {errors.address}
                  </Form.Control.Feedback>
                </InputGroup>
              </Form.Group>
            </Form.Row>
            <Form.Row>
              <Form.Group as={Col} md="12" controlId="city">
                <Form.Label>City</Form.Label>
                <Form.Control
                  type="text"
                  placeholder="City"
                  name="city"
                  value={values.city || ""}
                  onChange={handleChange}
                  isInvalid={touched.city && errors.city}
                />

                <Form.Control.Feedback type="invalid">
                  {errors.city}
                </Form.Control.Feedback>
              </Form.Group>
              <Form.Group as={Col} md="12" controlId="region">
                <Form.Label>Region</Form.Label>
                <Form.Control
                  type="text"
                  placeholder="Region"
                  name="region"
                  value={values.region || ""}
                  onChange={handleChange}
                  isInvalid={touched.region && errors.region}
                />
                <Form.Control.Feedback type="invalid">
                  {errors.region}
                </Form.Control.Feedback>
              </Form.Group>

              <Form.Group as={Col} md="12" controlId="country">
                <Form.Label>Country</Form.Label>
                <FormControl
                  as="select"
                  placeholder="Country"
                  name="country"
                  onChange={handleChange}
                  value={values.country || ""}
                  isInvalid={touched.region && errors.country}
                >
                  {COUNTRIES.map(c => (
                    <option key={c} value={c}>
                      {c}
                    </option>
                  ))}
                </FormControl>
                <Form.Control.Feedback type="invalid">
                  {errors.country}
                </Form.Control.Feedback>
              </Form.Group>

              <Form.Group as={Col} md="12" controlId="postalCode">
                <Form.Label>Postal Code</Form.Label>
                <Form.Control
                  type="text"
                  placeholder="Postal Code"
                  name="postalCode"
                  value={values.postalCode || ""}
                  onChange={handleChange}
                  isInvalid={touched.postalCode && errors.postalCode}
                />

                <Form.Control.Feedback type="invalid">
                  {errors.postalCode}
                </Form.Control.Feedback>
              </Form.Group>

              <Form.Group as={Col} md="12" controlId="phone">
                <Form.Label>Phone</Form.Label>
                <Form.Control
                  type="text"
                  placeholder="Phone"
                  name="phone"
                  value={values.phone || ""}
                  onChange={handleChange}
                  isInvalid={touched.phone && errors.phone}
                />

                <Form.Control.Feedback type="invalid">
                  {errors.phone}
                </Form.Control.Feedback>
              </Form.Group>

              <Form.Group as={Col} md="12" controlId="email">
                <Form.Label>Email</Form.Label>
                <Form.Control
                  type="text"
                  placeholder="Email"
                  name="email"
                  value={values.email || ""}
                  onChange={handleChange}
                  isInvalid={touched.email && errors.email}
                />

                <Form.Control.Feedback type="invalid">
                  {errors.email}
                </Form.Control.Feedback>
              </Form.Group>

              <Form.Group as={Col} md="12" controlId="age">
                <Form.Label>Age</Form.Label>
                <Form.Control
                  type="text"
                  placeholder="Age"
                  name="age"
                  value={values.age || ""}
                  onChange={handleChange}
                  isInvalid={touched.age && errors.age}
                />

                <Form.Control.Feedback type="invalid">
                  {errors.age}
                </Form.Control.Feedback>
              </Form.Group>
            </Form.Row>
            <Button type="submit" style={{ marginRight: "10px" }}>
              Save
            </Button>
            {edit ? (
              <Button type="button" onClick={onCancelEdit}>
                Cancel
              </Button>
            ) : (
              <Button type="button" onClick={onCancelAdd}>
                Cancel
              </Button>
            )}
          </Form>
        )}
      </Formik>
    </>
  );
};

export default ContactForm;

We pass in the contactsStore from the HomePage component, allowing us to use the data and functions in contactsStore.

For form validation, we use Formik to facilitate building our contact form here, with our Boostrap Form component nested in the Formik component so that we can use Formik’s handleChange, handleSubmit, values, touched and errors parameters.

handleChange is a function that lets us update the form field data from the inputs without writing the code ourselves. handleSubmit is the function that we passed into the onSubmit handler of the Formik component. The parameter in the function is the data we entered, with the field name as the key, as defined by the name attribute of each field and the value of each field as the value of those keys. Notice that in each value prop, we have ||'' so we do not get undefined values and prevent uncontrolled form warnings from getting triggered.

To display form validation messages, we have to pass in the isInvalid prop to each Form.Control component. The schema object is what Formik will check against for form validation. The argument in the required function is the validation error message. The second argument of the matches, min, and max functions are also validation messages.

The parameter of the ContactForm function are props, which we will pass in from the HomePage component that we will build later. The handleSubmit function checks if the data is valid, then if it is then it will proceed to saving according to whether it is adding or editing a contact. Then when saving is successful we set the contacts in the store and call onSave prop, which is a function to close the modal the form is in. The modal will be defined in the home page.

Next we create a file called exports.ts, and put:

export const COUNTRIES = [
  "Afghanistan",
  "Albania",
  "Algeria",
  "Andorra",
  "Angola",
  "Anguilla",
  "Antigua &amp; Barbuda",
  "Argentina",
  "Armenia",
  "Aruba",
  "Australia",
  "Austria",
  "Azerbaijan",
  "Bahamas",
  "Bahrain",
  "Bangladesh",
  "Barbados",
  "Belarus",
  "Belgium",
  "Belize",
  "Benin",
  "Bermuda",
  "Bhutan",
  "Bolivia",
  "Bosnia &amp; Herzegovina",
  "Botswana",
  "Brazil",
  "British Virgin Islands",
  "Brunei",
  "Bulgaria",
  "Burkina Faso",
  "Burundi",
  "Cambodia",
  "Cameroon",
  "Canada",
  "Cape Verde",
  "Cayman Islands",
  "Chad",
  "Chile",
  "China",
  "Colombia",
  "Congo",
  "Cook Islands",
  "Costa Rica",
  "Cote D Ivoire",
  "Croatia",
  "Cruise Ship",
  "Cuba",
  "Cyprus",
  "Czech Republic",
  "Denmark",
  "Djibouti",
  "Dominica",
  "Dominican Republic",
  "Ecuador",
  "Egypt",
  "El Salvador",
  "Equatorial Guinea",
  "Estonia",
  "Ethiopia",
  "Falkland Islands",
  "Faroe Islands",
  "Fiji",
  "Finland",
  "France",
  "French Polynesia",
  "French West Indies",
  "Gabon",
  "Gambia",
  "Georgia",
  "Germany",
  "Ghana",
  "Gibraltar",
  "Greece",
  "Greenland",
  "Grenada",
  "Guam",
  "Guatemala",
  "Guernsey",
  "Guinea",
  "Guinea Bissau",
  "Guyana",
  "Haiti",
  "Honduras",
  "Hong Kong",
  "Hungary",
  "Iceland",
  "India",
  "Indonesia",
  "Iran",
  "Iraq",
  "Ireland",
  "Isle of Man",
  "Israel",
  "Italy",
  "Jamaica",
  "Japan",
  "Jersey",
  "Jordan",
  "Kazakhstan",
  "Kenya",
  "Kuwait",
  "Kyrgyz Republic",
  "Laos",
  "Latvia",
  "Lebanon",
  "Lesotho",
  "Liberia",
  "Libya",
  "Liechtenstein",
  "Lithuania",
  "Luxembourg",
  "Macau",
  "Macedonia",
  "Madagascar",
  "Malawi",
  "Malaysia",
  "Maldives",
  "Mali",
  "Malta",
  "Mauritania",
  "Mauritius",
  "Mexico",
  "Moldova",
  "Monaco",
  "Mongolia",
  "Montenegro",
  "Montserrat",
  "Morocco",
  "Mozambique",
  "Namibia",
  "Nepal",
  "Netherlands",
  "Netherlands Antilles",
  "New Caledonia",
  "New Zealand",
  "Nicaragua",
  "Niger",
  "Nigeria",
  "Norway",
  "Oman",
  "Pakistan",
  "Palestine",
  "Panama",
  "Papua New Guinea",
  "Paraguay",
  "Peru",
  "Philippines",
  "Poland",
  "Portugal",
  "Puerto Rico",
  "Qatar",
  "Reunion",
  "Romania",
  "Russia",
  "Rwanda",
  "Saint Pierre &amp; Miquelon",
  "Samoa",
  "San Marino",
  "Satellite",
  "Saudi Arabia",
  "Senegal",
  "Serbia",
  "Seychelles",
  "Sierra Leone",
  "Singapore",
  "Slovakia",
  "Slovenia",
  "South Africa",
  "South Korea",
  "Spain",
  "Sri Lanka",
  "St Kitts &amp; Nevis",
  "St Lucia",
  "St Vincent",
  "St. Lucia",
  "Sudan",
  "Suriname",
  "Swaziland",
  "Sweden",
  "Switzerland",
  "Syria",
  "Taiwan",
  "Tajikistan",
  "Tanzania",
  "Thailand",
  "Timor L'Este",
  "Togo",
  "Tonga",
  "Trinidad &amp; Tobago",
  "Tunisia",
  "Turkey",
  "Turkmenistan",
  "Turks &amp; Caicos",
  "Uganda",
  "Ukraine",
  "United Arab Emirates",
  "United Kingdom",
  "United States",
  "United States Minor Outlying Islands",
  "Uruguay",
  "Uzbekistan",
  "Venezuela",
  "Vietnam",
  "Virgin Islands (US)",
  "Yemen",
  "Zambia",
  "Zimbabwe",
];

These are countries for the countries field in the form.

In index.tsx, replace the existing code with:

import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import * as serviceWorker from "./serviceWorker";
import { ContactsStore } from "./store";
const contactsStore = new ContactsStore();

ReactDOM.render(
  <App contactsStore={contactsStore} />,
  document.getElementById("root")
);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

This passes our MobX store into our App component.

In HomePage.tsx, we put:

import React from "react";
import { useState, useEffect } from "react";
import Table from "react-bootstrap/Table";
import ButtonToolbar from "react-bootstrap/ButtonToolbar";
import Button from "react-bootstrap/Button";
import Modal from "react-bootstrap/Modal";
import ContactForm from "./ContactForm";
import "./HomePage.css";
import { getContacts, deleteContact } from "./requests";
import { observer } from "mobx-react";
import { Contact, HomePageProps } from "./interfaces";

const HomePage: React.SFC<HomePageProps> = ({
  contactsStore
}: HomePageProps) => {
  const [openAddModal, setOpenAddModal] = useState(false);
  const [openEditModal, setOpenEditModal] = useState(false);
  const [initialized, setInitialized] = useState(false);
  const [selectedContact, setSelectedContact] = useState({});

  const openModal = () => {
    setOpenAddModal(true);
  };

  const closeModal = () => {
    setOpenAddModal(false);
    setOpenEditModal(false);
    getData();
  };

  const cancelAddModal = () => {
    setOpenAddModal(false);
  };

  const editContact = (contact: Contact) => {
    setSelectedContact(contact);
    setOpenEditModal(true);
  };

  const cancelEditModal = () => {
    setOpenEditModal(false);
  };

  const getData = async () => {
    const response = await getContacts();
    contactsStore.setContacts(response.data);
    setInitialized(true);
  };

  const deleteSelectedContact = async (id: number) => {
    await deleteContact(id);
    getData();
  };

  useEffect(() => {
    if (!initialized) {
      getData();
    }
  });

  return (
    <div className="home-page">
      <h1>Contacts</h1>
      <Modal show={openAddModal} onHide={closeModal}>
        <Modal.Header closeButton>
          <Modal.Title>Add Contact</Modal.Title>
        </Modal.Header>
        <Modal.Body>
          <ContactForm
            edit={false}
            onSave={closeModal.bind(this) as any}
            onCancelAdd={cancelAddModal as any}
            contactsStore={contactsStore}
          />
        </Modal.Body>
      </Modal>

      <Modal show={openEditModal} onHide={closeModal}>
        <Modal.Header closeButton>
          <Modal.Title>Edit Contact</Modal.Title>
        </Modal.Header>
        <Modal.Body>
          <ContactForm
            edit={true}
            onSave={closeModal.bind(this)}
            contact={selectedContact as Contact}
            onCancelEdit={cancelEditModal}
            contactsStore={contactsStore}
          />
        </Modal.Body>
      </Modal>
      <ButtonToolbar onClick={openModal}>
        <Button variant="outline-primary">Add Contact</Button>
      </ButtonToolbar>
      <br />
      <Table striped bordered hover>
        <thead>
          <tr>
            <th>First Name</th>
            <th>Last Name</th>
            <th>Address</th>
            <th>City</th>
            <th>Country</th>
            <th>Postal Code</th>
            <th>Phone</th>
            <th>Email</th>
            <th>Age</th>
            <th>Edit</th>
            <th>Delete</th>
          </tr>
        </thead>
        <tbody>
          {contactsStore.contacts.map((c: any) => (
            <tr key={c.id}>
              <td>{c.firstName}</td>
              <td>{c.lastName}</td>
              <td>{c.address}</td>
              <td>{c.city}</td>
              <td>{c.country}</td>
              <td>{c.postalCode}</td>
              <td>{c.phone}</td>
              <td>{c.email}</td>
              <td>{c.age}</td>
              <td>
                <Button
                  variant="outline-primary"
                  onClick={editContact.bind(this, c)}
                >
                  Edit
                </Button>
              </td>
              <td>
                <Button
                  variant="outline-primary"
                  onClick={deleteSelectedContact.bind(this, c.id)}
                >
                  Delete
                </Button>
              </td>
            </tr>
          ))}
        </tbody>
      </Table>
    </div>
  );
};
export default observer(HomePage);

Notice that we have export default observer(HomePage); instead of export default HomePage;. We need to wrap HomePage with the observer function call so that the latest data from the store will be propagated into this component.

We have the React.SFC<HomePageProps> annotation for the HomePage component so we get type checking for our component. The HomePageProps lets us check the data type of our props.

It has the table for displaying the contacts and buttons to add, edit, and delete a contact. It gets data once on the first load with the getData function called in the useEffect’s callback function. useEffect’s callback is called on every render so we want to set a initialized flag and check that it loads only if it’s true.

Note that we pass in all the props of this component to the ContactForm component. To pass an argument for the onClick handler function, we have to call bind on the function and pass in the argument for the function as a second argument to bind. For example, in this file, we have editContact.bind(this, c), where c is the contact object. The editContact function is defined as follows:

const editContact = (contact) => {
    setSelectedContact(contact);
    setOpenEditModal(true);
  }

c is the contact parameter we pass in.

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

.home-page {
  padding: 20px;
}

In index.tsx, we replace the existing code with:

import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import * as serviceWorker from "./serviceWorker";
import { ContactsStore } from "./store";
const contactsStore = new ContactsStore();

ReactDOM.render(
  <App contactsStore={contactsStore} />,
  document.getElementById("root")
);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

Then we make a file called requests.tsx and add:

import { Contact } from "./interfaces";

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

export const getContacts = () => axios.get(`${APIURL}/contacts`);

export const addContact = (data: Contact) => axios.post(`${APIURL}/contacts`, data);

export const editContact = (data: Contact) => axios.put(`${APIURL}/contacts/${data.id}`, data);

export const deleteContact = (id: number) => axios.delete(`${APIURL}/contacts/${id}`);

These are functions are making our HTTP requests to the back end to save and delete contacts.

Finally, in public/index.html, we replace the existing code with:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8" />
  <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <meta name="theme-color" content="#000000" />
  <meta name="description" content="Web site created using create-react-app" />
  <link rel="apple-touch-icon" href="logo192.png" />
  <link rel="manifest" crossorigin="use-credentials" href="%PUBLIC_URL%/manifest.json" />

  <!--
      manifest.json provides metadata used when your web app is installed on a
      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
    -->
  <!--
      Notice the use of %PUBLIC_URL% in the tags above.
      It will be replaced with the URL of the `public` folder during the build.
      Only files inside the `public` folder can be referenced from the HTML.

      Unlike  "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
      work correctly both with client-side routing and a non-root public URL.
      Learn how to configure a non-root public URL by running `npm run build`.
    -->
  <title>React Address Book App</title>
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
    integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous" />
</head>

<body>
  <noscript>You need to enable JavaScript to run this app.</noscript>
  <div id="root"></div>
  <!--
      This HTML file is a template.
      If you open it directly in the browser, you will see an empty page.

You can add webfonts, meta tags, or analytics to this file.
      The build step will place the bundled scripts into the <body> tag.

To begin the development, run `npm start` or `yarn start`.
      To create a production bundle, use `npm run build` or `yarn build`.
    -->
</body>

</html>

This changes the title and add the Bootstrap stylesheet.

Now we can run the app by running set PORT=3001 && react-scripts start on Windows or PORT=3006 react-scripts start on Linux.

To start back end, we first install the json-server package by running npm i json-server. Them go to our project folder and run:

json-server --watch db.json

In db.json, change the text to:

{
  "contacts": [
  ]
}

Now we have the contacts endpoints defined in requests.js available.

Categories
React

How to Add Localization to a React App

React is a great framework for building interactive web apps. It comes with a bare bones set of features. It can render your page when you update your data and provides a convenient syntax for you to write your code easily. We can easily use it to build apps that uses public APIs, like the New York Times API.

In this story, we will build an app that uses the New York Times API with multilingual capability. You can view the static text in the app in English or French. The app can get the news from different sections and also do searches. It supports cross domain HTTP requests so that we can write client side apps that uses the API.

Before building the app, you need to register for an API key at https://developer.nytimes.com/

To start building the app, we use the Create React App command line utility to generate the scaffolding code. To use it, we run npx create-react-app nyt-app to create the code in the nyt-app folder. After that we need to install some libraries. We need the Axios HTTP client, a library to convert objects into query strings, Bootstrap library to make everything look better, React Router for routing and create forms easily with Formik and Yup. For translation and localization, we use the React-i18next library to allow us to translate our text into English and French. To install the libraries, we run npm i axios bootstrap formik i18next i18next-browser-languagedetector i18next-xhr-backend querystring react-bootstrap react-i18next react-router-dom yup .

Now that we have all the libraries installed, we can start writing code. For simplicity’s sake, we put everything in the src folder. We start by modifying App.js . We replace the existing code 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";
import SearchPage from "./SearchPage";
import { useTranslation } from "react-i18next";
import { useState, useEffect } from "react";
const history = createHistory();

function App() {
  const { t, i18n } = useTranslation();
  const [initialized, setInitialized] = useState(false);
  const changeLanguage = lng => {
    i18n.changeLanguage(lng);
  };

  useEffect(() => {
    if (!initialized) {
      changeLanguage(localStorage.getItem("language") || "en");
      setInitialized(true);
    }
  });

  return (
    <div className="App">
      <Router history={history}>
        <TopBar />
        <Route path="/" exact component={HomePage} />
        <Route path="/search" exact component={SearchPage} />
      </Router>
    </div>
  );
}

export default App;

This is the root component of our app and is the component that is loaded when the app first loads. We use the useTranslation function from the react-i18next library returns an object with the t property and the i18n property,. Here, we destructured the returned object’s properties into its own variables. we will use the t , which takes a translation key, to get the English or French text depending on the language set. In this file, we use the i18n function to set the language with the provided i18n.changeLanguage function. We also set the language from local storage when provided so that the chosen language will be persisted after refresh.

We also add the routes for our pages here used by the React router.

In App.css ,we put:

.center {
  text-align: center;
}

to center some text.

Next we make the home page. we createHomePage.js and in the file, we put:

import React from "react";
import { useState, useEffect } from "react";
import Form from "react-bootstrap/Form";
import ListGroup from "react-bootstrap/ListGroup";
import Card from "react-bootstrap/Card";
import Button from "react-bootstrap/Button";
import { getArticles } from "./requests";
import { useTranslation } from "react-i18next";
import "./HomePage.css";

const 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(",");

function HomePage() {
  const [selectedSection, setSelectedSection] = useState("arts");
  const [articles, setArticles] = useState([]);
  const [initialized, setInitialized] = useState(false);
  const { t, i18n } = useTranslation();

  const load = async section => {
    setSelectedSection(section);
    const response = await getArticles(section);
    setArticles(response.data.results || []);
  };

  const loadArticles = async e => {
    if (!e || !e.target) {
      return;
    }
    setSelectedSection(e.target.value);
    load(e.target.value);
  };

  const initializeArticles = () => {
    load(selectedSection);
    setInitialized(true);
  };

  useEffect(() => {
    if (!initialized) {
      initializeArticles();
    }
  });

  return (
    <div className="HomePage">
      <div className="col-12">
        <div className="row">
          <div className="col-md-3 d-none d-md-block d-lg-block d-xl-block">
            <ListGroup className="sections">
              {sections.map(s => (
                <ListGroup.Item
                  key={s}
                  className="list-group-item"
                  active={s == selectedSection}
                >
                  <a
                    className="link"
                    onClick={() => {
                      load(s);
                    }}
                  >
                    {t(s)}
                  </a>
                </ListGroup.Item>
              ))}
            </ListGroup>
          </div>
          <div className="col right">
            <Form className="d-sm-block d-md-none d-lg-none d-xl-none">
              <Form.Group controlId="section">
                <Form.Label>{t("Section")}</Form.Label>
                <Form.Control
                  as="select"
                  onChange={loadArticles}
                  value={selectedSection}
                >
                  {sections.map(s => (
                    <option key={s} value={s}>{t(s)}</option>
                  ))}
                </Form.Control>
              </Form.Group>
            </Form>
            <h1>{t(selectedSection)}</h1>
            {articles.map((a, i) => (
              <Card key={i}>
                <Card.Body>
                  <Card.Title>{a.title}</Card.Title>
                  <Card.Img
                    variant="top"
                    className="image"
                    src={
                      Array.isArray(a.multimedia) &&
                      a.multimedia[a.multimedia.length - 1]
                        ? a.multimedia[a.multimedia.length - 1].url
                        : null
                    }
                  />
                  <Card.Text>{a.abstract}</Card.Text>
                  <Button
                    variant="primary"
                    onClick={() => (window.location.href = a.url)}
                  >
                    {t("Go")}
                  </Button>
                </Card.Body>
              </Card>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}

export default HomePage;

In this file, we display a responsive layout where there is a left bar is the screen is wide and the a drop down on the right pane if it’s not. We display the items in the chosen section we choose from the left pane or the drop down. To display the items, we use the Card widget from React Bootstrap. We also use the t function provided by react-i18next to load our text from our translation file, which we will create. To load the initial article entries, we run a function in the callback of the useEffect function to show load the items once from the New York Times API. We need the initialized flag so that the function in the callback won’t load on every re-render. In the drop down, we added code to load articles whenever the selection changes.

The we createHomePage.css , and add:

.link {
  cursor: pointer;
}

.right {
  padding: 20px;
}

.image {
  max-width: 400px;
  text-align: center;
}

.sections {
    margin-top: 20px;
}

We change the cursor style for the Go button and add some padding to the right pane.

Next we create a file for loading the translations and setting the default language. Create a file called i18n.js and add:

import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { resources } from "./translations";
import Backend from "i18next-xhr-backend";
import LanguageDetector from "i18next-browser-languagedetector";

i18n
  .use(Backend)
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources,
    lng: "en",
    fallbackLng: "en",
    debug: true,

    interpolation: {
      escapeValue: false,
    },
  });

export default i18n;

In this file, we load the translations from a file, and set the default language to English. Since react-i18next escapes everything, we can set escapeValue to false for interpolation since it’s redundant.

We need a file to put the code for making HTTP requests. To do so, we create a file called requests.js and add:

const APIURL = "https://api.nytimes.com/svc";
const axios = require("axios");
const querystring = require("querystring");

export const search = data => {
  Object.keys(data).forEach(key => {
    data["api-key"] = process.env.REACT_APP_APIKEY;
    if (!data[key]) {
      delete data[key];
    }
  });
  return axios.get(
    `${APIURL}/search/v2/articlesearch.json?${querystring.encode(data)}`
  );
};
export const getArticles = section =>
  axios.get(
    `${APIURL}/topstories/v2/${section}.json?api-key=${process.env.REACT_APP_APIKEY}`
  );

We load the API key from the process.env.REACT_APP_APIKEY variable, which is provided by an environment variable in the .env file located at the root folder. You have to create it yourself, and in there, put:

REACT_APP_APIKEY='you New York Times API key'

Replace the value on the right side with the API key you got after registering in the New York Times API website.

Next we create the search page. Create a file called SearchPage.js and add:

import React from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import "./SearchPage.css";
import * as yup from "yup";
import { Formik } from "formik";
import Form from "react-bootstrap/Form";
import Col from "react-bootstrap/Col";
import Button from "react-bootstrap/Button";
import { Trans } from "react-i18next";
import { search } from "./requests";
import Card from "react-bootstrap/Card";

const schema = yup.object({
  keyword: yup.string().required("Keyword is required"),
});

function SearchPage() {
  const { t } = useTranslation();
  const [articles, setArticles] = useState([]);
  const [count, setCount] = useState(0);

  const handleSubmit = async e => {
    const response = await search({ q: e.keyword });
    setArticles(response.data.response.docs || []);
  };

  return (
    <div className="SearchPage">
      <h1 className="center">{t("Search")}</h1>
      <Formik validationSchema={schema} onSubmit={handleSubmit}>
        {({
          handleSubmit,
          handleChange,
          handleBlur,
          values,
          touched,
          isInvalid,
          errors,
        }) => (
          <Form noValidate onSubmit={handleSubmit} className="form">
            <Form.Row>
              <Form.Group as={Col} md="12" controlId="keyword">
                <Form.Label>{t("Keyword")}</Form.Label>
                <Form.Control
                  type="text"
                  name="keyword"
                  placeholder={t("Keyword")}
                  value={values.keyword || ""}
                  onChange={handleChange}
                  isInvalid={touched.keyword && errors.keyword}
                />
                <Form.Control.Feedback type="invalid">
                  {errors.keyword}
                </Form.Control.Feedback>
              </Form.Group>
            </Form.Row>
            <Button type="submit" style={{ marginRight: "10px" }}>
              {t("Search")}
            </Button>
          </Form>
        )}
      </Formik>
      <h3 className="form">
        <Trans i18nKey="numResults" count={articles.length}>
          There are <strong>{{ count }}</strong> results.
        </Trans>
      </h3>
      {articles.map((a, i) => (
        <Card key={i}>
          <Card.Body>
            <Card.Title>{a.headline.main}</Card.Title>
            <Card.Text>{a.abstract}</Card.Text>
            <Button
              variant="primary"
              onClick={() => (window.location.href = a.web_url)}
            >
              {t("Go")}
            </Button>
          </Card.Body>
        </Card>
      ))}
    </div>
  );
}

export default SearchPage;

This is where we create a search form with the keyword field used for searching the API. When the use clicks Search, then it will search the New York Times API for articles using the keyword. We use Formik to handle the form value changes and make the values available in the e object in the handleSubmit parameter for us to use. We use React Bootstrap for the buttons, form elements, and the cards. After clicking Search, the articles variable get set and load the cards for the articles.

We use the Trans component provided by react-i18next for translating text that have some dynamic components, like in the example above. We have a variable in the text for the number of results. Whenever you have something like, you wrap it in the Trans component and then pass in the variables like in the example above by passing in the variables as props. Then you will show the variable in the text between the Trans tags. We we will also make interpolation available in the translations by putting “There are <1>{{count}}</1> results.” in the English translation and “Il y a <1>{{count}}</1> résultats.” in French translation. The 1 tag corresponds to the strong tag. The number in this case is arbitrary. As long as the pattern consistent with the component’s pattern, it will work, so strong tag in this case should always be 1 in the translation string.

To add the translations mentioned above along with the rest of the translations, create a file called translations.js and add:

const resources = {
  en: {
    translation: {
      "New York Times App": "New York Times App",
      arts: "Arts",
      automobiles: "Automobiles",
      books: "Books",
      business: "Business",
      fashion: "Fashion",
      food: "Food",
      health: "Health",
      home: "Home",
      insider: "Inside",
      magazine: "Magazine",
      movies: "Movies",
      national: "National",
      nyregion: "New York Region",
      obituaries: "Obituaries",
      opinion: "Opinion",
      politics: "Politics",
      realestate: "Real Estate",
      science: "Science",
      sports: "Sports",
      sundayreview: "Sunday Review",
      technology: "Technology",
      theater: "Theater",
      tmagazine: "T Magazine",
      travel: "Travel",
      upshot: "Upshot",
      world: "World",
      Search: "Search",
      numResults: "There are <1>{{count}}</1> results.",
      Home: "Home",
      Search: "Search",
      Language: "Language",
      English: "English",
      French: "French",
      Keyword: "Keyword",
      Go: "Go",
      Section: "Section",
    },
  },
  fr: {
    translation: {
      "New York Times App": "App New York Times",
      arts: "Arts",
      automobiles: "Les automobiles",
      books: "Livres",
      business: "Entreprise",
      fashion: "Mode",
      food: "Aliments",
      health: "Santé",
      home: "Maison",
      insider: "Initiée",
      magazine: "Magazine",
      movies: "Films",
      national: "Nationale",
      nyregion: "La région de new york",
      obituaries: "Notices nécrologiques",
      opinion: "Opinion",
      politics: "Politique",
      realestate: "Immobilier",
      science: "Science",
      sports: "Des sports",
      sundayreview: "Avis dimanche",
      technology: "La technologie",
      theater: "Théâtre",
      tmagazine: "Magazine T",
      travel: "Voyage",
      upshot: "Résultat",
      world: "Monde",
      Search: "Search",
      numResults: "Il y a <1>{{count}}</1> résultats.",
      Home: "Page d'accueil",
      Search: "Chercher",
      Language: "La langue",
      English: "Anglais",
      French: "Français",
      Keyword: "Mot-clé",
      Go: "Aller",
      Section: "Section",
    },
  },
};

export { resources };

We have the static text translations, and the interpolated text we mentioned above in this file.

Finally, we create the top bar by creating TopBar.js 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";
import { useTranslation } from "react-i18next";

function TopBar({ location }) {
  const { pathname } = location;
  const { t, i18n } = useTranslation();
  const changeLanguage = lng => {
    localStorage.setItem("language", lng);
    i18n.changeLanguage(lng);
  };

  return (
    <Navbar bg="primary" expand="lg" variant="dark">
      <Navbar.Brand href="#home">{t("New York Times 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 == "/"}>
            {t("Home")}
          </Nav.Link>
          <Nav.Link href="/search" active={pathname.includes("/search")}>
            {t("Search")}
          </Nav.Link>
          <NavDropdown title={t("Language")} id="basic-nav-dropdown">
            <NavDropdown.Item onClick={() => changeLanguage("en")}>
              {t("English")}
            </NavDropdown.Item>
            <NavDropdown.Item onClick={() => changeLanguage("fr")}>
              {t("French")}
            </NavDropdown.Item>
          </NavDropdown>
        </Nav>
      </Navbar.Collapse>
    </Navbar>
  );
}

export default withRouter(TopBar);

We use the NavBar component provided by React Boostrap and we add a drop down for users to select a language and when they click those items, they can set the language. Notice that we wrapped the TopBar component with the withRouter function so that we get the current route’s value with the location prop, and use it to set which link is active by setting the active prop in the Nav.Link components.

Finally, we replace the existing code in index.html with:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="Web site created using create-react-app"
    />
    <link rel="apple-touch-icon" href="logo192.png" />
    <!--
      manifest.json provides metadata used when your web app is installed on a
      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
    -->
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <!--
      Notice the use of %PUBLIC_URL% in the tags above.
      It will be replaced with the URL of the `public` folder during the build.
      Only files inside the `public` folder can be referenced from the HTML.

      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
      work correctly both with client-side routing and a non-root public URL.
      Learn how to configure a non-root public URL by running `npm run build`.
    -->
    <title>React New York Times App</title>
    <link
      rel="stylesheet"
      href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
      integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"
      crossorigin="anonymous"
    />
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <!--
      This HTML file is a template.
      If you open it directly in the browser, you will see an empty page.

      You can add webfonts, meta tags, or analytics to this file.
      The build step will place the bundled scripts into the <body> tag.

      To begin the development, run `npm start` or `yarn start`.
      To create a production bundle, use `npm run build` or `yarn build`.
    -->
  </body>
</html>

to add the Bootstrap CSS and change the title of the app.

Categories
Vue

How to Do Cross Field Validation with Vee-Validate 3

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

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

We often need to validate form fields depending on other fields. For example, we need to validate postal code formats based on country since different countries have different postal code formats.

This is easy to do with VeeValidate 3.

In this article, we will build a Vue app that runs on Windows. It is an address book app that allows us to add contacts and save them with a back end serving a JSON file.

To start building the app, we start by installing Vue CLI by running:

npm i -g @vue/cii

Next we create our Vue.js project by running vue create address-book-app . Be sure to select ‘Manually select features’, and after that choose to include Babel, Vuex, and Vue Router. This will create the initial files for our app.

Once we add that, we need to add our own libraries. We need Axios for making HTTP requests, Bootstrap-Vue for styling, and Vee-Validate for form validation. We install these by running:

npm i axios bootstrap-vue vee-validate

in the project folder.

Now that we installed our libraries, we can start building our address book app. We start by creating the contact form for adding and editing our contacts. We add a ContactFome.vue file into the components folder and add:

<template>
  <ValidationObserver ref="observer" v-slot="{ invalid }">
    <b-form @submit.prevent="onSubmit" novalidate>
      <b-form-group label="First Name">
        <ValidationProvider name="firstName" rules="required" v-slot="{ errors }">
          <b-form-input
            type="text"
            :state="errors.length == 0"
            v-model="form.firstName"
            required
            placeholder="First Name"
            name="firstName"
          ></b-form-input>
          <b-form-invalid-feedback :state="errors.length == 0">First name is requied.</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

      <b-form-group label="Last Name">
        <ValidationProvider name="lastName" rules="required" v-slot="{ errors }">
          <b-form-input
            type="text"
            :state="errors.length == 0"
            v-model="form.lastName"
            required
            placeholder="Last Name"
            name="lastName"
          ></b-form-input>
          <b-form-invalid-feedback :state="errors.length == 0">Last name is requied.</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

      <b-form-group label="Address">
        <ValidationProvider name="addressLineOne" rules="required" v-slot="{ errors }">
          <b-form-input
            type="text"
            :state="errors.length == 0"
            v-model="form.addressLineOne"
            required
            placeholder="Address"
            name="addressLineOne"
          ></b-form-input>
          <b-form-invalid-feedback :state="errors.length == 0">Address is required.</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

      <b-form-group label="City">
        <ValidationProvider name="city" rules="required" v-slot="{ errors }">
          <b-form-input
            type="text"
            :state="errors.length == 0"
            v-model="form.city"
            required
            placeholder="City"
            name="city"
          ></b-form-input>
          <b-form-invalid-feedback :state="errors.length == 0">City is required.</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

      <b-form-group label="Postal Code">
        <ValidationProvider
          name="postalCode"
          rules="required|postal_code:country"
          v-slot="{ errors }"
        >
          <b-form-input
            type="text"
            :state="errors.length == 0"
            v-model="form.postalCode"
            required
            placeholder="Postal Code"
            name="postalCode"
          ></b-form-input>
          <b-form-invalid-feedback :state="errors.length == 0">Postal code is requied.</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

      <b-form-group label="Country">
        <ValidationProvider name="country" rules="required" v-slot="{ errors }">
          <b-form-select
            :options="countries"
            :state="errors.length == 0"
            v-model="form.country"
            required
            placeholder="Country"
            name="country"
          ></b-form-select>
          <b-form-invalid-feedback :state="errors.length == 0">Country is requied.</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

      <b-form-group label="Email">
        <ValidationProvider name="email" rules="required|email" v-slot="{ errors }">
          <b-form-input
            type="text"
            :state="errors.length == 0"
            v-model="form.email"
            required
            placeholder="Email"
            name="email"
          ></b-form-input>
          <b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

      <b-form-group label="Phone">
        <ValidationProvider name="phone" rules="required|phone:country" v-slot="{ errors }">
          <b-form-input
            type="text"
            :state="errors.length == 0"
            v-model="form.phone"
            required
            placeholder="Phone"
            name="phone"
          ></b-form-input>
          <b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

      <b-form-group label="Age">
        <ValidationProvider
          name="age"
          rules="required|min_value:0|max_value:200"
          v-slot="{ errors }"
        >
          <b-form-input
            type="text"
            :state="errors.length == 0"
            v-model="form.age"
            required
            placeholder="Age"
            name="age"
          ></b-form-input>
          <b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

      <b-button type="submit" variant="primary">Submit</b-button>
      <b-button type="reset" variant="danger" @click="cancel()">Cancel</b-button>
    </b-form>
  </ValidationObserver>
</template>

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

export default {
  name: "ContactForm",
  mixins: [requestsMixin],
  props: {
    edit: Boolean,
    contact: Object
  },
  methods: {
    async onSubmit() {
      const isValid = await this.$refs.observer.validate();
      if (!isValid) {
        return;
      }

      if (this.edit) {
        await this.editContact(this.form);
      } else {
        await this.addContact(this.form);
      }
      const response = await this.getContacts();
      this.$store.commit("setContacts", response.data);
      this.$emit("saved");
    },
    cancel() {
      this.$emit("cancelled");
    }
  },
  data() {
    return {
      form: {},
      countries: COUNTRIES.map(c => ({ value: c.name, text: c.name }))
    };
  },
  watch: {
    contact: {
      handler(c) {
        this.form = c || {};
      },
      deep: true,
      immediate: true
    }
  }
};
</script>

In the form, we wrap each input with ValidationProvider so that we get form validation for each field, along with the form validation errors. We add :state=”errors.length == 0" in each b-form-input so that we get the right validation message displayed and styled properly for each input. The errors object has the form validation error messages for each input. We also need to specify the name prop in ValidationProvider and b-form-input so that form validation rules are applied to the input inside the ValidationProvider .

We use ValidationObserver to watch for validation errors in our form which is wrapped inside. We have the ref=”observer” prop in the ValidationObserver so that we can call await this.$refs.observer.validate(); to validate our form. observer is our ref for the ValidationObserver component. We put the form inside the ValidationObserver component here to let us validate the whole form. With Vee-Validate, we get the this.$refs.observer.validate() function when we use ValidationObserver like we did in the code above. It returns a promise that resolves to true if the form is valid and false otherwise. So if it resolves to false, we don’t run the rest of the function’s code.

In this form there is cross field validation. The country field is checked before checking the phone number and postal code formats. We will add those validation rules into main.js later.

To display the form validation error messages, we have the errors object available in the template only. The scoped slots built into the Vee-Validate components provides the errors object, which has the validation messages.

In the rules prop of each field, we passing the rule names separated by pipes. The phone and postal_code rules are cross field rules. The country after the colon is the name prop of the country field, which is country .

The form and inputs components are all provided by BootstrapVue.

When the form submit button is clicked, we call the onSubmit button. The onSubmit function is passed into the submit.prevent prop to prevent the default submit action so we can use Ajax to submit the form.

In this function, we use the this.$refs.observer.validate(); to validate the form. Then after that, we call editContact or addContact depending if edit prop is true or not. We passed those in from the HomePage.vue file which we will add. These 2 functions are for making HTTP requests to our server to submit our data.

Once either of the function is called, we get the latest data and put them into our Vuex store with:

this.$store.commit("setContacts", response.data);

this.$store is provided by Vuex.

Then we emit the saved event to HomePage.vue to close the modals.

The countries are imported from another file and the contact prop is passed in from HomePage.vue when the user selects an entry to edit.

Next create a helpers folder in the src folder and add an exports.js file. In there, add:

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" }
];

so we can have a list of countries in the Countries field drop down in ContactForm.vue .

Next we add the mixin that we referenced in ContactForm.vue . Create a mixins folder in the src folder and add a requestsMixin.js file. In there add:

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

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

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

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

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

These are functions for returning promises for the requests that we make to our back end.

Next in Home.vue , replace the existing code with the following:

<template>
  <div class="page">
    <h1 class="text-center">Address Book</h1>
    <b-button-toolbar>
      <b-button @click="openAddModal()">Add Contact</b-button>
      <b-button @click="getAllContacts()">Refresh</b-button>
    </b-button-toolbar>
    <br />
    <b-table-simple responsive>
      <b-thead>
        <b-tr>
          <b-th>First Name</b-th>
          <b-th>Last Name</b-th>
          <b-th>Address</b-th>
          <b-th>Phone</b-th>
          <b-th>Email</b-th>
          <b-th>Age</b-th>
          <b-th></b-th>
          <b-th></b-th>
        </b-tr>
      </b-thead>
      <b-tbody>
        <b-tr v-for="c in contacts" :key="c.id">
          <b-td>{{c.firstName}}</b-td>
          <b-td>{{c.lastName}}</b-td>
          <b-td>{{c.addressLineOne}}, {{c.city}}, {{c.region}}, {{c.country}}, {{c.postalCode}}</b-td>
          <b-td>{{c.phone}}</b-td>
          <b-td>{{c.email}}</b-td>
          <b-td>{{c.age}}</b-td>
          <b-td>
            <b-button @click="openEditModal(c)">Edit</b-button>
          </b-td>
          <b-td>
            <b-button @click="deleteOneContact(c.id)">Delete</b-button>
          </b-td>
        </b-tr>
      </b-tbody>
    </b-table-simple>

<b-modal id="add-modal" title="Add Contact" hide-footer>
      <ContactForm [@saved](http://twitter.com/saved "Twitter profile for @saved")="closeModal()" [@cancelled](http://twitter.com/cancelled "Twitter profile for @cancelled")="closeModal()" :edit="false"></ContactForm>
    </b-modal>

<b-modal id="edit-modal" title="Edit Contact" hide-footer>
      <ContactForm
        [@saved](http://twitter.com/saved "Twitter profile for @saved")="closeModal()"
        [@cancelled](http://twitter.com/cancelled "Twitter profile for @cancelled")="closeModal()"
        :edit="true"
        :contact="selectedContact"
      ></ContactForm>
    </b-modal>
  </div>
</template>

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

export default {
  name: "home",
  mixins: [requestsMixin],
  components: {
    ContactForm
  },
  computed: {
    contacts() {
      return this.$store.state.contacts;
    }
  },
  beforeMount() {
    this.getAllContacts();
  },
  data() {
    return {
      selectedContact: {}
    };
  },
  methods: {
    openAddModal() {
      this.$bvModal.show("add-modal");
    },
    openEditModal(contact) {
      this.$bvModal.show("edit-modal");
      this.selectedContact = contact;
    },
    closeModal() {
      this.$bvModal.hide("add-modal");
      this.$bvModal.hide("edit-modal");
      this.selectedContact = {};
    },
    async deleteOneContact(id) {
      await this.deleteContact(id);
      this.getAllContacts();
    },
    async getAllContacts() {
      const response = await this.getContacts();
      this.$store.commit("setContacts", response.data);
    }
  }
};
</script>

<style scoped>
#add-button {
  margin-bottom: 20px;
}
</style>

We have a table for displaying the list of contacts from the store. This component watches for our Vuex store updates by getting them from the contacts property in the computed field. The latest Vuex store data are always returned there.

The data is loaded when the page first loads with the getAllContacts function call in the beforeMount hook. getAllContacts set the contacts in the store after it’s retrieved from back end.

We have buttons to open and close the modals which contains our contact form. Note that we have to set the form to pass into the contact prop in the ContactForm in the edit modal by setting this.selectedContact in the openEditModal with the passed in contact argument. openEditModal is used by the Edit button in each row of the table.

In each row of the table, there’s also a Delete button, we pass in the ID of the contact in there so that we can delete it by ID.

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

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

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

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

<script>
export default {
  data() {
    return {
      path: this.$route && this.$route.path
    };
  },
  watch: {
    $route(route) {
      this.path = route.path;
    }
  }
};
</script>

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

button {
  margin-right: 10px;
}
</style>

In this file, we add the BootstrapVue navbar component and highlight the links by checking the path which we get in the watch block. The active prop is the where the highlighting is set. If active is true then the link will be highlighted. We choose to highlight the link if the path is equal to the route the user is in.

In the style block, we add some padding to our page and margins to our buttons.

In main.js replace the existing code with:

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import BootstrapVue from "bootstrap-vue";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required, email, min_value, max_value } from "vee-validate/dist/rules";

extend("required", required);
extend("email", email);
extend("min_value", min_value);
extend("max_value", max_value);
extend("phone", {
  validate: (value, { country }) => {
    if (["United States", "Canada"].includes(country)) {
      return /^((d{3})|d{3})-?d{3}-?d{4}$/.test(value);
    }
    return true;
  },
  message: "Phone number is invalid.",
  params: [{ name: "country", isTarget: true }]
});

extend("postal_code", {
  validate: (value, { country }) => {
    if ("United States" == country) {
      return /^[0-9]{5}(?:-[0-9]{4})?$/.test(value);
    } else if ("Canada" == country) {
      return /^[A-Za-z]d[A-Za-z][ -]?d[A-Za-z]d$/.test(value);
    }
    return true;
  },
  message: "Phone number is invalid.",
  params: [{ name: "country", isTarget: true }]
});

Vue.config.productionTip = false;
Vue.use(BootstrapVue);
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);

new Vue({
  router,
  store,
  render: h => h(App),
  mounted() {
    this.$router.push("/");
  }
}).$mount("#app");

We have the Vee-Validate validation rules added here and register our BootstrapVue components, and Vee-Validate validation components here so we can use it in our templates.

The cross field validation rules are phone and postal_code . We specified the params to be country in the template of ContactForm.vue , so if we specified params to be [{ name: “country”, isTarget: true }] , then we will get the country field in the second argument of the validate function. country has the value of the country field in ContactForm.vue . With that, we can check for the phone number by country in the phone validation rule.

The postal_code rule works the same way.

Note that we have:

mounted() {
  this.$router.push("/");
}

in the object passed into the Vue constructor so that our built Windows app won’t show a blank page.

In router.js we replace the existing code with:

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

Vue.use(Router);

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

to let us go to Home.vue .

In store.js , replace the existing code with:

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: {}
});

so that we can store contacts in the store for easy access by all components.

Now we can run npm run serve to run the app.

To start the back end, we first install the json-server package by running npm i json-server. Then, go to our project folder and run:

json-server --watch db.json

In db.json, change the text to:

{
  "contacts": [
  ]
}

So we have the contacts endpoints defined in the requests.js available.