Categories
Nuxt.js Vue Answers

How to access route parameters in a page with Nuxt?

(The source code is at https://github.com/jauyeunggithub/bravado-quest)

Sometimes, we want to load large amounts of data in the background in a Nuxt app with web workers

In this article, we’ll look at how to load large amounts of data in the background in a Nuxt app with web workers

How to access route parameters in a page with Nuxt?

To run background tasks in a Nuxt app with web workers, we can add the Webpack’s worker-loader package into our Nuxt project.

Also, we’ve to make sure that web workers are only loaded on client side.

We’ll make a project that loads a large JSON file and searches it.

To start, we create the Nuxt project with:

npx create-nuxt-app quest

Then we run:

cd quest
npm run dev

to change to the project folder and run the project.

Next, we add Vuetify into the project with:

npm install @nuxtjs/vuetify -D

And we add the worker-loader package with:

npm i worker-loader@^1.1.1

To store data with IndexedDB, we install the Dexie package.

To install it, we run:

npm i dexie

Next, in nuxt.config.js, we change it to:

export default {
  // Global page headers: https://go.nuxtjs.dev/config-head
  head: {
    title: 'quest',
    htmlAttrs: {
      lang: 'en',
    },
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { hid: 'description', name: 'description', content: '' },
      { name: 'format-detection', content: 'telephone=no' },
    ],
    link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
  },

  // Global CSS: https://go.nuxtjs.dev/config-css
  css: [],

  // Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
  plugins: [
    { src: '~/plugins/inject-ww', ssr: false }
  ],

  // Auto import components: https://go.nuxtjs.dev/config-components
  components: true,

  // Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules
  buildModules: ['@nuxtjs/vuetify'],

  // Modules: https://go.nuxtjs.dev/config-modules
  modules: [],

  // Build Configuration: https://go.nuxtjs.dev/config-build
  mode: 'spa',
  build: {
    extend(config, { isClient }) {
      config.output.globalObject = 'this'

      if (isClient) {
        config.module.rules.push({
          test: /\.worker\.js$/,
          loader: 'worker-loader',
          exclude: /(node_modules)/,
        })
      }
    },
  },
  ssr: false,
}

We added the build.extend method to invoke the worker-loader when any file that ends with .worker.js is found.

isClient makes sure workers only load on client side apps.

config.output.globalObject = 'this' is needed so the hot reloading will work with the web workers loaded.

We also set ssr to false to disable server side rendering.

Also, we add { src: '~/plugins/inject-ww', ssr: false } to add the /plugins/inject-ww to load the worker loading plugin.

We add '@nuxtjs/vuetify' to load Vuetify in Nuxt.

In the plugins folder, we add:

import Worker from '~/assets/js/data.worker.js'

export default (_, inject) => {
  inject('worker', {
    createWorker () {
      return new Worker()
    }
  })
}

to load the worker from the /assets/js/data.worker.js.

To create worker, we create the data.worker.js file in the /assets/js/ and add:

import db from '@/db'

onmessage = async () => {
  if ((await db.avatars.toCollection().toArray()).length > 0) {
    postMessage({
      loaded: true,
    })
    return
  }
  const usersObj = await import(`@/data/users.json`)
  const users = Object.values(usersObj)
  const usersWithId = users.map((u, id) => ({ ...u, id }))
  await db.avatars.bulkPut(usersWithId)
  postMessage({
    loaded: true,
  })
}

@/data/users.json is a big JSON file saved in the project.

We import the users.json file from the data folder and store it in Indexed DB with Dexie.

We can load very large JSON files without crashing the browser since web workers run in a separate thread from the main browser thread.

db comes from db.js, which has:

import Dexie from 'dexie'

const db = new Dexie('db')
db.version(1).stores({
  avatars: '++id, address, avatar, city, email, name, title',
})

export default db

We create the db database with the avatars collection with the given keys.

We call bulkPut to write the retrieved data to the collection.

Now we cache the data in Indexed DB so they don’t have to load every time we load the app.

We make sure we try to load the cached data first with:

if ((await db.avatars.toCollection().toArray()).length > 0) {
  postMessage({
    loaded: true,
  })
  return
}

And we call postMessage to communicate that loading is done.

The message will be picked by by the message event handler when we invoke this worker.

In the pages, folder, we add _keyword.vue, to add an input and the search results component:

<template>
  <v-app>
    <v-card width="600px" elevation="0" class="mx-auto">
      <v-card-text :elevation="0">
        <v-text-field
          v-model="keyword"
          hide-details
          :prepend-inner-icon="mdiMagnify"
          full-width
          solo
          dense
          background-color="#FAFAFA"
        />

        <SearchResults :keyword="keyword" />
      </v-card-text>
    </v-card>
  </v-app>
</template>

<script >
import { mdiMagnify } from '@mdi/js'
import SearchResults from '@/components/SearchResults'

export default {
  components: {
    SearchResults,
  },
  data() {4
    return {
      keyword: '',
      mdiMagnify,
    }
  },
  beforeMount() {
    this.keyword = this.$route.params.keyword
  },
}
</script>

<style>
html {
  overflow: hidden;
}
</style>

The name of the page starts with an underscore means that we can get the URL parameter with the file name as the property name with the URL parameter value, so we can access the URL parameter value with the this.$route.params.keyword property.

Then in components/SearchResults.vue that we created, we add:

<template>
  <div>
    <div v-if="loading" class="my-3">
      <v-card>
        <v-card-text> Loading... </v-card-text>
      </v-card>
    </div>
    <div v-else id="scrollable" class="my-3">
      <v-card
        v-for="r of filteredSearchResultsWithHighlight"
        :key="r.id"
        class="my-3"
        :style="{
          border: highlightStatus[r.id] ? '2px solid lightblue' : undefined,
        }"
        @click="
          highlightStatus = {}
          $set(highlightStatus, r.id, !highlightStatus[r.id])
        "
      >
        <v-card-text class="d-flex pa-0 ma-0">
          <img :src="r.avatar" class="avatar" />
          <div
            class="flex-grow-1 pa-3 d-flex flex-column justify-space-between right-pane"
          >
            <div class="d-flex justify-space-between">
              <div>
                <h2 v-html="r.name"></h2>
                <p class="py-0 my-0">
                  <b v-html="r.title"></b>
                </p>
                <p class="py-0 my-0">
                  <span v-html="r.address"></span>,
                  <span v-html="r.city"></span>
                </p>
              </div>
              <div v-html="r.email"></div>
            </div>

            <div>
              <v-btn text color="#00897B">Mark as Suitable</v-btn>
            </div>
          </div>
        </v-card-text>
      </v-card>
    </div>
  </div>
</template>

<script>
import db from '@/db'

export default {
  props: {
    keyword: {
      type: String,
      default: '',
    },
  },
  data() {
    return {
      highlightStatus: {},
      filteredSearchResults: [],
      loading: false,
    }
  },
  computed: {
    filteredSearchResultsWithHighlight() {
      const { keyword } = this
      if (!Array.isArray(this.filteredSearchResults)) {
        return []
      }
      const highlighted = this.filteredSearchResults?.map((u) => {
        const highlightedEntries = Object.entries(u)?.map(([key, val]) => {
          if (key === 'avatar' || key === 'id') {
            return [key, val]
          }
          const highlightedVal = val?.replace(
            new RegExp(keyword, 'gi'),
            (match) => `<mark>${match}</mark>`
          )
          return [key, highlightedVal]
        })
        return Object.fromEntries(highlightedEntries)
      })
      return highlighted
    },
  },
  watch: {
    keyword: {
      immediate: true,
      handler() {
        this.search()
      },
    },
  },
  beforeMount() {
    this.loadData()
  },
  methods: {
    async search() {
      await this.$nextTick()
      const { keyword } = this
      if (keyword) {
        const filteredSearchResults = await db.avatars
          .filter((u) => {
            const { address, city, email, name, title } = u
            return (
              address?.toLowerCase()?.includes(keyword?.toLowerCase()) ||
              city?.toLowerCase()?.includes(keyword?.toLowerCase()) ||
              email?.toLowerCase()?.includes(keyword?.toLowerCase()) ||
              name?.toLowerCase()?.includes(keyword?.toLowerCase()) ||
              title?.toLowerCase()?.includes(keyword?.toLowerCase())
            )
          })
          .limit(10)
          .toArray()
        this.filteredSearchResults = filteredSearchResults
      } else {
        this.filteredSearchResults = await db.avatars
          ?.toCollection()
          ?.limit(10)
          .toArray()
      }
    },
    loadData() {
      this.loading = true
      const worker = this.$worker.createWorker()
      worker.onmessage = () => {
        this.search()
        this.loading = false
      }
      worker.postMessage('load')
    },
  },
}
</script>

<style>
.avatar {
  background: #bdbdbd;
  width: 150px;
}

.right-pane {
  background: #fafafa;
}

mark {
  background: yellow;
}

#scrollable {
  height: calc(100vh - 100px);
  overflow-y: auto;
}
</style>

In the loadData method. we call this.$worker.createWorker to create the worker that we injected.

this.$worker is available since the inject-ww.js script is run when the app is loaded.

We set worker.onmessage to a function so that we recent messages from the worker when postMessage in the onmessage function of the worker is called.

Once we received a message from the web workwer, we call the this.search method to do the filtering according to the keyword value.

And we call worker.postMessage with anything so that the worker’s onmessage function in the worker will start running.

Now when we type in something into the text box, we see results displayed in the cards.

We should be able to load large amounts of data in the web worker without hanging the browser since it’s run in the background.

Conclusion

To run background tasks in a Nuxt app with web workers, we can add the Webpack’s worker-loader package into our Nuxt project.

Also, we’ve to make sure that web workers are only loaded on client side.

We’ll make a project that loads a large JSON file and searches it.

Categories
Vue

How to Create Web Components with Vue.js

Component-based architecture is the main architecture for front end development today. The World Wide Web Consortium (W3C) has caught up to the present by creating the web components API. It lets developers build custom elements that can be embedded in web pages. The elements can be reused and nested anywhere, allowing for code reuse in any pages or apps.

The custom elements are nested in the shadow DOM, which is rendered separately from the main DOM of a document. This means that they are completely isolated from other parts of the page or app, eliminating the chance of conflict with other parts,

There are also template and slot elements that aren’t rendered on the page, allowing you to reused the things inside in any place.

To create web components without using any framework, you have to register your element by calling CustomElementRegistry.define() and pass in the name of the element you want to define. Then you have to attach the shadow DOM of your custom element by calling Element.attachShawdow() so that your element will be displayed on your page.

This doesn’t include writing the code that you want for your custom elements, which will involve manipulating the shadow DOM of your element. It is going to be frustrating and error-prone if you want to build a complex element.

Vue.js abstracts away the tough parts by letting you build your code into a web component. You write code by importing and including the components in your Vue components instead of globally, and then you can run commands to build your code into one or more web components and test it.

We build the code into a web component with Vue CLI by running:

npm run build -- --target wc --inline-vue --name custom-element-name

The --inline-vue flag includes a copy of view in the built code, --target wc builds the code into a web component, and --name is the name of your element.

In this article, we will build a weather widget web component that displays the weather from the OpenWeatherMap API. We will add a search to let users look up the current weather and forecast from the API.

We will use Vue.js to build the web component. To begin building it, we start with creating the project with Vue CLI. Run npx @vue/cli create weather-widget to create the project. In the wizard, select Babel, SCSS and Vuex.

The OpenWeatherMap API is available at https://openweathermap.org/api. You can register for an API key here. Once you got an API key, create an .env file in the root folder and add VUE_APP_APIKEY as the key and the API key as the value.

Next, we install some packages that we need for building the web component. We need Axios for making HTTP requests, BootstrapVue for styling, and Vee-Validate for form validation. To install them, we run npm i axios bootstrap-vue vee-validate to install them.

With all the packages installed we can start writing our code. Create CurrentWeather.vue in the components folder and add:

<template>
  <div>
    <br />
    <b-list-group v-if="weather.main">
      <b-list-group-item>Current Temparature: {{weather.main.temp - 273.15}} C</b-list-group-item>
      <b-list-group-item>High: {{weather.main.temp_max - 273.15}} C</b-list-group-item>
      <b-list-group-item>Low: {{weather.main.temp_min - 273.15}} C</b-list-group-item>
      <b-list-group-item>Pressure: {{weather.main.pressure }}mb</b-list-group-item>
      <b-list-group-item>Humidity: {{weather.main.humidity }}%</b-list-group-item>
    </b-list-group>
  </div>
</template>

<script>
import { requestsMixin } from "@/mixins/requestsMixin";
import store from "../store";
import { BListGroup, BListGroupItem } from "bootstrap-vue";
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'

export default {
  store,
  name: "CurrentWeather",
  mounted() {},
  mixins: [requestsMixin],
  components: {
    BListGroup,
    BListGroupItem
  },
  computed: {
    keyword() {
      return this.$store.state.keyword;
    }
  },
  data() {
    return {
      weather: {}
    };
  },
  watch: {
    async keyword(val) {
      const response = await this.searchWeather(val);
      this.weather = response.data;
    }
  }
};
</script>

<style scoped>
p {
  font-size: 20px;
}
</style>

This component displays the current weather from the OpenWeatherMap API as the keyword from the Vuex store is updated. We will create the Vuex store later. The this.searchWeather function is from the requestsMixin , which is a Vue mixin that we will create. The computed block gets the keyword from the store via this.$store.state.keyword and return the latest value.

Note that we’re importing all the BootstrapVue components individually here. This is because we aren’t building an app. main.js in our project will not be run, so we cannot register components globally by calling Vue.use . Also, we have to import the store here, so that we have access to the Vuex store in the component.

Next, create Forecast.vue in the same folder and add:

<template>
  <div>
    <br />
    <b-list-group v-for="(l, i) of forecast.list" :key="i">
      <b-list-group-item>
        <b>Date: {{l.dt_txt}}</b>
      </b-list-group-item>
      <b-list-group-item>Temperature: {{l.main.temp - 273.15}} C</b-list-group-item>
      <b-list-group-item>High: {{l.main.temp_max - 273.15}} C</b-list-group-item>
      <b-list-group-item>Low: {{l.main.temp_min }}mb</b-list-group-item>
      <b-list-group-item>Pressure: {{l.main.pressure }}mb</b-list-group-item>
    </b-list-group>
  </div>
</template>

<script>
import { requestsMixin } from "@/mixins/requestsMixin";
import store from "../store";
import { BListGroup, BListGroupItem } from "bootstrap-vue";
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'

export default {
  store,
  name: "Forecast",
  mixins: [requestsMixin],
  components: {
    BListGroup,
    BListGroupItem
  },
  computed: {
    keyword() {
      return this.$store.state.keyword;
    }
  },
  data() {
    return {
      forecast: []
    };
  },
  watch: {
    async keyword(val) {
      const response = await this.searchForecast(val);
      this.forecast = response.data;
    }
  }
};
</script>

<style scoped>
p {
  font-size: 20px;
}
</style>

It’s very similar to CurrentWeather.vue . The only difference is that we are getting the current weather instead of the weather forecast.

Next, we create a mixins folder in the src folder and add:

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

export const requestsMixin = {
  methods: {
    searchWeather(loc) {
      return axios.get(
        `${APIURL}/data/2.5/weather?q=${loc}&appid=${process.env.VUE_APP_APIKEY}`
      );
    },

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

These functions are for getting the current weather and the forecast respectively from the OpenWeatherMap API. process.env.VUE_APP_APIKEY is obtained from our .env file that we created earlier.

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

<template>
  <div>
    <b-navbar toggleable="lg" type="dark" variant="info">
      <b-navbar-brand href="#">Weather App</b-navbar-brand>
    </b-navbar>
    <div class="page">
      <ValidationObserver ref="observer" v-slot="{ invalid }">
        <b-form @submit.prevent="onSubmit" novalidate>
          <b-form-group label="Keyword" label-for="keyword">
            <ValidationProvider name="keyword" rules="required" v-slot="{ errors }">
              <b-form-input
                :state="errors.length == 0"
                v-model="form.keyword"
                type="text"
                required
                placeholder="Keyword"
                name="keyword"
              ></b-form-input>
              <b-form-invalid-feedback :state="errors.length == 0">Keyword is required</b-form-invalid-feedback>
            </ValidationProvider>
          </b-form-group>

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

      <br />

      <b-tabs>
        <b-tab title="Current Weather">
          <CurrentWeather />
        </b-tab>
        <b-tab title="Forecast">
          <Forecast />
        </b-tab>
      </b-tabs>
    </div>
  </div>
</template>

<script>
import CurrentWeather from "@/components/CurrentWeather.vue";
import Forecast from "@/components/Forecast.vue";
import store from "./store";
import {
  BTabs,
  BTab,
  BButton,
  BForm,
  BFormGroup,
  BFormInvalidFeedback,
  BNavbar,
  BNavbarBrand,
  BFormInput
} from "bootstrap-vue";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required } from "vee-validate/dist/rules";
extend("required", required);

export default {
  store,
  name: "App",
  components: {
    CurrentWeather,
    Forecast,
    ValidationProvider,
    ValidationObserver,
    BTabs,
    BTab,
    BButton,
    BForm,
    BFormGroup,
    BFormInvalidFeedback,
    BNavbar,
    BNavbarBrand,
    BFormInput
  },
  data() {
    return {
      form: {}
    };
  },
  methods: {
    async onSubmit() {
      const isValid = await this.$refs.observer.validate();
      if (!isValid) {
        return;
      }
      localStorage.setItem("keyword", this.form.keyword);
      this.$store.commit("setKeyword", this.form.keyword);
    }
  },
  beforeMount() {
    this.form = { keyword: localStorage.getItem("keyword") || "" };
  },
  mounted() {
    this.$store.commit("setKeyword", this.form.keyword);
  }
};
</script>

<style lang="scss">
@import "./../node_modules/bootstrap/dist/css/bootstrap.css";
@import "./../node_modules/bootstrap-vue/dist/bootstrap-vue.css";
.page {
  padding: 20px;
}
</style>

We add the BootstrapVue b-navbar here to add a top bar to show the extension’s name. Below that, we added the form for searching the weather info. Form validation is done by wrapping the form in the ValidationObserver component and wrapping the input in the ValidationProvider component. We provide the rule for validation in the rules prop of ValidationProvider . The rules will be added in main.js later.

The error messages are displayed in the b-form-invalid-feedback component. We get the errors from the scoped slot in ValidationProvider . It’s where we get the errors object from.

When the user submits the number, the onSubmit function is called. This is where the ValidationObserver becomes useful as it provides us with the this.$refs.observer.validate() function to check for form validity.

If isValid resolves to true , then we set the keyword in local storage, and also in the Vuex store by running this.$store.commit(“setKeyword”, this.form.keyword); .

In the beforeMount hook, we set the keyword so that it will be populated when the extension first loads if a keyword was set in local storage. In the mounted hook, we set the keyword in the Vuex store so that the tabs will get the keyword to trigger the search for the weather data.

Like in the previous components, we import and register all the components and the Vuex store in this component, so that we can use the BootstrapVue components here. We also called Vee-Validate’s extend function so that we can use its required form validation rule for checking the input.

In style section of this file, we import the BootstrapVue styles, so that they can be accessed in this and the child components. We also add the page class so that we can add some padding to the 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: {
    keyword: ""
  },
  mutations: {
    setKeyword(state, payload) {
      state.keyword = payload;
    }
  },
  actions: {}
});

to add the Vuex store that we referenced in the components. We have the keyword state for storing the search keyword in the store, and the setKeyword mutation function so that we can set the keyword in our components.

Finally, in package.json , we add 2 scripts to the scripts section of the file:

"wc-build": "npm run build -- --target wc --inline-vue --name weather-widget",

"wc-test": "cd dist && live-server --port=8080 --entry-file=./demo.html"

The wc-build script builds our code into a web component as we described before, and the wc-test runs a local web server so that we can see what the web component looks like when it’s included in a web page. We use the live-server NPM package for serving the file. The --entry-file option specifies that we server demo.html as the home page, which we get when we run npm run wc-build .

If we run npm run wc-build and npm run wc-test , we get:

As you can see, we get the web component’s shadow DOM rendered in the browser and in the developer console.

We created a web component with less effort than using plain JavaScript, especially for something complex enough to have nesting and interactions.

Categories
Vue

How to Add Font Awesome Icons to Your Vue.js App

Font Awesome is a popular CSS library for adding icons to web apps. It is available as a raw CSS package, and there are also versions for major front end frameworks. For Vue.js, we can use vue-fontawesome to add icons in our apps.

In this article, we will make a grocery list app where users can search for dishes they want to cook with the MealDB API and add the ingredients they want from it. We will add ripple effects to buttons and list items to highlight them when the mouse pointer go over them.

To start building the app, we run the Vue CLI by running npx @vue/cli grocery-app. In the wizard, select ‘Manually select features’, then select Babel, Vuex, Vue Router, and CSS Preprocessor.

Next we install some additional packages. We need Axios for making HTTP requests, BootstrapVue for styling, Vue Font Awesome for adding icons, Vee-Validate for form validation and Vue Ripple Directive for adding the ripple effect. To install them, we run:

npm i axios @fortawesome/fontawesome-svg-core @fortawesome/free-solid-svg-icons @fortawesome/vue-fontawesome axios bootstrap-vue vee-validate vue-ripple-directive

With all the packages installed, we can build the app. We start by add a form for adding groceries. Create a GroceryForm.vue file in the components folder and add:

<template>
  <div>
    <ValidationObserver ref="observer" v-slot="{ invalid }">
      <b-form @submit.prevent="onSubmit" novalidate>
        <b-form-group label="Search for Dish and Add Ingredients From Result">
          <ValidationProvider name="keyword" rules="required" v-slot="{ errors }">
            <b-form-input
              type="text"
              :state="errors.length == 0"
              v-model="form.keyword"
              required
              placeholder="Search for Dish and Add Ingredients From Result"
              name="keyword"
            ></b-form-input>
            <b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
          </ValidationProvider>
        </b-form-group>

        <b-button v-ripple.mouseover.500 type="submit" variant="primary">Find Dishes</b-button>
      </b-form>
    </ValidationObserver>

    <b-card v-for="(m, i) in meals" :title="m.strMeal" :key="i">
      <b-card-text>
        <b-list-group>
          <b-list-group-item
            v-for="(key, index) in Object.keys(m).filter(k => k.includes('strIngredient') && m[k])"
            :key="index"
            v-ripple.mouseover="'rgba(255, 255, 255, 0.35)'"
          >{{m[key]}}</b-list-group-item>
        </b-list-group>
      </b-card-text>
      <b-button
        v-ripple.mouseover.500
        variant="primary"
        @click="addToGroceryList(i)"
      >Add Ingredients to Grocery List</b-button>
    </b-card>

    <h4>Your Chosen Ingredients</h4>
    <b-list-group>
      <b-list-group-item v-for="(ingredient, i) of ingredients" :key="i" v-ripple.mouseover>
        {{ingredient}}
        <font-awesome-icon icon="times" class="float-right" @click="removeIngredient(i)" />
      </b-list-group-item>
    </b-list-group>

   <br />

    <b-button v-ripple.mouseover.500 type="button" variant="primary" @click="saveGroceryList()">Save</b-button>
    <b-button v-ripple.mouseover.500 type="reset" variant="danger" @click="cancel()">Cancel</b-button>
  </div>
</template>

<script>
import { requestsMixin } from "@/mixins/requestsMixin";
import { faTimes } from "@fortawesome/free-solid-svg-icons";

export default {
  name: "GroceryForm",
  mixins: [requestsMixin],
  components: {
    faTimes
  },
  data() {
    return {
      form: {},
      meals: [],
      ingredients: []
    };
  },
  computed: {
    grocery() {
      return this.$store.state.grocery;
    }
  },
  methods: {
    cancel() {
      this.$emit("cancelled");
    },

    async onSubmit() {
      const isValid = await this.$refs.observer.validate();
      if (!isValid) {
        return;
      }
      const { data } = await this.findDishes(this.form.keyword);
      this.meals = data.meals;
    },

    addToGroceryList(index) {
      const meal = this.meals[index];
      const keys = Object.keys(meal).filter(
        k => k.includes("strIngredient") && meal[k]
      );
      const ingredients = keys.map(k => meal[k]);
      this.ingredients = Array.from(
        new Set(this.ingredients.concat(ingredients))
      );
    },

    removeIngredient(index) {
      this.ingredients.splice(index, 1);
    },

    async saveGroceryList() {
      const payload = { ingredients: this.ingredients };
      if (!this.groceryListId) {
        await this.addGrocery(payload);
      } else {
        await this.editGrocery(payload);
      }
      const { data } = await this.getGrocery();
      this.$store.commit("setGrocery", data);
      this.$emit("saved");
    }
  },
  watch: {
    grocery: {
      handler(val) {
        this.ingredients = val.ingredients || [];
      },
      deep: true,
      immediate: true
    }
  }
};
</script>

<style lang="scss" scoped>
.delete {
  cursor: pointer;
}
</style>

This form lets users search for dishes with the given keyword, then return a list of ingredients for the dishes and then the user can add them to a list with the duplicates removed. We use Vee-Validate to validate our inputs. We use the ValidationObserver component to watch for the validity of the form inside the component and ValidationProvider to check for the validation rule of the inputted value of the input inside the component. Inside the ValidationProvider, we have our BootstrapVue input for the text input fields. In the b-form-input components. We also add Vee-Validate validation to make sure that users have filled out the date before submitting. We make the keyword field required in the rules prop so that users will have to enter something before searching.

We have buttons in the list at the bottom of the form, which has the list of ingredients, to delete each of them. This is why we imported the faTimes icon here, which displays as an ‘x’, so that users can click on it and delete it. If that element is clicked, the removeIngredient function is called. Then the user clicks Save at the bottom of the form, then the saveGroceryList function is called which saves the list to our back end.

In this component, we also have a watch block to watch the grocery value, which is obtained from the Vuex store that we have to build. We get the latest list of ingredients as the grocery value is updated.

We have the ripple effect applied to the buttons and the list rows with Vue Ripple. v-ripple.mouseover.500 means that the ripple effect will show for 500 milliseconds when the mouse is over the element with this directive. To apply a different color to the ripple effect than the default we can also specify the color value in the parameter of the directive, like we have in v-ripple.mouseover=”’rgba(255, 255, 255, 0.35)’”. The ripple will have the color specified.

We style the element for deleting the form with cursor:pointer so the mouse icon will show up a hand instead of the arrow.

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

const APIURL = "http://localhost:3000";
const MEAL_DB_URL = "https://www.themealdb.com/api/json/v1/1/search.php?s=";

const axios = require("axios");

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

    addGrocery(data) {
      return axios.post(`${APIURL}/grocery`, data);
    },

    editGrocery(data) {
      return axios.put(`${APIURL}/grocery`, data);
    },

    findDishes(keyword) {
      return axios.get(`${MEAL_DB_URL}${keyword}`);
    }
  }
};

These are the functions we use in our components to make HTTP requests to get and save our grocery data and search the Meal DB API for dishes.

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

<template>
  <div class="page">
    <h1 class="text-center">Grocery List</h1>
    <b-button-toolbar class="button-toolbar">
      <b-button
        v-ripple.mouseover.500
        @click="openAddModal()"
        variant="primary"
      >Add Ingredients to Grocery List</b-button>
    </b-button-toolbar>

    <h4>Your Grocery List</h4>
    <b-list-group>
      <b-list-group-item
        v-for="(ingredient, i) of grocery.ingredients"
        :key="i"
        v-ripple.mouseover="'rgba(255, 255, 255, 0.35)'"
      >
        {{ingredient}}
        <font-awesome-icon icon="times" class="float-right" @click="removeIngredient(i)" />
      </b-list-group-item>
    </b-list-group>

    <b-modal id="add-modal" title="Add Ingredients to Grocery List" hide-footer>
      <GroceryForm
        @saved="closeModal()"
        @cancelled="closeModal()"
        :edit="false"
        :groceryListId="grocery.id"
      />
    </b-modal>
  </div>
</template>

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

export default {
  name: "home",
  components: {
    GroceryForm
  },
  mixins: [requestsMixin],
  computed: {
    grocery() {
      return this.$store.state.grocery;
    }
  },
  data() {
    return {
      ingredients: []
    };
  },
  beforeMount() {
    this.getGroceryList();
  },
  methods: {
    openAddModal() {
      this.$bvModal.show("add-modal");
    },
    closeModal() {
      this.$bvModal.hide("add-modal");
    },
    async getGroceryList() {
      const { data } = await this.getGrocery();
      this.$store.commit("setGrocery", data);
    },
    async removeIngredient(index) {
      this.ingredients.splice(index, 1);
      const payload = { id: this.grocery.id, ingredients: this.ingredients };
      await this.editGrocery(payload);
      const { data } = await this.getGrocery();
      this.$store.commit("setGrocery", data);
    }
  },
  watch: {
    grocery: {
      handler(val) {
        this.ingredients = val.ingredients || [];
      },
      deep: true,
      immediate: true
    }
  }
};
</script>

};

This is the component for the home page. We display the list of ingredients chosen obtained from our back end here. Also, we have a button to open a modal with the GroceryForm that we created earlier to add ingredients to our grocery list. Getting data is done in the getGroceryList function. We put the obtained data into our Vuex store in the last line of the function.

Also, we let users remove ingredients that they saved to the list in this page with the removeIngredient function. We call splice on the this.ingredients array, which we got from the grocery state in the store, then set to the current value in the handler of the watch block of grocery .

Again, we have the ripple effect applied to the buttons and the list rows with Vue Ripple. v-ripple.mouseover.500 to show the ripple effect for 500 milliseconds for the buttons and v-ripple.mouseover=”’rgba(255, 255, 255, 0.35)’”. The ripple will have the color specified in the list items like we did in GroceryForm.

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="/">Grocery List 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;
}

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

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

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

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

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import BootstrapVue from "bootstrap-vue";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required, min_value, max_value } from "vee-validate/dist/rules";
import Ripple from "vue-ripple-directive";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faTimes } from "@fortawesome/free-solid-svg-icons";

library.add(faTimes);
Vue.component("font-awesome-icon", FontAwesomeIcon);
Vue.directive("ripple", Ripple);
extend("required", required);
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
Vue.use(BootstrapVue);

Vue.config.productionTip = false;

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

We added all the libraries we need here, including BootstrapVue JavaScript and CSS, Vee-Validate components along with the validation rules, the Vue-Ripple library, and the Vue Font Awesome packages here. The faTimes is added to our app with the library.add function of Vue Font Awesome so that we can use it in our app.

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

This includes 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: {
    grocery: {}
  },
  mutations: {
    setGrocery(state, payload) {
      state.grocery = payload;
    }
  },
  actions: {}
});

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

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>Grocery List App</title>
  </head>
  <body>
    <noscript>
      <strong
        >We're sorry but vue-ripple-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>

This changes the title of our app.

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

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:

{
  "`grocery`": {}
}

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

Categories
Vue Answers

How Check all checkboxes with Vue.js?

Sometimes, we want to check all checkboxes with Vue.js.

In this article, we’ll look at how to check all checkboxes with Vue.js.

Check All Checkboxes with Vue.js

To check all checkboxes with Vue.js, we can add a change event listener to the checkbox that checks all the other checkboxes.

For instance, we write:

<template>
  <div id="app">
    <table>
      <tr>
        <th>
          <input type="checkbox" v-model="allSelected" @change="selectAll" />
        </th>
        <th align="left">select all</th>
      </tr>
      <tr v-for="user of users" :key="user.id">
        <td>
          <input type="checkbox" v-model="selected" :value="user.id" number />
        </td>
        <td>{{ user.name }}</td>
      </tr>
    </table>
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      users: [
        { id: "1", name: "jane smith", email: "jane.smith@yahoo.com" },
        { id: "2", name: "john doe", email: "jdoe@yahoo.com" },
        { id: "3", name: "dave jones", email: "davejones@hotmail.com" },
        { id: "4", name: "alex smith", email: "alex@leannon.com" },
      ],
      selected: [],
      allSelected: false,
    };
  },
  methods: {
    async selectAll() {
      if (this.allSelected) {
        const selected = this.users.map((u) => u.id);
        this.selected = selected;
      } else {
        this.selected = [];
      }
    },
  },
};
</script>

We have the users array that’s rendered in a table.

In the template, we have a table row for the select all checkbox.

And below that, we use the v-for directive to render the checkboxes from the users data.

We set v-model to selected so we can set it to the values we want.

And we set the value prop of each checkbox to user.id so that we can put the user.id values for the users we want to select into the selected array.

We set the selectAll method as the change handler for the select all checkbox.

In selectAll, set check if this.allSelected is true.

If it’s true, then we set this.selected to an array with the id values from each this.users entry to select all the checkboxes.

Otherwise, we set this.selected to an empty array to deselect all checkboxes.

Conclusion

To check all checkboxes with Vue.js, we can add a change event listener to the checkbox that checks all the other checkboxes.

Categories
Vue

How to Use Environment Variables in Your Vue.js App

Vue CLI makes using them in your app easy by allowing variables in an .env file that have keys starting with VUE_APP to be used in your app.

Environment variables are often used for things that do not belong in your code like API keys and URLs. Vue CLI makes using them in your app easy by allowing variables in an .env file that have keys starting with VUE_APP to be used in your app.

The variables can be accessed by using the process.env object. For example, if you have VUE_APP_API_KEY in your .env file, then you can access it by using process.env.VUE_APP_API_KEY .

You can also have an .env file for other environments by adding an extension to the .env file. For example, you can use .env.staging for the staging environment if you want to include your staging URLs when you want to deploy to your staging server.

In this article, we will build a photo app that allows users to search for images and display images in a masonry grid. The image grid will have infinite scroll to get more images. We will use the vue-masonry library for render the image grid, and vue-infinite-scroll for the infinite scrolling effect.

To implement the masonry effect, we have to set the width of the image proportional to the screen width and set the image height to be proportional to the aspect ratio of the image.

This is a pain to do if it’s done without any libraries, so people have made libraries to create this effect.

Our app will display images from the Pixabay API. You can view the API documentation and register for a key at https://pixabay.com/api/docs/

We will store the API key in the .env file on the project’s root folder.

Once we have the Pixabay API key, we can start writing our app. To start, we create a project called photo-app . To do this, run:

npx @vue/cli create photo-app

This will create the files for our app and install the packages for the built-in libraries. We choose ‘manually select features’ and choose Babel, Vue Router and CSS Preprocessor.

Next, we install our own packages. We need the vue-masonry library and vue-infinite-scroll we mentioned above. In addition, we need BootstrapVue for styling, Axios for making HTTP requests and Vee-Validate for form validation.

We install all the packages by running:

npm i axios bootstrap-vue vee-validate vue-infinite-scroll vue-masonry

With all the packages installed, we can start writing our app. Create a mixins folder in the src folder and create a requestsMixin.js file.

Then we add the following to the file:

const axios = require("axios");
const APIURL = "https://pixabay.com/api";

export const requestsMixin = {
  methods: {
    getImages(page = 1) {
      return axios.get(`${APIURL}/?page=${page}&key=${process.env.VUE_APP_API_KEY}`);
    },

    searchImages(keyword, page = 1) {
      return axios.get(
        `${APIURL}/?page=${page}&key=${process.env.VUE_APP_API_KEY}&q=${keyword}`
      );
    }
  }
};

We call the endpoints to search for images here. process.env.VUE_APP_API_KEY is retrieved from the .env file in the root folder of our project. Note that the environment variables we use have to have keys that begin with VUE_APP .

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

<template>
  <div class="page">
    <h1 class="text-center">Home</h1>
    <div
      v-infinite-scroll="getImagesByPage"
      infinite-scroll-disabled="busy"
      infinite-scroll-distance="10"
    >
      <div
        v-masonry="containerId"
        transition-duration="0.3s"
        item-selector=".item"
        gutter="5"
        fit-width="true"
        class="masonry-container"
      >
        <div>
          <img
            :src="item.previewURL"
            v-masonry-tile
            class="item"
            v-for="(item, index) in images"
            :key="index"
          />
        </div>
      </div>
    </div>
  </div>
</template>

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

export default {
  name: "home",
  mixins: [requestsMixin],
  data() {
    return {
      images: [],
      page: 1,
      containerId: null
    };
  },
  methods: {
    async getImagesByPage() {
      const response = await this.getImages(this.page);
      this.images = this.images.concat(response.data.hits);
      this.page++;
    }
  },
  beforeMount() {
    this.getImagesByPage();
  }
};
</script>

We use the vue-infinite-scroll and vue-masonry packages here. Note that we specified the transition-duration to tweak the transition from showing nothing to showing the images, fit-width makes the columns fit the container. gutter specifies the width of the space between each column in pixels. We also set a CSS class name in the v-masonry container to change the styles later.

Inside the v-masonry div, we loop through the images, we set the v-masonry-tile to indicate that it is tile so that it will resize them to a masonry grid.

In the script object, we get the images when the page loads with the beforeMount hook. Since we are adding infinite scrolling, we keep adding images to the array as the user scrolls down. We call getImagesByPage as the user scrolls down as indicated by the v-infinite-scroll prop. We set infinite-scroll-disabled to busy to set disable scrolling if busy is set to true . infinite-scroll-distance indicates the distance from the bottom of the page in percent for scrolling to be triggered.

Next create ImageSearchPage.vue in the views folder and add:

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

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

    <br />

    <div
      v-infinite-scroll="searchAllImages"
      infinite-scroll-disabled="busy"
      infinite-scroll-distance="10"
    >
      <div
        v-masonry="containerId"
        transition-duration="0.3s"
        item-selector=".item"
        gutter="5"
        fit-width="true"
        class="masonry-container"
      >
        <div>
          <img
            :src="item.previewURL"
            v-masonry-tile
            class="item"
            v-for="(item, index) in images"
            :key="index"
          />
        </div>
      </div>
    </div>
  </div>
</template>

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

export default {
  mixins: [requestsMixin],
  data() {
    return {
      form: {},
      page: 1,
      containerId: null,
      images: []
    };
  },
  methods: {
    async onSubmit() {
      const isValid = await this.$refs.observer.validate();
      if (!isValid) {
        return;
      }
      this.page = 1;
      await this.searchAllImages();
    },

    async searchAllImages() {
      if (!this.form.keyword) {
        return;
      }
      const response = await this.searchImages(this.form.keyword, this.page);
      if (this.page == 1) {
        this.images = response.data.hits;
      } else {
        this.images = this.images.concat(response.data.hits);
      }
      this.page++;
    }
  }
};
</script>

The infinite scrolling and masonry layout are almost the same, except when the keyword changes, we reassign the this.images array to the new items instead of keep adding them to the existing array so that users see the new results.

The form is wrapped inside the ValidationObserver so that we can get the validation status of the whole form inside the ValidationObserver . In the form, we wrap the input with ValidationProvider so that the form field can be validated and a validation error message displayed for the input. We check if keyword is filled in.

Once the user clicks Search, onSubmit is run, which runs await this.$refs.observer.validate(); to get the form validation status. If that results to true , then searchAllImages will be run to get the images.

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

<template>
  <div>
    <b-navbar toggleable="lg" type="dark" variant="info">
      <b-navbar-brand href="#">Photo 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-nav-item to="/imagesearch" :active="path == '/imagesearch'">Image Search</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;
}

.item {
  width: 30vw;
}

.masonry-container {
  margin: 0 auto;
}
</style>

We add the BootstrapVue b-navbar here to display a top bar with links to our pages. In the script section, we watch the current route by getting this.$route.path . We set the active prop by check the path against our watched path to highlight the links.

In the style section, we set the padding of our pages with the page class, we set the photo width with the item class as indicated in the item-selector of our v-masonry div, and we set the masonry-container ‘s margin to 0 auto so that it will be centered in the page.

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

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
import BootstrapVue from "bootstrap-vue";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required } from "vee-validate/dist/rules";
import { VueMasonryPlugin } from "vue-masonry";
import infiniteScroll from "vue-infinite-scroll";

Vue.config.productionTip = false;

extend("required", required);
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
Vue.use(VueMasonryPlugin);
Vue.use(infiniteScroll);
Vue.use(BootstrapVue);

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

to add all the libraries we used in the components and the Vee-Validate validation rules that we used. Also, we import our Bootstrap styles here so that we see the styles everywhere.

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

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

Vue.use(Router);

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

to add our routes.

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>Photo App</title>
  </head>
  <body>
    <noscript>
      <strong
        >We're sorry but vue-masonry-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 rename the title of our app.