UNPKG

backbone-filtered-collection

Version:

Create a filtered version of a backbone collection that stays in sync.

229 lines (182 loc) 5.94 kB
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;