Categories
Quasar

Developing Vue Apps with the Quasar Library — Accordion Tree, Filter, and Selection

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.

Accordion Tree Mode

We can show the tree view as an accordion with the accordion 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-tree
        :nodes="data"
        node-key="label"
        selected-color="primary"
        :selected.sync="selected"
        default-expand-all
        accordion
      >
      </q-tree>
    </div>
    <script>
      const data = [
        {
          label: "Hotel",
          children: [
            {
              label: "Food",
              icon: "restaurant_menu"
            },
            {
              label: "Room service",
              icon: "room_service"
            },
            {
              label: "Room view",
              icon: "photo"
            }
          ]
        }
      ];

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

Filter Tree Nodes

We can add a filter to let users search for tree nodes.

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-input ref="filter" filled v-model="filter" label="Filter">
        <template v-slot:append>
          <q-icon
            v-if="filter !== ''"
            name="clear"
            class="cursor-pointer"
            @click="resetFilter"
          >
          </q-icon>
        </template>
      </q-input>

      <q-tree
        :nodes="data"
        node-key="label"
        :filter="filter"
        default-expand-all
      >
      </q-tree>
    </div>
    <script>
      const data = [
        {
          label: "Hotel",
          children: [
            {
              label: "Food",
              icon: "restaurant_menu"
            },
            {
              label: "Room service",
              icon: "room_service"
            },
            {
              label: "Room view",
              icon: "photo"
            }
          ]
        }
      ];

      new Vue({
        el: "#q-app",
        data: {
          data,
          filter: ""
        },
        methods: {
          resetFilter() {
            this.filter = "";
            this.$refs.filter.focus();
          }
        }
      });
    </script>
  </body>
</html>

We add a q-input that’s bound to the filter reactive property.

Then we pass in the filter reactive property as the value of filter prop of the q-tree component.

This will let us search for nodes as we type into the input box.

Selectable Nodes

We can set nodes as selected by setting the reactive property bound to the q-tree as the value.

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">
      <div>
        <div class="q-gutter-sm">
          <q-btn
            size="sm"
            color="primary"
            @click="selectRoomService"
            label="Select Room service"
          >
          </q-btn>
          <q-btn
            v-if="selected"
            size="sm"
            color="red"
            @click="unselectNode"
            label="Unselect node"
          >
          </q-btn>
        </div>
      </div>

      <q-tree
        :nodes="data"
        default-expand-all
        :selected.sync="selected"
        node-key="label"
      >
      </q-tree>
    </div>
    <script>
      const data = [
        {
          label: "Hotel",
          children: [
            {
              label: "Food",
              icon: "restaurant_menu"
            },
            {
              label: "Room service",
              icon: "room_service"
            },
            {
              label: "Room view",
              icon: "photo"
            }
          ]
        }
      ];

      new Vue({
        el: "#q-app",
        data: {
          data,
          selected: ""
        },
        methods: {
          selectRoomService() {
            if (this.selected !== "Room service") {
              this.selected = "Room service";
            }
          },
          unselectNode() {
            this.selected = null;
          }
        }
      });
    </script>
  </body>
</html>

In the selectRoomService method, we set this.selected to 'Room service' .

And since this.selected is bound to q-tree with v-model , the node will be selected.

And if we set it to null as we did in unselectNode , the node will be unselected.

Conclusion

We can customize Quasar’s tree component and add filter and selection features to it.

Categories
Quasar

Developing Vue Apps with the Quasar Library — Tree View

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.

Tree View

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

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;">
        <div class="q-pa-md">
          <q-tree :nodes="data" node-key="label"> </q-tree>
        </div>
      </q-layout>
    </div>
    <script>
      const data = [
        {
          label: "Satisfied customers",
          avatar: "https://cdn.quasar.dev/img/avatar.png",
          children: [
            {
              label: "Good food (with icon)",
              icon: "restaurant_menu",
              children: [
                { label: "Good ingredients" },
                { label: "Good recipe" }
              ]
            },
            {
              label: "Good service (disabled node with icon)",
              icon: "room_service",
              disabled: true,
              children: [
                { label: "Prompt attention" },
                { label: "Professional waiter" }
              ]
            },
            {
              label: "Pleasant surroundings (with icon)",
              icon: "photo",
              children: [
                {
                  label: "Happy atmosphere",
                  img: "https://cdn.quasar.dev/img/logo_calendar_128px.png"
                },
                { label: "Good presentation" },
                { label: "Pleasing decor" }
              ]
            }
          ]
        }
      ];

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

We pass the tree’s data into the node prop.

node-key has the unique ID of each tree node.

The disabled property lets us disable interactivity with a tree node.

We can remove the connectors with the no-connectors prop.

We can bind the selected node to a reactive property with the v-model directive.

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-splitter v-model="splitterModel" style="height: 400px;">
        <template v-slot:before>
          <div class="q-pa-md">
            <q-tree
              :nodes="data"
              node-key="label"
              selected-color="primary"
              :selected.sync="selected"
              default-expand-all
            >
            </q-tree>
          </div>
        </template>

        <template v-slot:after>
          <q-tab-panels
            v-model="selected"
            animated
            transition-prev="jump-up"
            transition-next="jump-up"
          >
            <q-tab-panel name="Hotel">
              <div class="text-h4 q-mb-md">Welcome</div>
              <p>
                Lorem ipsum dolor sit
              </p>
            </q-tab-panel>

            <q-tab-panel name="Food">
              <div class="text-h4 q-mb-md">Food</div>
              <p>
                Lorem ipsum dolor sit
              </p>
            </q-tab-panel>

            <q-tab-panel name="Room service">
              <div class="text-h4 q-mb-md">Room service</div>
              <p>
                Lorem ipsum dolor sit
              </p>
            </q-tab-panel>

            <q-tab-panel name="Room view">
              <div class="text-h4 q-mb-md">Room view</div>
              <p>
                Lorem ipsum dolor sit
              </p>
            </q-tab-panel>
          </q-tab-panels>
        </template>
      </q-splitter>
    </div>
    <script>
      const data = [
        {
          label: "Hotel",
          children: [
            {
              label: "Food",
              icon: "restaurant_menu"
            },
            {
              label: "Room service",
              icon: "room_service"
            },
            {
              label: "Room view",
              icon: "photo"
            }
          ]
        }
      ];

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

The splitterModel reactive property is bound to the label property value of the node that’s been clicked on.

Conclusion

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

Categories
Quasar

Developing Vue Apps with the Quasar Library — Toolbar

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.

Toolbar

We can add a toolbar with Quasar’sq-toolbar component.

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;">
        <div class="q-pa-md">
          <q-toolbar class="text-primary">
            <q-btn flat round dense icon="menu"></q-btn>
            <q-toolbar-title>
              Toolbar
            </q-toolbar-title>
            <q-btn flat round dense icon="more_vert"></q-btn>
          </q-toolbar>
        </div>
      </q-layout>
    </div>
    <script>
      new Vue({
        el: "#q-app",
        data: {
          targetEl: "#target-img-1"
        }
      });
    </script>
  </body>
</html>

to add a q-btn and q-toolbar-title into the toolbar.

q-toolbar-title has the toolbar title.

We can add a q-avatar in our toolbar:

<!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;">
        <div class="q-pa-md">
          <q-toolbar class="text-primary">
            <q-btn flat round dense icon="menu"></q-btn>
            <q-avatar>
              <img src="https://cdn.quasar.dev/logo/svg/quasar-logo.svg" />
            </q-avatar>
            <q-toolbar-title>
              Toolbar
            </q-toolbar-title>
            <q-btn flat round dense icon="more_vert"></q-btn>
          </q-toolbar>
        </div>
      </q-layout>
    </div>
    <script>
      new Vue({
        el: "#q-app",
        data: {}
      });
    </script>
  </body>
</html>

We can also add a dropdown into the q-toolbar :

<!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;">
        <div class="q-pa-md">
          <q-toolbar class="bg-primary text-white q-my-md shadow-2">
            <q-btn flat round dense icon="menu" class="q-mr-sm"></q-btn>
            <q-separator dark vertical inset></q-separator>
            <q-btn stretch flat label="Link"></q-btn>

<q-space></q-space>

<q-btn-dropdown stretch flat label="Dropdown">
              <q-list>
                <q-item-label header>Folders</q-item-label>
                <q-item
                  v-for="n in 3"
                  :key="`x.${n}`"
                  clickable
                  v-close-popup
                  tabindex="0"
                >
                  <q-item-section avatar>
                    <q-avatar
                      icon="folder"
                      color="secondary"
                      text-color="white"
                    >
                    </q-avatar>
                  </q-item-section>
                  <q-item-section>
                    <q-item-label>Photos</q-item-label>
                    <q-item-label caption>February 22, 2016</q-item-label>
                  </q-item-section>
                  <q-item-section side>
                    <q-icon name="info"></q-icon>
                  </q-item-section>
                </q-item>
                <q-separator inset spaced></q-separator>
                <q-item-label header>Files</q-item-label>
                <q-item
                  v-for="n in 3"
                  :key="`y.${n}`"
                  clickable
                  v-close-popup
                  tabindex="0"
                >
                  <q-item-section avatar>
                    <q-avatar
                      icon="assignment"
                      color="primary"
                      text-color="white"
                    >
                    </q-avatar>
                  </q-item-section>
                  <q-item-section>
                    <q-item-label>Vacation</q-item-label>
                    <q-item-label caption>February 22, 2016</q-item-label>
                  </q-item-section>
                  <q-item-section side>
                    <q-icon name="info"></q-icon>
                  </q-item-section>
                </q-item>
              </q-list>
            </q-btn-dropdown>
            <q-separator dark vertical></q-separator>
            <q-btn stretch flat label="Link"></q-btn>
          </q-toolbar>
        </div>
      </q-layout>
    </div>
    <script>
      new Vue({
        el: "#q-app",
        data: {}
      });
    </script>
  </body>
</html>

Conclusion

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

Categories
Vue

Add a Timeline to Your Vue.js App

A timeline is great for displaying chronological items. It looks good and it’s easy to read. We can easily add one to a Vue.js with the timeline-vuejs package, located at https://github.com/pablosirera/timeline-vuejs. It displays a vertical timeline with the year and the content of your choice.

In this article, we will build a journal entry app that lets users enter journal entries in and save them. They can also delete them as they wish. The saved entries will be displayed in a vertical timeline with the year on the left and the content on the right. To start, we will run the Vue CLI by running:

npx @vue/cli create journal-app

Vuex, and Babel.

Next we install some packages. We will use Axios for making HTTP requests, BootstrapVue for styling, Timeline-Vuejs for displaying the user’s diary in a timeline, VueFilterDateFormat for formatting dates in templates, and Vee-Validate for form validation. To install them, we run:

npm i axios bootstrap-vue timeline-vuejs vee-validate vue-filter-date-format

Now we can being building our app. Create a file called JournalForm.vue in the components folder and add:

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

      <b-form-group label="Diary">
        <ValidationProvider name="diary" rules="required" v-slot="{ errors }">
          <b-form-textarea
            :state="errors.length == 0"
            v-model="form.diary"
            required
            placeholder="Diary"
            name="diary"
            rows="3"
          ></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: "JournalForm",
  mixins: [requestsMixin],
  props: {
    edit: Boolean,
    journal: Object
  },
  data() {
    return {
      form: {}
    };
  },
  methods: {
    async onSubmit() {
      const isValid = await this.$refs.observer.validate();
      if (!isValid) {
        return;
      }
      const offDate = new Date(this.form.date);
      const correctedDate = new Date(
        offDate.getTime() + Math.abs(offDate.getTimezoneOffset() * 60000)
      );

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

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

The file is the form for letting users entering their journal entries.

In the onSubmit function, we validate our form with Vee-Validate by calling this.$refs.observer.validate(); to make sure diary is entered and that it’s entered before saving it, and the date field is entered in the YYYY-MM-DD format. We use Vee-Validate to validate the form fields. The ValidationObserver component is for validating the whole form, while the ValidationProvider component is for validating the form fields that it wraps around. The validation rule is specified by the rule prop of the date and diary fields. The state prop is for setting the validation state which shows the green when errors has length 0 and red otherwise. The error messages are shown in the b-form-invalid-feedback component. This page only has the countries drop down.

We correct the dates before submitting since dates in YYYY-MM-DD format has to be corrected by adding an offset for the time zone to save the date in UTC. Once the data is saved, we get the latest data and then put the data in the store. Then we emit the saved event to the HomePage so that we can close the modal.

Next we create a mixins folder in the src folder and create a file called requestsMixin.js file. In there, we add:

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

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

    addJournal(data) {
      return axios.post(`${APIURL}/journals`, data);
    },

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

    deleteJournal(id) {
      return axios.delete(`${APIURL}/journals/${id}`);
    }
  }
};

These are the functions to get and save our journal data to back end.

Next in the views folder, we replace the code in the Home.vue file with:

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

    <div class="text-center">
      <b-button @click="openAddModal()" variant="primary">Add Journal Entry</b-button>
      <b-button @click="openDeleteModal()" variant="primary">Delete Journal Entries</b-button>
    </div>

    <br />

    <Timeline
      :timeline-items="journals"
      message-when-no-items="No Entries Found"
      :unique-year="true"
      order="desc"
    />

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

    <b-modal id="delete-modal" title="Delete Journal Entries" hide-footer>
      <b-card v-for="(j, i) of journals" :key="i">
        <b-card-text>
          <h2>{{j.from | dateFormat('YYYY-MM-DD')}}</h2>
          <p>{{j.description}}</p>
        </b-card-text>
        <b-button @click="deleteOneJournal(j.id)" variant="primary">Delete</b-button>
      </b-card>
    </b-modal>
  </div>
</template>

<script>
// @ is an alias to /src
import JournalForm from "@/components/JournalForm.vue";
import Timeline from "timeline-vuejs";
import { requestsMixin } from "@/mixins/requestsMixin";

export default {
  name: "home",
  components: {
    JournalForm,
    Timeline
  },

  mixins: [requestsMixin],
  computed: {
    journals() {
      return this.$store.state.journals
        .sort((a, b) => +new Date(b.date) - +new Date(a.date))
        .map(j => ({
          id: j.id,
          from: new Date(j.date),
          title: j.title,
          description: j.diary
        }));
    }
  },
  beforeMount() {
    this.getAllJournals();
  },
  data() {
    return {
      selectedJournal: {}
    };
  },
  methods: {
    openAddModal() {
      this.$bvModal.show("add-modal");
    },
    openDeleteModal() {
      this.$bvModal.show("delete-modal");
    },
    closeModal() {
      this.$bvModal.hide("add-modal");
      this.$bvModal.hide("delete-modal");
    },
    async deleteOneJournal(id) {
      await this.deleteJournal(id);
      this.getAllJournals();
    },
    async getAllJournals() {
      const { data } = await this.getJournals();
      this.$store.commit("setJournals", data);
      this.closeModal();
    }
  }
};
</script>

<style lang="scss" scoped>
.timeline {
  margin: 0 auto;
}
</style>

This is the home page our app. We have a button for adding and deleting journal entries at the top of the page. When we click the buttons we open the modals to open a form for entering diary entries and display a list of diaries where you can click Delete button to delete them individually respectively. Below the buttons, there’s the timeline for displaying the journal entries. We pass in the journals array into the timeline-items .

Below that we have the modals for displaying the JournalForm and a list of cards stacked vertically to display the existing items, where they can be read and deleted individually. we handle the saved event from the JournalForm to close the modal after items are saved.

In the delete-modal , we list the items and have a button to delete each entry. The from field is formatted to YYYY-MM-DD format by the VueFilterDateFormat package to display the dates properly.

In the scripts section, we have the journals property in the computed object to get the latest journal entries. We map the fields to the ones required by the Timeline-Vuejs package so that we can display the timeline properly. We sort them by reverse chronological order to view the latest items at the top and older items below it. Also, we have the beforeMount hook to get all the password entries during page load with the getJournals function we wrote in our mixin. To delete a journal entry, we call deleteOneJournal our mixin to make the request to the back end via the deleteJournal function.

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="/">Journal 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 also add margins to our app’s buttons here.

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

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import BootstrapVue from "bootstrap-vue";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
import "../node_modules/timeline-vuejs/dist/timeline-vuejs.css";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required } from "vee-validate/dist/rules";
import VueFilterDateFormat from "vue-filter-date-format";

extend("required", required);
extend("date", {
  validate: value =>
    /([12]d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]d|3[01]))/.test(value),
  message: "Date must be in YYYY-MM-DD format"
});
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
Vue.use(VueFilterDateFormat);
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. We created the date rule which verifies that the inputs that used the rule will be in YYYY-MM-DD format. Also, we added the VueFilterDateFormat package to format the dates in the Date column of our table in Home.vue .

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

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

to change the title.

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

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

json-server --watch db.json

In db.json, change the text to:

{
  "journals": [],
}

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

Categories
Angular

Add Tool Tips to Your Angular App to Help Users Use Your App

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

Ngx-Bootstrap has tool tips built in as a component. It also has many other components that we can use. Adding tool tips using Ngx-Bootstrap is easy. See https://valor-software.com/ngx-bootstrap/#/tooltip for a full set of options for the tool tip. It can have text, position can change, also can change position according to screen size changes. The content can also be HTML. Trigger for tool tips can also be customized.

In this article, we will write an employee manager app that lets users enter their employee data in a form. Users can add, edit and delete their data with tool tips to guide them through the forms when they use the form.

To start the project, we install the Angular CLI if not installed already by running npm i -g @angular/cli . Next we run the Angular CLI to create the project by typing:

ng new employee-manager

In the wizard, we choose to include routing and use SCSS as our CSS preprocessor.

Then we install some packages. We need the Ngx-Bootstrap for styling, modal, and the tool tips, as well as MobX to store the countries in a shared store. To install them, we run:

npm i ngx-bootatrap mobx mobx-angular

Next we create our components, classes and services. To do this, we run:

ng g component employeeForm
ng g component homePage
ng g service employees
ng g class employeeStore
ng g class employee

Now we are ready to write some code. In employee-form.component.html, we replace the existing code with:

<form (ngSubmit)="save(employeeForm)" #employeeForm="ngForm">
  <div class="form-group">
    <label tooltip="Enter employee's name here" placement="right">Name</label>
    <input
      type="text"
      class="form-control"
      placeholder="Name"
      #name="ngModel"
      name="name"
      [(ngModel)]="form.name"
      required
    />
    <div *ngIf="name?.invalid && (name.dirty || name.touched)">
      <div *ngIf="name.errors.required">
        Name is required.
      </div>
    </div>
  </div>

  <div class="form-group">
    <label tooltip="Enter employee's address here" placement="right"
      >Address</label
    >
    <input
      type="text"
      class="form-control"
      placeholder="Address"
      #address="ngModel"
      name="address"
      [(ngModel)]="form.address"
      required
    />
    <div *ngIf="address?.invalid && (address.dirty || address.touched)">
      <div *ngIf="address.errors.required">
        Address is required.
      </div>
    </div>
  </div>

  <div class="form-group">
    <label tooltip="Enter employee's position here" placement="right"
      >Position</label
    >
    <input
      type="text"
      class="form-control"
      placeholder="Position"
      #position="ngModel"
      name="position"
      [(ngModel)]="form.position"
      required
    />
    <div *ngIf="position?.invalid && (position.dirty || position.touched)">
      <div *ngIf="position.errors.required">
        Position is required.
      </div>
    </div>
  </div>

  <div class="form-group">
    <label tooltip="Select employee's employment status" placement="right"
      >Employment Status</label
    >
    <select
      class="form-control"
      #status="ngModel"
      name="status"
      [(ngModel)]="form.status"
      required
    >
      <option value="Employed">Employed</option>
      <option value="Terminated">Terminated</option>
    </select>
    <div *ngIf="status?.invalid && (status.dirty || status.touched)">
      <div *ngIf="status.errors.required">
        Status is required.
      </div>
    </div>
  </div>

  <button class="btn btn-primary">Save</button>
</form>

This is the template for the employee form for letting users add and edit employee data. We have name, address, position, and status fields, and they’re all set as required. Error messages are displayed when any of them aren’t filled in and submitted will be stopped if the user tries to submit without any of these data. The labels will show the tool tips when users hover over them. We set the placementto right so that they display on the right. When users click the Save button the save function in the code is called.

Then in employee-form.component.ts , we replace the existing code with:

import { Component, OnInit, Output, Input, SimpleChanges, EventEmitter } from '@angular/core';
import { employeeStore } from '../employee-store';
import { EmployeesService } from '../employees.service';
import { NgForm } from '@angular/forms';
import { Employee } from '../employee';

@Component({
  selector: 'app-employee-form',
  templateUrl: './employee-form.component.html',
  styleUrls: ['./employee-form.component.scss']
})
export class EmployeeFormComponent implements OnInit {
  form: Employee = <Employee>{};
  @Output('saved') saved = new EventEmitter();
  @Input() edit: boolean;
  @Input() selectedEmployee: Employee;
  store = employeeStore;

  constructor(private employeeService: EmployeesService) { }

  ngOnInit() {
  }

  ngOnChanges(changes: SimpleChanges) {
    this.form = Object.assign({}, this.selectedEmployee);
  }

  save(employeeForm: NgForm) {
    if (employeeForm.invalid) {
      return;
    }
    if (this.edit) {
      this.employeeService.editEmployee(this.form)
        .subscribe(res => {
          this.getEmployees()
          this.saved.emit();
        })
    }
    else {
      this.employeeService.addEmployee(this.form)
        .subscribe(res => {
          this.getEmployees()
          this.saved.emit();
        })
    }
  }

  getEmployees() {
    this.employeeService.getEmployees()
      .subscribe((res: Employee[]) => {
        this.store.setEmployees(res);
      })
  }

}

This file has the functions that we called in the previous template like the save function. We also have the Inputs to get the data from the home page, as well as an Output to emit a saved event to the home page. Since we use the form for editing, we also need to pass in the selected entry with the selectedEmployee Input, so that it can be edited. To update the form object with the selectedEmployee values, we copied the value whenever the selectedEmployee Input changes.

In the save function, we validate the form and call different functions for saving depending on whether the form is being used for adding or editing an entry. The latest entries will be populated in our MobX store by calling the getEmployees function and the saved event will be emitted once that’s done.

Next in home-page.component.html , we replace the code with:

<ng-template #addTemplate>
  <div class="modal-header">
    <h2 class="modal-title pull-left">Add Employee</h2>
  </div>
  <div class="modal-body">
    <app-employee-form (saved)="closeModals()"></app-employee-form>
  </div>
</ng-template>

<ng-template #editTemplate>
  <div class="modal-header">
    <h2 class="modal-title pull-left">Edit Employee</h2>
  </div>
  <div class="modal-body">
    <app-employee-form
      [edit]="true"
      (saved)="closeModals()"
      [selectedEmployee]="selectedEmployee"
    ></app-employee-form>
  </div>
</ng-template>

<h1 class="text-center">Employee Manager</h1>

<div class="btn-group" role="group" id="add-group">
  <button
    type="button"
    class="btn btn-secondary"
    (click)="openAddModal(addTemplate)"
  >
    Add Employee
  </button>
</div>

<br />

<div class="card" *ngFor="let e of store.employees">
  <div class="card-body">
    <h5 class="card-title">{{ e.name }}</h5>
    <p class="card-text">Position: {{ e.position }}</p>
    <p class="card-text">Address: {{ e.address }}</p>
    <p class="card-text">Employment Status: {{ e.status }}</p>
    <button (click)="openEditModal(editTemplate, e)" class="btn btn-primary">
      Edit
    </button>
    <button (click)="deleteEmployee(e.id)" class="btn btn-primary">
      Delete
    </button>
  </div>
</div>

to add buttons for adding, editing, and deleting entries. The employee entries are displayed in the cards. The Edit and Delete buttons are displayed at the bottom of the cards. Also, we have the modals for adding and editing entries that we open with the Add and Edit buttons respectively.

In home-page.component.scss , we add:

$margin: 10px;

#add-group {
  margin-bottom: $margin;
}

button {
  margin-right: $margin;
}

to add some margins to our buttons.

Next in home-page.component.ts , we replace the existing code with:

import { Component, OnInit, TemplateRef } from '@angular/core';
import { employeeStore } from '../employee-store';
import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal';
import { EmployeesService } from '../employees.service';
import { Employee } from '../employee';

@Component({
  selector: 'app-home-page',
  templateUrl: './home-page.component.html',
  styleUrls: ['./home-page.component.scss']
})
export class HomePageComponent implements OnInit {
  addModalRef: BsModalRef;
  editModalRef: BsModalRef;
  selectedEmployee: Employee = <Employee>{};
  store = employeeStore;

  constructor(
    private modalService: BsModalService,
    private employeeService: EmployeesService
  ) { }

  ngOnInit() {
    this.getEmployees()
  }

  getEmployees() {
    this.employeeService.getEmployees()
      .subscribe((res: Employee[]) => {
        this.store.setEmployees(res);
      })
  }

  openAddModal(template: TemplateRef<any>) {
    this.addModalRef = this.modalService.show(template);
  }

  openEditModal(template: TemplateRef<any>, employee: Employee) {
    this.editModalRef = this.modalService.show(template);
    this.selectedEmployee = employee;
  }

  closeModals() {
    this.addModalRef && this.addModalRef.hide();
    this.editModalRef && this.editModalRef.hide();
  }

  deleteEmployee(id) {
    this.employeeService.deleteEmployee(id)
      .subscribe((res: Employee[]) => {
        this.getEmployees();
      })
  }
}

We have the openAddModal and openEditModal functions to open the Add and Edit modals. The closeModals function is for closing the modals when things are saved in the app-employee-form component. The deleteEmployee function is for deleting the employee, and the getEmployees is used for getting the entries when the page loads when items are deleted and out the items in our store so every component can access it.

In app-routing.module.ts , we put:

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HomePageComponent } from './home-page/home-page.component';

const routes: Routes = [
  { path: '', component: HomePageComponent }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

so users can see the pages we just added when they click on the links or enter the URLs.

Next in app.component.html , we put:

<nav class="navbar navbar-expand-lg navbar-light bg-light">
  <a class="navbar-brand" routerLink="/">Employee Manager</a>
  <button
    class="navbar-toggler"
    type="button"
    data-toggle="collapse"
    data-target="#navbarSupportedContent"
    aria-controls="navbarSupportedContent"
    aria-expanded="false"
    aria-label="Toggle navigation"
  >
    <span class="navbar-toggler-icon"></span>
  </button>

  <div class="collapse navbar-collapse" id="navbarSupportedContent">
    <ul class="navbar-nav mr-auto">
      <li class="nav-item active">
        <a class="nav-link" routerLink="/">Home </a>
      </li>
    </ul>
  </div>
</nav>

<div class="page">
  <router-outlet></router-outlet>
</div>

to add the links to our pages and expose the router-outlet so users can see our pages.

Then in app.component.scss , we add:

.page {
  padding: 20px;
}

nav {
  background-color: aquamarine !important;
}

to add padding to our pages and change the color of our Bootstrap navigation bar.

In app.module.ts , we replace the existing code with:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HomePageComponent } from './home-page/home-page.component';
import { EmployeeFormComponent } from './employee-form/employee-form.component';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { TooltipModule } from 'ngx-bootstrap/tooltip';
import { ModalModule } from 'ngx-bootstrap/modal';
import { EmployeesService } from './employees.service';

@NgModule({
  declarations: [
    AppComponent,
    HomePageComponent,
    EmployeeFormComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    FormsModule,
    HttpClientModule,
    ModalModule.forRoot(),
    TooltipModule.forRoot()
  ],
  providers: [
    EmployeesService
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

we add our components, services, and libraries that we use in our app. Note that we add each component in Ngx-Bootstrap separately so we only add what we need.

In employee.ts , we add:

export class Employee {
    public id: number;
    public name: string;
    public address: string;
    public position: string;
    public status: string;
}

to add types to our employee form model.

Then in employeeStore.ts , we add:

import { observable, action } from 'mobx-angular';
import { Employee } from './employee';

class EmployeeStore {
    @observable employees: Employee[] = [];
    @action setEmployees(employees) {
        this.employees = employees;
    }
}

export const employeeStore = new EmployeeStore();

to create the MobX store to get our components share the data. Whenever we call this.store.setEmployees our components we set the currencies data in this store since we added the @action decorator before it. When we call this.store.employees in our component code we are always getting the latest value from this store since has the @observable decorator. We add the Employee[] type to employees so we don’t have to guess the fields available.

Then in employee.service.ts , we replace the existing code with:

import { Injectable } from '@angular/core';
import { environment } from 'src/environments/environment';
import { HttpClient } from '@angular/common/http';

@Injectable({
  providedIn: 'root'
})
export class EmployeesService {

  constructor(private http: HttpClient) { }

  getEmployees() {
    return this.http.get(`${environment.apiUrl}/employees`);
  }

  addEmployee(data) {
    return this.http.post(`${environment.apiUrl}/employees`, data);
  }

  editEmployee(data) {
    return this.http.put(`${environment.apiUrl}/employees/${data.id}`, data);
  }

  deleteEmployee(id) {
    return this.http.delete(`${environment.apiUrl}/employees/${id}`);
  }

}

so that we can make HTTP requests to our back end to get, save and delete the user’s employee entries.

Next in environment.prod.ts and environment.ts , we replace the existing code with:

export const environment = {
  production: true,
  apiUrl: 'http://localhost:3000'
};

to add our API’s URL.

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

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Employee Manager</title>
    <base href="/" />

<meta name="viewport" content="width=device-width, initial-scale=1" />
    <link
      href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css"
      rel="stylesheet"
    />
    <link rel="icon" type="image/x-icon" href="favicon.ico" />
    <script
      src="https://code.jquery.com/jquery-3.3.1.slim.min.js"
      integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo"
      crossorigin="anonymous"
    ></script>
    <script
      src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"
      integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1"
      crossorigin="anonymous"
    ></script>
    <script
      src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"
      integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM"
      crossorigin="anonymous"
    ></script>
  </head>
  <body>
    <app-root></app-root>
  </body>
</html>

to add the Bootstrap CSS and JavaScript dependencies into our app, as well as changing the title.

After all the work, we can run ng serve to run the app.

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

json-server --watch db.json

In db.json, change the text to:

{
  "employees": [
  ]
}

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