Gauge charts are handy for showing if something is close to the maximum or minimum amount. It’s handy for visualizing any quantities that have maximum or minimum values. Making our own would be a real pain since we have to draw shapes, lines and text on the canvas to create the gauge. Fortunately, developers have created premade solutions for us to use.
For Vue.js apps, we can use the VGauge library, located at https://github.com/amroessam/vgauge, to put gauges on our own apps. In this article, we will create a piggy bank app that lets users set their goals for saving and add their amount of money saved for a given date. We will display displaying the gauge to let users know if they reached their savings goal or not. To start, we will run the Vue CLI by running:
npx @vue/cli create piggy-bank
In the wizard, we select the ‘Manually select features’ and select Vue Router, Vuex, and Babel.
Next we install some packages. We will use Axios for making HTTP requests, BootstrapVue for styling, VGauge for displaying the gauge for showing their savings relative to their goal, VueFilterDateFormat for formatting dates in templates, and Vee-Validate for form validation. To install them, we run:
npm i axios bootstrap-vue vgauge vee-validate vue-filter-date-format
Now we can being building our app. Create a file called GoalForm.vue
in the components
folder and add:
<template>
<ValidationObserver ref="observer" v-slot="{ invalid }">
<b-form @submit.prevent="onSubmit" novalidate>
<b-form-group label="Savings Amount Goal">
<ValidationProvider name="amount" rules="required|min_value:0" v-slot="{ errors }">
<b-form-input
type="text"
:state="errors.length == 0"
v-model="form.amount"
required
placeholder="Savings Amount Goal"
name="amount"
></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">Save</b-button>
</b-form>
</ValidationObserver>
</template>
<script>
import { requestsMixin } from "@/mixins/requestsMixin";
export default {
name: "SavingForm",
mixins: [requestsMixin],
data() {
return {
form: {}
};
},
computed: {
goal() {
return this.$store.state.goal;
}
},
beforeMount() {
this.getSavingsGoal();
},
methods: {
cancel() {
this.$emit("cancelled");
},
async getSavingsGoal() {
const { data } = await this.getGoal();
this.$store.commit("setGoal", data);
},
async onSubmit() {
const isValid = await this.$refs.observer.validate();
if (!isValid) {
return;
}
await this.setGoal(this.form);
const { data } = await this.getSavings();
this.$store.commit("setGoal", data);
this.$emit("saved");
}
},
watch: {
goal: {
handler(val) {
this.form = val || {};
},
deep: true,
immediate: true
}
}
};
</script>
The file is the form for letting users entering their savings goal. We have a getSavingsGoal
function for getting the savings of the user from back end. It’s called in the beforeMount
hook and at the end of the onSubmit
function to get the latest values.
In the onSubmit
function, we validate our form with Vee-Validate by calling this.$refs.observer.validate();
to make sure amount
is entered and that it’s 0 or higher before saving it. 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 amount
field. 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.
Then create a SavingForm.vue
file in the same 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="Amount Saved">
<ValidationProvider name="amount" rules="required|min_value:0" v-slot="{ errors }">
<b-form-input
type="text"
:state="errors.length == 0"
v-model="form.amount"
required
placeholder="Amount Saved"
name="amount"
></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">Save</b-button>
</b-form>
</ValidationObserver>
</template>
<script>
import { requestsMixin } from "@/mixins/requestsMixin";
import * as moment from "moment";
export default {
name: "SavingForm",
props: {
saving: Object,
edit: Boolean
},
mixins: [requestsMixin],
data() {
return {
form: {}
};
},
computed: {
savings() {
return this.$store.state.savings;
}
},
methods: {
cancel() {
this.$emit("cancelled");
},
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.addSaving(params);
} else {
await this.editSaving(params);
}
const { data } = await this.getSavings();
this.$store.commit("setSavings", data);
this.$emit("saved");
}
},
watch: {
saving: {
handler(val) {
this.form = JSON.parse(JSON.stringify(val || {}));
if (this.form.date) {
this.form.date = moment(this.form.date).format("YYYY-MM-DD");
}
},
deep: true,
immediate: true
}
}
};
</script>
The file is the form for letting users entering their savings data. We have a form with the date
and amount
fields to let users save their savings amount for the date they entered.
In the onSubmit
function, we validate our form with Vee-Validate by calling this.$refs.observer.validate();
to make sure and date
and amount
are entered and that date is in YYYY-MM-DD format and amount is 0 or higher before saving it. 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 form fields. date
is a custom made validation rule that we will add. 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.
Next we create amixins
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: {
getSavings() {
return axios.get(`${APIURL}/savings`);
},
addSaving(data) {
return axios.post(`${APIURL}/savings`, data);
},
editSaving(data) {
return axios.put(`${APIURL}/savings/${data.id}`, data);
},
deleteSaving(id) {
return axios.delete(`${APIURL}/savings/${id}`);
},
getGoal() {
return axios.get(`${APIURL}/goal`);
},
setGoal(data) {
return axios.post(`${APIURL}/goal`, data);
}
}
};
These are the functions to get and save our savings and savings goal 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">Piggy Bank App</h1>
<b-button-toolbar class="button-toolbar">
<b-button @click="openAddModal()" variant="primary">Add Money Saved</b-button>
<b-button @click="openGoalModal()" variant="primary">Set Saving Goal</b-button>
</b-button-toolbar>
<div class="text-center" v-if="goal && totalSavings">
<v-gauge :maxValue="+goal" :minValue="0" :value="+totalSavings" unit="USD" top />
<p>Savings goal is {{+goal}} USD.</p>
</div>
<b-modal id="add-modal" title="Add Money Saved" hide-footer>
<SavingForm@saved="closeModal()" @cancelled="closeModal()" :edit="false" />
</b-modal>
<b-modal id="edit-modal" title="Edit Money Saved" hide-footer>
<SavingForm
@saved="closeModal()"
@cancelled="closeModal()"
:edit="true"
:saving="selectedSaving"
/>
</b-modal>
<b-modal id="goal-modal" title="Set Saving Goal" hide-footer>
<GoalForm@saved="closeModal()" @cancelled="closeModal()" />
</b-modal>
<b-table-simple responsive>
<b-thead>
<b-tr>
<b-th sticky-column>Date</b-th>
<b-th>Money Saved</b-th>
<b-th>Edit</b-th>
<b-th>Delete</b-th>
</b-tr>
</b-thead>
<b-tbody>
<b-tr v-for="s in savings" :key="s.id">
<b-th sticky-column>{{ new Date(s.date) | dateFormat('YYYY-MM-DD') }}</b-th>
<b-td>{{s.amount}}</b-td>
<b-td>
<b-button @click="openEditModal(s)">Edit</b-button>
</b-td>
<b-td>
<b-button @click="deleteOneSaving(s.id)">Delete</b-button>
</b-td>
</b-tr>
</b-tbody>
</b-table-simple>
</div>
</template>
<script>
// @ is an alias to /src
import SavingForm from "@/components/SavingForm.vue";
import GoalForm from "@/components/GoalForm.vue";
import { requestsMixin } from "@/mixins/requestsMixin";
import VGauge from "vgauge";
export default {
name: "home",
components: {
SavingForm,
GoalForm,
VGauge
},
mixins: [requestsMixin],
data() {
return {
selectedSaving: {},
goal: 0,
totalSavings: 0
};
},
computed: {
savings() {
return this.$store.state.savings.sort(
(a, b) => +new Date(b.date) - +new Date(a.date)
);
}
},
beforeMount() {
this.getAllSavings();
this.getSavingsGoal();
},
methods: {
openAddModal() {
this.$bvModal.show("add-modal");
},
openEditModal(saving) {
this.$bvModal.show("edit-modal");
this.selectedSaving = saving;
},
closeModal() {
this.$bvModal.hide("add-modal");
this.$bvModal.hide("edit-modal");
this.$bvModal.hide("goal-modal");
this.getSavingsGoal();
this.getAllSavings();
},
openGoalModal() {
this.$bvModal.show("goal-modal");
},
async getAllSavings() {
this.totalSavings = 0;
const { data } = await this.getSavings();
this.$store.commit("setSavings", data);
this.totalSavings = +this.$store.state.savings
.map(s => s.amount)
.reduce((a, b) => +a + +b, 0);
},
async deleteOneSaving(id) {
await this.deleteSaving(id);
this.getAllSavings();
},
async getSavingsGoal() {
this.goal = 0;
const { data } = await this.getGoal();
this.goal = +data.amount;
}
}
};
</script>
We have buttons to add saving data as well as edit and delete them in each row. The Add Money Saved and Set Saving Goal will open modals with the SavingForm
and GoalForm
respectively. We use the SavingForm
for both adding and editing saving entries.
The savings data is obtained from back end and saved in our Vuex store. Once it’s there, we get the data in the computed
property and then display the data from the computed
property.
We have a table to display the saving data entered. We sort it by date descending date to display them in reverse chronological order. In the scripts
section, we have the beforeMount
hook to get all the password entries during page load with the getAllSavings
function we wrote in our mixin. When the Edit button is clicked, the selectedSaving
variable is set, and we pass it to the SavingForm
for editing.
To delete a recipe, we call deleteSaving
in our mixin to make the request to the back end via the deleteOneSaving
function.
The VGauge component is used here. The v-gauge
element in the template will display the gauge graph. The maxValue
prop is set to our saving goal, which is stored in the goal
variable, and the value
prop is set to the totalSavings
variable. The units
is set to USD and it’s displayed at the end of the amount, and the top
prop makes the value
display on the top of the gauge. The v-if=”goal && totalSavings”
is very important. It allows us to refresh the graph by setting goal
and totalSavings
to 0 then setting the values obtained from the store or back end again. We did this in the getAllSavings
and getSavingsGoal
functions.
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="/">Piggy Bank 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>
import { requestsMixin } from "@/mixins/requestsMixin";
export default {
mixins: [requestsMixin],
data() {
return {
path: this.$route && this.$route.path
};
},
watch: {
$route(route) {
this.path = route.path;
}
},
beforeMount() {
this.getSavingsGoal();
},
methods: {
async getSavingsGoal() {
const { data } = await this.getGoal();
this.$store.commit("setGoal", data);
}
}
};
</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.
In the script
section, we get the savings goal by calling this.getSavingsGoal
and put them in our Vuex store.
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 { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required, min_value } from "vee-validate/dist/rules";
import VueFilterDateFormat from "vue-filter-date-format";
extend("required", required);
extend("min_value", min_value);
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.use(VueFilterDateFormat);
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
Vue.use(BootstrapVue);
Vue.config.productionTip = false;
new Vue({
router,
store,
render: h => h(App)
}).$mount("#app");
We added all the libraries we need here, including BootstrapVue JavaScript and CSS, Vee-Validate components along with the validation rules. 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: {
savings: [],
goal: {}
},
mutations: {
setSavings(state, payload) {
state.savings = payload;
},
setGoal(state, payload) {
state.goal = payload;
}
},
actions: {}
});
to add our recipes
state to the store so we can observer it in the computed
block of GoalForm
,SavingForm,
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(“setSavings”, data);
like we did in SavingForm
, and the setGoal
to set the savings goal data for the GoalForm
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>Piggy Bank App</title>
</head>
<body>
<noscript>
<strong
>We're sorry but vgauge-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:
{
"savings": [],
"goal": {}
}
So we have the savings
and goal
endpoints defined in the requests.js
available.
After all the hard work, we get: