UNPKG

13.3 kBJavaScriptView Raw
1/*$AMPERSAND_VERSION*/
2var includes = require('lodash.includes');
3var difference = require('lodash.difference');
4var forEach = require('lodash.foreach');
5var every = require('lodash.every');
6var assign = require('lodash.assign');
7var isArray = require('lodash.isarray');
8var isEqual = require('lodash.isequal');
9var keys = require('lodash.keys');
10var reduce = require('lodash.reduce');
11var sortBy = require('lodash.sortby');
12var sortedIndex = require('lodash.sortedindex');
13var union = require('lodash.union');
14var classExtend = require('ampersand-class-extend');
15var Events = require('ampersand-events');
16
17var slice = Array.prototype.slice;
18
19
20function FilteredCollection(collection, spec) {
21 this.collection = collection;
22 this.indexes = collection.indexes || [];
23 this._indexes = {};
24 this._resetIndexes(this._indexes);
25 this.mainIndex = collection.mainIndex;
26 this.models = []; //Our filtered, models
27 this.configure(spec || {}, true);
28 this.listenTo(this.collection, 'all', this._onCollectionEvent);
29}
30
31assign(FilteredCollection.prototype, Events, {
32 // Public API
33
34 // add a filter function directly
35 addFilter: function (filter) {
36 this.swapFilters([filter], []);
37 },
38
39 // remove filter function directly
40 removeFilter: function (filter) {
41 this.swapFilters([], [filter]);
42 },
43
44 // clears filters fires events for each add/remove
45 clearFilters: function () {
46 this._resetFilters();
47 this._runFilters();
48 },
49
50 // Swap out a set of old filters with a set of
51 // new filters
52 swapFilters: function (newFilters, oldFilters) {
53 var self = this;
54
55 if (!oldFilters) {
56 oldFilters = this._filters;
57 } else if (!isArray(oldFilters)) {
58 oldFilters = [oldFilters];
59 }
60
61 if (!newFilters) {
62 newFilters = [];
63 } else if (!isArray(newFilters)) {
64 newFilters = [newFilters];
65 }
66
67 oldFilters.forEach(function (filter) {
68 self._removeFilter(filter);
69 });
70
71 newFilters.forEach(function (filter) {
72 self._addFilter(filter);
73 });
74
75 this._runFilters();
76 },
77
78 // Update config with potentially new filters/where/etc
79 configure: function (opts, clear) {
80 if (clear) this._resetFilters(clear);
81 this._parseSpec(opts);
82 if (clear) this._runFilters();
83 },
84
85 // gets a model at a given index
86 at: function (index) {
87 return this.models[index];
88 },
89
90 // proxy `get` method to the underlying collection
91 get: function (query, indexName) {
92 var model = this.collection.get(query, indexName);
93 if (model && includes(this.models, model)) return model;
94 },
95
96 // clear all filters, reset everything
97 reset: function () {
98 this.configure({}, true);
99 },
100
101 // Internal API
102
103 // try to get a model by index
104 _indexedGet: function (query, indexName) {
105 if (!query) return;
106 var index = this._indexes[indexName || this.mainIndex];
107 return index[query] || index[query[this.mainIndex]] || this._indexes.cid[query] || this._indexes.cid[query.cid];
108 },
109
110 _parseSpec: function (spec) {
111 if (spec.watched) this._watch(spec.watched);
112 //this.comparator = this.collection.comparator;
113 if (spec.comparator) this.comparator = spec.comparator;
114 if (spec.where) {
115 forEach(spec.where, function (value, item) {
116 this._addFilter(function (model) {
117 return (model.get ? model.get(item) : model[item]) === value;
118 });
119 }, this);
120 // also make sure we watch all `where` keys
121 this._watch(keys(spec.where));
122 }
123 if (spec.filter) {
124 this._addFilter(spec.filter);
125 }
126 if (spec.filters) {
127 spec.filters.forEach(this._addFilter, this);
128 }
129 },
130 // internal method registering new filter function
131 _addFilter: function (filter) {
132 this._filters.push(filter);
133 },
134
135 // remove filter if found
136 _removeFilter: function (filter) {
137 var index = this._filters.indexOf(filter);
138 if (index !== -1) {
139 this._filters.splice(index, 1);
140 }
141 },
142
143 // just reset filters, no model changes
144 _resetFilters: function (resetComparator) {
145 this._filters = [];
146 this._watched = [];
147 if (resetComparator) this.comparator = undefined;
148 },
149
150 // adds a property or array of properties to watch, ensures uniquness.
151 _watch: function (items) {
152 this._watched = union(this._watched, items);
153 },
154
155 // removes a watched property
156 _unwatch: function (item) {
157 this._watched = difference(this._watched, isArray(item) ? item : [item]);
158 },
159
160 _sortModels: function (newModels, comparator) {
161 comparator = comparator || this.comparator || this.collection.comparator;
162 if (comparator) {
163 if (typeof comparator === 'string' || comparator.length === 1) {
164 newModels = sortBy(newModels, comparator);
165 } else {
166 // lodash sortBy does not allow for traditional a, b comparisons
167 newModels = newModels.sort(comparator);
168 }
169 } else {
170 // This only happens when parent got a .set with options.at defined
171 this._runFilters();
172 }
173 return newModels;
174 },
175
176 //Add a model to this filtered collection that has already passed the filters
177 _addModel: function (model, options, eventName) {
178 var newModels = slice.call(this.models);
179 var comparator = this.comparator || this.collection.comparator;
180 //Whether or not we are to expect a sort event from our collection later
181 var sortable = eventName === 'add' && this.collection.comparator && (options.at == null) && options.sort !== false;
182 if (!sortable) {
183 var index = sortedIndex(newModels, model, comparator);
184 newModels.splice(index, 0, model);
185 } else {
186 newModels.push(model);
187 if (options.at) newModels = this._sortModels(newModels);
188 }
189
190 this.models = newModels;
191 this._addIndex(this._indexes, model);
192 if (this.comparator && !sortable) {
193 this.trigger('sort', this);
194 }
195 },
196
197 //Remove a model if it's in this filtered collection
198 _removeModel: function (model) {
199 var newModels = slice.call(this.models);
200 var modelIndex = newModels.indexOf(model);
201 if (modelIndex > -1) {
202 newModels.splice(modelIndex, 1);
203 this.models = newModels;
204 this._removeIndex(this._indexes, model);
205 return true;
206 }
207 return false;
208 },
209
210
211 //Test if a model passes our filters
212 _testModel: function (model) {
213 if (this._filters.length === 0) {
214 return true;
215 }
216 return every(this._filters, function (filter) {
217 return filter(model);
218 });
219 },
220
221 _addIndex: function (newIndexes, model) {
222 for (var name in this._indexes) {
223 var indexVal = model[name] || (model.get && model.get(name));
224 if (indexVal) newIndexes[name][indexVal] = model;
225 }
226 },
227
228 _removeIndex: function (newIndexes, model) {
229 for (var name in this._indexes) {
230 delete this._indexes[name][model[name] || (model.get && model.get(name))];
231 }
232 },
233
234 _resetIndexes: function (newIndexes) {
235 var list = slice.call(this.indexes);
236 list.push(this.mainIndex);
237 list.push('cid');
238 for (var i = 0; i < list.length; i++) {
239 newIndexes[list[i]] = {};
240 }
241 },
242
243 // Re-run the filters on all our parent's models
244 _runFilters: function () {
245 // make a copy of the array for comparisons
246 var existingModels = slice.call(this.models);
247 var rootModels = slice.call(this.collection.models);
248 var newIndexes = {};
249 var newModels, toAdd, toRemove;
250
251 this._resetIndexes(newIndexes);
252
253 // reduce base model set by applying filters
254 if (this._filters.length) {
255 newModels = reduce(this._filters, function (startingArray, filterFunc) {
256 return startingArray.filter(filterFunc);
257 }, rootModels);
258 } else {
259 newModels = slice.call(rootModels);
260 }
261
262 // sort it
263 if (this.comparator) newModels = this._sortModels(newModels, this.comparator);
264
265 newModels.forEach(function (model) {
266 this._addIndex(newIndexes, model);
267 }, this);
268
269 // Cache a reference to the full filtered set to allow this._filtered.length. Ref: #6
270 if (rootModels.length) {
271 this._filtered = newModels;
272 this._indexes = newIndexes;
273 } else {
274 this._filtered = [];
275 this._resetIndexes(this._indexes);
276 }
277
278 // now we've got our new models time to compare
279 toAdd = difference(newModels, existingModels);
280 toRemove = difference(existingModels, newModels);
281
282 // save 'em
283 this.models = newModels;
284
285 forEach(toRemove, function (model) {
286 this.trigger('remove', model, this);
287 }, this);
288
289 forEach(toAdd, function (model) {
290 this.trigger('add', model, this);
291 }, this);
292
293 // unless we have the same models in same order trigger `sort`
294 if (!isEqual(existingModels, newModels) && this.comparator) {
295 this.trigger('sort', this);
296 }
297 },
298
299 _onCollectionEvent: function (event, model, that, options) {
300 /*jshint -W030 */
301 options || (options = {});
302 var accepted;
303 var eventName = event.split(':')[0];
304 var propName = event.split(':')[1];
305 var action = event;
306 var alreadyHave = this._indexedGet(model);
307 //Whether or not we are to expect a sort event from our collection later
308 var sortable = this.collection.comparator && (options.at == null) && (options.sort !== false);
309 var add = options.add;
310 var remove = options.remove;
311 var ordered = !sortable && add && remove;
312
313 if (
314 (propName !== undefined && propName === this.comparator) ||
315 includes(this._watched, propName)
316 ) { //If a property we care about changed
317 accepted = this._testModel(model);
318
319 if (!alreadyHave && accepted) {
320 action = 'add';
321 } else if (alreadyHave && !accepted) {
322 action = 'remove';
323 } else {
324 action = 'ignore';
325 }
326 } else if (action === 'add') { //See if we really want to add
327 if (!this._testModel(model) || alreadyHave) {
328 action = 'ignore';
329 }
330 } else if (eventName === 'change' && !alreadyHave) {
331 //Don't trigger change events that are not from this collection
332 action = 'ignore';
333 }
334
335 // action has now passed the filters
336
337 if (action === 'reset') return this._runFilters();
338
339 if (action === 'add') {
340 if (this.models.length === 0) {
341 this._runFilters();
342 } else {
343 this._addModel(model, options, event);
344 this.trigger('add', model, this);
345 }
346 return;
347 }
348
349 if (action === 'remove') {
350 if (this._removeModel(model)) {
351 this.trigger('remove', model, this);
352 }
353 return;
354 }
355
356 if (action !== 'ignore') {
357 this.trigger.apply(this, arguments);
358 }
359
360 //If we were asked to sort, or we aren't gonna get a sort later and had a sortable property change
361 if (
362 action === 'sort' ||
363 (propName && !sortable && includes([this.comparator, this.collection.comparator], propName))
364 ) {
365 if (ordered && model.isNew) return; //We'll get a sort later
366 this.models = this._sortModels(this.models);
367 if (this.comparator && action !== 'sort') {
368 this.trigger('sort', this);
369 }
370 }
371
372 }
373
374});
375
376Object.defineProperty(FilteredCollection.prototype, 'length', {
377 get: function () {
378 return this.models.length;
379 }
380});
381
382Object.defineProperty(FilteredCollection.prototype, 'isCollection', {
383 get: function () {
384 return true;
385 }
386});
387
388var arrayMethods = [
389 'indexOf',
390 'lastIndexOf',
391 'every',
392 'some',
393 'forEach',
394 'map',
395 'filter',
396 'reduce',
397 'reduceRight'
398];
399
400arrayMethods.forEach(function (method) {
401 FilteredCollection.prototype[method] = function () {
402 return this.models[method].apply(this.models, arguments);
403 };
404});
405
406// alias each/forEach for maximum compatibility
407FilteredCollection.prototype.each = FilteredCollection.prototype.forEach;
408
409// methods to copy from parent
410var collectionMethods = [
411 'serialize',
412 'toJSON'
413];
414
415collectionMethods.forEach(function (method) {
416 FilteredCollection.prototype[method] = function () {
417 return this.collection[method].apply(this, arguments);
418 };
419});
420
421FilteredCollection.extend = classExtend;
422
423module.exports = FilteredCollection;