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.

Categories
Vue

Add a Calendar into a Vue App with Vue-FullCalendar

A calendar is something that is hard to create from scratch.

Therefore, there’re many calendar components created for Vue apps.

In this article, we’ll look at how to add a calendar with Vue-FullCalendar.

Installation

We can install the Vue-FullCalendar and its plugins with:

npm install --save @fullcalendar/vue @fullcalendar/daygrid @fullcalendar/interaction

@fullcalendar/vue has the Vue-FullCalendar plugin.

@fullcalendar/daygrid lets us show the day grid in the calendar.

And @fullcalendar/interaction lets us interact with the calendar.

Then we can add a calendar into our Vue component by writing:

<template>
  <FullCalendar :options="calendarOptions" />
</template>

<script>
import FullCalendar from "@fullcalendar/vue";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";

export default {
  components: {
    FullCalendar,
  },
  data() {
    return {
      calendarOptions: {
        plugins: [dayGridPlugin, interactionPlugin],
        initialView: "dayGridMonth",
      },
    };
  },
};
</script>

The calendarOptions.plugins property lets us add plugins to add the addons we installed.

And the initialView is set to 'dayGridMonth' to let us show a monthly calendar with today’s date as the default date.

To add events to the calendar, we can add the calendarOptions.events property:

<template>
  <FullCalendar :options="calendarOptions" />
</template>

<script>
import FullCalendar from "@fullcalendar/vue";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";

export default {
  components: {
    FullCalendar,
  },
  data() {
    return {
      calendarOptions: {
        plugins: [dayGridPlugin, interactionPlugin],
        initialView: "dayGridMonth",
        events: [
          { title: "event 1", date: "2020-12-01" },
          { title: "event 2", date: "2020-12-02" },
        ],
      },
    };
  },
};
</script>

title has the event title and date has the event date.

And to listen to events like clicking on dates, we can add more properties.

To listen to date clicks, we add the dateClick property:

<template>
  <FullCalendar :options="calendarOptions" />
</template>

<script>
import FullCalendar from "@fullcalendar/vue";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";

export default {
  components: {
    FullCalendar,
  },
  data() {
    return {
      calendarOptions: {
        plugins: [dayGridPlugin, interactionPlugin],
        initialView: "dayGridMonth",
        events: [
          { title: "event 1", date: "2020-12-01" },
          { title: "event 2", date: "2020-12-02" },
        ],
        dateClick: this.onDateClick,
      },
    };
  },
  methods: {
    onDateClick(arg) {
      console.log(arg.dateStr);
    },
  },
};
</script>

We set it to the onDateClick event handler to run it when we click on a date.

And we can get the date string of the date that’s clicked with the dateStr property.

We can add other options like show or hide weekends.

To hide weekends, we can set the calendarOptions.weekends property to false :

<template>
  <FullCalendar :options="calendarOptions" />
</template>

<script>
import FullCalendar from "@fullcalendar/vue";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";

export default {
  components: {
    FullCalendar,
  },
  data() {
    return {
      calendarOptions: {
        plugins: [dayGridPlugin, interactionPlugin],
        initialView: "dayGridMonth",
        events: [
          { title: "event 1", date: "2020-12-01" },
          { title: "event 2", date: "2020-12-02" },
        ],
        weekends: false,
      },
    };
  },
};
</script>

It also comes with some useful utilities like a formatDate function to format dates our way:

<template>
  <FullCalendar :options="calendarOptions" />
</template>

<script>
import FullCalendar from "@fullcalendar/vue";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import { formatDate } from "@fullcalendar/vue";

const str = formatDate(new Date(), {
  month: "long",
  year: "numeric",
  day: "numeric",
});

console.log(str);

export default {
  components: {
    FullCalendar,
  },
  data() {
    return {
      calendarOptions: {
        plugins: [dayGridPlugin, interactionPlugin],
        initialView: "dayGridMonth",
        events: [
          { title: "event 1", date: "2020-12-01" },
          { title: "event 2", date: "2020-12-02" },
        ],
        weekends: false,
      },
    };
  },
};
</script>

Conclusion

We can add the Vue-FullCalendar library to add an event calendar easily into our Vue app.

Categories
Vue

Add a Calendar into a Vue App with Vue-Simple-Calendar

A calendar is something that is hard to create from scratch.

Therefore, there’re many calendar components created for Vue apps.

In this article, we’ll look at how to add a calendar with Vue-Simple-Calendar.

Installation

We can install the plugin by running:

npm i --save vue-simple-calendar

Usage

Once we installed it, we can use the calendar by writing:

<template>
  <div id="app">
    <calendar-view
      :show-date="showDate"
      class="theme-default holiday-us-traditional holiday-us-official"
    >
      <calendar-view-header
        slot="header"
        slot-scope="t"
        :header-props="t.headerProps"
        @input="setShowDate"
      />
    </calendar-view>
  </div>
</template>
<script>
import { CalendarView, CalendarViewHeader } from "vue-simple-calendar";
import "vue-simple-calendar/static/css/default.css";
import "vue-simple-calendar/static/css/holidays-us.css";

export default {
  name: "app",
  data() {
    return { showDate: new Date() };
  },
  components: {
    CalendarView,
    CalendarViewHeader,
  },
  methods: {
    setShowDate(d) {
      this.showDate = d;
    },
  },
};
</script>

We set the show-date prop to set the default date.

Then we populate the header slot to show the render the header with the calendar-view-header component.

This lets us navigate to different months.

We can set the show the displayPeriodUom prop to show the different kinds of periods in the calendar.

We can set it to year to show years and week to show weeks.

The default is month .

We can also set the starting day of the week with the startingDayOfWeek prop.

dateClasses is an object with different date classes for different dates.

We can add more options by adding more props:

<template>
  <div id="app">
    <div class="calendar-controls">
      <div v-if="message" class="notification is-success">{{ message }}</div>

      <div class="box">
        <div class="field">
          <label class="label">Period UOM</label>
          <div class="control">
            <div class="select">
              <select v-model="displayPeriodUom">
                <option>month</option>
                <option>week</option>
                <option>year</option>
              </select>
            </div>
          </div>
        </div>
        <div class="field">
          <label class="label">Period Count</label>
          <div class="control">
            <div class="select">
              <select v-model="displayPeriodCount">
                <option :value="1">1</option>
                <option :value="2">2</option>
                <option :value="3">3</option>
              </select>
            </div>
          </div>
        </div>
        <div class="field">
          <label class="checkbox">
            <input v-model="useTodayIcons" type="checkbox" />
            Use icon for today's period
          </label>
        </div>
        <div class="field">
          <label class="checkbox">
            <input v-model="displayWeekNumbers" type="checkbox" />
            Show week number
          </label>
        </div>
        <div class="field">
          <label class="checkbox">
            <input v-model="showTimes" type="checkbox" />
            Show times
          </label>
        </div>
        <div class="field">
          <label class="label">Themes</label>
          <label class="checkbox">
            <input v-model="useDefaultTheme" type="checkbox" />
            Default
          </label>
        </div>
        <div class="field">
          <label class="checkbox">
            <input v-model="useHolidayTheme" type="checkbox" />
            Holidays
          </label>
        </div>
      </div>

      <div class="box">
        <div class="field">
          <label class="label">Title</label>
          <div class="control">
            <input v-model="newItemTitle" class="input" type="text" />
          </div>
        </div>
        <div class="field">
          <label class="label">Start date</label>
          <div class="control">
            <input v-model="newItemStartDate" class="input" type="date" />
          </div>
        </div>
        <div class="field">
          <label class="label">End date</label>
          <div class="control">
            <input v-model="newItemEndDate" class="input" type="date" />
          </div>
        </div>
        <button class="button is-info" @click="clickTestAddItem">
          Add Item
        </button>
      </div>
    </div>
    <div class="calendar-parent">
      <calendar-view
        :items="items"
        :show-date="showDate"
        :time-format-options="{ hour: 'numeric', minute: '2-digit' }"
        :enable-drag-drop="true"
        :disable-past="disablePast"
        :disable-future="disableFuture"
        :show-times="showTimes"
        :date-classes="myDateClasses"
        :display-period-uom="displayPeriodUom"
        :display-period-count="displayPeriodCount"
        :starting-day-of-week="startingDayOfWeek"
        :period-changed-callback="periodChanged"
        :current-period-label="useTodayIcons ? 'icons' : ''"
        :displayWeekNumbers="displayWeekNumbers"
        :enable-date-selection="true"
        :selection-start="selectionStart"
        :selection-end="selectionEnd"
        @date-selection-start="setSelection"
        @date-selection="setSelection"
        @date-selection-finish="finishSelection"
        @click-date="onClickDay"
        @click-item="onClickItem"
      >
        <calendar-view-header
          slot="header"
          slot-scope="{ headerProps }"
          :header-props="headerProps"
          @input="setShowDate"
        />
      </calendar-view>
    </div>
  </div>
</template>
<script>
import { CalendarView, CalendarViewHeader } from "vue-simple-calendar";
import "vue-simple-calendar/static/css/default.css";
import "vue-simple-calendar/static/css/holidays-us.css";

export default {
  name: "app",
  components: {
    CalendarView,
    CalendarViewHeader,
  },
  data() {
    return {
      showDate: this.thisMonth(1),
      message: "",
      startingDayOfWeek: 0,
      disablePast: false,
      disableFuture: false,
      displayPeriodUom: "month",
      displayPeriodCount: 1,
      displayWeekNumbers: false,
      showTimes: true,
      selectionStart: null,
      selectionEnd: null,
      newItemTitle: "",
      newItemStartDate: "",
      newItemEndDate: "",
      useDefaultTheme: true,
      useHolidayTheme: true,
      useTodayIcons: false,
      items: [
        {
          id: "e0",
          startDate: "2020-01-05",
        },
        {
          id: "e1",
          startDate: new Date(),
        },
        {
          id: "e2",
          startDate: new Date(2020, 11, 1),
          endDate: new Date(2020, 11, 10),
          title: "Multi-day item with a long title and times",
        },
      ],
    };
  },
  computed: {
    userLocale() {
      return this.getDefaultBrowserLocale;
    },
    myDateClasses() {
      const o = {
        ides: new Date().getDate() === 1,
      };
      return o;
    },
  },
  methods: {
    periodChanged() {},
    thisMonth(d, h, m) {
      const t = new Date();
      return new Date(t.getFullYear(), t.getMonth(), d, h || 0, m || 0);
    },
    onClickDay(d) {
      this.selectionStart = null;
      this.selectionEnd = null;
      this.message = `You clicked: ${d.toLocaleDateString()}`;
    },
    onClickItem(e) {
      this.message = `You clicked: ${e.title}`;
    },
    setShowDate(d) {
      this.message = `Changing calendar view to ${d.toLocaleDateString()}`;
      this.showDate = d;
    },
    setSelection(dateRange) {
      this.selectionEnd = dateRange[1];
      this.selectionStart = dateRange[0];
    },
    finishSelection(dateRange) {
      this.setSelection(dateRange);
      this.message = `You selected: ${this.selectionStart.toLocaleDateString()} -${this.selectionEnd.toLocaleDateString()}`;
    },
    clickTestAddItem() {
      this.items.push({
        startDate: this.newItemStartDate,
        endDate: this.newItemEndDate,
        title: this.newItemTitle,
        id: "e" + Math.random().toString(36).substr(2, 10),
      });
      this.message = "You added a calendar item!";
    },
  },
};
</script>

We add items to the items array with the clickTestAddItem method to add calendar events.

We also have select elements to change the period displayed and the number of periods displayed.

Conclusion

We can add the Vue-Simple-Calendar component to add an event calendar with many options in our Vue app.

Categories
Vue

Add a Calendar into a Vue App with Vue2-Simple-Calendar

A calendar is something that is hard to create from scratch.

Therefore, there’re many calendar components created for Vue apps.

In this article, we’ll look at how to add a calendar with Vue2-Simple-Calendar.

Vue2-Simple-Calendar

We can install Vue2-Simple-Calendar by running:

npm install vue2-simple-calendar

with NPM or:

yarn add vue2-simple-calendar

Then we can use it by writing:

main.js

import Vue from "vue";
import App from "./App.vue";
import vueCalendar from "vue2-simple-calendar";
import "./assets/calendar.css";

Vue.use(vueCalendar, {});
Vue.config.productionTip = false;

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

App.vue

<template>
  <div id="app">
    <vue-calendar
      :show-limit="3"
      :events="events"
      :disable="disabledDays"
      :highlight="highlightDays"
      @show-all="showAll"
      @day-clicked="dayClicked"
      @event-clicked="eventClicked"
      @month-changed="monthChanged"
    ></vue-calendar>
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      events: [
        {
          title: "event",
          start: new Date(),
          end: new Date(),
        },
      ],
      disabledDays: {
        to: new Date(2020, 9, 5),
        from: new Date(2020, 11, 26),
      },
      highlightDays: {
        days: [6, 0],
      },
    };
  },
  methods: {
    showAll(events) {
      // Do something...
    },
    dayClicked(day) {
      // Do something...
    },
    eventClicked(event) {
      // Do something...
    },
    monthChanged(start, end) {
      // Do something...
    },
  },
  created() {
    this.$calendar.eventBus.$on("show-all", (events) => this.showAll(events));
    this.$calendar.eventBus.$on("day-clicked", (day) => this.dayClicked(day));
    this.$calendar.eventBus.$on("event-clicked", (event) => console.log(event));
    this.$calendar.eventBus.$on("month-changed", (start, end) =>
      console.log(start, end)
    );
  },
};
</script>

/assets/calendar.css

.vue-calendar {
  display: grid;
  grid-template-rows: 10% 90%;
  background: #fff;
  margin: 0 auto;
}
.calendar-header {
  align-items: center;
}
.header-left,
.header-right {
  flex: 1;
}
.header-center {
  flex: 3;
  text-align: center;
}
.title {
  margin: 0 5px;
}
.next-month,
.prev-month {
  cursor: pointer;
}
.calendar-body {
  display: grid;
  grid-template-rows: 5% 95%;
}
.days-header {
  display: grid;
  grid-auto-columns: 14.25%;
  grid-template-areas: "a a a a a a a";
  border-top: 1px solid #e0e0e0;
  border-left: 1px solid #e0e0e0;
  border-bottom: 1px solid #e0e0e0;
}
.days-body {
  display: grid;
  grid-template-rows: auto;
}
.day-number {
  text-align: right;
  margin-right: 10px;
}
.day-label {
  text-align: center;
  border-right: 1px solid #e0e0e0;
}
.week-row {
  display: grid;
  grid-template-areas: "a a a a a a a";
  grid-row-gap: 5px;
  grid-auto-columns: 14.25%;
  border-left: 1px solid #e0e0e0;
}
.week-day {
  padding: 4px;
  border-right: 1px solid #e0e0e0;
  border-bottom: 1px solid #e0e0e0;
}
.week-day.disabled {
  background-color: #f5f5f5;
}
.week-day.not-current > .day-number {
  color: #c3c3c3;
}
.week-day.today > .day-number {
  font-weight: 700;
  color: red;
}
.events {
  font-size: 12px;
  cursor: pointer;
  padding: 0 0 0 4px;
}
.events .event {
  height: 18px;
  line-height: 18px;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  margin: 0 4px 2px 0;
  color: rgba(0, 0, 0, 0.87);
  background-color: #d4dcec;
}
.events .more-link {
  color: rgba(0, 0, 0, 0.38);
}

We add the grid layout with the days-header and the week-row classes.

In main.js , we add the VueCalendar plugin so we can use it our components.

We need to import the calendar.css to apply the styles from the CSS styles.

In App.vue , we add the vue-calendar component to add the calendar.

show-limit has the max number of events shown in a day.

events has an array of events.

disable has days that we want to disable.

show-all event is emitted when the show more link is clicked.

day-clicked is emitted when a day is clicked.

event-clicked is emitted when an event is clicked.

month-changed is emitted when the month changed.

Conclusion

The Vue2-Simple-Calendar lets us add an event calendar easily into our Vue app.