backbone-filtered-collection
Version:
Create a filtered version of a backbone collection that stays in sync.
229 lines (182 loc) • 5.94 kB
JavaScript
var _ = require('underscore');
var Backbone = require('backbone');
var proxyCollection = require('backbone-collection-proxy');
var createFilter = require('./src/create-filter.js');
// Beware of `this`
// All of the following functions are meant to be called in the context
// of the FilteredCollection object, but are not public functions.
function invalidateCache() {
this._filterResultCache = {};
}
function invalidateCacheForFilter(filterName) {
for (var cid in this._filterResultCache) {
if (this._filterResultCache.hasOwnProperty(cid)) {
delete this._filterResultCache[cid][filterName];
}
}
}
function addFilter(filterName, filterObj) {
// If we've already had a filter of this name, we need to invalidate
// any and all of the cached results
if (this._filters[filterName]) {
invalidateCacheForFilter.call(this, filterName);
}
this._filters[filterName] = filterObj;
this.trigger('filtered:add', filterName);
}
function removeFilter(filterName) {
delete this._filters[filterName];
invalidateCacheForFilter.call(this, filterName);
this.trigger('filtered:remove', filterName);
}
function execFilterOnModel(model) {
if (!this._filterResultCache[model.cid]) {
this._filterResultCache[model.cid] = {};
}
var cache = this._filterResultCache[model.cid];
for (var filterName in this._filters) {
if (this._filters.hasOwnProperty(filterName)) {
// if we haven't already calculated this, calculate it and cache
if (!cache.hasOwnProperty(filterName)) {
cache[filterName] = this._filters[filterName].fn(model);
}
if (!cache[filterName]) {
return false;
}
}
}
return true;
}
function execFilter() {
var filtered = [];
// Filter the collection
if (this._superset) {
filtered = this._superset.filter(_.bind(execFilterOnModel, this));
}
this._collection.reset(filtered);
this.length = this._collection.length;
}
function onAddChange(model) {
// reset the cached results
this._filterResultCache[model.cid] = {};
if (execFilterOnModel.call(this, model)) {
if (!this._collection.get(model.cid)) {
var index = this.superset().indexOf(model);
// Find the index at which to insert the model in the
// filtered collection by finding the index of the
// previous non-filtered model in the filtered collection
var filteredIndex = null;
for (var i = index - 1; i >= 0; i -= 1) {
if (this.contains(this.superset().at(i))) {
filteredIndex = this.indexOf(this.superset().at(i)) + 1;
break;
}
}
filteredIndex = filteredIndex || 0;
this._collection.add(model, { at: filteredIndex });
}
} else {
if (this._collection.get(model.cid)) {
this._collection.remove(model);
}
}
this.length = this._collection.length;
}
// This fires on 'change:[attribute]' events. We only want to
// remove this model if it fails the test, but not add it if
// it does. If we remove it, it will prevent the 'change'
// events from being forwarded, and if we add it, it will cause
// an unneccesary 'change' event to be forwarded without the
// 'change:[attribute]' that goes along with it.
function onModelAttributeChange(model) {
// reset the cached results
this._filterResultCache[model.cid] = {};
if (!execFilterOnModel.call(this, model)) {
if (this._collection.get(model.cid)) {
this._collection.remove(model);
}
}
}
function onAll(eventName, model, value) {
if (eventName.slice(0, 7) === "change:") {
onModelAttributeChange.call(this, arguments[1]);
}
}
function onModelRemove(model) {
if (this.contains(model)) {
this._collection.remove(model);
}
this.length = this._collection.length;
}
function Filtered(superset) {
// Save a reference to the original collection
this._superset = superset;
// The idea is to keep an internal backbone collection with the filtered
// set, and expose limited functionality.
this._collection = new Backbone.Collection(superset.toArray());
proxyCollection(this._collection, this);
// Set up the filter data structures
this.resetFilters();
this.listenTo(this._superset, 'reset sort', execFilter);
this.listenTo(this._superset, 'add change', onAddChange);
this.listenTo(this._superset, 'remove', onModelRemove);
this.listenTo(this._superset, 'all', onAll);
}
var methods = {
defaultFilterName: '__default',
filterBy: function(filterName, filter) {
// Allow the user to skip the filter name if they're only using one filter
if (!filter) {
filter = filterName;
filterName = this.defaultFilterName;
}
addFilter.call(this, filterName, createFilter(filter));
execFilter.call(this);
return this;
},
removeFilter: function(filterName) {
if (!filterName) {
filterName = this.defaultFilterName;
}
removeFilter.call(this, filterName);
execFilter.call(this);
return this;
},
resetFilters: function() {
this._filters = {};
invalidateCache.call(this);
this.trigger('filtered:reset');
execFilter.call(this);
return this;
},
superset: function() {
return this._superset;
},
refilter: function(arg) {
if (typeof arg === "object" && arg.cid) {
// is backbone model, refilter that one
onAddChange.call(this, arg);
} else {
// refilter everything
invalidateCache.call(this);
execFilter.call(this);
}
return this;
},
getFilters: function() {
return _.keys(this._filters);
},
hasFilter: function(name) {
return _.contains(this.getFilters(), name);
},
destroy: function() {
this.stopListening();
this._collection.reset([]);
this._superset = this._collection;
this.length = 0;
this.trigger('filtered:destroy');
}
};
// Build up the prototype
_.extend(Filtered.prototype, methods, Backbone.Events);
module.exports = Filtered;