Skip to content

Instantly share code, notes, and snippets.

@jasonporritt
Forked from sebastianseilund/merged_array.js
Last active August 29, 2015 14:20
Show Gist options
  • Save jasonporritt/727a908543ec7b483c2c to your computer and use it in GitHub Desktop.
Save jasonporritt/727a908543ec7b483c2c to your computer and use it in GitHub Desktop.
/**
* `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() {
//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());
// This needs to happen last, after the content property is set, so that observers
// set up by the ArrayProxy class are observing the right thing.
this._super();
},
/**
* 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);
});
// This test case fails without the moved `this.super()` call in `init`
test('computed property on MergedArray', function() {
var objClass = Ember.Object.extend({
sourceArray1: [],
sourceArray2: [{bar:'bar'}],
mergedArray: MergedArray.create(),
computedArrayHasABar: (function() {
return this.get('mergedArray').any( function(x){
return Em.get(x,'bar');
});
}).property('mergedArray.@each.bar')
});
var obj = objClass.create();
// Set an initial source for the merged array, without bar
Ember.run(function(){
obj.get('mergedArray').addSource(obj,'sourceArray1');
});
ok(!obj.get('computedArrayHasABar'), 'initial source has no bar');
// Add an object with a bar and see the computed property changes to true
var barObj = {bar:'bar'};
Ember.run(function(){
obj.get('sourceArray1').pushObject(barObj);
});
ok(obj.get('computedArrayHasABar'), 'adding object with bar changes property to true');
// Remove the object with a bar and see the computed property is now false
Ember.run(function(){
obj.get('sourceArray1').removeObject(barObj);
});
ok(!obj.get('computedArrayHasABar'), 'removing object with bar changes property to false');
// Add a new source that already has a bar, and see the property changes to true
Ember.run(function(){
obj.get('mergedArray').addSource(obj,'sourceArray2');
});
ok(obj.get('computedArrayHasABar'), 'adding new source with bar changes property to true');
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment