Categories
Quasar

Developing Vue Apps with the Quasar Library — Table

Quasar is a popular Vue UI library for developing good looking Vue apps.

In this article, we’ll take a look at how to create Vue apps with the Quasar UI library.

Table

Quasar comes with the q-table component to let us add a table into our Vue app.

To use it, we write:

<!DOCTYPE html>
<html>
  <head>
    <link
      href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons"
      rel="stylesheet"
      type="text/css"
    />
    <link
      href="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.min.css"
      rel="stylesheet"
      type="text/css"
    />
  </head>
  <body class="body--dark">
    <script src="https://cdn.jsdelivr.net/npm/vue@^2.0.0/dist/vue.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.umd.min.js"></script>
    <div id="q-app">
      <q-layout
        view="lHh Lpr lFf"
        container
        style="height: 100vh;"
        class="shadow-2 rounded-borders"
      >
        <div class="q-pa-md">
          <q-table
            title="Treats"
            :data="data"
            :columns="columns"
            row-key="name"
          >
          </q-table>
        </div>
      </q-layout>
    </div>
    <script>
      const columns = [
        {
          name: "name",
          required: true,
          label: "Dessert",
          align: "left",
          field: (row) => row.name,
          format: (val) => `${val}`,
          sortable: true
        },
        {
          name: "calories",
          align: "center",
          label: "Calories",
          field: "calories",
          sortable: true
        },
        { name: "fat", label: "Fat (g)", field: "fat", sortable: true },
        {
          name: "calcium",
          label: "Calcium (%)",
          field: "calcium",
          sortable: true,
          sort: (a, b) => parseInt(a, 10) - parseInt(b, 10)
        }
      ];
      const data = [
        {
          name: "Frozen Yogurt",
          calories: 159,
          fat: 6.0,
          calcium: "14%"
        },
        {
          name: "Ice cream sandwich",
          calories: 237,
          fat: 9.0,
          calcium: "8%"
        },
        {
          name: "Eclair",
          calories: 262,
          fat: 16.0,
          calcium: "6%"
        },
        {
          name: "Honeycomb",
          calories: 408,
          fat: 3.2,
          calcium: "0%"
        },
        {
          name: "Donut",
          calories: 452,
          fat: 25.0,
          calcium: "2%"
        },
        {
          name: "KitKat",
          calories: 518,
          fat: 26.0,
          calcium: "12%"
        }
      ];

      new Vue({
        el: "#q-app",
        data: {
          columns,
          data
        }
      });
    </script>
  </body>
</html>

The columns prop has the column definition.

data has the table data.

title has the table title.

row-key sets the property name with the unique ID value.

In the columns array, the field is a function that returns the value that we want to display from the table data.

format returns the formatted value

align sets the cell content alignment.

required sets whether the column is required.

label has the column label.

sortable lets us enable or disable sorting of the column.

sort is a method that lets us change how to sort the column data.

name is the property name of the data entry to display.

Pagination is built into the table by default.

We can add the dark prop to set the table to show with a dark background:

<!DOCTYPE html>
<html>
  <head>
    <link
      href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons"
      rel="stylesheet"
      type="text/css"
    />
    <link
      href="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.min.css"
      rel="stylesheet"
      type="text/css"
    />
  </head>
  <body class="body--dark">
    <script src="https://cdn.jsdelivr.net/npm/vue@^2.0.0/dist/vue.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.umd.min.js"></script>
    <div id="q-app">
      <q-layout
        view="lHh Lpr lFf"
        container
        style="height: 100vh;"
        class="shadow-2 rounded-borders"
      >
        <div class="q-pa-md">
          <q-table
            title="Treats"
            :data="data"
            :columns="columns"
            row-key="name"
            dark
          >
          </q-table>
        </div>
      </q-layout>
    </div>
    <script>
      const columns = [
        {
          name: "name",
          required: true,
          label: "Dessert",
          align: "left",
          field: (row) => row.name,
          format: (val) => `${val}`,
          sortable: true
        },
        {
          name: "calories",
          align: "center",
          label: "Calories",
          field: "calories",
          sortable: true
        },
        { name: "fat", label: "Fat (g)", field: "fat", sortable: true },
        {
          name: "calcium",
          label: "Calcium (%)",
          field: "calcium",
          sortable: true,
          sort: (a, b) => parseInt(a, 10) - parseInt(b, 10)
        }
      ];
      const data = [
        {
          name: "Frozen Yogurt",
          calories: 159,
          fat: 6.0,
          calcium: "14%"
        },
        {
          name: "Ice cream sandwich",
          calories: 237,
          fat: 9.0,
          calcium: "8%"
        },
        {
          name: "Eclair",
          calories: 262,
          fat: 16.0,
          calcium: "6%"
        },
        {
          name: "Honeycomb",
          calories: 408,
          fat: 3.2,
          calcium: "0%"
        },
        {
          name: "Donut",
          calories: 452,
          fat: 25.0,
          calcium: "2%"
        },
        {
          name: "KitKat",
          calories: 518,
          fat: 26.0,
          calcium: "12%"
        }
      ];

      new Vue({
        el: "#q-app",
        data: {
          columns,
          data
        }
      });
    </script>
  </body>
</html>

Conclusion

We can add a table into our Vue app with Quasar’s q-table component.

Categories
Quasar

Developing Vue Apps with the Quasar Library — Stepper Header Options

Quasar is a popular Vue UI library for developing good looking Vue apps.

In this article, we’ll take a look at how to create Vue apps with the Quasar UI library.

Stepper Header Options

We can change the stepper header to different styles.

For instance, we can write:

<!DOCTYPE html>
<html>
  <head>
    <link
      href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons"
      rel="stylesheet"
      type="text/css"
    />
    <link
      href="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.min.css"
      rel="stylesheet"
      type="text/css"
    />
  </head>
  <body class="body--dark">
    <script src="https://cdn.jsdelivr.net/npm/vue@^2.0.0/dist/vue.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.umd.min.js"></script>
    <div id="q-app">
      <q-layout
        view="lHh Lpr lFf"
        container
        style="height: 100vh;"
        class="shadow-2 rounded-borders"
      >
        <div class="q-pa-md">
          <q-stepper v-model="step" ref="stepper" color="primary" animated>
            <q-step
              :name="1"
              :error="step < 3"
              title="Step 1"
              icon="settings"
              :done="step > 1"
            >
              step 1
            </q-step>

            <q-step
              :name="2"
              title="Step 2"
              caption="Optional"
              icon="create_new_folder"
              :done="step > 2"
            >
              step 2
            </q-step>

            <q-step :name="3" title="Step 3" icon="assignment" disable>
              This step won't show up because it is disabled.
            </q-step>

            <q-step :name="4" title="Step 4" icon="add_comment">
              step 4
            </q-step>

            <template v-slot:navigation>
              <q-stepper-navigation>
                <q-btn
                  @click="$refs.stepper.next()"
                  color="primary"
                  :label="step === 4 ? 'Finish' : 'Continue'"
                >
                </q-btn>
                <q-btn
                  v-if="step > 1"
                  flat
                  color="primary"
                  @click="$refs.stepper.previous()"
                  label="Back"
                  class="q-ml-sm"
                >
                </q-btn>
              </q-stepper-navigation>
            </template>
          </q-stepper>
        </div>
      </q-layout>
    </div>
    <script>
      new Vue({
        el: "#q-app",
        data: {
          step: 1
        }
      });
    </script>
  </body>
</html>

to add the error prop into the q-step component.

The error prop lets us set the condition for when to display an error icon and red text.

We can also change the label style to display with the icon stacked above the label text with the alternative-labels prop:

<!DOCTYPE html>
<html>
  <head>
    <link
      href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons"
      rel="stylesheet"
      type="text/css"
    />
    <link
      href="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.min.css"
      rel="stylesheet"
      type="text/css"
    />
  </head>
  <body class="body--dark">
    <script src="https://cdn.jsdelivr.net/npm/vue@^2.0.0/dist/vue.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.umd.min.js"></script>
    <div id="q-app">
      <q-layout
        view="lHh Lpr lFf"
        container
        style="height: 100vh;"
        class="shadow-2 rounded-borders"
      >
        <div class="q-pa-md">
          <q-stepper
            alternative-labels
            v-model="step"
            ref="stepper"
            color="primary"
            animated
          >
            <q-step :name="1" title="Step 1" icon="settings" :done="step > 1">
              step 1
            </q-step>

<q-step
              :name="2"
              title="Step 2"
              icon="create_new_folder"
              :done="step > 2"
            >
              step 2
            </q-step>

            <q-step :name="3" title="Step 3" icon="assignment" disable>
              This step won't show up because it is disabled.
            </q-step>

            <q-step :name="4" title="Step 4" icon="add_comment">
              step 4
            </q-step>

            <template v-slot:navigation>
              <q-stepper-navigation>
                <q-btn
                  @click="$refs.stepper.next()"
                  color="primary"
                  :label="step === 4 ? 'Finish' : 'Continue'"
                >
                </q-btn>
                <q-btn
                  v-if="step > 1"
                  flat
                  color="primary"
                  @click="$refs.stepper.previous()"
                  label="Back"
                  class="q-ml-sm"
                >
                </q-btn>
              </q-stepper-navigation>
            </template>
          </q-stepper>
        </div>
      </q-layout>
    </div>
    <script>
      new Vue({
        el: "#q-app",
        data: {
          step: 1
        }
      });
    </script>
  </body>
</html>

We can also change the color of the icon with the inactive-color , active-color and done-color props to change the label colors at those states:

<!DOCTYPE html>
<html>
  <head>
    <link
      href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons"
      rel="stylesheet"
      type="text/css"
    />
    <link
      href="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.min.css"
      rel="stylesheet"
      type="text/css"
    />
  </head>
  <body class="body--dark">
    <script src="https://cdn.jsdelivr.net/npm/vue@^2.0.0/dist/vue.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.umd.min.js"></script>
    <div id="q-app">
      <q-layout
        view="lHh Lpr lFf"
        container
        style="height: 100vh;"
        class="shadow-2 rounded-borders"
      >
        <div class="q-pa-md">
          <q-stepper v-model="step" ref="stepper" color="primary" animated>
            <q-step
              done-color="deep-orange"
              active-color="purple"
              inactive-color="secondary"
              :name="1"
              title="Step 1"
              icon="settings"
              :done="step > 1"
            >
              step 1
            </q-step>

            <q-step
              :name="2"
              title="Step 2"
              icon="create_new_folder"
              :done="step > 2"
            >
              step 2
            </q-step>

            <q-step :name="3" title="Step 3" icon="assignment" disable>
              This step won't show up because it is disabled.
            </q-step>

            <q-step :name="4" title="Step 4" icon="add_comment">
              step 4
            </q-step>

            <template v-slot:navigation>
              <q-stepper-navigation>
                <q-btn
                  @click="$refs.stepper.next()"
                  color="primary"
                  :label="step === 4 ? 'Finish' : 'Continue'"
                >
                </q-btn>
                <q-btn
                  v-if="step > 1"
                  flat
                  color="primary"
                  @click="$refs.stepper.previous()"
                  label="Back"
                  class="q-ml-sm"
                >
                </q-btn>
              </q-stepper-navigation>
            </template>
          </q-stepper>
        </div>
      </q-layout>
    </div>
    <script>
      new Vue({
        el: "#q-app",
        data: {
          step: 1
        }
      });
    </script>
  </body>
</html>

Conclusion

We can add the stepper with various options with Quasar’s q-stepper component.

Categories
Quasar

Developing Vue Apps with the Quasar Library — Stepper

Quasar is a popular Vue UI library for developing good looking Vue apps.

In this article, we’ll take a look at how to create Vue apps with the Quasar UI library.

Stepper

Quasar comes with the q-stepper component to let us create multi-step content.

For instance, we can use it by writing:

<!DOCTYPE html>
<html>
  <head>
    <link
      href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons"
      rel="stylesheet"
      type="text/css"
    />
    <link
      href="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.min.css"
      rel="stylesheet"
      type="text/css"
    />
  </head>
  <body class="body--dark">
    <script src="https://cdn.jsdelivr.net/npm/vue@^2.0.0/dist/vue.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.umd.min.js"></script>
    <div id="q-app">
      <q-layout
        view="lHh Lpr lFf"
        container
        style="height: 100vh;"
        class="shadow-2 rounded-borders"
      >
        <div class="q-pa-md">
          <q-stepper v-model="step" ref="stepper" color="primary" animated>
            <q-step :name="1" title="Step 1" icon="settings" :done="step > 1">
              step 1
            </q-step>

            <q-step
              :name="2"
              title="Step 2"
              caption="Optional"
              icon="create_new_folder"
              :done="step > 2"
            >
              step 2
            </q-step>

            <q-step :name="3" title="Step 3" icon="assignment" disable>
              This step won't show up because it is disabled.
            </q-step>

            <q-step :name="4" title="Step 4" icon="add_comment">
              step 4
            </q-step>

            <template v-slot:navigation>
              <q-stepper-navigation>
                <q-btn
                  @click="$refs.stepper.next()"
                  color="primary"
                  :label="step === 4 ? 'Finish' : 'Continue'"
                >
                </q-btn>
                <q-btn
                  v-if="step > 1"
                  flat
                  color="primary"
                  @click="$refs.stepper.previous()"
                  label="Back"
                  class="q-ml-sm"
                >
                </q-btn>
              </q-stepper-navigation>
            </template>
          </q-stepper>
        </div>
      </q-layout>
    </div>
    <script>
      new Vue({
        el: "#q-app",
        data: {
          step: 1
        }
      });
    </script>
  </body>
</html>

The v-model binds to the step number.

We put the q-step components inside the q-stepper to show the step content.

The name prop has the step number which is compared with the step reactive property to determine which step to display.

The title prop is shown in the title bar.

caption is the subtitle of the step.

icon has the icon name.

And done is the condition when the step icon and text highlighted.

The disable prop disables the stepper.

The navigation slot has the content which shows at the bottom of the stepper that lets us navigate the steps.

$ref.stepper.next() moves to the next step and $ref.stepper.previous() moves to the previous step.

We can make the stepper display vertically with the vertical prop:

<!DOCTYPE html>
<html>
  <head>
    <link
      href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons"
      rel="stylesheet"
      type="text/css"
    />
    <link
      href="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.min.css"
      rel="stylesheet"
      type="text/css"
    />
  </head>
  <body class="body--dark">
    <script src="https://cdn.jsdelivr.net/npm/vue@^2.0.0/dist/vue.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.umd.min.js"></script>
    <div id="q-app">
      <q-layout
        view="lHh Lpr lFf"
        container
        style="height: 100vh;"
        class="shadow-2 rounded-borders"
      >
        <div class="q-pa-md">
          <q-stepper
            vertical
            v-model="step"
            ref="stepper"
            color="primary"
            animated
          >
            <q-step :name="1" title="Step 1" icon="settings" :done="step > 1">
              step 1
            </q-step>

            <q-step
              :name="2"
              title="Step 2"
              caption="Optional"
              icon="create_new_folder"
              :done="step > 2"
            >
              step 2
            </q-step>

            <q-step :name="3" title="Step 3" icon="assignment" disable>
              This step won't show up because it is disabled.
            </q-step>

            <q-step :name="4" title="Step 4" icon="add_comment">
              step 4
            </q-step>

            <template v-slot:navigation>
              <q-stepper-navigation>
                <q-btn
                  @click="$refs.stepper.next()"
                  color="primary"
                  :label="step === 4 ? 'Finish' : 'Continue'"
                >
                </q-btn>
                <q-btn
                  v-if="step > 1"
                  flat
                  color="primary"
                  @click="$refs.stepper.previous()"
                  label="Back"
                  class="q-ml-sm"
                >
                </q-btn>
              </q-stepper-navigation>
            </template>
          </q-stepper>
        </div>
      </q-layout>
    </div>
    <script>
      new Vue({
        el: "#q-app",
        data: {
          step: 1
        }
      });
    </script>
  </body>
</html>

Conclusion

We can add a stepper component into our Vue app with Quasar’s q-stepper component.

Categories
Vue

Check if an Input Element is Focused with Vue-Focus

To make an app’s user experience better, often you have to do something when an input element is focused. For example, you might want to highlight the label for the input when the input is focused so that users know which field they’re filling in. With Vue.js, the easiest way is the use the Vue-Focus library to do this. It provides a directive and mixin that lets you handle focused and blur events and bind it to a data field of a component.

In this article, we will make a website bookmark manager app that lets users bookmark their favorite URLs. The labels will be highlighted when the input is in focus. To start building the project, we run the Vue CLI by running:

npx @vue/cli create bookmark-app

When the wizard runs, we select ‘Manually select features’, and select Babel, CSS preprocessor, Vuex, and Vue Router.

Next we install some packages. We need Axios to make HTTP requests to our back end, Bootstrap-Vue for styling, Vee-Validate for form validation, and Vue-Focus for handling the focus state of the inputs. To install the packages, we run npm i axios bootstrap-vue vee-validate vue-focus. After installing the packages we can start building our bookmark app.

First, we create our form for letting users add and edit their bills. In the components folder, create a file called BookmarkForm.vue and add:

<template>
  <ValidationObserver ref="observer" v-slot="{ invalid }">
    <b-form @submit.prevent="onSubmit" novalidate>
      <b-form-group>
        <ValidationProvider name="name" rules="required" v-slot="{ errors }">
          <label :class="{'highlight': nameFocused}">Name</label>
          <b-form-input
            type="text"
            v-model="form.name"
            placeholder="Name"
            name="name"
            [@focus](http://twitter.com/focus "Twitter profile for @focus")="nameFocused = true"
            [@blur](http://twitter.com/blur "Twitter profile for @blur")="nameFocused = false"
            v-focus="nameFocused"
            :state="errors.length == 0"
          ></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>
        <ValidationProvider name="url" rules="required|url" v-slot="{ errors }">
          <label :class="{'highlight': urlFocused}">URL</label>
          <b-form-input
            type="text"
            :state="errors.length == 0"
            v-model="form.url"
            required
            placeholder="URL"
            name="url"
            [@focus](http://twitter.com/focus "Twitter profile for @focus")="urlFocused = true"
            [@blur](http://twitter.com/blur "Twitter profile for @blur")="urlFocused = false"
            v-focus="urlFocused"
          ></b-form-input>
          <b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

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

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

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

if (this.edit) {
        await this.editBookmark(this.form);
      } else {
        await this.addBookmark(this.form);
      }
      const { data } = await this.getBookmarks();
      this.$store.commit("setBookmarks", data);
      this.$emit("saved");
    },
    cancel() {
      this.$emit("cancelled");
    }
  },
  data() {
    return {
      form: {},
      nameFocused: false,
      urlFocused: false
    };
  },
  watch: {
    bookmark: {
      handler(val) {
        this.form = JSON.parse(JSON.stringify(val || {}));
      },
      deep: true,
      immediate: true
    }
  }
};
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
.highlight {
  color: #42b983;
}
</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 it. We make the name and url fields required in the rules prop so that users will have to enter all of them to save the bill. Also, we made a custom url Vee-Validate validation rule checks if the URL is valid.

In the inputs, we used the v-focus directive provided by Vue-Focus to set the focus state of the inputs. We bind the focus state of the inputs to the nameFocused and urlFocused variables respectively. Once users put the cursor in the input, the label for the input will be highlighted since we set the highlight class to the label depending on the state of the focus of the input.

We validate the values in the onSubmit function by running this.$refs.observer.validate() . If that resolves to true , then we run the code to save the data by calling the functions in the if block, then we call getNotes to get the notes. These functions are from the requestsMixin that we will add. The obtained data are stored in our Vuex store by calling this.$store.commit .

In this component, we also have a watch block to watch the bill value, which is obtained from the Vuex store that we have to build. We get the latest list of ingredients as the billvalue is updated so that the latest can be edited by the user as we copy the values to this.form .

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

    addBookmark(data) {
      return axios.post(`${APIURL}/bookmarks`, data);
    },

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

    deleteBookmark(id) {
      return axios.delete(`${APIURL}/bookmarks/${id}`);
    }
  }
};

These are the functions we use in our components to make HTTP requests to our back end to save the bookmarks.

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

<template>
  <div class="page">
    <h1 class="text-center">Bookmark App</h1>
    <b-button-toolbar>
      <b-button @click="openAddModal()">Add Bookmark</b-button>
    </b-button-toolbar>
    <br />
    <b-table-simple responsive>
      <b-thead>
        <b-tr>
          <b-th>Name</b-th>
          <b-th>Link</b-th>
          <b-th></b-th>
          <b-th></b-th>
        </b-tr>
      </b-thead>
      <b-tbody>
        <b-tr v-for="b in bookmarks" :key="b.id">
          <b-td>{{b.name}}</b-td>
          <b-td>
            <a :href="b.url">Link</a>
          </b-td>
          <b-td>
            <b-button @click="openEditModal(b)">Edit</b-button>
          </b-td>
          <b-td>
            <b-button @click="deleteOnebookmark(b.id)">Delete</b-button>
          </b-td>
        </b-tr>
      </b-tbody>
    </b-table-simple>

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

    <b-modal id="edit-modal" title="Edit Bookmark" hide-footer>
      <BookmarkForm
        @saved"closeModal()"
        @cancelled="closeModal()"
        :edit="true"
        :bookmark="selectedBookmark"
      ></BookmarkForm>
    </b-modal>
  </div>
</template>

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

export default {
  name: "home",
  components: {
    BookmarkForm
  },
  mixins: [requestsMixin],
  computed: {
    bookmarks() {
      return this.$store.state.bookmarks;
    }
  },
  beforeMount() {
    this.getAllBookmarks();
  },
  data() {
    return {
      selectedBookmark: {}
    };
  },
  methods: {
    openAddModal() {
      this.$bvModal.show("add-modal");
    },

    openEditModal(bookmark) {
      this.$bvModal.show("edit-modal");
      this.selectedBookmark = bookmark;
    },

    closeModal() {
      this.$bvModal.hide("add-modal");
      this.$bvModal.hide("edit-modal");
      this.selectedBookmark = {};
      this.getAllBookmarks();
    },

    async deleteOnebookmark(id) {
      await this.deleteBookmark(id);
      this.getAllBookmarks();
    },

    async getAllBookmarks () {
      const { data } = await this.getBookmarks();
      this.$store.commit("setBookmarks", data);
    }
  }
};
</script>

This is where we display the bills in a BootstrapVue table. The columns are the name, the amount, and the due date, along with the Edit button to open the edit modal, and Delete button to delete an entry when it’s clicked.

We also added an ‘Add Bill’ button to open the modal to let users add a bill. The notes are obtained from the back end by running the this.getAllBookmarks function in the beforeMount hook which stores the data in our Vuex store.

The openAddModal, openEditModal, closeModal open the open and close modals, and close the modal respectively. When openEditModal is called, we set the this.selectedBookmark variable so that we can pass it to our BookmarkForm .

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="/">Bookmark 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>

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

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

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

extend("url", {
  validate: value => {
    return /(https?://(?:www.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9].[^s]{2,}|www.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9].[^s]{2,}|https?://(?:www.|(?!www))[a-zA-Z0-9]+.[^s]{2,}|www.[a-zA-Z0-9]+.[^s]{2,})/.test(
      value
    );
  },
  message: "Invalid URL."
});
Vue.use(BootstrapVue);
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
extend("required", required);

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 and Vee-Validate components along with the required validation rule and a url rule for validating that the URL entered is valid by checking against the given regex.

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

to add our bookmarks state to the store so we can observer it in the computed block of BookmarkForm and HomePage components. We have the setBookmarks function to update the notes state and we use it in the components by call this.$store.commit(“setBookmarks”, data); like we did in BookmarkForm 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>Bookmark App</title>
  </head>
  <body>
    <noscript>
      <strong
        >We're sorry but vue-focus-tutorial-app doesn't work properly without
        JavaScript enabled. Please enable it to continue.</strong
      >
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>

to change the title of our app.

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

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

json-server --watch db.json

In db.json, change the text to:

{
  "bookmarks": []
}

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

After all the hard work, we get:

Categories
Vue

Add Popups and Menus Easily with V-Click-Outside

Popups and menus are common features in web apps. It’s often used for giving a place for exposing users to add functionality in an app. The frequency of its use led to developers developing libraries for us to use to add popups and menus to apps. UI libraries like Bootstrap have menus built-in. For customized menus, we can add one by creating a div with a button that toggles the opening and closing of the menu.

When users click outside the menu and the button, then the menu closes. For Vue.js apps, we have the V-Click-Outside library for handling clicks outside an element. We can use it easily to add popups and menus to our apps.

In this article, we will write a note-taking app that lets users take notes. There will be a table to display the notes and a popup menu in each row that lets users click a button to edit or delete a note. To start building the project, we run the Vue CLI by running:

npx @vue/cli create bookmark-app

When the wizard runs, we select ‘Manually select features’, and select Babel, CSS preprocessor, Vuex, and Vue Router.

Next, we install some packages. We need Axios to make HTTP requests to our back end, Bootstrap-Vue for styling, Vee-Validate for form validation, and V-Click-Outside for handling the focus state of the inputs. To install the packages, we run npm i axios bootstrap-vue vee-validate v-click-outside. After installing the packages we can start building our note-taking app.

First, we create our form for letting users add and edit their bills. In the components folder, create a file called NoteForm.vue and add:

<template>
  <ValidationObserver ref="observer" v-slot="{ invalid }">
    <b-form @submit.prevent="onSubmit" novalidate>
      <b-form-group>
        <ValidationProvider name="name" rules="required" v-slot="{ errors }">
          <label>Name</label>
          <b-form-input
            type="text"
            v-model="form.name"
            placeholder="Name"
            name="name"
            :state="errors.length == 0"
          ></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>
        <ValidationProvider name="note" rules="required" v-slot="{ errors }">
          <label>Note</label>
          <b-form-textarea
            type="text"
            :state="errors.length == 0"
            v-model="form.note"
            required
            placeholder="Note"
            name="note"
            rows="5"
          ></b-form-textarea>
          <b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

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

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

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

      if (this.edit) {
        await this.editNote(this.form);
      } else {
        await this.addNote(this.form);
      }
      const { data } = await this.getNotes();
      this.$store.commit("setNotes", data);
      this.$emit("saved");
    },
    cancel() {
      this.$emit("cancelled");
    }
  },
  data() {
    return {
      form: {}
    };
  },
  watch: {
    note: {
      handler(val) {
        this.form = JSON.parse(JSON.stringify(val || {}));
      },
      deep: true,
      immediate: true
    }
  }
};
</script>

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 name and note fields required in the rules prop so that users will have to enter all of them to save the note.

We validate the values in the onSubmit function by running this.$refs.observer.validate() . If that resolves to true , then we run the code to save the data by calling the functions in the if block, then we call getNotes to get the notes. These functions are from the requestsMixin that we will add. The obtained data are stored in our Vuex store by calling this.$store.commit .

In this component, we also have a watch block to watch the note value, which is obtained from the Vuex store that we have to build. We get the latest list of ingredients as the note value is updated so that the latest can be edited by the user as we copy the values to this.form .

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

    addNote(data) {
      return axios.post(`${APIURL}/notes`, data);
    },

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

    deleteNote(id) {
      return axios.delete(`${APIURL}/notes/${id}`);
    }
  }
};

These are the functions we use in our components to make HTTP requests to our back end to save the bookmarks.

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

<template>
  <div class="page">
    <h1 class="text-center">Note Taking App</h1>
    <b-button-toolbar>
      <b-button @click="openAddModal()">Add Note</b-button>
    </b-button-toolbar>
    <br />
    <b-table-simple responsive>
      <b-thead>
        <b-tr>
          <b-th>Name</b-th>
          <b-th>Note</b-th>
          <b-th></b-th>
        </b-tr>
      </b-thead>
      <b-tbody>
        <b-tr v-for="n in notes" :key="n.id">
          <b-td>{{n.name}}</b-td>
          <b-td>{{n.note}}</b-td>
          <b-td>
            <b-button @click="toggleMenu(n.id)" class="menu-button">Menu</b-button>
            <div class="dropdown" v-show="openMenu[n.id]" v-click-outside="onClickOutside">
              <b-list-group>
                <b-list-group-item @click="openEditModal(n)">Edit</b-list-group-item>
                <b-list-group-item @click="deleteOneNote(n.id)">Delete</b-list-group-item>
              </b-list-group>
            </div>
          </b-td>
        </b-tr>
      </b-tbody>
    </b-table-simple>

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

    <b-modal id="edit-modal" title="Edit Note" hide-footer>
      <NoteForm @saved="closeModal()" @cancelled="closeModal()" :edit="true" :note="selectedNote"></NoteForm>
    </b-modal>
  </div>
</template>

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

export default {
  name: "home",
  components: {
    NoteForm
  },
  mixins: [requestsMixin],
  computed: {
    notes() {
      return this.$store.state.notes;
    }
  },
  beforeMount() {
    this.getAllNotes();
  },
  data() {
    return {
      selectedNote: {},
      openMenu: {}
    };
  },
  methods: {
    toggleMenu(id) {
      this.$set(this.openMenu, id, !this.openMenu[id]);
    },
    onClickOutside(event, el) {
      if (!event.target.className.includes("menu-button")) {
        this.openMenu = {};
      }
    },
    openAddModal() {
      this.$bvModal.show("add-modal");
    },

    openEditModal(note) {
      this.$bvModal.show("edit-modal");
      this.selectedNote = note;
    },

    closeModal() {
      this.$bvModal.hide("add-modal");
      this.$bvModal.hide("edit-modal");
      this.selectedNote = {};
      this.getAllNotes();
    },

    async deleteOneNote(id) {
      await this.deleteNote(id);
      this.getAllNotes();
    },

    async getAllNotes() {
      const { data } = await this.getNotes();
      this.$store.commit("setNotes", data);
    }
  }
};
</script>

<style lang="scss" scoped>
.dropdown {
  position: absolute;
  max-width: 100px;
}

.list-group-item {
  cursor: pointer;
}
</style>

This is where we display the bills in a BootstrapVue table. The columns are the name, the amount, and the due date, along with the Edit button to open the edit modal, and Delete button to delete an entry when it’s clicked. We also added an ‘Add Bill’ button to open the modal to let users add a bill. The notes are obtained from the back end by running the this.getAllNotes function in the beforeMount hook which stores the data in our Vuex store.

In the table, we have the notes displayed in the table rows. On the rightmost column, we have the menu that we built from scratch. We added a Menu button to each row and a div below it to serve as the container of the list group, which contains our Edit and Delete items for users to click on to Edit and Delete the note respectively. We toggle the menu with the toggleMenu function when the user clicks the Menu button. Notice that we need to call the this.$set function to force Vue.js to refresh since we’re modifying an entry in an object. Vue cannot detect changes within an object automatically. For more details about this function, see https://vuejs.org/v2/api/#Vue-set.

In the styles section, we style out menu popup by setting the dropdown class with absolute position and set its max-width to 100px. The absolute position will make it stack on top of our table, right below the button for each row.

The openAddModal, openEditModal, closeModal open the open and close modals, and close the modal respectively. When openEditModal is called, we set the this.selectedNote variable so that we can pass it to our NoteForm .

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="/">Note Taking 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>

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

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

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import BootstrapVue from "bootstrap-vue";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required } from "vee-validate/dist/rules";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
import vClickOutside from "v-click-outside";

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

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 , and the Vee-Validate components along with the required validation rule. We also include our V-Click-Outside library here so we can use it in any component.

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

to add our notes state to the store so we can observe it in the computed block of NoteForm and HomePage components. We have the setNotes function to update the notes state and we use it in the components by call this.$store.commit(“setNotes”, data); like we did in NoteForm 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>Note Taking App</title>
  </head>
  <body>
    <noscript>
      <strong
        >We're sorry but v-click-outside-tutorial-app doesn't work properly
        without JavaScript enabled. Please enable it to continue.</strong
      >
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>

to change the title of our app.

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

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

json-server --watch db.json

In db.json, change the text to:

{
  "`notes`": []
}

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

After all the hard work, we get: