Money input is handy for apps that need users to enter monetary amounts. Building an input component specifically for monetary input is tedious work. You have to add labels to indicate that it’s a money input, and you have to check that the input is valid. Some inputs also need prefixes, suffixes, and input masks, which makes building the input tougher. Fortunately, for Vue.js, there is the V-Money library, which is readily available for developers.
In this article, we will build a bill tracker app that tracks the expenses of the user. It lets users enter the name of the bill, the amount, and the due date, and also edit and delete them. To start building the project, we run the Vue CLI by running:
npx @vue/cli create bill-tracker
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-Money for the money input. To install the packages, we run npm i axios bootstrap-vue vee-validate v-money
. After installing the packages we can start building our bill tracker app.
First, we create our form for letting users add and edit their bills. In the components
folder, create a file called BillForm.vue
and add:
<template>
<ValidationObserver ref="observer" v-slot="{ invalid }">
<b-form @submit.prevent="onSubmit" novalidate>
<b-form-group label="Name">
<ValidationProvider name="name" rules="required" v-slot="{ errors }">
<b-form-input
type="text"
:state="errors.length == 0"
v-model="form.name"
required
placeholder="Name"
name="name"
></b-form-input>
<b-form-invalid-feedback :state="errors.length == 0">Name is requied.</b-form-invalid-feedback>
</ValidationProvider>
</b-form-group>
<b-form-group label="Amount">
<ValidationProvider name="amount" rules="required|min_value:0" v-slot="{ errors }">
<money
v-model="form.amount"
v-bind="money"
class="form-control"
:class="{'is-valid': errors.length == 0, 'is-invalid': errors.length > 0}"
></money>
<b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
</ValidationProvider>
</b-form-group>
<b-form-group label="Due Date">
<ValidationProvider name="dueDate" rules="required|date" v-slot="{ errors }">
<b-form-input
type="text"
:state="errors.length == 0"
v-model="form.dueDate"
required
placeholder="Due Date"
name="dueDate"
></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 { requestsMixin } from "@/mixins/requestsMixin";
export default {
name: "BillForm",
mixins: [requestsMixin],
props: {
bill: Object,
edit: Boolean
},
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.editBill(params);
} else {
await this.addBill(params);
}
const { data } = await this.getBills();
this.$store.commit("setBills", data);
this.$emit("saved");
},
cancel() {
this.$emit("cancelled");
}
},
data() {
return {
form: {},
money: {
decimal: ".",
thousands: ",",
prefix: "$ ",
precision: 2,
masked: false
}
};
},
watch: {
bill: {
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 it. We make the name
, amount
, and dueDate
fields required in the rules
prop so that users will have to enter all of them to save the bill.
For the amount field input, we use the money
component from the V-Money library to add the money input for the bill amount. The v-bind
directive is used for setting the options for the money input. The money
object in the data
field has the options. We specified that the decimal point is marked by a period, a comma separates every 3 digits with the thousands
option, and prefix the amount with a dollar sign to make the input clear. precision
2 means that we let users enter up to 2 digits, and masked
false means that we disabled the input mask. We applied our CSS classes for Bootstrap directly on the money
component so that we make the styles consistent with the other 2 inputs. Both static and dynamic classes work properly with the money
component.
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 bill
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: {
getBills() {
return axios.get(`${APIURL}/bills`);
},
addBill(data) {
return axios.post(`${APIURL}/bills`, data);
},
editBill(data) {
return axios.put(`${APIURL}/bills/${data.id}`, data);
},
deleteBill(id) {
return axios.delete(`${APIURL}/bills/${id}`);
}
}
};
These are the functions we use in our components to make HTTP requests to our back end to save the bills.
Next in Home.vue
, replace the existing code with:
<template>
<div class="page">
<h1 class="text-center">Bill Tracker</h1>
<b-button-toolbar>
<b-button @click="openAddModal()">Add Bill</b-button>
</b-button-toolbar>
<br />
<b-table-simple responsive>
<b-thead>
<b-tr>
<b-th>Name</b-th>
<b-th>Amount</b-th>
<b-th>Due Date</b-th>
<b-th></b-th>
<b-th></b-th>
</b-tr>
</b-thead>
<b-tbody>
<b-tr v-for="b in bills" :key="b.id">
<b-td>{{b.name}}</b-td>
<b-td>${{b.amount}}</b-td>
<b-td>{{b.dueDate}}</b-td>
<b-td>
<b-button @click="openEditModal(b)">Edit</b-button>
</b-td>
<b-td>
<b-button @click="deleteOneBill(b.id)">Delete</b-button>
</b-td>
</b-tr>
</b-tbody>
</b-table-simple>
<b-modal id="add-modal" title="Add Bill" hide-footer>
<BillForm @saved="closeModal()" @cancelled="closeModal()" :edit="false"></BillForm>
</b-modal>
<b-modal id="edit-modal" title="Edit Bill" hide-footer>
<BillForm @saved="closeModal()" @cancelled="closeModal()" :edit="true" :bill="selectedBill"></BillForm>
</b-modal>
</div>
</template>
<script>
import BillForm from "@/components/BillForm.vue";
import { requestsMixin } from "@/mixins/requestsMixin";
export default {
name: "home",
components: {
BillForm
},
mixins: [requestsMixin],
computed: {
bills() {
return this.$store.state.bills;
}
},
beforeMount() {
this.getAllBills();
},
data() {
return {
selectedBill: {}
};
},
methods: {
openAddModal() {
this.$bvModal.show("add-modal");
},
openEditModal(bill) {
this.$bvModal.show("edit-modal");
this.selectedBill = bill;
},
closeModal() {
this.$bvModal.hide("add-modal");
this.$bvModal.hide("edit-modal");
this.selectedBill = {};
this.getAllBills();
},
async deleteOneBill(id) {
await this.deleteBill(id);
this.getAllBills();
},
async getAllBills() {
const { data } = await this.getBills();
this.$store.commit("setBills", 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.getAllBills
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.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="/">Bill Tracker</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 money from "v-money";
import VueFilterDateFormat from "vue-filter-date-format";
import BootstrapVue from "bootstrap-vue";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required, min_value } from "vee-validate/dist/rules";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
extend("required", required);
extend("min_value", min_value);
extend("date", {
validate: value => {
return /([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.use(BootstrapVue);
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
Vue.use(money, { precision: 4 });
Vue.use(VueFilterDateFormat);
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 date
rule for validating that the due date is YYYY-MM-DD format.
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: {
bills: []
},
mutations: {
setBills(state, payload) {
state.bills = payload;
}
},
actions: {}
});
to add our bills
state to the store so we can observe it in the computed
block of BillForm
and HomePage
components. We have the setBills
function to update the notes
state and we use it in the components by call this.$store.commit(“setBills”, data);
like we did in BillForm
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>Bill Tracker</title>
</head>
<body>
<noscript>
<strong
>We're sorry but v-money-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:
{
"`bills`": []
}
So we have the bills
endpoints defined in the requests.js
available.