Skip to content

Instantly share code, notes, and snippets.

@sebastianseilund
Last active December 20, 2015 07:58
Show Gist options
  • Star 37 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save sebastianseilund/6096696 to your computer and use it in GitHub Desktop.
Save sebastianseilund/6096696 to your computer and use it in GitHub Desktop.
An implementation of a merged array in Ember.js that combines items from multiple source arrays so you can easily list them together in your Handlebars templates. Read the blog post at the [Billy's Billing Developer Blog](http://dev.billysbilling.com/blog/How-to-merge-multiple-data-sources-into-one-array-in-Ember-js)
/**
* `Ember.MergedArray` is an array that observes multiple other arrays (called source arrays) for changes and includes
* all items from all source arrays in an efficient way.
*
* Usage:
*
* ```javascript
* var obj = Ember.Object.create({
* people: [
* {
* name: "John"
* }
* ],
* pets: [
* {
* name: "Fido",
* species: "dog"
* }
* ]
* });
* var everybody = Ember.MergedArray.create();
* everybody.addSource(obj, 'people', function(person) {
* return {
* description: person.get('name') + ' is a person';
* }
* });
* everybody.addSource(obj, 'pets', function(pet) {
* return {
* description: pet.get('name') + ' is a ' + pet.get('species');
* }
* });
* console.log(everybody.mapProperty('description')); //Logs ['John is a person', 'Fido is a dog']
* ```
*
* It mixes in `Ember.SortableMixin` so you can easily sort the contents of the merged array by setting either the
* `sortProperties` or `sortProperty` property.
*
* @class MergedArray
* @namespae Ember
* @extends Ember.ArrayProxy
* @uses Ember.SortableMixin
*/
Ember.MergedArray = Ember.ArrayProxy.extend(Ember.SortableMixin, {
init: function() {
this._super();
//We need these private properties to store information about the sources
this._sources = {};
this._observerProxies = {};
//This is an array proxy, so we need to initiate it's content with an empty array
this.set('content', Em.A());
},
/**
* Call this method to add a source to be merged into this array.
*
* @param {Object} sourceObj The object that holds the source as a property.
* @param {String} sourceKey The name of the property that holds the source.
* @param {Function|null} mapFn A function that maps a source record into a uniform object so that all items in this
* array are compatible. If not set, the real source record will be used, i.e. no mapping will take place.
*/
addSource: function(sourceObj, sourceKey, mapFn) {
//If mapFn is unset we default to a function that simply returns the same item
if (!mapFn) {
mapFn = function(item) {
return item;
};
}
//Store information about the source (combination of `sourceObj` and `sourceKey`) in a private property
this._sources[Em.guidFor(sourceObj)+sourceKey] = {
sourceObj: sourceObj,
sourceKey: sourceKey,
mapFn: mapFn
};
//Observe `sourceObj` for when the `sourceKey` property changes.
sourceObj.addBeforeObserver(sourceKey, this._getObserverProxy('_sourceWillChange', sourceObj, sourceKey));
sourceObj.addObserver(sourceKey, this._getObserverProxy('_sourceDidChange', sourceObj, sourceKey));
//Trigger that the source did change, so we can add items from the source right away and array content observers
this._sourceDidChange(sourceObj, sourceKey);
},
/**
* This method is used to always return the same function for a specific source.
*
* We need it so we can easily remove the observers that we setup when we no longer need them.
*
* @param {String} method The name of a method in this class
* @param {Object} sourceObj
* @param {String} sourceKey
* @returns {Function}
*/
_getObserverProxy: function(method, sourceObj, sourceKey) {
var k = method + Em.guidFor(sourceObj) + sourceKey,
proxy = this._observerProxies[k];
if (!proxy) {
proxy = this._observerProxies[k] = $.proxy(this[method], this, sourceObj, sourceKey);
}
return proxy;
},
/**
* When we're done using an observer function we should forget about it.
*
* @param {String} method
* @param {Object} sourceObj
* @param {String} sourceKey
*/
_removeObserverProxy: function(method, sourceObj, sourceKey) {
var k = method + Em.guidFor(sourceObj) + sourceKey;
delete this._observerProxies[k];
},
/**
* When this array is destroyed we need to clean up all the observers we've setup on the sourcre arrays.
*/
willDestroy: function() {
this._super();
var sources = this._sources,
k,
source,
sourceKey,
sourceObj;
//Iterate over each registered source
for (k in sources) {
if (!sources.hasOwnProperty(k)) continue;
source = sources[k];
sourceKey = source.sourceKey;
sourceObj = source.sourceObj;
//Remove observers on `sourceObj`
Ember.removeBeforeObserver(sourceObj, sourceKey, this._getObserverProxy('_sourceWillChange', sourceObj, sourceKey));
sourceObj.removeObserver(sourceKey, this._getObserverProxy('_sourceDidChange', sourceObj, sourceKey));
//Forget about observer proxies - we don't need them anymore
this._removeObserverProxy('_sourceWillChange', sourceObj, sourceKey);
this._removeObserverProxy('_sourceDidChange', sourceObj, sourceKey);
//Trigger that the source will change, so we can remove items from this merged array and remove array content observers
this._sourceWillChange(sourceObj, sourceKey);
}
delete this._sources;
},
/**
* When a source array is about the change (not when the content changes, but when the whole array is replaces) we
* need to remove content observers from the old source array.
*
* @param {Object} sourceObj
* @param {String} sourceKey
* @private
*/
_sourceWillChange: function(sourceObj, sourceKey) {
var self = this,
sourceArray = sourceObj.get(sourceKey);
if (sourceArray) {
//Remove all items in the source array from this merged array
sourceArray.forEach(function(item) {
self._removeItem(item);
});
//Remove array observers
sourceArray.removeArrayObserver(this, {
willChange: this._getObserverProxy('_sourceContentWillChange', sourceObj, sourceKey),
didChange: this._getObserverProxy('_sourceContentDidChange', sourceObj, sourceKey)
});
//Forget about observer proxies - we don't need them anymore
this._removeObserverProxy('_sourceContentWillChange', sourceObj, sourceKey);
this._removeObserverProxy('_sourceContentDidChange', sourceObj, sourceKey);
}
},
/**
* When a source array did change (the whole array was replaced) we need to add content observers to the new array.
*
* @param {Object} sourceObj
* @param {String} sourceKey
* @private
*/
_sourceDidChange: function(sourceObj, sourceKey) {
var self = this,
sourceArray = sourceObj.get(sourceKey);
if (sourceArray) {
//Add all items from the source array to this merged array.
sourceArray.forEach(function(item) {
self._addItem(sourceObj, sourceKey, item);
});
//Add array observers. These will get called every time an item is added to or removed from the source array
sourceArray.addArrayObserver(this, {
willChange: this._getObserverProxy('_sourceContentWillChange', sourceObj, sourceKey),
didChange: this._getObserverProxy('_sourceContentDidChange', sourceObj, sourceKey)
});
}
},
/**
* This observer is triggered every time an item from a source array is either about the be added or removed.
*
* We need to remove from this merged array all item that were removed from the source array.
*
* The reason why we need to remove in `willChange` is that after this method has been called it will be too late
* to get the removed items from the source array (they will already be gone).
*
* @param {Object} sourceObj
* @param {String} sourceKey
* @param {Object} sourceArray
* @param {Number} start The index where items were added/removed from
* @param {Number} removed Number of items that are about the be removed
* @param {Number} added Number of items that are about the be added
* @private
*/
_sourceContentWillChange: function(sourceObj, sourceKey, sourceArray, start, removed, added) {
var i,
item;
for (i = start; i < start + removed; i++) {
item = sourceArray.objectAt(i);
this._removeItem(item);
}
},
/**
* This observer is triggered right after and item from a source array was either added or removed.
*
* We need to add to this merged array all item that were added to the source array.
*
* The reason why we need to add in `didChange` is that they won't be accessible in the source array until after
* they have already been added.
*
* @param {Object} sourceObj
* @param {String} sourceKey
* @param {Object} sourceArray
* @param {Number} start The index where items were added/removed from
* @param {Number} removed Number of items that was removed
* @param {Number} added Number of items that was added
* @private
*/
_sourceContentDidChange: function(sourceObj, sourceKey, sourceArray, start, removed, added) {
var i,
item;
for (i = start; i < start + added; i++) {
item = sourceArray.objectAt(i);
this._addItem(sourceObj, sourceKey, item);
}
},
/**
* Helper method to remove a source item from this merged array.
*
* It takes into account that the mapFn might have returned a different object than the source item itself.
*
* @param {Object} item An item from any source array
* @private
*/
_removeItem: function(item) {
var i,
len = this.get('length'),
mappedItem;
for (i = 0; i < len; i++) {
mappedItem = this.objectAt(i);
if (mappedItem._originalItem === item) {
this.removeAt(i);
break;
}
}
},
/**
* Helper method to add a source item to this merged array.
*
* The item will be mapped using the source's `mapFn`.
*
* @param {Object} sourceObj
* @param {String} sourceKey
* @param {Object} item An item from any source array
* @private
*/
_addItem: function(sourceObj, sourceKey, item) {
var mapFn = this._sources[Em.guidFor(sourceObj)+sourceKey].mapFn,
mappedItem = mapFn(item);
//Since the `mapFn` might return a different object that item, we need to store a reference on the object of
//what the original source item was, so we can easily remove the `mappedItem` from this merged array later
mappedItem._originalItem = item;
this.pushObject(mappedItem);
},
/**
* Ember.SortableMixin needs an array of `sortProperties` to know what to sort by.
*
* Often you only sort by one property, so this shortcut is a little simpler to use.
*/
sortProperty: function(key, value) {
if (arguments.length > 1) {
this.set('sortProperties', value ? [value] : null);
return value;
} else {
var sortProperties = this.get('sortProperties');
return sortProperties ? sortProperties[0] : null;
}
}.property('sortProperties')
});
module('Ember.MergedArray');
test('adds items instantly', function() {
var obj = Ember.Object.create({
invoices: [
{
id: 'i1'
},
{
id: 'i2'
}
]
});
var a = Em.MergedArray.create();
a.addSource(obj, 'invoices');
equal(a.get('length'), 2);
});
test('removes items when source is removed', function() {
var obj = Ember.Object.create({
invoices: [
{
id: 'i1'
}
]
});
var a = Em.MergedArray.create();
a.addSource(obj, 'invoices');
obj.set('invoices', null);
equal(a.get('length'), 0);
});
test('adds items when new source is set', function() {
var obj = Ember.Object.create({
invoices: [
{
id: 'i1'
}
]
});
var a = Em.MergedArray.create();
a.addSource(obj, 'invoices');
obj.set('invoices', [
{
id: 'i1'
},
{
id: 'i2'
}
]);
equal(a.get('length'), 2);
});
test('adds item when source content changes', function() {
var invoices = [
{
id: 'i1'
}
];
var obj = Ember.Object.create({
invoices: invoices
});
var a = Em.MergedArray.create();
a.addSource(obj, 'invoices');
invoices.pushObject({
id: 'i2'
});
equal(a.get('length'), 2);
});
test('does not add item when old source content changes', function() {
var invoices = [
{
id: 'i1'
}
];
var obj = Ember.Object.create({
invoices: invoices
});
var a = Em.MergedArray.create();
a.addSource(obj, 'invoices');
obj.set('invoices', null);
invoices.pushObject({
id: 'i2'
});
equal(a.get('length'), 0);
});
test('adds items from multiple sources instantly', function() {
var invoices = [
{
id: 'i1'
}
];
var bills = [
{
id: 'b1'
},
{
id: 'b2'
}
];
var obj = Ember.Object.create({
invoices: invoices,
bills: bills
});
var a = Em.MergedArray.create();
a.addSource(obj, 'invoices');
a.addSource(obj, 'bills');
equal(a.get('length'), 3);
});
test('adds items from multiple sources when their content changes', function() {
var invoices = [
{
id: 'i1'
}
];
var bills = [
];
var obj = Ember.Object.create({
invoices: invoices,
bills: bills
});
var a = Em.MergedArray.create();
a.addSource(obj, 'invoices');
a.addSource(obj, 'bills');
bills.pushObject({
id: 'b1'
});
equal(a.get('length'), 2);
});
test('default mapFn just returns item', function() {
var obj = Ember.Object.create({
invoices: [
{
id: 'i1'
}
]
});
var a = Em.MergedArray.create();
a.addSource(obj, 'invoices');
strictEqual(a.get('firstObject'), obj.get('invoices.firstObject'));
});
test('custom mapFn returns custom item', function() {
var obj = Ember.Object.create({
invoices: [
{
id: 'i1'
}
],
bills: [
{
id: 'b1'
}
]
});
var a = Em.MergedArray.create();
a.addSource(obj, 'invoices', function(item) {
return {
name: 'invoice:' + item.id
};
});
a.addSource(obj, 'bills', function(item) {
return {
name: 'bill:' + item.id
};
});
deepEqual(a.mapProperty('name'), ['invoice:i1', 'bill:b1']);
});
test('sortProperty shortcut', function() {
var a = Em.MergedArray.create();
equal(a.get('sortProperties'), null);
equal(a.get('sortProperty'), null);
a.set('sortProperty', 'date');
deepEqual(a.get('sortProperties'), ['date']);
equal(a.get('sortProperty'), 'date');
a.set('sortProperty', null);
equal(a.get('sortProperties'), null);
equal(a.get('sortProperty'), null);
a.set('sortProperties', ['date', 'amount']);
deepEqual(a.get('sortProperties'), ['date', 'amount']);
equal(a.get('sortProperty'), 'date');
});
test('items are sorted instantly, when changed, when added, and when sortProperty changes', function() {
var females = [
Ember.Object.create({
name: 'Alice',
age: 25
}),
Ember.Object.create({
name: 'Eva',
age: 27
})
];
var males = [
Ember.Object.create({
name: 'Adam',
age: 29
}),
Ember.Object.create({
name: 'John',
age: 31
})
];
var obj = Ember.Object.create({
females: females,
males: males
});
var a = Em.MergedArray.create({
sortProperty: 'name'
});
a.addSource(obj, 'females');
a.addSource(obj, 'males');
deepEqual(a.mapProperty('name'), ['Adam', 'Alice', 'Eva', 'John']);
males[0].set('name', 'Zebra');
deepEqual(a.mapProperty('name'), ['Alice', 'Eva', 'John', 'Zebra']);
females.pushObject({
name: 'Jane',
age: 33
});
deepEqual(a.mapProperty('name'), ['Alice', 'Eva', 'Jane', 'John', 'Zebra']);
a.set('sortProperty', 'age');
deepEqual(a.mapProperty('age'), [25, 27, 29, 31, 33]);
a.set('sortAscending', false);
deepEqual(a.mapProperty('age'), [33, 31, 29, 27, 25]);
});
test('destroy', function() {
var invoices = [
{
id: 'i1'
}
];
equal(invoices.get('hasArrayObservers'), false);
var obj = Ember.Object.create({
invoices: invoices
});
var a = Em.MergedArray.create();
a.addSource(obj, 'invoices');
equal(invoices.get('hasArrayObservers'), true);
Ember.run(function() {
a.destroy();
});
equal(invoices.get('hasArrayObservers'), false);
});
@paulferrett
Copy link

@jasonporritt
Copy link

The call to this._super() in init needs to happen after you've set up the content array so that the observers created by the ArrayProxy init() method observe the correct content array. See our fork of the gist, which:

  • Adds a test case that matches our use and fails when the init() order is incorrect
  • Fixes the init order

https://gist.github.com/jasonporritt/727a908543ec7b483c2c

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment