Step 11: Finishing Budgets with Vue.js Dynamic Components

You may be cracking your fingers, anxious to move on the our final module, Transactions. We aren't quite finished with Budgets yet, but we're close.

A few steps back we put together a list of items that we needed to complete.

  • add a budget "month"
  • add individual budget categories to the created month
  • as budgets are added update the month record
  • automatically generate IDs for the month and the budgets
  • create the "month" view component so the user can see their budget for that month
  • save and load data from IndexedDB
  • add a route for each component
  • navigate between accounts and budgetin

All of those items are done done. But we also created a list of actions the user should be able to perform. It's always helpful to think of development from the user's perspective. We already started doing this but we never formalized it. In fact, user stories are a big part of agile development. The basic premise is that you write your development requirements in this format:

as a I want so that ___

If you follow this format religiously, everything you do is done for the sake of the user and you hopefully eliminate unnecessary work. It also helps you prioritize tasks based on the user's need. Despite my being a marketer who puts a strong focus on gathering user feedback to make marketing decisions, I don't stick firmly to user stories when I am working on a development project. Though it is a tool that I break out often. It makes you take a step back and evaluate the purpose of what you're doing, often bringing a new perspective or shining new light on an old problem. Here was the user list we created. A user should be able to

  1. view their budget for this month
  2. add a spending category with a budgeted amount
  3. check how much money they have left to spend in a category
  4. update the amount of money budgeted toward a category
  5. copy their entire budget from last month
  6. view how much they budgeted and spent in each category for previous months
  7. check their total income, budget, and amount spent for this month

It's easy to see how these would fit into a user story format.

We've already checked off everything except items 4 and 5. We previously created an edit view for accounts and for budgets, but budget items are a little different since we're editing them inline. A user should be able to view the budget, click on an edit link for the buget item they want to change, enter a new value. (Hey, that sounds like a user story!) We need to be able to quickly switch between a 'view' mode and an 'edit' mode. With accounts, we switched between create and edit modes by looking at a prop value and changing up our fields. That could work here, but since our view mode is nothing like our edit mode they should really be separate components that we switch between.

Once again, Vue.js is already 2 steps ahead of us and gives us something called dynamic components. Dynamic components allow the developer to insert a component placeholder in the template of another component. This parent component can then change which child component is loaded in the placeholder. This lets us switch the component at any point - even after the parent is mounted. In this case, we will switch from view to edit when the user clicks a button.

First, we need to change our budget item table row into a component. It's just a handful of td elements so it didn't seem to necessitate its own component, but now it needs to be swapped in and out as a component.

<template>
  <tr class="budget-item">
    <td><span class="subtitle is-5">{{ getCategoryById(value.category).name }}</span></td>
    <td><span class="subtitle is-5">${{ value.budgeted }}</span></td>
    <td><span class="subtitle is-5">${{ value.spent }}</span></td>
    <td><span class="subtitle is-5">${{ value.budgeted - value.spent }}</span></td>
    <td><a class='button' @click="$emit('edit-budget-category')">Edit</a></td>
  </tr>
</template>

<script>
import { mapGetters } from 'vuex';

export default {
  name: 'budget-item',

  props: ['value'],

  computed: {
    ...mapGetters(['getCategoryById'])
  }
};
</script>
...

        <tbody>
          <template
            v-for="value, key in selectedBudget.budgetCategories"
          >
            <budget-item v-model="value"></budget-item>
          </template>
          <CreateUpdateBudgetCategory v-on:add-budget-category="addBudgetCategory"></CreateUpdateBudgetCategory>
        </tbody>

...

import CreateUpdateBudgetCategory from './CreateUpdateBudgetCategory';
import BudgetItem from './BudgetItem';

export default {
  name: 'budget-create-edit-view',

  components: {
    Datepicker,
    CreateUpdateBudgetCategory,
    BudgetItem
  },

...

At the same time we added an edit button that emits an event saying we want to edit this budget category. We'll create a listener for it later.

We already have a CreateUpdateBudgetCategory.vue, but it can only create - time to add update abilities. We start with the mutation/action pair for updating. We'll also need a getter for grabbing the budget category by its ID.

...

  CREATE_BUDGET_CATEGORY (state, payload) {
    Vue.set(state.budgets[payload.budget.id].budgetCategories, payload.budgetCategory.id, payload.budgetCategory);
  },

  UPDATE_BUDGET_CATEGORY (state, payload) {
    state.budgets[payload.budget.id].budgetCategories[payload.budgetCategory.id] = payload.budgetCategory;
  }
};
...

  });
};

export const updateBudgetCategory = ({ commit, dispatch, getters }, data) => {
  let newBudget = data.budgetCategory.budgeted;
  let oldBudget = getters.getBudgetCategoryById(data.budget.id, data.budgetCategory.id).budgeted;

  if (newBudget !== oldBudget) {
    dispatch('updateBudgetBalance', {
      budget: data.budget,
      param: 'budgeted',
      value: newBudget - oldBudget
    });
  }

  commit('UPDATE_BUDGET_CATEGORY', data);

  // save using the budget in our store
  saveBudget(getters.getBudgetById(data.budget.id));
};
...

  },

  getBudgetCategoryById: (state, getters) => (budgetId, budgetCategoryId) => {
    return state.budgets && budgetId in state.budgets
      ? state.budgets[budgetId].budgetCategories && budgetCategoryId in state.budgets[budgetId].budgetCategories
        ? state.budgets[budgetId].budgetCategories[budgetCategoryId]
        : false
      : false;
  }
};

No new concepts here. When we update the budget category we check to see if the user changed the budget amount so we can forward that change onto the overall budget. The getter code is... well, you may be questioning the chained ternary. We could write it out with if-else statements, but I don't think it actually helps readability. The code simply checks that the data exists and returns it if it does, otherwise returning false.

The backend logic to edit budget categories is now in place. We need to add the interface, then figure out how to show the interface to the user using dynamic components.

...

<a class="button is-primary" @click.prevent="processSave">
  {{ editing ? 'Save' : 'Add' }}
</a>

...

components: {
    Multiselect
  },

  props: [
    'value'
  ],

  data: () => {
    return {
      budgetCategory: {},
      editing: false
    };
  },

  mounted () {
    this.loadCategories();
    if (this.value) {
      this.budgetCategory = Object.assign({}, this.value);

      // we need the selected category name and ID, but the budgetCategory object only holds the ID by default
      this.budgetCategory.category = this.getCategoryById(this.budgetCategory.category);

      this.editing = true;
    }
  },

  methods: {
    ...mapActions([
      'createCategory',
      'loadCategories'
    ]),

    processSave () {
      // we are passing the saves up to the budget because this budget
      // category view isn't aware of its parent budget object
      if (this.editing) {
        this.$emit('update-budget-category', this.budgetCategory);
      } else {
        this.$emit('add-budget-category', this.budgetCategory);
        this.budgetCategory = {};
      }
    },

    ...

  computed: {
    ...mapGetters([
      'getCategorySelectList',
      'getCategoryById'
    ])
  }
};
</script>
...

        <tbody>
          <template
            v-for="value, key in selectedBudget.budgetCategories"
          >
            <component
              :is="budgetCategoryComponent(value)"
              v-model="value"
              v-on:update-budget-category="saveBudgetCategory"
              v-on:edit-budget-category="activeBudgetCategory = value"
            ></component>
          </template>
          <CreateUpdateBudgetCategory v-on:add-budget-category="addBudgetCategory"></CreateUpdateBudgetCategory>
        </tbody>

...

  components: {
    Datepicker,
    CreateUpdateBudgetCategory,
    BudgetCategory
  },

  data: () => {
    return {
      selectedBudget: {},
      editing: false,
      activeBudgetCategory: null
    };
  },

  mounted () {
    if ('budgetId' in this.$route.params) {
      this.loadBudgets().then(() => {
        let selectedBudget = this.getBudgetById(this.$route.params.budgetId);
        if (selectedBudget) {
          this.editing = true;
          this.selectedBudget = Object.assign({}, selectedBudget);
        }
      });
    }
  },

  methods: {
    ...mapActions([
      'createBudget',
      'updateBudget',
      'loadBudgets',
      'createBudgetCategory',
      'updateBudgetCategory'
    ]),

    ...

    },

    saveBudgetCategory (budgetCategory) {
      // format it how our action expects
      budgetCategory.category = budgetCategory.category.id;
      this.updateBudgetCategory({
        budget: this.selectedBudget,
        budgetCategory: budgetCategory
      }).then(() => {
        this.selectedBudget = Object.assign({}, this.getBudgetById(this.$route.params.budgetId));
      });
    },

    budgetCategoryComponent (budgetCategory) {
      return this.activeBudgetCategory && this.activeBudgetCategory === budgetCategory ? 'create-update-budget-category' : 'budget-category';
    }

...

The Create/Update component now passes the update event up the chain. Because the budget category is a part of its parent budget, but is not aware of its parent, the budget itself has to deal with updates.

Finally we get to the dynamic component. It uses the <component></component> tag as a placeholder. The :is attribute tells it to rely on the result of executing the budgetCategoryComponent(value) method to determine which component to actually use. That method checks if the activeBudgetCategory is the budget category currently being looped over. If so it uses the CreateUpdateBudgetCategory component, and if not it falls back to BudgetCategory.

We then tack on the event listeners we need to tie everything together.

Snapshot of the budget edit screen

This code is functional and it works great for our small application. Do you see any downsides to using dynamic components here? Can you think of any alternative ways to approach this task?

Time to commit.

0e8972c

At this point a user can edit their budget category, that was item 4. The final task is to let a user copy an entire budget from one month to another. This ends up being easier than you might expect, and we're going to take it in one fell swoop here, with one exception.

...

export const duplicateBudget = ({ commit, dispatch, getters, state }, data) => {
  /*
  * Expects an existing budget object, budget, and an budget to be copied, baseBudget
  * Duplicates all budget categories and budgeted amounts to the new budget
   */
  if (!(data.budget && data.baseBudget)) return Promise.reject(new Error('Incorrect data sent to duplicateBudget'));

  // clone our object in case we received something from the store
  let budget = Object.assign({}, data.budget);

  // let's reset some information first
  budget.budgeted = 0;
  budget.budgetCategories = null;
  // note that we don't reset the spent or income because we aren't
  // changing any transactions, which are what determine those values
  // but the individual category spent/income will need to be recalculated

  commit('UPDATE_BUDGET', { budget: budget });

  budget = getters.getBudgetById(budget.id);

  if ('budgetCategories' in data.baseBudget) {
    Object.keys(data.baseBudget.budgetCategories).forEach((key) => {
      dispatch('createBudgetCategory', {
        budget: budget,
        budgetCategory: {
          category: data.baseBudget.budgetCategories[key].category,
          budgeted: data.baseBudget.budgetCategories[key].budgeted,
          spent: 0 // TODO: grab this value when we have transactions!
        }
      });
    });
  }

  saveBudget(budget);

  return budget;
};
...

        <tfoot>
          <tr>
            <td>
              Copy entire budget from:
              <select
                class="select"
                @change="processDuplicateBudget($event.target.value)"
              >
                <option
                  v-for="value, key in budgets"
                  :value="key"
                >
                  {{ value.month | moment }}
                </option>
              </select>
            </td>
            <td>${{ selectedBudget.budgeted }}</td>

...

<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import Datepicker from 'vuejs-datepicker';

import CreateUpdateBudgetCategory from './CreateUpdateBudgetCategory';
import BudgetCategory from './BudgetCategory';
import { moment } from '../../../filters';

export default {
  name: 'budget-create-edit-view',

  components: {
    Datepicker,
    CreateUpdateBudgetCategory,
    BudgetCategory
  },

  data: () => {
    return {
      selectedBudget: {},
      editing: false,
      activeBudgetCategory: null,
      lastBudget: null
    };
  },

  filters: {
    moment
  },

  mounted () {
    if ('budgetId' in this.$route.params) {
      this.loadBudgets().then(() => {
        let selectedBudget = this.getBudgetById(this.$route.params.budgetId);
        if (selectedBudget) {
          this.editing = true;
          this.selectedBudget = Object.assign({}, selectedBudget);
        }
      });
    }
  },

  methods: {
    ...mapActions([
      'createBudget',
      'updateBudget',
      'loadBudgets',
      'createBudgetCategory',
      'updateBudgetCategory',
      'duplicateBudget'
    ]),

    resetAndGo () {
      this.selectedBudget = {};
      this.$router.push({ name: 'budgetsList' });
    },

    saveNewBudget () {
      this.createBudget(this.selectedBudget).then(() => {
        this.resetAndGo();
      }).catch((err) => {
        alert(err);
      });
    },

    saveBudget () {
      this.updateBudget(this.selectedBudget).then(() => {
        this.resetAndGo();
      }).catch((err) => {
        alert(err);
      });
    },

    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 = Object.assign({}, this.getBudgetById(this.$route.params.budgetId));
      });
    },

    saveBudgetCategory (budgetCategory) {
      // format it how our action expects
      budgetCategory.category = budgetCategory.category.id;
      this.updateBudgetCategory({
        budget: this.selectedBudget,
        budgetCategory: budgetCategory
      }).then(() => {
        this.selectedBudget = Object.assign({}, this.getBudgetById(this.$route.params.budgetId));
      });
    },

    budgetCategoryComponent (budgetCategory) {
      return this.activeBudgetCategory && this.activeBudgetCategory === budgetCategory ? 'create-update-budget-category' : 'budget-category';
    },

    processDuplicateBudget (budgetId) {
      if (confirm('Are you sure you want to duplicate that budget? Doing this will overwrite all existing data for this month (transaction data will NOT be erased).')) {
        this.duplicateBudget({
          budget: this.selectedBudget,
          baseBudget: this.getBudgetById(budgetId)
        }).then((budget) => {
          this.selectedBudget = budget;
        });
      }
    }
  },

  computed: {
    ...mapGetters([
      'getBudgetById',
      'getCategoryById'
    ]),

    ...mapState({
      'budgets': state => state.budgets.budgets
    })
  }
};
</script>

The action code first clears the budget for this month, then loops over all of the categories from the old budget that the user is copying and adds them to this month's budget. We're using existing actions and mutations, so there are no new mutations to write.

On the view side of things, we show the user a dropdown with all of the existing budgets. The interface might not work well once the user has multiple years worth of budgeting data, but it's more than sufficient for now. When the user changes the select value we start the duplication process. ($event is a special variable Vue gives us for any event. The $event.target.value will hold the new value of the select element, which will be the budget ID we set in the options elements.)

We confirm that the user knows what they are doing before overwriting pre-existing data - this is always a good idea! Then we fire off the action we created, sending it the current and old budgets. After that is done we update the selected budget and we're done.

91820c8

There are some small pieces we haven't implemented that we would want to have on a completed application. Deleting a budget category line. Canceling an edit. And probably a dozen others that we would discover with a few minutes of real world usage. I'm content with where we are at and am ready to move on to the final module, transactions. Feel free to implement some additional features on your own in the meantime!

Are your budgets out of order in the duplicate budget select box? If not, try adding some older budget months and recheck. Since we're viewing the list of budgets in multiple places, and we will almost always want them sorted it would be nice to have some sort of DRY solution for this. Take a stab at it before moving onto the next section. Remember that we already wrote the sort function in BudgetsList.vue. You can see my solution at 6e4737e.

Continue to step 12, Planning for Transactions


Originally published on


Sign up to receive updates for new articles.
And spam. Definitely lots of spam.