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](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](http://twitter.com/saved "Twitter profile for @saved")="closeModal()" [@cancelled](http://twitter.com/cancelled "Twitter profile for @cancelled")="closeModal()" :edit="false"></NoteForm>
</b-modal>
<b-modal id="edit-modal" title="Edit Note" hide-footer>
<NoteForm [@saved](http://twitter.com/saved "Twitter profile for @saved")="closeModal()" [@cancelled](http://twitter.com/cancelled "Twitter profile for @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: