Categories
React

How to Add Geolocation to a React App

Many apps want to get data based on location. This is where the HTML Geolocation API comes in. You can use it easily to get the location of the current device via the Internet.

To get the location of the device the browser is running with plain JavaScript, we write:

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

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

As you can see, getting the latitude and longitude is very easy. We can also easily add geolocation to any React app. The react-geolocated package is a great add-on for adding geolocation capabilities to your app. It is located at https://www.npmjs.com/package/react-geolocated.

It provides a promise-based API for getting the location of the device, so we can easily use async and await to get the location with this package.

In this article, we will build an app to weather app that lets you get the current weather and forecast of the current location you’re in. It also lets you search the weather by inputting the location manually.

To start we will run Create React App to create the React project. Run:

npx create-react-app weather-app

to create the project.

Next, we have to add some libraries. We need Axios for making HTTP requests, Formik and Yup for form value handling and validation, MobX for state management, Bootstrap for styling, React Geolocated for getting geolocation data, and React Router for routing. We install them by running:

npm i axios formik mobx mobx-react react-bootstrap react-geolocated react-router-dom yup

With all the packages installed, we can start building the app. We start by replacing the code in App.css :

.page {
  padding: 20px;
}

to add some padding to our page.

Next, replace the code in App.js with:

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({ keywordStore }) {
  return (
    <div className="App">
      <Router history={history}>
        <Navbar bg="primary" expand="lg" variant="dark">
          <Navbar.Brand href="#home">Geolocation Weather App</Navbar.Brand>
          <Navbar.Toggle aria-controls="basic-navbar-nav" />
          <Navbar.Collapse id="basic-navbar-nav">
            <Nav className="mr-auto">
              <Nav.Link href="/" active>
                Home
              </Nav.Link>
            </Nav>
          </Navbar.Collapse>
        </Navbar>
        <Route
          path="/"
          exact
          component={props => (
            <HomePage {...props} keywordStore={keywordStore} />
          )}
        />
      </Router>
    </div>
  );
}

export default App;

Here we add the React Bootstrap navigation bar and the route to our home 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 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 for 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 Card s 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";
import { geolocated } from "react-geolocated";
import { getLocationByLatLng } from "./request";

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

const buttonStyle = { marginRight: "10px" };

function HomePage({ keywordStore, coords }) {
  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);
  };

  const getWeatherCurrentLocation = async () => {
    const { latitude, longitude } = coords;
    const { data } = await getLocationByLatLng(latitude, longitude);
    const keyword = (
      data.results[0].address_components.find(c =>
        c.types.includes("locality")
      ) || {}
    ).long_name;
    localStorage.setItem("keyword", keyword);
    keywordStore.setKeyword(keyword);
  };

  const clear = () => {
    localStorage.clear();
    keywordStore.setKeyword("");
  };

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

  return (
    <div className="page">
      <h1>Weather</h1>
      <Formik
        validationSchema={schema}
        onSubmit={handleSubmit}
        initialValues={{ keyword: localStorage.getItem("keyword") || "" }}
        enableReinitialize={true}
      >
        {({
          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={buttonStyle}>
              Search
            </Button>
            <Button
              type="button"
              onClick={getWeatherCurrentLocation}
              style={buttonStyle}
              disabled={!coords}
            >
              Get Weather of Current Location
            </Button>
            <Button type="button" onClick={clear}>
              Clear
            </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>
  );
}

HomePage = observer(HomePage);

export default geolocated({
  positionOptions: {
    enableHighAccuracy: false
  },
  userDecisionTimeout: 5000
})(HomePage);

We use the React-Geolocated package here to add the geolocation functionality to our app. The package provides us with the geolocated higher-order component to let users get their current location if they allow our app to do so. We set enableHighAccuracy to false so that our app will respond faster by using a less accurate location, which is already accurate in most cases. userDecisionTimeout is set so that after 5 seconds, geolocation will be assumed to be disabled by the user.

Since we wrapped our HomePage component with the geolocated component, we will get the coords prop so that we get the location when geolocation is enabled. We add check for the coords prop in our ‘Get Weather of Current Location‘ button so that users can only use the geolocation functionality when they have geolocation enabled.

In the callback for the useEffect hook, we set the array in the second argument to watch for keywordStore.keyword changes so that it will re-render when that changes. We need this so that we get the latest keyword into our form via the intialValues prop of our Formik component. When we click the ‘Get Weather of Current Location‘ button, we get set the keyword in both local storage and the MobX store, so we can just watch the store to make it render the local storage’s keyword value.

In the getWeatherCurrentLocation function, we get the location with the Google Maps API, then get the city name by finding the component with locality in the types property, and get the long_name from that, then set the keyword in our MobX store for use by the previous components we created and also in local storage so that we see it in our form and keep the same value set after refreshing the value.

Next in index.js 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 { KeywordStore } from "./store";
const keywordStore = new KeywordStore();

ReactDOM.render(
  <App keywordStore={keywordStore} />,
  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();

to pass or MobX store into our app.

Then in we create requests.js in the src folder, and add the following:

const APIURL = "http://api.openweathermap.org";
const GOOGLE_MAP_API_URL = "https://maps.googleapis.com/maps/api/geocode/json";
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}`
  );

export const getLocationByLatLng = (lat, lng) =>
  axios.get(
    `${GOOGLE_MAP_API_URL}?latlng=${lat},${lng}&key=${process.env.REACT_APP_GOOGLE_APIKEY}`
  );

We have functions to make HTTP requests to get the weather and location with the Google API. To get the properties in process.env , put them in your .env file in your project’s root folder with those field properties as keys and the corresponding API keys as the values.

After that, create 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 };

to create our MobX Store. We have the keyword field that is watched by other components by wrappingobserver function outside our component when we export them. Also, we have the setKeyword function to set the latest values of the keyword field.

Finally, in index.js , 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" />
    <!--
      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>Geolocation Weather 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 change the title and add Bootstrap CSS.

Categories
JavaScript APIs

Formatting Language-Sensitive Lists in JavaScript with ListFormat

With the Intl.ListFormat constructor, we can format language-sensitive lists with ease. It lets us set the locale in which to format a list. Also, it lets us set options for formatting lists such as the type of list or style of the list. To use it, we can create a new ListFormat object with the Intl.ListFormat constructor.

The constructor takes up to two arguments. The first argument is a string of the locale that you want to format the list for. The second argument takes an object with the options for formatting and styling the list. An example use of the Intl.ListFormat constructor is:

const arr = ['bus', 'car', 'train'];
const formatter = new Intl.ListFormat('en', {
  style: 'long',
  type: 'conjunction'
});
const formattedList = formatter.format(arr);
console.log(formattedList);

If we run the code above, we will see bus, car, and train logged from the console.log statement. The Intl.ListFormat constructor creates a formatter object that has the format method to convert an array into a list according to the locale and formatting options that we set.

In the example above, we set the locale to en for English, we set the style property to long, which formats an array of strings into something like A, B, and C or A, B, or C. The type property specifies the kind of list we want to format the list into. Conjunction means that we format a list into A, B, and C.

The arguments of the constructor are the locales that you want to format the list into. It can be a string or an array of strings containing the locale of the list you want to format it into. The locale string or strings should be a BCP 47 language tag. The locales argument is optional. An abridged list of BCP-47 language tags includes:

ar — Arabic

bg — Bulgarian

ca — Catalan

zh-Hans — Chinese, Han (Simplified variant)

cs — Czech

da — Danish

de — German

el — Modern Greek (1453 and later)

en — English

es — Spanish

fi — Finnish

fr — French

he — Hebrew

hu — Hungarian

is — Icelandic

it — Italian

ja — Japanese

ko — Korean

nl — Dutch

no — Norwegian

pl — Polish

pt — Portuguese

rm — Romansh

ro — Romanian

ru — Russian

hr — Croatian

sk — Slovak

sq — Albanian

sv — Swedish

th — Thai

tr — Turkish

ur — Urdu

id — Indonesian

The second argument for the constructor is an object that lets us set the options for how to format the list’s string. There are three properties for this object: localeMatcher, type, and style. The localeMatcher option specifies the locale-matching algorithm to use. The possible values are lookup and best fit.

The lookup algorithm searches for the locale until it finds the one that fits the character set of the strings that are being compared.

best fit finds the locale that is at least as, but possibly more, suitable than the lookup algorithm.

The type property can take on two possible values: conjunction, disjunction, or unit. conjunction means that the list is joined with an and, as in A, B, and C. This is the default option. disnjunction means the list is joined with an or, as in A, B, or C.

unit stands for a list of values with units.

The style property specifies the length of the formatted message. There are two possible values for this property. It can either be long (e.g., A, B, and C), short (e.g., A, B, C), or narrow (e.g., A B C). When style is short or narrow, unit is the only allowed value for this option.

For example, we can use it to format a list into a string that is joined with an and at the end. We can write the following to do so:

const arr = ['bus', 'car', 'bicycle'];
const formatter = new Intl.ListFormat('en', {
  style: 'long',
  type: 'conjunction'
});
const formattedList = formatter.format(arr);
console.log(formattedList);

If we log the formattedList constant like we did above, we get bus, car, and bicycle. We set the en locale to format the string for the English locale, with style long and as a conjunction, which means the list will be joined with an and at the end. If we want to get an or-based list, we can change the type property to a disjunction, as in the following code:

const arr = ['bus', 'car', 'bicycle'];
const formatter = new Intl.ListFormat('en', {
  style: 'long',
  type: 'disjunction'
});
const formattedList = formatter.format(arr);
console.log(formattedList);

If we run the code above, then we get bus, car, or bicycle logged in the console.log statement above.

We can convert it to a shorter list by using the short or narrow option for the style property. For example, we can write:

const arr = ['bus', 'car', 'bicycle'];
const formatter = new Intl.ListFormat('en', {
  style: 'short',
  type: 'conjunction'
});
const formattedList = formatter.format(arr);
console.log(formattedList);

Then we get bus, car, & bicycle in the console.log output when we run the code above. The short and disjunction combination is the same as the long and disjunction combination. If we write:

const arr = ['bus', 'car', 'bicycle'];
const formatter = new Intl.ListFormat('en', {
  style: 'short',
  type: 'disjunction'
});
const formattedList = formatter.format(arr);
console.log(formattedList);

Then we get bus, car, or bicycle. The narrow option would make it even shorter. For example, if we put:

const arr = ['bus', 'car', 'bicycle'];
const formatter = new Intl.ListFormat('en', {
  style: 'narrow',
  type: 'conjunction'
});
const formattedList = formatter.format(arr);
console.log(formattedList);

Then we get bus, car, bicycle logged in the console.log when we run the code above.

This also works for non-English locales. For example, if we want to format a list of Chinese strings into a list, we can write the following code:

const arr = ['日', '月', '星'];
const formatter = new Intl.ListFormat('zh-hant', {
  style: 'narrow',
  type: 'conjunction'
});
const formattedList = formatter.format(arr);
console.log(formattedList);

Then we get ‘日、月和星’, which means sun, moon, and stars. If we switch the style option to long or short, we would get the same thing because in Chinese there’s only one way to write a conjunction, unlike in English. disjunction also works with Chinese. For instance, if we have:

const arr = ['日', '月', '星'];
const formatter = new Intl.ListFormat('zh-hant', {
  style: 'long',
  type: 'disjunction'
});
const formattedList = formatter.format(arr);
console.log(formattedList);

Then we get ‘日、月或星’ which means sun, moon, or stars. If we switch the style option to long or short, as with Chinese conjunctions, we would get the same thing because in Chinese there’s only one way to write a disjunction, unlike in English.

formatToParts() Method

In addition to the format method, the Intl.ListFormat instance also has the formatToParts method, which formats an array into a conjunction or disjunction string and then returns it as an array of the parts of the formatted string.

For example, if we want to return an array of the parts of the formatted English string for a list, then we can write the following code:

const arr = ['bus', 'car', 'bicycle'];
const formatter = new Intl.ListFormat('en', {
  style: 'long',
  type: 'conjunction'
});
const formattedList = formatter.formatToParts(arr);
console.log(formattedList);

Then we get:

[
  {
    "type": "element",
    "value": "bus"
  },
  {
    "type": "literal",
    "value": ", "
  },
  {
    "type": "element",
    "value": "car"
  },
  {
    "type": "literal",
    "value": ", and "
  },
  {
    "type": "element",
    "value": "bicycle"
  }
]

from the console.log statement. These are the parts of the formatted string that we get with the format method, but broken apart into individual entries. This method is handy if we only want some parts of the formatted string.

With the Intl.ListFormat constructor, formatting language-sensitive lists is easy. The constructor takes a locale string or an array of locale strings as the first argument and an object with some options for the second argument. We can convert an array into a string that has the list formatted into conjunction or disjunction with the Intl.ListFormat instance’s format method, which takes a locale string and options for the length style of the formatted string and whether it’s a conjunction, disjunction, or unit string. It also has a formatToParts method to convert it to a formatted list and then break up the parts into an array.

Categories
Vue

How to Add Copy to Clipboard Feature to Your Vue.js App

Copy to clipboard feature is a popular feature for web apps like password managers, where it is inconvenient for people to highlight text and then copy it. It is an easy feature to add to your own web app.

In this article, we will build a password manager that lets you enter, edit, and delete passwords and let them copy their username and password to the clipboard to use them anywhere they like. We will use Vue.js to build the app.

Getting Started

To start we create the project by running npx @vue/cli create password-manager. In the wizard, choose ‘Manually select features’ and choose to include Babel, Vue Router, and Vuex in our app.

Next, we install some libraries we need. We need Axios for making HTTP requests, Bootstrap Vue for styling, V-Clipboard for the copy to clipboard functionality, and Vee-Validate for form validation. We install them by running:

npm i axios bootstrap-vue v-clipboard vee-validate

After we install the libraries, we can start building the app. First, in the components folder, create a file called PasswordForm.vue for our password form. Then in there, we add:

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

      <b-form-group label="URL">
        <ValidationProvider name="url" rules="required|url" v-slot="{ errors }">
          <b-form-input
            type="text"
            :state="errors.length == 0"
            v-model="form.url"
            required
            placeholder="URL"
            name="url"
          ></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="Username">
        <ValidationProvider name="username" rules="required" v-slot="{ errors }">
          <b-form-input
            type="text"
            :state="errors.length == 0"
            v-model="form.username"
            required
            placeholder="Username"
            name="username"
          ></b-form-input>
          <b-form-invalid-feedback :state="errors.length == 0">Username is requied.</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

      <b-form-group label="Password">
        <ValidationProvider name="password" rules="required" v-slot="{ errors }">
          <b-form-input
            type="password"
            :state="errors.length == 0"
            v-model="form.password"
            required
            placeholder="Password"
            name="password"
          ></b-form-input>
          <b-form-invalid-feedback :state="errors.length == 0">Password is requied.</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: "PasswordForm",
  mixins: [requestsMixin],
  props: {
    edit: Boolean,
    password: Object
  },
  methods: {
    async onSubmit() {
      const isValid = await this.$refs.observer.validate();
      if (!isValid) {
        return;
      }

if (this.edit) {
        await this.editPassword(this.form);
      } else {
        await this.addPassword(this.form);
      }
      const response = await this.getPasswords();
      this.$store.commit("setPasswords", response.data);
      this.$emit("saved");
    },
    cancel() {
      this.$emit("cancelled");
    }
  },
  data() {
    return {
      form: {}
    };
  },
  watch: {
    password: {
      handler(p) {
        this.form = JSON.parse(JSON.stringify(p || {}));
      },
      deep: true,
      immediate: true
    }
  }
};
</script>

We have the password form in this component. The form includes name, URL, username, and password fields. All of them are required. We use Vee-Validate to validate the form fields. The ValidationObserver component is for validating the whole form, while the ValidationProvider component is for validating the form fields that it wraps around.

The validation rule is specified by the rule prop of each field. We have a special url rule for the URL field. We show the validation error messages when the errors object from the scope slot has a non-zero length. The state prop is for setting the validation state which shows the green when errors has length 0 and red otherwise. The error messages are shown in the b-form-invalid-feedback component.

When the user clicks to Save button, the onSubmit function is called. We get the validation state of the form by using this.$refs.observer.validate(); . The ref refers to the ref of the ValidationObserver . If it resolves to true , then we call addPassword or editPassword to save the entry depending on the edit prop. Then we get the passwords by calling getPasswords and then put it in our Vuex store by dispatching the setPasswords mutation. Then we emit the saved event to close the modal on the home page.

We have a watch block mainly used when an existing entry is being edited, we get the password prop and set it to this.form by making a copy of the prop so that we only update the form object and nothing when data is binding.

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

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

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

    addPassword(data) {
      return axios.post(`${APIURL}/passwords`, data);
    },

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

    deletePassword(id) {
      return axios.delete(`${APIURL}/passwords/${id}`);
    }
  }
};

This contains the code to make the HTTP requests in the back end. We include this mixin in our components so that we can make requests to back end from them.

Copy to clipboard functionality

To copy the username and password buttons, we use the v-clipboard directive to let us copy the username and password respectively to the clipboard when the button is clicked.

In Home.vue, we replace the existing code with:

<template>
  <div class="page">
    <h1 class="text-center">Password Manager</h1>
    <b-button-toolbar>
      <b-button @click="openAddModal()">Add Password</b-button>
    </b-button-toolbar>
    <br />
    <b-table-simple responsive>
      <b-thead>
        <b-tr>
          <b-th>Name</b-th>
          <b-th>URL</b-th>
          <b-th>Username</b-th>
          <b-th>Password</b-th>
          <b-th></b-th>
          <b-th></b-th>
          <b-th></b-th>
          <b-th></b-th>
        </b-tr>
      </b-thead>
      <b-tbody>
        <b-tr v-for="p in passwords" :key="p.id">
          <b-td>{{p.name}}</b-td>
          <b-td>{{p.url}}</b-td>
          <b-td>{{p.username}}</b-td>
          <b-td>******</b-td>
          <b-td>
            <b-button v-clipboard="() => p.username">Copy Username</b-button>
          </b-td>
          <b-td>
            <b-button v-clipboard="() => p.password">Copy Password</b-button>
          </b-td>
          <b-td>
            <b-button @click="openEditModal(p)">Edit</b-button>
          </b-td>
          <b-td>
            <b-button @click="deleteOnePassword(p.id)">Delete</b-button>
          </b-td>
        </b-tr>
      </b-tbody>
    </b-table-simple>

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

    <b-modal id="edit-modal" title="Edit Password" hide-footer>
      <PasswordForm
        @saved="closeModal()"
        @cancelled="closeModal()"
        :edit="true"
        :password="selectedPassword"
      ></PasswordForm>
    </b-modal>
  </div>
</template>

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

export default {
  name: "home",
  components: {
    PasswordForm
  },
  mixins: [requestsMixin],
  computed: {
    passwords() {
      return this.$store.state.passwords;
    }
  },
  beforeMount() {
    this.getAllPasswords();
  },
  data() {
    return {
      selectedPassword: {}
    };
  },
  methods: {
    openAddModal() {
      this.$bvModal.show("add-modal");
    },
    openEditModal(password) {
      this.$bvModal.show("edit-modal");
      this.selectedPassword = password;
    },
    closeModal() {
      this.$bvModal.hide("add-modal");
      this.$bvModal.hide("edit-modal");
      this.selectedPassword = {};
    },
    async deleteOnePassword(id) {
      await this.deletePassword(id);
      this.getAllPasswords();
    },
    async getAllPasswords() {
      const response = await this.getPasswords();
      this.$store.commit("setPasswords", response.data);
    }
  }
};
</script>

In this file, we have a table to display a list of password entries and let users open and close the add/edit modals. We have buttons in each row to copy the username and passwords, and also to let users edit or delete each entry.

In the scripts section, we have the beforeMount hook to get all the password entries during page load with the getPasswords function we wrote in our mixin. When the Edit button is clicked, the selectedPassword variable is set, and we pass it to the PasswordForm for editing.

To delete a password, we call deletePassword in our mixin to make the request to the back end.

Finishing the app

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="#">Password Manager</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>

This adds a Bootstrap navigation bar to the top of our pages, and a router-view to display the routes we define.

Next in main.js, replace the 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 Clipboard from "v-clipboard";
import { required } from "vee-validate/dist/rules";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";

extend("required", required);
extend("url", {
  validate: value => {
    return /^(http://www.|https://www.|http://|https://)?[a-z0-9]+([-.]{1}[a-z0-9]+)*.[a-z]{2,5}(:[0-9]{1,5})?(/.*)?$/.test(
      value
    );
  },
  message: "URL is invalid."
});
Vue.use(BootstrapVue);
Vue.use(Clipboard);
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);

Vue.config.productionTip = false;

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

To add the libraries we installed to our app so we can use it in our components. We add the V-Clipboard library here so we can use it in our home page. We call extend from Vee-Validate to add the form validation rules that we want to use. Also, we imported the Bootstrap CSS in this file to get the styles.

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 only include our home page.

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

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

Vue.use(Vuex);

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

This adds our passwords state to the store so we can observe it in the computed block of PasswordForm and HomePage components. We have the setPasswords function to update the passwords state and we use it in the components by call this.$store.commit(“setPasswords”, response.data); like we did in PasswordForm .

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

Demo backend

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:

{
  "passwords": [
  ]
}

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

Categories
Vue

How to Add Auto Complete Input to Your Vue.js App

To let users select from long lists easily, an input with autocomplete is preferable to a plain select dropdown because it lets users search for the entry they want instead of selecting from a list. This is a common feature of web apps, so the developer has developed autocomplete components that we add the feature easily.

In this article, we will make a currency converter that lets users select currencies to convert to and list exchange rates by the base currency. We will use Vue.js to build the app, use the Foreign Exchange Rate API located at https://exchangeratesapi.io/ to get our exchange rates and the Open Exchange Rates API, located at http://openexchangerates.org, to get our list of currencies.

To start building the app, we will run the Vue CLI to create the project. Run npx @vue/cli create currency-converter to create the project. In the wizard, we select ‘Manually select features’ and pick Babel, CSS Preprocessor, and Vuex, and Vue Router from the list.

Next, we install some libraries. We will use Axios for making HTTP requests, BootstrapVue for styling, Vee-Validate for form validation, and Vue-Autosuggest for the autocomplete input. Vue-Autosuggest lets us customize all parts of the component. It does not have any opinion on styling, which means that it fits well with Bootstrap styles.

We install all the packages by running npm i axios bootstrap-vue vee-validate vue-autosuggest to install all the libraries.

Next, we write the code for our app. We start by adding a mixin for sending our HTTP requests to the APIs to get data. Create a mixins folder in the src folder and then add requestsMixin.js in the src folder, then add the following code to the file:

const APIURL = "https://api.exchangeratesapi.io";
const OPEN_EXCHANGE_RATES_URL =
  "http://openexchangerates.org/api/currencies.json";
const axios = require("axios");

export const requestsMixin = {
  methods: {
    getCurrenciesList() {
      return axios.get(OPEN_EXCHANGE_RATES_URL);
    },

  getExchangeRates(baseCurrency) {
      return axios.get(`${APIURL}/latest?base=${baseCurrency}`);
    }
  }
};

We’re using Axios to make the requests to the APIs.

Next, we build a page to let users convert currencies. Create ConvertCurrency.vue in the views folder and add:

<template>
  <div class="page">
    <h1 class="text-center">Convert Currency</h1>
    <ValidationObserver ref="observer" v-slot="{ invalid }">
      <b-form @submit.prevent="onSubmit" novalidate>
        <b-form-group label="Amount" label-for="title">
          <ValidationProvider name="amount" rules="required|min_value:0" v-slot="{ errors }">
            <b-form-input
              v-model="form.amount"
              type="text"
              required
              placeholder="Amount"
              name="amount"
            ></b-form-input>
            <b-form-invalid-feedback :state="errors.length == 0">Amount is required</b-form-invalid-feedback>
          </ValidationProvider>
        </b-form-group>

        <b-form-group label="Currency to Convert From" label-for="start">
          <ValidationProvider name="fromCurrency" rules="required" v-slot="{ errors }">
            <vue-autosuggest
              :suggestions="filteredFromCurrencies"
              :input-props="{id:'autosuggest__input', placeholder:'Select Currency to Convert From', class: 'form-control'}"
              v-model="form.fromCurrency"
              :get-suggestion-value="getSuggestionValue"
              :render-suggestion="renderSuggestion"
              component-attr-class-autosuggest-results-container="result"
              @selected="onSelectedFromCurrency"
            ></vue-autosuggest>
            <b-form-invalid-feedback
              :state="errors.length == 0"
            >Currency to Convert From is required</b-form-invalid-feedback>
          </ValidationProvider>
        </b-form-group>

        <b-form-group label="Currency to Convert To" label-for="end">
          <ValidationProvider name="toCurrency" rules="required" v-slot="{ errors }">
            <vue-autosuggest
              :suggestions="filteredToCurrencies"
              :input-props="{id:'autosuggest__input', placeholder:'Select Currency to Convert To', class: 'form-control'}"
              v-model="form.toCurrency"
              :get-suggestion-value="getSuggestionValue"
              :render-suggestion="renderSuggestion"
              component-attr-class-autosuggest-results-container="result"
              @selected="onSelectedToCurrency"
            ></vue-autosuggest>
            <b-form-invalid-feedback :state="errors.length == 0">Currency to Convert To is required</b-form-invalid-feedback>
          </ValidationProvider>
        </b-form-group>

        <b-button type="submit" variant="primary">Convert</b-button>
      </b-form>
    </ValidationObserver>

    <div v-if="convertedAmount" class="text-center">
      <h2>Converted Amount</h2>
      <p>{{form.amount}} {{selectedFromCurrencyCode}} is equal to {{convertedAmount}} {{selectedToCurrencyCode}}</p>
    </div>
  </div>
</template>

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

export default {
  name: "ConvertCurrency",
  mixins: [requestsMixin],
  computed: {
    currencies() {
      return Object.keys(this.$store.state.currencies).map(key => ({
        value: key,
        name: this.$store.state.currencies[key]
      }));
    },
    filteredFromCurrencies() {
      const filtered =
        this.currencies.filter(
          c =>
            (c.value || "").toLowerCase() !=
              (this.selectedToCurrencyCode || "").toLowerCase() &&
            (c.value || "")
              .toLowerCase()
              .includes((this.form.fromCurrency || "").toLowerCase())
        ) ||
        (c.name || "")
          .toLowerCase()
          .includes((this.form.fromCurrency || "").toLowerCase());
      return [
        {
          data: filtered || []
        }
      ];
    },
    filteredToCurrencies() {
      const filtered =
        this.currencies.filter(
          c =>
            (c.value || "").toLowerCase() !=
              (this.selectedFromCurrencyCode || "").toLowerCase() &&
            (c.value || "")
              .toLowerCase()
              .includes((this.form.toCurrency || "").toLowerCase())
        ) ||
        (c.name || "")
          .toLowerCase()
          .includes((this.form.toCurrency || "").toLowerCase());
      return [
        {
          data: filtered || []
        }
      ];
    }
  },
  data() {
    return {
      form: {
        currency: ""
      },
      exchangeRates: {},
      ratesFound: false,
      selectedFromCurrencyCode: "",
      selectedToCurrencyCode: "",
      convertedAmount: 0
    };
  },
  methods: {
    getSuggestionValue(suggestion) {
      return suggestion && suggestion.item.name;
    },
    renderSuggestion(suggestion) {
      return suggestion && suggestion.item.name;
    },
    onSelectedFromCurrency(item) {
      this.selectedFromCurrencyCode = item && item.item.value;
    },
    onSelectedToCurrency(item) {
      this.selectedToCurrencyCode = item && item.item.value;
    },
    async onSubmit() {
      const isValid = await this.$refs.observer.validate();
      if (!isValid) {
        return;
      }
      try {
        const { data } = await this.getExchangeRates(
          this.selectedFromCurrencyCode
        );
        const rate = data.rates[this.selectedToCurrencyCode];
        this.convertedAmount = this.form.amount * rate;
      } catch (error) {}
    }
  }
};
</script>

The list of currencies are retrieved when App.vue loads and stored in the Vuex store so we can use it in all our pages without reloading the request for getting the currencies list.

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 amount field.

The Vue-Autosuggest components to let users select the currencies they want to convert from and to. The suggestions prop contains the list of currencies filtered by what the user inputted and also filters out what the currency that the other field is set to. The input-props prop contains an object with the placeholder of the inputs. v-model has sets what the user has entered so far, which we will use in the scripts section to filter out currencies. get-suggestion-value prop takes a function that returns the suggested items in a way that you prefer. render-suggestion prop displays the selection in a way you prefer by passing in a function to the prop. The component-attr-class-autosuggest-results-container lets us set the class for the result drop-down list and the selected event handler lets us set the final value that is selected.

In the filteredFromCurrencies and filteredToCurrencies functions, we filter out the currencies by excluding the currency already entered the other drop-down and also filter by what the user has entered so far in a case insensitive manner.

Once the user clicks Save, then the onSubmit function is called. Inside the function, this.$refs.observer.validate(); is called to check for form validation. observer is the ref of the ValidationObserver . The observed form validation value is here. If it resolves to true , We get the exchange rates for the base currency by calling the getExchangeRates function which is added from the mixin and then convert it to the final converted amount and display it in the template below the form.

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

<template>
  <div class="page">
    <h1 class="text-center">Exchange Rates</h1>

    <vue-autosuggest
      :suggestions="filteredCurrencies"
      :input-props="{id:'autosuggest__input', placeholder:'Select Currency', class: 'form-control'}"
      v-model="form.currency"
      :get-suggestion-value="getSuggestionValue"
      :render-suggestion="renderSuggestion"
      component-attr-class-autosuggest-results-container="result"
      @selected="onSelected"
    >
      <div slot-scope="{suggestion}">
        <span class="my-suggestion-item">{{suggestion.item.name}}</span>
      </div>
    </vue-autosuggest>

    <h2>Rates</h2>

    <b-list-group v-if="ratesFound">
      <b-list-group-item v-for="(key, value) in exchangeRates.rates" :key="key">{{key}} - {{value}}</b-list-group-item>
    </b-list-group>

    <b-list-group v-else>
      <b-list-group-item>Rate not found.</b-list-group-item>
    </b-list-group>
  </div>
</template>

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

export default {
  name: "home",
  mixins: [requestsMixin],
  computed: {
    currencies() {
      return Object.keys(this.$store.state.currencies).map(key => ({
        value: key,
        name: this.$store.state.currencies[key]
      }));
    },
    filteredCurrencies() {
      const filtered = this.currencies.filter(
        c =>
          (c.value || "")
            .toLowerCase()
            .includes(this.form.currency.toLowerCase()) ||
          (c.name || "")
            .toLowerCase()
            .includes(this.form.currency.toLowerCase())
      );
      return [
        {
          data: filtered
        }
      ];
    }
  },
  data() {
    return {
      form: {
        currency: ""
      },
      exchangeRates: {},
      ratesFound: false
    };
  },
  methods: {
    getSuggestionValue(suggestion) {
      return suggestion.item.name;
    },
    renderSuggestion(suggestion) {
      return suggestion.item.name;
    },
    async onSelected(item) {
      try {
        const { data } = await this.getExchangeRates(item.item.value);
        this.exchangeRates = data;
        this.ratesFound = true;
      } catch (error) {
        this.ratesFound = false;
      }
    }
  }
};
</script>

<style lang="scss" scoped>
</style>

This is the home page of our app. On the top, we have the Vue-Autosuggest component to filter user inputs from the list of currencies. The currencies list is from the Vuex store. Once the user selected their final value, we run this.getExchangeRates , which is from the requestsMixin, to load the latest exchange rates for the selected currency if they are found.

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

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

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

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

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

.result {
  position: absolute;
  background-color: white;
  min-width: 350px;
  z-index: 1000;
  ul {
    margin: 0;
    padding: 0;
    border: 1px solid #ced4da;
    border-radius: 3px;
    li {
      list-style-type: none;
      padding-left: 10px;
    }
  }
}
</style>

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

export default {
  mixins: [requestsMixin],
  data() {
    return {
      path: this.$route && this.$route.path
    };
  },
  watch: {
    $route(route) {
      this.path = route.path;
    }
  },
  beforeMount() {
    this.getCurrencies();
  },
  methods: {
    async getCurrencies() {
      const { data } = await this.getCurrenciesList();
      this.$store.commit("setCurrencies", data);
    }
  }
};
</script>

Here we add the BootstrapVue navigation bar. We also have the router-view for showing our routes. In the scripts section, we watch the $route variable to get the current route the user has navigated to set the active prop of the the b-nav-item . Also, when this component loads, we get the currencies and put it in our Vuex store so that we get the data in all of our components. We load it here because this is the entry component for the app.

This component also holds the global styles for our app. The result class is for styling the autocomplete dropdown. We set position to absolute so that it displays above everything else, and allowing it to overlap with other items. We also set the color of the drop-down and added a border to it. The dot for the list items are removed with list-style-type set to none . We have the page class to add some padding to our pages.

Next 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 VueAutosuggest from "vue-autosuggest";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required, min_value } from "vee-validate/dist/rules";
extend("required", required);
extend("min_value", min_value);
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
Vue.use(VueAutosuggest);
Vue.use(BootstrapVue);

Vue.config.productionTip = false;

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

We add BootstrapVue, Vue-Autosuggest, and Vee-Validate to our app here. In addition, we add the Vee-Validate validation rules that we use here, which include the required rule to make sure everything is filled, and the min_value for the amount. The Bootstrap CSS is also included here to styling all our components.

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

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

Vue.use(Router);

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

to add our routes so users can see our pages.

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: {
    currencies: {}
  },
  mutations: {
    setCurrencies(state, payload) {
      state.currencies = payload;
    }
  },
  actions: {}
});

to store the list of currencies that we use in all our components. We have the setter function in the mutation object and the currencies state which is observed by our components.

Then 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>Currency Converter</title>
  </head>
  <body>
    <noscript>
      <strong
        >We're sorry but vue-autocomplete-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.

Categories
Vue

How to Add Tool Tips to Your Vue.js App

Tooltips are common for providing hints on how to use different parts of a web app. It is easy to add and it helps users understand the app more. They’re also useful for display long text that would be too long.

In Vue.js, adding tooltips is easy with the V-Tooltip directive, located at https://github.com/Akryum/v-tooltip. It is a directive for configurable tooltips. You can change the color, text, delay in displaying, and many other options associated with tooltips.

In this article, we will build a recipe app that has tooltips to guide users on how to add recipes into a form. Users can enter the name of their dish, the ingredients, the steps and upload a photo. We will build the app with Vue.js

We start building the app by running the Vue CLI. We run it by entering:

npx @vue/cli create recipe-app

Then select ‘Manually select features’. Next, we select Babel, Vue Router, Vuex, and CSS Preprocessor in the list. After that, we install a few packages. We will install Axios for making HTTP requests to our back end. BootstrapVue for styling, V-Tooltip for the tooltips, and Vee-Validate for form validation. We install the packages by running npm i axios bootstrap-vue v-tooltip vee-validate .

Now we move on to creating the components. Create a file called RecipeForm.vue in the components folder and add:

<template>
  <ValidationObserver ref="observer" v-slot="{ invalid }">
    <b-form @submit.prevent="onSubmit" novalidate>
      <b-form-group
        label="Name"
        v-tooltip="{
          content: 'Enter Your Recipe Name Here',
          classes: ['info'],
          targetClasses: ['it-has-a-tooltip'],
        }"
      >
        <ValidationProvider name="name" rules="required" v-slot="{ errors }">
          <b-form-input
            type="text"
            :state="errors.length == 0"
            v-model="form.name"
            required
            placeholder="Name"
            name="name"
          ></b-form-input>
          <b-form-invalid-feedback :state="errors.length == 0">Name is requied.</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

<b-form-group
        label="Ingredients"
        v-tooltip="{
          content: 'Enter Your Recipe Description Here',
          classes: ['info'],
          targetClasses: ['it-has-a-tooltip'],
        }"
      >
        <ValidationProvider name="ingredients" rules="required" v-slot="{ errors }">
          <b-form-textarea
            :state="errors.length == 0"
            v-model="form.ingredients"
            required
            placeholder="Ingredients"
            name="ingredients"
            rows="8"
          ></b-form-textarea>
          <b-form-invalid-feedback :state="errors.length == 0">Ingredients is requied.</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

<b-form-group
        label="Recipe"
        v-tooltip="{
          content: 'Enter Your Recipe Here',
          classes: ['info'],
          targetClasses: ['it-has-a-tooltip'],
        }"
      >
        <ValidationProvider name="recipe" rules="required" v-slot="{ errors }">
          <b-form-textarea
            :state="errors.length == 0"
            v-model="form.recipe"
            required
            placeholder="Recipe"
            name="recipe"
            rows="15"
          ></b-form-textarea>
          <b-form-invalid-feedback :state="errors.length == 0">Recipe is requied.</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

<b-form-group label="Photo">
        <input type="file" style="display: none" ref="file" @change="onChangeFileUpload($event)" />
        <b-button
          @click="$refs.file.click()"
          v-tooltip="{
            content: 'Upload Photo of Your Dish Here',
            classes: ['info'],
            targetClasses: ['it-has-a-tooltip'],
          }"
        >Upload Photo</b-button>
      </b-form-group>

<img ref="photo" :src="form.photo" class="photo" />

<br />

<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: "RecipeForm",
  mixins: [requestsMixin],
  props: {
    edit: Boolean,
    recipe: Object
  },
  methods: {
    async onSubmit() {
      const isValid = await this.$refs.observer.validate();
      if (!isValid || !this.form.photo) {
        return;
      }

      if (this.edit) {
        await this.editRecipe(this.form);
      } else {
        await this.addRecipe(this.form);
      }
      const { data } = await this.getRecipes();
      this.$store.commit("setRecipes", data);
      this.$emit("saved");
    },
    cancel() {
      this.$emit("cancelled");
    },
    onChangeFileUpload($event) {
      const file = $event.target.files[0];
      const reader = new FileReader();
      reader.onload = () => {
        this.$refs.photo.src = reader.result;
        this.form.photo = reader.result;
      };
      reader.readAsDataURL(file);
    }
  },
  data() {
    return {
      form: {}
    };
  },
  watch: {
    recipe: {
      handler(val) {
        this.form = JSON.parse(JSON.stringify(val || {}));
      },
      deep: true,
      immediate: true
    }
  }
};
</script>

<style>
.photo {
  width: 100%;
  margin-bottom: 10px;
}
</style>

In this file, we have a form to let users enter their recipe. We have text inputs and a file upload file to let users upload a photo. 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.

Each form field has a tooltip with additional instructions. The v-tooltip directive is provided by the V-Tooltip library. We set the content of the tooltip and the classes here, and we can set other options like delay in displaying, the position and the background color of the tooltip. A full list of options is available at https://github.com/Akryum/v-tooltip.

The photo upload works by letting users open the file upload dialog with the Upload Photo button. The button would click on the hidden file input when the Upload Photo button is clicked. After the user selects a file, then the onChangeFileUpload function is called. In this function, we have the FileReader object which sets the src attribute of the img tag to show the uploaded image, and also the this.form.photo field. readAsDataUrl reads the image into a string so we can submit it without extra effort.

This form is also used for editing recipes, so we have a watch block to watch for the recipe prop, which we will pass into this component when there is something to be edited.

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: {
    getRecipes() {
      return axios.get(`${APIURL}/recipes`);
    },

    addRecipe(data) {
      return axios.post(`${APIURL}/recipes`, data);
    },

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

    deleteRecipe(id) {
      return axios.delete(`${APIURL}/recipes/${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">Recipes</h1>
    <b-button-toolbar class="button-toolbar">
      <b-button @click="openAddModal()" variant="primary">Add Recipe</b-button>
    </b-button-toolbar>

    <b-card
      v-for="r in recipes"
      :key="r.id"
      :title="r.name"
      :img-src="r.photo"
      img-alt="Image"
      img-top
      tag="article"
      class="recipe-card"
      img-bottom
    >
      <b-card-text>
        <h1>Ingredients</h1>
        <div class="wrap">{{r.ingredients}}</div>
      </b-card-text>

      <b-card-text>
        <h1>Recipe</h1>
        <div class="wrap">{{r.recipe}}</div>
      </b-card-text>

      <b-button @click="openEditModal(r)" variant="primary">Edit</b-button>

      <b-button @click="deleteOneRecipe(r.id)"  variant="danger">Delete</b-button>
    </b-card>

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

    <b-modal id="edit-modal" title="Edit Recipe" hide-footer>
      <RecipeForm
        @saved="closeModal()"
        @cancelled="closeModal()"
        :edit="true"
        :recipe="selectedRecipe"
      />
    </b-modal>
  </div>
</template>

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

export default {
  name: "home",
  components: {
    RecipeForm
  },
  mixins: [requestsMixin],
  computed: {
    recipes() {
      return this.$store.state.recipes;
    }
  },
  beforeMount() {
    this.getAllRecipes();
  },
  data() {
    return {
      selectedRecipe: {}
    };
  },
  methods: {
    openAddModal() {
      this.$bvModal.show("add-modal");
    },
    openEditModal(recipe) {
      this.$bvModal.show("edit-modal");
      this.selectedRecipe = recipe;
    },
    closeModal() {
      this.$bvModal.hide("add-modal");
      this.$bvModal.hide("edit-modal");
      this.selectedRecipe = {};
    },
    async deleteOneRecipe(id) {
      await this.deleteRecipe(id);
      this.getAllRecipes();
    },
    async getAllRecipes() {
      const { data } = await this.getRecipes();
      this.$store.commit("setRecipes", data);
    }
  }
};
</script>

<style scoped>
.recipe-card {
  width: 95vw;
  margin: 0 auto;
  max-width: 700px;
}

.wrap {
  white-space: pre-wrap;
}
</style>

In this file, we have a list of BootstrapVue cards to display a list of recipe entries and let users open and close the add and edit modals. We have buttons in each card to let users edit or delete each entry. Each card has an image of the recipe at the bottom which was uploaded when the recipe is entered.

In the scripts section, we have the beforeMount hook to get all the password entries during page load with the getRecipes function we wrote in our mixin. When the Edit button is clicked, the selectedRecipe variable is set, and we pass it to the RecipeForm for editing.

To delete a recipe, we call deleteRecipe in our mixin to make the request to the back end.

The CSS in the wrap class is for rendering line break characters as line breaks.

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="/">Recipes App</b-navbar-brand>

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

      <b-collapse id="nav-collapse" is-nav>
        <b-navbar-nav>
          <b-nav-item to="/" :active="path  == '/'">Home</b-nav-item>
        </b-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;
  margin: 0 auto;
  max-width: 700px;
}

button {
  margin-right: 10px !important;
}

.button-toolbar {
  margin-bottom: 10px;
}

.tooltip {
  display: block !important;
  z-index: 10000;

.tooltip-inner {
    background: black;
    color: white;
    border-radius: 16px;
    padding: 5px 10px 4px;
  }

.tooltip-arrow {
    width: 0;
    height: 0;
    border-style: solid;
    position: absolute;
    margin: 5px;
    border-color: black;
  }

&[x-placement^="top"] {
    margin-bottom: 5px;

.tooltip-arrow {
      border-width: 5px 5px 0 5px;
      border-left-color: transparent !important;
      border-right-color: transparent !important;
      border-bottom-color: transparent !important;
      bottom: -5px;
      left: calc(50% - 5px);
      margin-top: 0;
      margin-bottom: 0;
    }
  }

&[x-placement^="bottom"] {
    margin-top: 5px;

.tooltip-arrow {
      border-width: 0 5px 5px 5px;
      border-left-color: transparent !important;
      border-right-color: transparent !important;
      border-top-color: transparent !important;
      top: -5px;
      left: calc(50% - 5px);
      margin-top: 0;
      margin-bottom: 0;
    }
  }

&[x-placement^="right"] {
    margin-left: 5px;

.tooltip-arrow {
      border-width: 5px 5px 5px 0;
      border-left-color: transparent !important;
      border-top-color: transparent !important;
      border-bottom-color: transparent !important;
      left: -5px;
      top: calc(50% - 5px);
      margin-left: 0;
      margin-right: 0;
    }
  }

&[x-placement^="left"] {
    margin-right: 5px;

.tooltip-arrow {
      border-width: 5px 0 5px 5px;
      border-top-color: transparent !important;
      border-right-color: transparent !important;
      border-bottom-color: transparent !important;
      right: -5px;
      top: calc(50% - 5px);
      margin-left: 0;
      margin-right: 0;
    }
  }

&[aria-hidden="true"] {
    visibility: hidden;
    opacity: 0;
    transition: opacity 0.15s, visibility 0.15s;
  }

&[aria-hidden="false"] {
    visibility: visible;
    opacity: 1;
    transition: opacity 0.15s;
  }
}
</style>

to add a Bootstrap navigation bar to the top of our pages, and a router-view to display the routes we define. Also, we have the V-Tooltip styles in the style section. This style section isn’t scoped so the styles will apply globally. In the .page selector, we add some padding to our pages and set max-width to 700px so that the cards won’t be too wide. We also added some margins to our buttons.

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

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import BootstrapVue from "bootstrap-vue";
import VTooltip from "v-tooltip";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required } from "vee-validate/dist/rules";
extend("required", required);
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
Vue.use(BootstrapVue);
Vue.use(VTooltip);

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, and the V-Tooltip directive we used in the components.

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: {
    recipes: []
  },
  mutations: {
    setRecipes(state, payload) {
      state.recipes = payload;
    }
  },
  actions: {}
});

to add our recipes state to the store so we can observe it in the computed block of RecipeFormand HomePage components. We have the setRecipes function to update the passwords state and we use it in the components by call this.$store.commit(“setRecipes”, response.data); like we did in RecipeForm .

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>Recipe App</title>
  </head>
  <body>
    <noscript>
      <strong
        >We're sorry but vue-tooltip-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.

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:

{
  "`recipes`": [
]
}

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