Upgrade to ExtJS 4.0.2 - Released 06/09/2011
[extjs.git] / src / util / AbstractMixedCollection.js
1 /*
2
3 This file is part of Ext JS 4
4
5 Copyright (c) 2011 Sencha Inc
6
7 Contact:  http://www.sencha.com/contact
8
9 GNU General Public License Usage
10 This file may be used under the terms of the GNU General Public License version 3.0 as published by the Free Software Foundation and appearing in the file LICENSE included in the packaging of this file.  Please review the following information to ensure the GNU General Public License version 3.0 requirements will be met: http://www.gnu.org/copyleft/gpl.html.
11
12 If you are unsure which license is appropriate for your use, please contact the sales department at http://www.sencha.com/contact.
13
14 */
15 /**
16  * @class Ext.util.AbstractMixedCollection
17  */
18 Ext.define('Ext.util.AbstractMixedCollection', {
19     requires: ['Ext.util.Filter'],
20     
21     mixins: {
22         observable: 'Ext.util.Observable'
23     },
24
25     constructor: function(allowFunctions, keyFn) {
26         var me = this;
27
28         me.items = [];
29         me.map = {};
30         me.keys = [];
31         me.length = 0;
32
33         me.addEvents(
34             /**
35              * @event clear
36              * Fires when the collection is cleared.
37              */
38             'clear',
39
40             /**
41              * @event add
42              * Fires when an item is added to the collection.
43              * @param {Number} index The index at which the item was added.
44              * @param {Object} o The item added.
45              * @param {String} key The key associated with the added item.
46              */
47             'add',
48
49             /**
50              * @event replace
51              * Fires when an item is replaced in the collection.
52              * @param {String} key he key associated with the new added.
53              * @param {Object} old The item being replaced.
54              * @param {Object} new The new item.
55              */
56             'replace',
57
58             /**
59              * @event remove
60              * Fires when an item is removed from the collection.
61              * @param {Object} o The item being removed.
62              * @param {String} key (optional) The key associated with the removed item.
63              */
64             'remove'
65         );
66
67         me.allowFunctions = allowFunctions === true;
68
69         if (keyFn) {
70             me.getKey = keyFn;
71         }
72
73         me.mixins.observable.constructor.call(me);
74     },
75     
76     /**
77      * @cfg {Boolean} allowFunctions Specify <tt>true</tt> if the {@link #addAll}
78      * function should add function references to the collection. Defaults to
79      * <tt>false</tt>.
80      */
81     allowFunctions : false,
82
83     /**
84      * Adds an item to the collection. Fires the {@link #add} event when complete.
85      * @param {String} key <p>The key to associate with the item, or the new item.</p>
86      * <p>If a {@link #getKey} implementation was specified for this MixedCollection,
87      * or if the key of the stored items is in a property called <tt><b>id</b></tt>,
88      * the MixedCollection will be able to <i>derive</i> the key for the new item.
89      * In this case just pass the new item in this parameter.</p>
90      * @param {Object} o The item to add.
91      * @return {Object} The item added.
92      */
93     add : function(key, obj){
94         var me = this,
95             myObj = obj,
96             myKey = key,
97             old;
98
99         if (arguments.length == 1) {
100             myObj = myKey;
101             myKey = me.getKey(myObj);
102         }
103         if (typeof myKey != 'undefined' && myKey !== null) {
104             old = me.map[myKey];
105             if (typeof old != 'undefined') {
106                 return me.replace(myKey, myObj);
107             }
108             me.map[myKey] = myObj;
109         }
110         me.length++;
111         me.items.push(myObj);
112         me.keys.push(myKey);
113         me.fireEvent('add', me.length - 1, myObj, myKey);
114         return myObj;
115     },
116
117     /**
118       * MixedCollection has a generic way to fetch keys if you implement getKey.  The default implementation
119       * simply returns <b><code>item.id</code></b> but you can provide your own implementation
120       * to return a different value as in the following examples:<pre><code>
121 // normal way
122 var mc = new Ext.util.MixedCollection();
123 mc.add(someEl.dom.id, someEl);
124 mc.add(otherEl.dom.id, otherEl);
125 //and so on
126
127 // using getKey
128 var mc = new Ext.util.MixedCollection();
129 mc.getKey = function(el){
130    return el.dom.id;
131 };
132 mc.add(someEl);
133 mc.add(otherEl);
134
135 // or via the constructor
136 var mc = new Ext.util.MixedCollection(false, function(el){
137    return el.dom.id;
138 });
139 mc.add(someEl);
140 mc.add(otherEl);
141      * </code></pre>
142      * @param {Object} item The item for which to find the key.
143      * @return {Object} The key for the passed item.
144      */
145     getKey : function(o){
146          return o.id;
147     },
148
149     /**
150      * Replaces an item in the collection. Fires the {@link #replace} event when complete.
151      * @param {String} key <p>The key associated with the item to replace, or the replacement item.</p>
152      * <p>If you supplied a {@link #getKey} implementation for this MixedCollection, or if the key
153      * of your stored items is in a property called <tt><b>id</b></tt>, then the MixedCollection
154      * will be able to <i>derive</i> the key of the replacement item. If you want to replace an item
155      * with one having the same key value, then just pass the replacement item in this parameter.</p>
156      * @param o {Object} o (optional) If the first parameter passed was a key, the item to associate
157      * with that key.
158      * @return {Object}  The new item.
159      */
160     replace : function(key, o){
161         var me = this,
162             old,
163             index;
164
165         if (arguments.length == 1) {
166             o = arguments[0];
167             key = me.getKey(o);
168         }
169         old = me.map[key];
170         if (typeof key == 'undefined' || key === null || typeof old == 'undefined') {
171              return me.add(key, o);
172         }
173         index = me.indexOfKey(key);
174         me.items[index] = o;
175         me.map[key] = o;
176         me.fireEvent('replace', key, old, o);
177         return o;
178     },
179
180     /**
181      * Adds all elements of an Array or an Object to the collection.
182      * @param {Object/Array} objs An Object containing properties which will be added
183      * to the collection, or an Array of values, each of which are added to the collection.
184      * Functions references will be added to the collection if <code>{@link #allowFunctions}</code>
185      * has been set to <tt>true</tt>.
186      */
187     addAll : function(objs){
188         var me = this,
189             i = 0,
190             args,
191             len,
192             key;
193
194         if (arguments.length > 1 || Ext.isArray(objs)) {
195             args = arguments.length > 1 ? arguments : objs;
196             for (len = args.length; i < len; i++) {
197                 me.add(args[i]);
198             }
199         } else {
200             for (key in objs) {
201                 if (objs.hasOwnProperty(key)) {
202                     if (me.allowFunctions || typeof objs[key] != 'function') {
203                         me.add(key, objs[key]);
204                     }
205                 }
206             }
207         }
208     },
209
210     /**
211      * Executes the specified function once for every item in the collection, passing the following arguments:
212      * <div class="mdetail-params"><ul>
213      * <li><b>item</b> : Mixed<p class="sub-desc">The collection item</p></li>
214      * <li><b>index</b> : Number<p class="sub-desc">The item's index</p></li>
215      * <li><b>length</b> : Number<p class="sub-desc">The total number of items in the collection</p></li>
216      * </ul></div>
217      * The function should return a boolean value. Returning false from the function will stop the iteration.
218      * @param {Function} fn The function to execute for each item.
219      * @param {Object} scope (optional) The scope (<code>this</code> reference) in which the function is executed. Defaults to the current item in the iteration.
220      */
221     each : function(fn, scope){
222         var items = [].concat(this.items), // each safe for removal
223             i = 0,
224             len = items.length,
225             item;
226
227         for (; i < len; i++) {
228             item = items[i];
229             if (fn.call(scope || item, item, i, len) === false) {
230                 break;
231             }
232         }
233     },
234
235     /**
236      * Executes the specified function once for every key in the collection, passing each
237      * key, and its associated item as the first two parameters.
238      * @param {Function} fn The function to execute for each item.
239      * @param {Object} scope (optional) The scope (<code>this</code> reference) in which the function is executed. Defaults to the browser window.
240      */
241     eachKey : function(fn, scope){
242         var keys = this.keys,
243             items = this.items,
244             i = 0,
245             len = keys.length;
246
247         for (; i < len; i++) {
248             fn.call(scope || window, keys[i], items[i], i, len);
249         }
250     },
251
252     /**
253      * Returns the first item in the collection which elicits a true return value from the
254      * passed selection function.
255      * @param {Function} fn The selection function to execute for each item.
256      * @param {Object} scope (optional) The scope (<code>this</code> reference) in which the function is executed. Defaults to the browser window.
257      * @return {Object} The first item in the collection which returned true from the selection function.
258      */
259     findBy : function(fn, scope) {
260         var keys = this.keys,
261             items = this.items,
262             i = 0,
263             len = items.length;
264
265         for (; i < len; i++) {
266             if (fn.call(scope || window, items[i], keys[i])) {
267                 return items[i];
268             }
269         }
270         return null;
271     },
272
273     //<deprecated since="0.99">
274     find : function() {
275         if (Ext.isDefined(Ext.global.console)) {
276             Ext.global.console.warn('Ext.util.MixedCollection: find has been deprecated. Use findBy instead.');
277         }
278         return this.findBy.apply(this, arguments);
279     },
280     //</deprecated>
281
282     /**
283      * Inserts an item at the specified index in the collection. Fires the {@link #add} event when complete.
284      * @param {Number} index The index to insert the item at.
285      * @param {String} key The key to associate with the new item, or the item itself.
286      * @param {Object} o (optional) If the second parameter was a key, the new item.
287      * @return {Object} The item inserted.
288      */
289     insert : function(index, key, obj){
290         var me = this,
291             myKey = key,
292             myObj = obj;
293
294         if (arguments.length == 2) {
295             myObj = myKey;
296             myKey = me.getKey(myObj);
297         }
298         if (me.containsKey(myKey)) {
299             me.suspendEvents();
300             me.removeAtKey(myKey);
301             me.resumeEvents();
302         }
303         if (index >= me.length) {
304             return me.add(myKey, myObj);
305         }
306         me.length++;
307         Ext.Array.splice(me.items, index, 0, myObj);
308         if (typeof myKey != 'undefined' && myKey !== null) {
309             me.map[myKey] = myObj;
310         }
311         Ext.Array.splice(me.keys, index, 0, myKey);
312         me.fireEvent('add', index, myObj, myKey);
313         return myObj;
314     },
315
316     /**
317      * Remove an item from the collection.
318      * @param {Object} o The item to remove.
319      * @return {Object} The item removed or false if no item was removed.
320      */
321     remove : function(o){
322         return this.removeAt(this.indexOf(o));
323     },
324
325     /**
326      * Remove all items in the passed array from the collection.
327      * @param {Array} items An array of items to be removed.
328      * @return {Ext.util.MixedCollection} this object
329      */
330     removeAll : function(items){
331         Ext.each(items || [], function(item) {
332             this.remove(item);
333         }, this);
334
335         return this;
336     },
337
338     /**
339      * Remove an item from a specified index in the collection. Fires the {@link #remove} event when complete.
340      * @param {Number} index The index within the collection of the item to remove.
341      * @return {Object} The item removed or false if no item was removed.
342      */
343     removeAt : function(index){
344         var me = this,
345             o,
346             key;
347
348         if (index < me.length && index >= 0) {
349             me.length--;
350             o = me.items[index];
351             Ext.Array.erase(me.items, index, 1);
352             key = me.keys[index];
353             if (typeof key != 'undefined') {
354                 delete me.map[key];
355             }
356             Ext.Array.erase(me.keys, index, 1);
357             me.fireEvent('remove', o, key);
358             return o;
359         }
360         return false;
361     },
362
363     /**
364      * Removed an item associated with the passed key fom the collection.
365      * @param {String} key The key of the item to remove.
366      * @return {Object} The item removed or false if no item was removed.
367      */
368     removeAtKey : function(key){
369         return this.removeAt(this.indexOfKey(key));
370     },
371
372     /**
373      * Returns the number of items in the collection.
374      * @return {Number} the number of items in the collection.
375      */
376     getCount : function(){
377         return this.length;
378     },
379
380     /**
381      * Returns index within the collection of the passed Object.
382      * @param {Object} o The item to find the index of.
383      * @return {Number} index of the item. Returns -1 if not found.
384      */
385     indexOf : function(o){
386         return Ext.Array.indexOf(this.items, o);
387     },
388
389     /**
390      * Returns index within the collection of the passed key.
391      * @param {String} key The key to find the index of.
392      * @return {Number} index of the key.
393      */
394     indexOfKey : function(key){
395         return Ext.Array.indexOf(this.keys, key);
396     },
397
398     /**
399      * Returns the item associated with the passed key OR index.
400      * Key has priority over index.  This is the equivalent
401      * of calling {@link #key} first, then if nothing matched calling {@link #getAt}.
402      * @param {String/Number} key The key or index of the item.
403      * @return {Object} If the item is found, returns the item.  If the item was not found, returns <tt>undefined</tt>.
404      * If an item was found, but is a Class, returns <tt>null</tt>.
405      */
406     get : function(key) {
407         var me = this,
408             mk = me.map[key],
409             item = mk !== undefined ? mk : (typeof key == 'number') ? me.items[key] : undefined;
410         return typeof item != 'function' || me.allowFunctions ? item : null; // for prototype!
411     },
412
413     /**
414      * Returns the item at the specified index.
415      * @param {Number} index The index of the item.
416      * @return {Object} The item at the specified index.
417      */
418     getAt : function(index) {
419         return this.items[index];
420     },
421
422     /**
423      * Returns the item associated with the passed key.
424      * @param {String/Number} key The key of the item.
425      * @return {Object} The item associated with the passed key.
426      */
427     getByKey : function(key) {
428         return this.map[key];
429     },
430
431     /**
432      * Returns true if the collection contains the passed Object as an item.
433      * @param {Object} o  The Object to look for in the collection.
434      * @return {Boolean} True if the collection contains the Object as an item.
435      */
436     contains : function(o){
437         return Ext.Array.contains(this.items, o);
438     },
439
440     /**
441      * Returns true if the collection contains the passed Object as a key.
442      * @param {String} key The key to look for in the collection.
443      * @return {Boolean} True if the collection contains the Object as a key.
444      */
445     containsKey : function(key){
446         return typeof this.map[key] != 'undefined';
447     },
448
449     /**
450      * Removes all items from the collection.  Fires the {@link #clear} event when complete.
451      */
452     clear : function(){
453         var me = this;
454
455         me.length = 0;
456         me.items = [];
457         me.keys = [];
458         me.map = {};
459         me.fireEvent('clear');
460     },
461
462     /**
463      * Returns the first item in the collection.
464      * @return {Object} the first item in the collection..
465      */
466     first : function() {
467         return this.items[0];
468     },
469
470     /**
471      * Returns the last item in the collection.
472      * @return {Object} the last item in the collection..
473      */
474     last : function() {
475         return this.items[this.length - 1];
476     },
477
478     /**
479      * Collects all of the values of the given property and returns their sum
480      * @param {String} property The property to sum by
481      * @param {String} root Optional 'root' property to extract the first argument from. This is used mainly when
482      * summing fields in records, where the fields are all stored inside the 'data' object
483      * @param {Number} start (optional) The record index to start at (defaults to <tt>0</tt>)
484      * @param {Number} end (optional) The record index to end at (defaults to <tt>-1</tt>)
485      * @return {Number} The total
486      */
487     sum: function(property, root, start, end) {
488         var values = this.extractValues(property, root),
489             length = values.length,
490             sum    = 0,
491             i;
492
493         start = start || 0;
494         end   = (end || end === 0) ? end : length - 1;
495
496         for (i = start; i <= end; i++) {
497             sum += values[i];
498         }
499
500         return sum;
501     },
502
503     /**
504      * Collects unique values of a particular property in this MixedCollection
505      * @param {String} property The property to collect on
506      * @param {String} root Optional 'root' property to extract the first argument from. This is used mainly when
507      * summing fields in records, where the fields are all stored inside the 'data' object
508      * @param {Boolean} allowBlank (optional) Pass true to allow null, undefined or empty string values
509      * @return {Array} The unique values
510      */
511     collect: function(property, root, allowNull) {
512         var values = this.extractValues(property, root),
513             length = values.length,
514             hits   = {},
515             unique = [],
516             value, strValue, i;
517
518         for (i = 0; i < length; i++) {
519             value = values[i];
520             strValue = String(value);
521
522             if ((allowNull || !Ext.isEmpty(value)) && !hits[strValue]) {
523                 hits[strValue] = true;
524                 unique.push(value);
525             }
526         }
527
528         return unique;
529     },
530
531     /**
532      * @private
533      * Extracts all of the given property values from the items in the MC. Mainly used as a supporting method for
534      * functions like sum and collect.
535      * @param {String} property The property to extract
536      * @param {String} root Optional 'root' property to extract the first argument from. This is used mainly when
537      * extracting field data from Model instances, where the fields are stored inside the 'data' object
538      * @return {Array} The extracted values
539      */
540     extractValues: function(property, root) {
541         var values = this.items;
542
543         if (root) {
544             values = Ext.Array.pluck(values, root);
545         }
546
547         return Ext.Array.pluck(values, property);
548     },
549
550     /**
551      * Returns a range of items in this collection
552      * @param {Number} startIndex (optional) The starting index. Defaults to 0.
553      * @param {Number} endIndex (optional) The ending index. Defaults to the last item.
554      * @return {Array} An array of items
555      */
556     getRange : function(start, end){
557         var me = this,
558             items = me.items,
559             range = [],
560             i;
561
562         if (items.length < 1) {
563             return range;
564         }
565
566         start = start || 0;
567         end = Math.min(typeof end == 'undefined' ? me.length - 1 : end, me.length - 1);
568         if (start <= end) {
569             for (i = start; i <= end; i++) {
570                 range[range.length] = items[i];
571             }
572         } else {
573             for (i = start; i >= end; i--) {
574                 range[range.length] = items[i];
575             }
576         }
577         return range;
578     },
579
580     /**
581      * <p>Filters the objects in this collection by a set of {@link Ext.util.Filter Filter}s, or by a single
582      * property/value pair with optional parameters for substring matching and case sensitivity. See
583      * {@link Ext.util.Filter Filter} for an example of using Filter objects (preferred). Alternatively,
584      * MixedCollection can be easily filtered by property like this:</p>
585 <pre><code>
586 //create a simple store with a few people defined
587 var people = new Ext.util.MixedCollection();
588 people.addAll([
589     {id: 1, age: 25, name: 'Ed'},
590     {id: 2, age: 24, name: 'Tommy'},
591     {id: 3, age: 24, name: 'Arne'},
592     {id: 4, age: 26, name: 'Aaron'}
593 ]);
594
595 //a new MixedCollection containing only the items where age == 24
596 var middleAged = people.filter('age', 24);
597 </code></pre>
598      *
599      *
600      * @param {Array/String} property A property on your objects, or an array of {@link Ext.util.Filter Filter} objects
601      * @param {String/RegExp} value Either string that the property values
602      * should start with or a RegExp to test against the property
603      * @param {Boolean} anyMatch (optional) True to match any part of the string, not just the beginning
604      * @param {Boolean} caseSensitive (optional) True for case sensitive comparison (defaults to False).
605      * @return {MixedCollection} The new filtered collection
606      */
607     filter : function(property, value, anyMatch, caseSensitive) {
608         var filters = [],
609             filterFn;
610
611         //support for the simple case of filtering by property/value
612         if (Ext.isString(property)) {
613             filters.push(Ext.create('Ext.util.Filter', {
614                 property     : property,
615                 value        : value,
616                 anyMatch     : anyMatch,
617                 caseSensitive: caseSensitive
618             }));
619         } else if (Ext.isArray(property) || property instanceof Ext.util.Filter) {
620             filters = filters.concat(property);
621         }
622
623         //at this point we have an array of zero or more Ext.util.Filter objects to filter with,
624         //so here we construct a function that combines these filters by ANDing them together
625         filterFn = function(record) {
626             var isMatch = true,
627                 length = filters.length,
628                 i;
629
630             for (i = 0; i < length; i++) {
631                 var filter = filters[i],
632                     fn     = filter.filterFn,
633                     scope  = filter.scope;
634
635                 isMatch = isMatch && fn.call(scope, record);
636             }
637
638             return isMatch;
639         };
640
641         return this.filterBy(filterFn);
642     },
643
644     /**
645      * Filter by a function. Returns a <i>new</i> collection that has been filtered.
646      * The passed function will be called with each object in the collection.
647      * If the function returns true, the value is included otherwise it is filtered.
648      * @param {Function} fn The function to be called, it will receive the args o (the object), k (the key)
649      * @param {Object} scope (optional) The scope (<code>this</code> reference) in which the function is executed. Defaults to this MixedCollection.
650      * @return {MixedCollection} The new filtered collection
651      */
652     filterBy : function(fn, scope) {
653         var me = this,
654             newMC  = new this.self(),
655             keys   = me.keys,
656             items  = me.items,
657             length = items.length,
658             i;
659
660         newMC.getKey = me.getKey;
661
662         for (i = 0; i < length; i++) {
663             if (fn.call(scope || me, items[i], keys[i])) {
664                 newMC.add(keys[i], items[i]);
665             }
666         }
667
668         return newMC;
669     },
670
671     /**
672      * Finds the index of the first matching object in this collection by a specific property/value.
673      * @param {String} property The name of a property on your objects.
674      * @param {String/RegExp} value A string that the property values
675      * should start with or a RegExp to test against the property.
676      * @param {Number} start (optional) The index to start searching at (defaults to 0).
677      * @param {Boolean} anyMatch (optional) True to match any part of the string, not just the beginning.
678      * @param {Boolean} caseSensitive (optional) True for case sensitive comparison.
679      * @return {Number} The matched index or -1
680      */
681     findIndex : function(property, value, start, anyMatch, caseSensitive){
682         if(Ext.isEmpty(value, false)){
683             return -1;
684         }
685         value = this.createValueMatcher(value, anyMatch, caseSensitive);
686         return this.findIndexBy(function(o){
687             return o && value.test(o[property]);
688         }, null, start);
689     },
690
691     /**
692      * Find the index of the first matching object in this collection by a function.
693      * If the function returns <i>true</i> it is considered a match.
694      * @param {Function} fn The function to be called, it will receive the args o (the object), k (the key).
695      * @param {Object} scope (optional) The scope (<code>this</code> reference) in which the function is executed. Defaults to this MixedCollection.
696      * @param {Number} start (optional) The index to start searching at (defaults to 0).
697      * @return {Number} The matched index or -1
698      */
699     findIndexBy : function(fn, scope, start){
700         var me = this,
701             keys = me.keys,
702             items = me.items,
703             i = start || 0,
704             len = items.length;
705
706         for (; i < len; i++) {
707             if (fn.call(scope || me, items[i], keys[i])) {
708                 return i;
709             }
710         }
711         return -1;
712     },
713
714     /**
715      * Returns a regular expression based on the given value and matching options. This is used internally for finding and filtering,
716      * and by Ext.data.Store#filter
717      * @private
718      * @param {String} value The value to create the regex for. This is escaped using Ext.escapeRe
719      * @param {Boolean} anyMatch True to allow any match - no regex start/end line anchors will be added. Defaults to false
720      * @param {Boolean} caseSensitive True to make the regex case sensitive (adds 'i' switch to regex). Defaults to false.
721      * @param {Boolean} exactMatch True to force exact match (^ and $ characters added to the regex). Defaults to false. Ignored if anyMatch is true.
722      */
723     createValueMatcher : function(value, anyMatch, caseSensitive, exactMatch) {
724         if (!value.exec) { // not a regex
725             var er = Ext.String.escapeRegex;
726             value = String(value);
727
728             if (anyMatch === true) {
729                 value = er(value);
730             } else {
731                 value = '^' + er(value);
732                 if (exactMatch === true) {
733                     value += '$';
734                 }
735             }
736             value = new RegExp(value, caseSensitive ? '' : 'i');
737         }
738         return value;
739     },
740
741     /**
742      * Creates a shallow copy of this collection
743      * @return {MixedCollection}
744      */
745     clone : function() {
746         var me = this,
747             copy = new this.self(),
748             keys = me.keys,
749             items = me.items,
750             i = 0,
751             len = items.length;
752
753         for(; i < len; i++){
754             copy.add(keys[i], items[i]);
755         }
756         copy.getKey = me.getKey;
757         return copy;
758     }
759 });
760