Step 9: Racing Through Budgets
This is part 9 of an 18 part series titled Vue.js Application Tutorial - Creating a Simple Budgeting App with Vue. It is highly recommended that you read the series in order, as each section builds on the last.
- Step 0: Intro
- Step 1: Planning Your Application
- Step 2: Data Architecture
- Step 3: Setup & Project Structure
- Step 4: Create & View Accounts
- Step 5: Edit & Delete Accounts
- Step 6: Adding LocalStorage to our Vue.js Application
- Step 7: Interlude & Refactor
- Step 8: Budgeting
- Step 9: Racing Through Budgets
- Step 10: Styling & Navigation
- Step 11: Finishing Budgets with Vue.js Dynamic Components
- Step 12: Planning for Transactions
- Step 13 - All Aboard the Transaction Train
- Step 14 - User Testing
The next entry is expected to be published on 03 August 2017. Sign up using the form at the bottom of this post to receive updates when posts are published.
It's time for the rubber to hit the road. Much of the core of the budgeting module is intrinsically similar to what we already built over in accounts, so we're going to really race through some of the similar sections. That isn't an excuse to make mistakes, and I'll try not to gloss over new principles as we come across them.
Since we're now familiar with saving and fetching data from our local database we will do that inline as we create the new module. I'm also going to begin to commit more frequently since we're knocking out big chunks of the application all at once.
As usual, we start with the data layer. We add in our default, empty object for budgets. A single budget
object will represent a month, with individual budget items contained within.
// /src/app/budgets/vuex/index.js
import * as actions from './actions';
import mutations from './mutations';
import getters from './getters';
const state = {
budgets: {}
...
We know the user will need to create, load, update, and delete budgets. My first instinct is to add all of that code at once, but it's better to slow down and work on one piece at a time. This helps ensure we don't skip critical pieces. It also allows us to get various components in a working state before moving on to the next one. (Using a test-driven-development approach would really force us to slow down and work methodically.)
Let's start with creation. We need to 1) add the mutator, 2) add the action, 3) save it to local storage.
export default {
CREATE_BUDGET (state, payload) {
state.budgets[payload.budget.id] = payload.budget;
}
};
import { guid } from '../../../utils';
import { saveBudget } from '../api';
export const createBudget = ({ commit }, data) => {
let id = guid();
let budget = Object.assign({ id: id }, data);
commit('CREATE_BUDGET', { budget: budget });
saveBudget(budget).then((value) => {
// we saved the budget, what's next?
});
};
import localforage from 'localforage';
const BUDGET_NAMESPACE = 'BUDGET-';
export const saveBudget = (budget) => {
return localforage.setItem(
BUDGET_NAMESPACE + budget.id,
budget
).then((value) => {
return value;
}).catch((err) => {
console.log('had a little trouble saving that budget', err);
});
};
So far this is looking very similar to our accounts code.
Next let's work up the component, route it, and add a temporary navigation link. We won't worry about adding budget items yet. The user can't edit their income or amount spent directly - that is updated via transactions. There is no real difference between creation and edit mode like there was with accounts.
- src/app/budgets/routes.js
- src/app/budgets/components/index.js
- src/app/budgets/components/CreateUpdateBudget.vue
import * as components from './components';
export default [
{
path: '/budgets',
component: components.BudgetsListView
},
{
path: '/budgets/create',
component: components.CreateUpdateBudget,
name: 'createBudget'
}
];
export { default as BudgetsListView } from './BudgetsList';
export { default as CreateUpdateBudget } from './CreateUpdateBudget';
<template>
<div id="budget-create-edit-view">
You can create and edit budgets with me, woot!
<form class="form" @submit.prevent="processSave">
<label for="month" class="label">Month</label>
<p class="control">
<input type="text" class="input" name="month" v-model="selectedBudget.month">
</p>
<label for="budgeted" class="label">Budgeted amount</label>
<p class="control">
<input type="text" class="input" name="budgeted" v-model="selectedBudget.budgeted">
</p>
<p class="control">
Spent: {{ selectedBudget.spent }}
</p>
<p class="control">
Income: {{ selectedBudget.income }}
</p>
<div class="control is-grouped">
<p class="control">
<button class="button is-primary">Submit</button>
</p>
</div>
</form>
</div>
</template>
<script>
import { mapActions } from 'vuex';
export default {
name: 'budget-create-edit-view',
data: () => {
return {
selectedBudget: {}
};
},
methods: {
...mapActions([
'createBudget'
]),
resetAndGo () {
this.selectedBudget = {};
// todo: redirect here
},
saveNewBudget () {
this.createBudget(this.selectedBudget).then(() => {
this.resetAndGo();
});
},
processSave () {
this.$route.params.budgetId ? this.saveNewBudget() : false;
}
}
};
</script>
You can now navigate to /budgets/create
, add a naive budget object, and see it in the store.
We can go ahead and add the edit code too. It's very similar to create, the biggest piece we need to create is the loadBudgets
method.
- src/app/budgets/vuex/mutations.js
- src/app/budgets/vuex/actions.js
- src/app/budgets/vuex/getters.js
- src/app/budgets/api.js
- src/app/budgets/components/CreateUpdateBudget.vue
- src/app/budgets/routes.js
- src/app/budgets/vuex/index.js
- src/app/budgets/vuex/getters.js
export default {
CREATE_BUDGET (state, payload) {
state.budgets[payload.budget.id] = payload.budget;
},
UPDATE_BUDGET (state, payload) {
state.budgets[payload.budget.id] = payload.budget;
},
LOAD_BUDGETS (state, payload) {
state.budgets = payload;
}
};
import { guid } from '../../../utils';
import { saveBudget, fetchBudgets } from '../api';
export const createBudget = ({ commit }, data) => {
let id = guid();
let budget = Object.assign({ id: id }, data);
commit('CREATE_BUDGET', { budget: budget });
saveBudget(budget).then((value) => {
// we saved the budget, what's next?
});
};
export const updateBudget = ({ commit }, data) => {
commit('UPDATE_BUDGET', { budget: data });
saveBudget(data);
};
export const loadBudgets = (state) => {
if (!state.budgets || Object.keys(state.budgets).length === 0) {
return fetchBudgets().then((res) => {
state.commit('LOAD_BUDGETS', res);
});
}
};
export default {
getBudgetById: (state, getters) => (budgetId) => {
return state.budgets && budgetId in state.budgets ? state.budgets[budgetId] : false;
}
};
import localforage from 'localforage';
import { processAPIData } from '../../utils';
const BUDGET_NAMESPACE = 'BUDGET-';
export const saveBudget = (budget) => {
return localforage.setItem(
BUDGET_NAMESPACE + budget.id,
budget
).then((value) => {
return value;
}).catch((err) => {
console.log('had a little trouble saving that budget', err);
});
};
export const fetchAccounts = () => {
return localforage.startsWith(BUDGET_NAMESPACE).then((res) => {
return processAPIData(res);
});
};
<script>
import { mapActions, mapGetters } from 'vuex';
export default {
name: 'budget-create-edit-view',
data: () => {
return {
selectedBudget: {}
};
},
mounted () {
if ('budgetId' in this.$route.params) {
this.loadBudgets().then(() => {
let selectedBudget = this.getBudgetById(this.$route.params.budgetId);
if (selectedBudget) {
this.selectedBudget = Object.assign({}, selectedBudget);
}
});
}
},
methods: {
...mapActions([
'createBudget',
'updateBudget',
'loadBudgets'
]),
resetAndGo () {
this.selectedBudget = {};
// todo: redirect here
},
saveNewBudget () {
this.createBudget(this.selectedBudget).then(() => {
this.resetAndGo();
});
},
saveBudget () {
this.updateBudget(this.selectedBudget).then(() => {
this.resetAndGo();
});
},
processSave () {
this.$route.params.budgetId ? this.saveBudget() : this.saveNewBudget();
}
},
computed: {
...mapGetters([
'getBudgetById'
])
}
};
</script>
import * as components from './components';
export default [
{
path: '/budgets',
component: components.BudgetsListView
},
{
path: '/budgets/create',
component: components.CreateUpdateBudget,
name: 'createBudget'
},
{
path: '/budgets/:budgetId/update',
component: components.CreateUpdateBudget,
name: 'updateBudget'
}
];
import * as actions from './actions';
import mutations from './mutations';
import getters from './getters';
const state = {
budgets: {}
};
export default {
state,
actions,
mutations,
getters
};
export default {
getBudgetById: (state, getters) => (budgetId) => {
return state.budgets && budgetId in state.budgets ? state.budgets[budgetId] : false;
}
};
Whew! I told you we'd be racing through the code. Most of it is the same as our accounts code. At this point the user can:
- visit the budget creation page
- create a simple budget object which is persisted to our local database
- visit the budget edit page for that ID and edit the budget they just created
Everything we just wrote supports those tasks. It's a good time to commit.
It should be simple to add the budget list view page so we have a little bit of navigation for our budget app, instead of manually tweaking URLs. I'm also taking this time to remove View
from my budget naming, a spot I missed when refactoring accounts.
- src/app/budgets/routes.js
- src/app/budgets/components/index.js
- src/app/budgets/components/BudgetsList.vue
- src/app/budgets/components/CreateUpdateBudget.vue
import * as components from './components';
export default [
{
path: '/budgets',
component: components.BudgetsList,
name: 'budgetsList'
},
{
path: '/budgets/create',
component: components.CreateUpdateBudget,
name: 'createBudget'
},
{
path: '/budgets/:budgetId/update',
component: components.CreateUpdateBudget,
name: 'updateBudget'
}
];
export { default as BudgetsList } from './BudgetsList';
export { default as CreateUpdateBudget } from './CreateUpdateBudget';
<template>
<div id="budgets-list">
I'm a list of budgets!
<router-link :to="{ name: 'createBudget' }">Add a budget</router-link>
<router-link :to="{ name: 'accountsListView' }">View accounts</router-link>
<ul>
<li v-for="budget, key in budgets">
{{ budget.month }}
${{ budget.budgeted }}
${{ budget.spent }}
${{ budget.income }}
<router-link :to="{ name: 'updateBudget', params: { budgetId: budget.id } }">Edit</router-link>
</li>
</ul>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
export default {
name: 'budgets-list',
mounted () {
this.loadBudgets();
},
methods: {
...mapActions([
'loadBudgets'
])
},
computed: {
...mapState({
'budgets': state => state.budgets.budgets
})
}
};
</script>
<style scoped lang='scss'>
#budgets-list-view {
}
</style>
<template>
<div id="budget-create-edit-view">
You can create and edit budgets with me, woot!
<router-link :to="{ name: 'budgetsList' }">View all budgets</router-link>
<form class="form" @submit.prevent="processSave">
<label for="month" class="label">Month</label>
<p class="control">
<input type="text" class="input" name="month" v-model="selectedBudget.month">
</p>
<label for="budgeted" class="label">Budgeted amount</label>
<p class="control">
<input type="text" class="input" name="budgeted" v-model="selectedBudget.budgeted">
</p>
<p class="control">
Spent: {{ selectedBudget.spent }}
</p>
<p class="control">
Income: {{ selectedBudget.income }}
</p>
<div class="control is-grouped">
<p class="control">
<button class="button is-primary">Submit</button>
</p>
<p class="control">
<router-link :to="{ name: 'budgetsList' }"><button class="button is-link">Cancel</button></router-link>
</p>
</div>
</form>
</div>
</template>
It's still not pretty, but we can now view budgets and navigate the existing application.
Have you been maintaining your programmer's notebook? I have a whole lot of items listed out in mine already. The most glaring item I see is that we need to handle the budget month as a date.
- let the user choose it with a date picker
- format it as "Month Year"
- store it in a Date object (or similar)
- process it to/from JSON when saving to the database
- sort the list of budgets by month
- ensure the user can only add 1 budget per month
We are going to use two libraries to help us. Vuejs-datepicker will give the user a widget for selecting dates. Unfortunately it doesn't have an option for selecting months directly, but it will serve our needs. Moment.js helps us parse, format, and manipulate dates and time periods. We don't strictly need this, but it will make many of our date operations easier.
npm install vuejs-datepicker moment --save
Adding the datepicker to our budget's month field is easy. We need to
1) register the datepicker component with our own component 2) import and install the datepicker in our template 3) tie its value to our budget month by using v-model
// /src/app/budgets/components/CreateUpdateBudget.vue
...
<form class="form" @submit.prevent="processSave">
<label for="month" class="label">Month</label>
<p class="control">
<datepicker name="month" input-class="input" format="MMMM yyyy" v-model="selectedBudget.month"></datepicker>
</p>
<label for="budgeted" class="label">Budgeted amount</label>
...
<script>
import { mapActions, mapGetters } from 'vuex';
import Datepicker from 'vuejs-datepicker';
export default {
name: 'budget-create-edit-view',
components: {
Datepicker
},
data: () => {
...
Before you go saving budgets all over the place we need to process this new Date object before we can save it to our database, and then convert to a Date object again when retrieving it. When saving we convert the Date object to JSON. When retrieving we create a new Date object from the JSON string.
// /src/app/budgets/vuex/api.js
import localforage from 'localforage';
import { processAPIData } from '../../utils';
const BUDGET_NAMESPACE = 'BUDGET-';
export const saveBudget = (budget) => {
budget = Object.assign({}, budget); // clone our object so we can manipulate it before saving
budget.month = budget.month.toJSON();
return localforage.setItem(
BUDGET_NAMESPACE + budget.id,
budget
).then((value) => {
return value;
}).catch((err) => {
console.log('had a little trouble saving that budget', err);
});
};
export const fetchBudgets = () => {
return localforage.startsWith(BUDGET_NAMESPACE).then((res) => {
let budgets = processAPIData(res);
Object.keys(budgets).forEach((o) => {
budgets[o].month = new Date(budgets[o].month);
});
return budgets;
});
};
Now that is done, we can go back to the budget list page and see that our month is being presented as an ugly datetime value.
We're going to create a quick filter to format this in a human friendly format. The filter terminology might throw you off here, but filters in Vue.js are primarily used for text formatting. We didn't plan any filters in our project structure, but we're going to add a filters.js file in our core directory. This date format filter will be used in multiple places. If it were specific to the budgets module we would put it there.
A filter simply accepts a value from a template along with any number of arguments, changes the value, and returns the result. Our filter will pass a native Date object, format it using moment.js, and return the formatted date. We could hard code it to always use the MMMM yyyy
format, but we might want to use it elsewhere with different formats so we will make it flexible. We want to default to MMMM yyyy
, but allow for a format argument to be passed in.
// /src/filters.js
import momentjs from 'moment';
export const moment = (date, format) => {
format = format || 'MMMM YYYY';
return momentjs(date).format(format);
};
Note that vuejs-datepicker and moment.js use slightly different formatting arguments.
Then in BudgetsList.vue
we import and install the filter, and use it in our list.
// /src/app/budgets/components/BudgetsList.vue
...
<li v-for="budget, key in budgets">
{{ budget.month | moment }}
${{ budget.budgeted }}
...
import { moment } from '../../../filters';
export default {
name: 'budgets-list',
filters: {
moment
},
mounted () {
...
Now our list looks like this:
If you wanted to show the dates in another format you can pass an argument to the filter. It's important to realize that we are still dealing with our dates in the standard JavaScript Date format. We are only using moment.js for display and date math.
{{ budget.month | moment('MMM YYYY') }}
We have now checked the first 3 items off our list. Next we need to sort the list of budgets by month and ensure that there are never more than two budgets for a month.
Sorting Lists with Vue.js
Vue.js makes filtering or sorting a list of items exceptionally simple through the use of computed properties. Since a computed properties are automatically updated when data inside them is changed we never have to think about re-sorting. It just happens. All that is left to us is to compare each budget's date object. This is the first time our approach to data storage is affecting us negatively. If all of our budgets were in a list we could do this:
budgets.sort((a,b) => { return a.month - b.month; });
If we were using a true relational database we could use our query to let the database sort the data structure.
As it is, the solution isn't too difficult. We can get the keys of an object as an array by calling Object.keys(obj)
. We have done this a few times already. This array can be iterated over to sort the results. Finally, we don't actually need the results in key-value format. This template only needs the values, so that's what we're going to give it.
// /src/app/budgets/components/BudgetsList.vue
...
computed: {
...mapState({
'budgets': state => state.budgets.budgets
}),
sortedBudgets () {
let sortedKeys = Object.keys(this.budgets).sort((a, b) => {
return this.budgets[b].month - this.budgets[a].month;
});
return sortKeys.map((key) => {
return this.budgets[key];
});
}
}
...
It may not be the most elegant or the most efficient code, but it works. We also reversed the order of the budgets - b - a
- so that the newest ones show first. Then we return an array of the resulting values. We could have still returned a new object, but this slightly simplifies our template like so:
<li v-for="budget in sortedBudgets">
Checking for Unique Values
Now we need to check that our budget months are unique to prevent the user from created multiple budgets for the same month. There are two places we could (and maybe should) do this. The first is on our forms with form validation. We'll deal with form validation more extensively later. The more important place we should validate is in our data layer. Since we aren't working with a server backend it's up to Budgeterbium to ensure data quality and integrity. That means when we save an object in our store we should verify there are no collisions. We're going to dive into the createBudget
and updateBudget
actions to perform this check.
// /src/app/budgets/vuex/actions.js
import moment from 'moment';
import { guid } from '../../../utils';
import { saveBudget, fetchBudgets } from '../api';
const verifyUniqueMonth = (budgets, budget) => {
// accepts a list of budgets, and the budget being updated
// returns true if there is no date collision
// returns false if a budget already exists in budgets with the same month as budget
let month = moment(budget.month);
return !Object.values(budgets).find((o) => {
return month.isSame(o.month, 'month');
});
};
export const createBudget = ({ commit, state }, data) => {
let unique = verifyUniqueMonth(state.budgets, data);
if (!unique) {
return Promise.reject(new Error('A budget already exists for this month.'));
}
let id = guid();
...
The verifyUniqueMonth
function simply loops through all of our budgets and returns true if we find one that matches the budget being saved. We then return Promise.reject
with a new error. If the code simply threw the error here, throw new Error()
, then our Vue.js component would have to wrap the save code in a try...catch block. Since we're using Promises already, as in saveBudget().then()
we continue using that method. Then in our component we need to handle rejection better. For now we will simply alert the user and not redirect away if a budget already exists with this month. Later we'll add proper error handling.
// /src/app/budgets/components/CreateUpdateBudget.vue
...
saveNewBudget () {
this.createBudget(this.selectedBudget).then(() => {
this.resetAndGo();
}).catch((err) => {
alert(err);
});
},
saveBudget () {
this.updateBudget(this.selectedBudget).then(() => {
this.resetAndGo();
}).catch((err) => {
alert(err);
});
},
...
If we needed to write more than 1 line to handle our rejection we would want to make it a separate method.
With all of that in place, if a user tries creating or updating a budget item and the month is already in use on another budget item, we shut them down.
Here was our checklist again:
- X let the user choose it with a date picker
- X format it as "Month Year"
- X store it in a Date object (or similar)
- X process it to/from JSON when saving to the database
- X sort the list of budgets by month
- X ensure the user can only add 1 budget per month
Budget Categories
Take a look back at our planning session in step 08. It's time to let the user add budget categories for this month, which we already began planning for. A category will be a separate data object linked to the month's budget by its ID with an intermediate budgets
object.
'budgets': {
'de7ednve': {
'id': 'de7ednve',
'date': '2017-02-16T22:48:39.330Z',
'budgeted': 1359.29,
'spent': 1274,
'income': 1459.41,
'budgetCategories': {
'jcijeojde88': {
'id': 'jcijeojde88',
'category': 'ijdoiejf8e',
'budgeted': 150,
'spent': 87.36
}
}
}
}
'categories': {
'ijdoiejf8e': {
"id": "ijdoiejf8e",
"name": "Groceries"
}
}
This is the first time we have actually worked with any linked data, so we'll slow down to cover this section. But only a little.
If you're familiar with relational databases you might have noticed that this is basically a Many-To-Many relationship. A budget
has many categories
through budgetCategories
. Each budgetCategories
object has a budgeted and a spent amount that we need to total in the parent budget. When the user is creating a budget for this month they should somehow be presented with existing categories so that they can maintain consistency. But we also want to make it exceptionally easy for them to create new categories on the fly.
Instead of adding budgeted categories on a separate page, a user will build their entire budget for the month on the budget edit page. This will require us to build some components to be imported and used on this page. First we must begin with the data layer. Let's update our store, mutators, actions, API. While budgetCategories
will ultimately be saved as part of the parent budget
object, we do need to add the separate categories
object.
- src/app/budgets/vuex/index.js
- src/app/budgets/vuex/mutations.js
- src/app/budgets/vuex/actions.js
- src/app/budgets/api.js
import * as actions from './actions';
import mutations from './mutations';
import getters from './getters';
const state = {
budgets: {},
categories: {}
};
export default {
state,
actions,
mutations,
getters
};
export default {
CREATE_BUDGET (state, payload) {
state.budgets[payload.budget.id] = payload.budget;
},
UPDATE_BUDGET (state, payload) {
state.budgets[payload.budget.id] = payload.budget;
},
LOAD_BUDGETS (state, payload) {
state.budgets = payload;
},
CREATE_CATEGORY (state, payload) {
state.categories[payload.category.id] = payload.category;
},
UPDATE_CATEGORY (state, payload) {
state.categories[payload.category.id] = payload.category;
},
LOAD_CATEGORIES (state, payload) {
state.categories = payload;
}
};
import moment from 'moment';
import { guid } from '../../../utils';
import { saveBudget, fetchBudgets, saveCategory, fetchCategories } from '../api';
const verifyUniqueMonth = (budgets, budget) => {
// accepts a list of budgets, and the budget being updated
// returns true if there is no date collision
// returns false if a budget already exists in budgets with the same month as budget
let month = moment(budget.month);
return !Object.values(budgets).find((o) => {
if (o.id === budget.id) return false; // it's the budget we're examining, let's not check if the months are the same
return month.isSame(o.month, 'month');
});
};
export const createBudget = ({ commit, state }, data) => {
if (!verifyUniqueMonth(state.budgets, data)) {
return Promise.reject(new Error('A budget already exists for this month.'));
}
let id = guid();
let budget = Object.assign({ id: id }, data);
commit('CREATE_BUDGET', { budget: budget });
saveBudget(budget).then((value) => {
// we saved the budget, what's next?
});
};
export const updateBudget = ({ commit, state }, data) => {
if (!verifyUniqueMonth(state.budgets, data)) {
return Promise.reject(new Error('A budget already exists for this month.'));
}
commit('UPDATE_BUDGET', { budget: data });
saveBudget(data);
};
export const loadBudgets = ({ state, commit }) => {
if (!state.budgets || Object.keys(state.budgets).length === 0) {
return fetchBudgets().then((res) => {
commit('LOAD_BUDGETS', res);
});
}
};
export const createCategory = ({ commit, state }, data) => {
let id = guid();
let category = Object.assign({ id: id }, data);
commit('CREATE_CATEGORY', { category: category });
saveCategory(category);
};
export const loadCategories = ({ state, commit }) => {
if (!state.categories || Object.keys(state.categories).length === 0) {
return fetchCategories().then((res) => {
commit('LOAD_CATEGORIES', res);
});
}
};
import localforage from 'localforage';
import { processAPIData } from '../../utils';
const BUDGET_NAMESPACE = 'BUDGET-';
const CATEGORY_NAMESPACE = 'CATEGORY-';
export const saveBudget = (budget) => {
budget = Object.assign({}, budget); // clone our object so we can manipulate it before saving
budget.month = budget.month.toJSON();
return localforage.setItem(
BUDGET_NAMESPACE + budget.id,
budget
).then((value) => {
return value;
}).catch((err) => {
console.log('had a little trouble saving that budget', err);
});
};
export const fetchBudgets = () => {
return localforage.startsWith(BUDGET_NAMESPACE).then((res) => {
let budgets = processAPIData(res);
Object.keys(budgets).forEach((o) => {
budgets[o].month = new Date(budgets[o].month);
});
return budgets;
});
};
export const saveCategory = (category) => {
return localforage.setItem(
CATEGORY_NAMESPACE + category.id,
category
).then((value) => {
return value;
}).catch((err) => {
console.log('category problems abound! ', err);
});
};
export const fetchCategories = () => {
return localforage.startsWith(CATEGORY_NAMESPACE).then((res) => {
return processAPIData(res);
});
};
Great. There is one more small piece I know we will need later - a getter to grab a category based on its ID.
// /src/app/budgets/vuex/getters.js
export default {
getBudgetById: (state, getters) => (budgetId) => {
return state.budgets && budgetId in state.budgets ? state.budgets[budgetId] : false;
},
getCategoryById: (state, getters) => (categoryId) => {
return state.categories && categoryId in state.categories ? state.categories[categoryId] : false;
}
};
Now a user needs to add an object to the budgetCategories
on a particular month's budget. You might be scratching your head wondering where and when we created the budgetCategories
. The answer is we didn't - we don't have a relational database with predefined tables and columns to dictate our data, and we're doing minimal data validation during the saving process. We can go back into our budget saving code and ensure we add it into the budget object, or we can verify it's there when the user actually adds a category to a budget. We're going to do the latter.
Once again, we begin with the data layer. Thinking through the budget categories, we realize that we need to update the parent budget's balance when we add one. Also... it seems we may have made a mistake by allowing the user to manually set the month's budget. Like the amount spent, it should be completely controlled by other factors. We are going to fix that now and ensure that budgets start out with $0 balances, so that there aren't type issues when adding budget categories.
- src/app/budgets/vuex/mutations.js
- src/app/budgets/vuex/actions.js
- src/app/budgets/components/CreateUpdateBudget.vue
import Vue from 'vue';
...
UPDATE_BUDGET_BALANCE (state, payload) {
if (!(payload['param'] === 'budgeted' || payload['param'] === 'spent') || payload['param'] === 'income') {
throw new Error('UPDATE_BUDGET_BALANCE expects either { param: "budgeted" } or { param: "spent" } or { param: "income" }');
}
state.budgets[payload.budget.id][payload.param] += parseFloat(payload.value);
},
CREATE_EMPTY_BUDGET_CATEGORY_OBJECT (state, payload) {
Vue.set(state.budgets[payload.id], 'budgetCategories', {});
},
CREATE_BUDGET_CATEGORY (state, payload) {
Vue.set(state.budgets[payload.budget.id].budgetCategories, payload.budgetCategory.id, payload.budgetCategory);
}
...
import moment from 'moment';
import { guid } from '../../../utils';
import { saveBudget, fetchBudgets, saveCategory, fetchCategories } from '../api';
const verifyUniqueMonth = (budgets, budget) => {
// accepts a list of budgets, and the budget being updated
// returns true if there is no date collision
// returns false if a budget already exists in budgets with the same month as budget
let month = moment(budget.month);
return !Object.values(budgets).find((o) => {
if (o.id === budget.id) return false; // it's the budget we're examining, let's not check if the months are the same
return month.isSame(o.month, 'month');
});
};
export const createBudget = ({ commit, state }, data) => {
if (!verifyUniqueMonth(state.budgets, data)) {
return Promise.reject(new Error('A budget already exists for this month.'));
}
let id = guid();
let budget = Object.assign({ id: id }, data);
budget.budget = 0;
budget.spent = 0;
budget.income = 0;
commit('CREATE_BUDGET', { budget: budget });
saveBudget(budget).then((value) => {
// we saved the budget, what's next?
});
};
export const updateBudget = ({ commit, state }, data) => {
if (!verifyUniqueMonth(state.budgets, data)) {
return Promise.reject(new Error('A budget already exists for this month.'));
}
commit('UPDATE_BUDGET', { budget: data });
saveBudget(data);
};
export const loadBudgets = ({ state, commit }) => {
if (!state.budgets || Object.keys(state.budgets).length === 0) {
return fetchBudgets().then((res) => {
commit('LOAD_BUDGETS', res);
});
}
};
export const updateBudgetBalance = ({ commit, getters }, data) => {
/*
Accepts a budget and a parameter-value to be updated
param: budgeted|spent
value: num
*/
commit('UPDATE_BUDGET_BALANCE', data);
saveBudget(getters.getBudgetById(data.budget.id));
};
export const createCategory = ({ commit, state }, data) => {
let id = guid();
let category = Object.assign({ id: id }, data);
commit('CREATE_CATEGORY', { category: category });
saveCategory(category);
};
export const loadCategories = ({ state, commit }) => {
if (!state.categories || Object.keys(state.categories).length === 0) {
return fetchCategories().then((res) => {
commit('LOAD_CATEGORIES', res);
});
}
};
export const createBudgetCategory = ({ commit, dispatch, getters }, data) => {
// create an empty budget categories object if it doesn't exist
if (!data.budget.budgetCategories || Object.keys(data.budget.budgetCategories).length === 0) {
commit('CREATE_EMPTY_BUDGET_CATEGORY_OBJECT', data.budget);
}
let id = guid();
let budgetCategory = Object.assign({ id: id }, data.budgetCategory);
commit('CREATE_BUDGET_CATEGORY', { budget: data.budget, budgetCategory: budgetCategory });
// save using the budget in our store
saveBudget(getters.getBudgetById(data.budget.id));
dispatch('updateBudgetBalance', {
budget: data.budget,
param: 'budgeted',
value: budgetCategory.budgeted
});
};
...
<p class="control">
<datepicker name="month" input-class="input" format="MMMM yyyy" v-model="selectedBudget.month"></datepicker>
</p>
<p class="control">
Budgeted: ${{ selectedBudget.budget }}
</p>
<p class="control">
Spent: ${{ selectedBudget.spent }}
</p>
<p class="control">
Income: ${{ selectedBudget.income }}
</p>
...
There's a whole lot of interaction happening here, so study the code closely. We not only add the new budget category, but once it is added we make a call to update the budget's total balance.
You may be thrown off by the Vue.set()
calls we make. Vue cannot detect object additions or deletions, so any time you want to add a new property to an object or delete an existing one you need to use Vue.set()
and Vue.delete()
respectively. That ensures Vue can react to the updates.
Create Categories on the Fly with Vue-Multiselect
When a user is creating their budget we want the process to be as smooth and seamless as possible. Selecting from a dropdown list of categories works if they have already created all the categories they need. But we also want them to be able to create a new category as easy as assigning to an existing one. For this we will use vue-multiselect. It gives a nice dropdown select option where the user can begin typing and then complete from the given choices, or use their own new value. When they use a new value we will instantly create that category.
npm install vue-multiselect@next --save
Let's create a barebones CreateUpdateBudgetCategory
component now, then we'll tie in vue-multiselect afterward. Remember this will be embedded in CreateUpdateBudget.vue
.
// /src/app/budgets/components/CreateUpdateBudgetCategory.vue
<template>
<div id="budget-category-create-edit-view">
<form class="form" @submit.prevent="processSave">
<div class="control is-horizontal">
<div class="control is-grouped">
<div class="control is-expanded">
<input type="text" v-model="budgetCategory.category">
</div>
<div class="control is-expanded">
#####replaceparse40#####lt;input type="number" class="input" v-model="budgetCategory.budgeted" />
</div>
<div class="control is-expanded">
{{ budgetCategory.spent }}
</div>
<button @click.prevent="processSave">Add</button>
</div>
</div>
</form>
</div>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
export default {
name: 'budget-categorycreate-edit-view',
components: {
},
data: () => {
return {
budgetCategory: {}
};
},
mounted () {
},
methods: {
...mapActions([
]),
processSave () {
}
},
computed: {
...mapGetters([
])
}
};
</script>
It's easy to install and use this in CreateUpdateBudget.vue
. We've already done the same process with the datepicker.
// /src/app/budgets/components/CreateUpdateBudget.vue
...
</div>
</form>
<create-update-budget-category></create-update-budget-category>
</div>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
import Datepicker from 'vuejs-datepicker';
import CreateUpdateBudgetCategory from './CreateUpdateBudgetCategory';
export default {
name: 'budget-create-edit-view',
components: {
Datepicker,
CreateUpdateBudgetCategory
},
...
When you view the budget edit page you'll now see some depressingly unlabeled and undesigned fields. We'll spruce up the design soon, I promise!
Since budget categories are not their own, distinct objects, rather a part of budget
object, we need to decide if the CreateUpdateBudgetCategory
component will be aware of its parent. We could pass the parent budget
object to it as a prop and let the budget category component component handle saving the budget. Or we could pass a budgetCategory
object back up to CreateUpdateBudget
which then saves the budget
. As a general rule, data should trickle upward. Components should not be aware of their parents. In other words, it should not matter to the component if it is loaded at its own URL, or as part of another page. This keeps the child component decoupled from everything happening around it, which makes it infinitely more usable.
When the user adds a budget category, that component needs to emit an event to let its parent component handle saving. Vue.js already handles the hard work and gives us a way to emit events that propagate upward, and can then be listened to by a component's ancestors. We add that into the processSave
method.
// /src/app/budgets/components/CreateUpdateBudgetCategory.vue
...
processSave () {
this.$emit('add-budget-category', this.budgetCategory);
this.budgetCategory = {};
}
...
Now CreateUpdateBudget.vue
can listen for the add-budget-category
event on its CreateUpdateBudgetCategory
component, and respond accordingly. In this case, we need it to create a budget category on its selected budget object, using the information we emitted in the event. Since we already coded the data layer, we simply have to tie everything together.
// /src/app/budgets/components/CreateUpdateBudget.vue
...
<CreateUpdateBudgetCategory v-on:add-budget-category="addBudgetCategory"></CreateUpdateBudgetCategory>
...
...mapActions([
'createBudget',
'updateBudget',
'loadBudgets',
'createBudgetCategory'
]),
...
processSave () {
this.$route.params.budgetId ? this.saveBudget() : this.saveNewBudget();
},
addBudgetCategory (budgetCategory) {
if (!budgetCategory.category) return;
this.createBudgetCategory({
budget: this.selectedBudget,
budgetCategory: {
category: budgetCategory.category.id,
budgeted: budgetCategory.budgeted,
spent: 0
}
}).then(() => {
this.selectedBudget = this.getBudgetById(this.$route.params.budgetId);
});
}
...
First, we listen for the event add-budget-category
on the CreateUpdateBudgetCategory
component. When it fires, we want this component to call its addBudgetCategory
method. This puts the data in the correct format and dispatches the createBudgetCategory
action we created. Afterward it reloads the selected budget - this is necessary, because we are editing we are working with a clone of the Vuex budget object and not the original, so it wouldn't be updated automatically.
The user can now add a budget category and see the total budgeted amount change. They don't see any of the actual budget categories though. We'll present them as a simple table for now.
// /src/app/budgets/components/CreateUpdateBudget.vue
...
</form>
<table>
<thead>
<tr>
<th>Category</th>
<th>Budgeted</th>
<th>Spent</th>
<th>Remaining</th>
</tr>
</thead>
<tbody>
<tr v-for="bc in selectedBudget.budgetCategories">
<td>{{ getCategoryById(bc.category).name }}</td>
<td>${{ bc.budgeted }}</td>
<td>${{ bc.spent }}</td>
<td>${{ bc.budgeted - bc.spent }}</td>
</tr>
</tbody>
<tfoot>
<tr>
<td></td>
<td>${{ selectedBudget.budgeted }}</td>
<td>${{ selectedBudget.spent }}</td>
<td>${{ selectedBudget.budgeted - selectedBudget.spent }}</td>
</tr>
</tfoot>
</table>
<CreateUpdateBudgetCategory v-on:add-budget-category="addBudgetCategory"></CreateUpdateBudgetCategory>
...
...mapGetters([
'getBudgetById',
'getCategoryById'
])
...
This works great, except we still don't see the categories. That's because we still aren't adding them yet! Let's fix that, then it's desparately time for a commit.
Vue-multiselect should work seamlessly with Vue 2's v-model
directive. In actuality, I had trouble creating new categories when using v-model
, so we will set the value directly and listen for events. First we import the component and replace our simple input
with the multiselect
.
// /src/app/budgets/components/CreateUpdateBudgetCategory.vue
...
<multiselect
:value="budgetCategory.category"
:taggable="true"
@tag="handleCreateCategory"
@input="updateCategorySelection"
:options="getCategorySelectList"
placeholder="Select or create a category"
label="name"
track-by="id"
></multiselect>
</div>
<div class="control is-expanded">
#####replaceparse45#####lt;input type="number" class="input" v-model="budgetCategory.budgeted" />
</div>
...
import Multiselect from 'vue-multiselect';
import 'vue-multiselect/dist/vue-multiselect.min.css';
export default {
name: 'budget-category-create-edit-view',
components: {
Multiselect
},
...
There's a whole lot going on in just that component tag. We tie its value to budgetCategory.category
. Remember that Vue.js props only sync one direction - any changes made to the value in the multiselect won't be automatically seen by our code. (If we had used v-model
it would sync.) We need a way to react to changes, so we use the @update
event that the multiselect will fire when the value changes.
We then tell our multiselect that we want it to be taggable. This feature of multiselect will allow the user to create new categories on the fly, which we respond to when it emits the @tag
event. :options
gives the multiselect its choices for the dropdown. It must be an array. Finally, label
and track-by
map to object properties in our options
array. name
and id
are the two values from our category
objects that we are interested in.
We still need to create 3 methods - handleCreateCategory
, updateCategorySelection
, and getCategorySelectList
.
// /src/app/budgets/components/CreateUpdateBudgetCategory.vue
...
handleCreateCategory (category) {
let newCategory = { name: category };
this.createCategory(newCategory).then((val) => {
this.updateCategorySelection(val);
});
},
updateCategorySelection (category) {
// if using v-model and not using Vue.set directly, vue-multiselect seems to struggle to properly
// keep its internal value up to date with the value in our component. So we're skipping v-model
// and handling updates manually.
this.$set(this.budgetCategory, 'category', category);
}
...
When a user selects a category we use our trusty Vue.set
to update the object they are editing. We respond to the @tag
event by creating a new category and then updating.
We need to make a small tweak to the CREATE_CATEGORY
mutation so it updates us when the user creates a new category.
// /src/app/budgets/vuex/mutations.js
...
CREATE_CATEGORY (state, payload) {
Vue.set(state.categories, payload.category.id, payload.category);
},
...
Then we'll add a getter for the budget categories that simply converts the categories
object to a flat array of categories for handling by multiselect.
// /src/app/budgets/vuex/getter.js
...
getCategorySelectList: (state, getters) => {
return state.categories && Object.keys(state.categories).length > 0 ? Object.values(state.categories) : [];
}
Out of breath? Me too. Time for a commit
By the way, if your local database gets messy during development, Chrome makes it easy to wipe it clean and start over.
While there is still some work to be done and a number of items in my programmer's notebook, I think that is quite enough for today's section of the tutorial. It has grown rather long, but hopefully not unwieldy. As always, you can browse the code on the GitHub repository and follow changes as they happen by browsing commit history. In the next update we will finally style the application, while also creating a simple but powerful navigation object.