Decorating Arrays in Ember

Josh Clayton

I’ve been working on an Ember pet project that aggregates data from Mint. It has a model named Category that has many TransactionCaches:

// app/models/category.js
import DS from 'ember-data';
const { attr, hasMany } = DS;

export default DS.Model.extend({
  transactionCaches: hasMany('transaction-cache', { async: true }),

  name: attr('string'),
});
// app/models/transaction-cache.js
import DS from 'ember-data';
const { attr, belongsTo } = DS;

export default DS.Model.extend({
  category: belongsTo('category', { async: true }),

  occurredOn: attr('moment'),
  amount: attr('number'),
  averageAmount: attr('number'),
});

A Category might have a name (“Groceries”), and a TransactionCache is an aggregate (amount, 100.0) of all transactions for a given month (occurredOn, October 1, 2013). Additionally, it has a rolling twelve-month average (averageAmount, 125.0) calculated when importing the data.

I created a categories/category-list component which rendered the list of categories:

{{!-- app/templates/components/categories/category-list.hbs --}}
{{#each categories as |category|}}
  {{categories/category-list-item category=category}}
{{/each}}

However, the server returns categories in an unknown order; with over 100 categories in total, this list needs to be sorted in an order that’s meaningful. I started by ordering the list by the most recent average monthly spend, but the data was stored on each category’s associated TransactionCaches instead of on the category itself.

As a first pass, I introduced this data onto Category itself with a computed property:

// app/models/category.js
import DS from 'ember-data';
import Ember from 'ember';
const { attr, hasMany } = DS;
const { computed } = Ember;

export default DS.Model.extend({
  transactionCaches: hasMany('transaction-cache', { async: true }),

  name: attr('string'),

  averageMonthlySpend: computed.alias('_averageAmounts.firstObject'),

  _averageAmounts: computed.mapBy('_sortedTransactionCaches', 'averageAmount'),
  _sortedTransactionCaches: computed.sort('transactionCaches', '_occurredOnSort'),
  _occurredOnSort: ['occurredOn:desc'],
});

Next, I updated the component and template to sort categories by this data:

// app/components/categories/category-list.js
import Ember from 'ember';
const { computed } = Ember;

export default Ember.Component.extend({
  sortedCategories: computed.sort('categories', '_categoriesSort'),
  _categoriesSort: ['averageMonthlySpend:desc'],
});
{{!-- app/templates/components/categories/category-list.hbs --}}
{{#each sortedCategories as |category|}}
  {{categories/category-list-item category=category}}
{{/each}}

While the component and corresponding template seemed like they were in a good place, I didn’t feel great about Category; the introduction of this data caused the file to almost double in size, and given the data model, I felt confident it would likely gravitate towards becoming a God Class.

Ember includes ObjectProxy, which allows us to introduce the decorator pattern. It expects one piece of data, content, and additional data can be provided. Any functions not defined on the decorator get proxied to the decorated object.

// app/decorators/monthly-spending.js
import Ember from 'ember';
const { computed } = Ember;

export default Ember.ObjectProxy.extend({
  averageMonthlySpend: computed.alias('_averageAmounts.firstObject'),

  _averageAmounts: computed.mapBy('_sortedTransactionCaches', 'averageAmount'),
  _sortedTransactionCaches: computed.sort('transactionCaches', '_occurredOnSort'),
  _occurredOnSort: ['occurredOn:desc'],
});

I removed the code I previously added to Category:

// app/models/category.js
import DS from 'ember-data';
const { attr, hasMany } = DS;

export default DS.Model.extend({
  transactionCaches: hasMany('transaction-cache', { async: true }),

  name: attr('string'),
});

And decorated the items in the collection at the component level when sorting:

// app/components/categories/category-list.js
import Ember from 'ember';
import MonthlySpending from 'appname/decorators/monthly-spending';
const { computed } = Ember;

export default Ember.Component.extend({
  sortedCategories: computed.sort('_decoratedCategories', '_categoriesSort'),

  _decoratedCategories: computed.map('categories', (category) => {
    return MonthlySpending.create({ content: category });
  }),
  _categoriesSort: ['averageMonthlySpend:desc'],
});

This pattern ensures Category doesn’t become bloated, and the additional information from TransactionCache is isolated to the decorator itself.