Upgrade to ExtJS 4.0.2 - Released 06/09/2011
[extjs.git] / src / selection / Model.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.selection.Model
17  * @extends Ext.util.Observable
18  *
19  * Tracks what records are currently selected in a databound widget.
20  *
21  * This is an abstract class and is not meant to be directly used.
22  *
23  * DataBound UI widgets such as GridPanel, TreePanel, and ListView
24  * should subclass AbstractStoreSelectionModel and provide a way
25  * to binding to the component.
26  *
27  * The abstract methods onSelectChange and onLastFocusChanged should
28  * be implemented in these subclasses to update the UI widget.
29  */
30 Ext.define('Ext.selection.Model', {
31     extend: 'Ext.util.Observable',
32     alternateClassName: 'Ext.AbstractSelectionModel',
33     requires: ['Ext.data.StoreManager'],
34     // lastSelected
35
36     /**
37      * @cfg {String} mode
38      * Modes of selection.
39      * Valid values are SINGLE, SIMPLE, and MULTI. Defaults to 'SINGLE'
40      */
41
42     /**
43      * @cfg {Boolean} allowDeselect
44      * Allow users to deselect a record in a DataView, List or Grid. Only applicable when the SelectionModel's mode is 'SINGLE'. Defaults to false.
45      */
46     allowDeselect: false,
47
48     /**
49      * @property selected
50      * READ-ONLY A MixedCollection that maintains all of the currently selected
51      * records.
52      */
53     selected: null,
54
55
56     /**
57      * Prune records when they are removed from the store from the selection.
58      * This is a private flag. For an example of its usage, take a look at
59      * Ext.selection.TreeModel.
60      * @private
61      */
62     pruneRemoved: true,
63
64     constructor: function(cfg) {
65         var me = this;
66
67         cfg = cfg || {};
68         Ext.apply(me, cfg);
69
70         me.addEvents(
71             /**
72              * @event selectionchange
73              * Fired after a selection change has occurred
74              * @param {Ext.selection.Model} this
75              * @param  {Array} selected The selected records
76              */
77              'selectionchange'
78         );
79
80         me.modes = {
81             SINGLE: true,
82             SIMPLE: true,
83             MULTI: true
84         };
85
86         // sets this.selectionMode
87         me.setSelectionMode(cfg.mode || me.mode);
88
89         // maintains the currently selected records.
90         me.selected = Ext.create('Ext.util.MixedCollection');
91
92         me.callParent(arguments);
93     },
94
95     // binds the store to the selModel.
96     bind : function(store, initial){
97         var me = this;
98
99         if(!initial && me.store){
100             if(store !== me.store && me.store.autoDestroy){
101                 me.store.destroy();
102             }else{
103                 me.store.un("add", me.onStoreAdd, me);
104                 me.store.un("clear", me.onStoreClear, me);
105                 me.store.un("remove", me.onStoreRemove, me);
106                 me.store.un("update", me.onStoreUpdate, me);
107             }
108         }
109         if(store){
110             store = Ext.data.StoreManager.lookup(store);
111             store.on({
112                 add: me.onStoreAdd,
113                 clear: me.onStoreClear,
114                 remove: me.onStoreRemove,
115                 update: me.onStoreUpdate,
116                 scope: me
117             });
118         }
119         me.store = store;
120         if(store && !initial) {
121             me.refresh();
122         }
123     },
124
125     /**
126      * Select all records in the view.
127      * @param {Boolean} suppressEvent True to suppress any selects event
128      */
129     selectAll: function(suppressEvent) {
130         var me = this,
131             selections = me.store.getRange(),
132             i = 0,
133             len = selections.length,
134             start = me.getSelection().length;
135
136         me.bulkChange = true;
137         for (; i < len; i++) {
138             me.doSelect(selections[i], true, suppressEvent);
139         }
140         delete me.bulkChange;
141         // fire selection change only if the number of selections differs
142         me.maybeFireSelectionChange(me.getSelection().length !== start);
143     },
144
145     /**
146      * Deselect all records in the view.
147      * @param {Boolean} suppressEvent True to suppress any deselect events
148      */
149     deselectAll: function(suppressEvent) {
150         var me = this,
151             selections = me.getSelection(),
152             i = 0,
153             len = selections.length,
154             start = me.getSelection().length;
155
156         me.bulkChange = true;
157         for (; i < len; i++) {
158             me.doDeselect(selections[i], suppressEvent);
159         }
160         delete me.bulkChange;
161         // fire selection change only if the number of selections differs
162         me.maybeFireSelectionChange(me.getSelection().length !== start);
163     },
164
165     // Provides differentiation of logic between MULTI, SIMPLE and SINGLE
166     // selection modes. Requires that an event be passed so that we can know
167     // if user held ctrl or shift.
168     selectWithEvent: function(record, e, keepExisting) {
169         var me = this;
170
171         switch (me.selectionMode) {
172             case 'MULTI':
173                 if (e.ctrlKey && me.isSelected(record)) {
174                     me.doDeselect(record, false);
175                 } else if (e.shiftKey && me.lastFocused) {
176                     me.selectRange(me.lastFocused, record, e.ctrlKey);
177                 } else if (e.ctrlKey) {
178                     me.doSelect(record, true, false);
179                 } else if (me.isSelected(record) && !e.shiftKey && !e.ctrlKey && me.selected.getCount() > 1) {
180                     me.doSelect(record, keepExisting, false);
181                 } else {
182                     me.doSelect(record, false);
183                 }
184                 break;
185             case 'SIMPLE':
186                 if (me.isSelected(record)) {
187                     me.doDeselect(record);
188                 } else {
189                     me.doSelect(record, true);
190                 }
191                 break;
192             case 'SINGLE':
193                 // if allowDeselect is on and this record isSelected, deselect it
194                 if (me.allowDeselect && me.isSelected(record)) {
195                     me.doDeselect(record);
196                 // select the record and do NOT maintain existing selections
197                 } else {
198                     me.doSelect(record, false);
199                 }
200                 break;
201         }
202     },
203
204     /**
205      * Selects a range of rows if the selection model {@link #isLocked is not locked}.
206      * All rows in between startRow and endRow are also selected.
207      * @param {Ext.data.Model/Number} startRow The record or index of the first row in the range
208      * @param {Ext.data.Model/Number} endRow The record or index of the last row in the range
209      * @param {Boolean} keepExisting (optional) True to retain existing selections
210      */
211     selectRange : function(startRow, endRow, keepExisting, dir){
212         var me = this,
213             store = me.store,
214             selectedCount = 0,
215             i,
216             tmp,
217             dontDeselect,
218             records = [];
219
220         if (me.isLocked()){
221             return;
222         }
223
224         if (!keepExisting) {
225             me.deselectAll(true);
226         }
227
228         if (!Ext.isNumber(startRow)) {
229             startRow = store.indexOf(startRow);
230         }
231         if (!Ext.isNumber(endRow)) {
232             endRow = store.indexOf(endRow);
233         }
234
235         // swap values
236         if (startRow > endRow){
237             tmp = endRow;
238             endRow = startRow;
239             startRow = tmp;
240         }
241
242         for (i = startRow; i <= endRow; i++) {
243             if (me.isSelected(store.getAt(i))) {
244                 selectedCount++;
245             }
246         }
247
248         if (!dir) {
249             dontDeselect = -1;
250         } else {
251             dontDeselect = (dir == 'up') ? startRow : endRow;
252         }
253
254         for (i = startRow; i <= endRow; i++){
255             if (selectedCount == (endRow - startRow + 1)) {
256                 if (i != dontDeselect) {
257                     me.doDeselect(i, true);
258                 }
259             } else {
260                 records.push(store.getAt(i));
261             }
262         }
263         me.doMultiSelect(records, true);
264     },
265
266     /**
267      * Selects a record instance by record instance or index.
268      * @param {Ext.data.Model/Index} records An array of records or an index
269      * @param {Boolean} keepExisting
270      * @param {Boolean} suppressEvent Set to false to not fire a select event
271      */
272     select: function(records, keepExisting, suppressEvent) {
273         this.doSelect(records, keepExisting, suppressEvent);
274     },
275
276     /**
277      * Deselects a record instance by record instance or index.
278      * @param {Ext.data.Model/Index} records An array of records or an index
279      * @param {Boolean} suppressEvent Set to false to not fire a deselect event
280      */
281     deselect: function(records, suppressEvent) {
282         this.doDeselect(records, suppressEvent);
283     },
284
285     doSelect: function(records, keepExisting, suppressEvent) {
286         var me = this,
287             record;
288
289         if (me.locked) {
290             return;
291         }
292         if (typeof records === "number") {
293             records = [me.store.getAt(records)];
294         }
295         if (me.selectionMode == "SINGLE" && records) {
296             record = records.length ? records[0] : records;
297             me.doSingleSelect(record, suppressEvent);
298         } else {
299             me.doMultiSelect(records, keepExisting, suppressEvent);
300         }
301     },
302
303     doMultiSelect: function(records, keepExisting, suppressEvent) {
304         var me = this,
305             selected = me.selected,
306             change = false,
307             i = 0,
308             len, record;
309
310         if (me.locked) {
311             return;
312         }
313
314
315         records = !Ext.isArray(records) ? [records] : records;
316         len = records.length;
317         if (!keepExisting && selected.getCount() > 0) {
318             if (me.doDeselect(me.getSelection(), suppressEvent) === false) {
319                 return;
320             }
321             // TODO - coalesce the selectionchange event in deselect w/the one below...
322         }
323
324         function commit () {
325             selected.add(record);
326             change = true;
327         }
328
329         for (; i < len; i++) {
330             record = records[i];
331             if (keepExisting && me.isSelected(record)) {
332                 continue;
333             }
334             me.lastSelected = record;
335
336             me.onSelectChange(record, true, suppressEvent, commit);
337         }
338         me.setLastFocused(record, suppressEvent);
339         // fire selchange if there was a change and there is no suppressEvent flag
340         me.maybeFireSelectionChange(change && !suppressEvent);
341     },
342
343     // records can be an index, a record or an array of records
344     doDeselect: function(records, suppressEvent) {
345         var me = this,
346             selected = me.selected,
347             i = 0,
348             len, record,
349             attempted = 0,
350             accepted = 0;
351
352         if (me.locked) {
353             return false;
354         }
355
356         if (typeof records === "number") {
357             records = [me.store.getAt(records)];
358         } else if (!Ext.isArray(records)) {
359             records = [records];
360         }
361
362         function commit () {
363             ++accepted;
364             selected.remove(record);
365         }
366
367         len = records.length;
368         
369         for (; i < len; i++) {
370             record = records[i];
371             if (me.isSelected(record)) {
372                 if (me.lastSelected == record) {
373                     me.lastSelected = selected.last();
374                 }
375                 ++attempted;
376                 me.onSelectChange(record, false, suppressEvent, commit);
377             }
378         }
379
380         // fire selchange if there was a change and there is no suppressEvent flag
381         me.maybeFireSelectionChange(accepted > 0 && !suppressEvent);
382         return accepted === attempted;
383     },
384
385     doSingleSelect: function(record, suppressEvent) {
386         var me = this,
387             changed = false,
388             selected = me.selected;
389
390         if (me.locked) {
391             return;
392         }
393         // already selected.
394         // should we also check beforeselect?
395         if (me.isSelected(record)) {
396             return;
397         }
398
399         function commit () {
400             me.bulkChange = true;
401             if (selected.getCount() > 0 && me.doDeselect(me.lastSelected, suppressEvent) === false) {
402                 delete me.bulkChange;
403                 return false;
404             }
405             delete me.bulkChange;
406
407             selected.add(record);
408             me.lastSelected = record;
409             changed = true;
410         }
411
412         me.onSelectChange(record, true, suppressEvent, commit);
413
414         if (changed) {
415             if (!suppressEvent) {
416                 me.setLastFocused(record);
417             }
418             me.maybeFireSelectionChange(!suppressEvent);
419         }
420     },
421
422     /**
423      * @param {Ext.data.Model} record
424      * Set a record as the last focused record. This does NOT mean
425      * that the record has been selected.
426      */
427     setLastFocused: function(record, supressFocus) {
428         var me = this,
429             recordBeforeLast = me.lastFocused;
430         me.lastFocused = record;
431         me.onLastFocusChanged(recordBeforeLast, record, supressFocus);
432     },
433
434     /**
435      * Determines if this record is currently focused.
436      * @param Ext.data.Record record
437      */
438     isFocused: function(record) {
439         return record === this.getLastFocused();
440     },
441
442
443     // fire selection change as long as true is not passed
444     // into maybeFireSelectionChange
445     maybeFireSelectionChange: function(fireEvent) {
446         var me = this;
447         if (fireEvent && !me.bulkChange) {
448             me.fireEvent('selectionchange', me, me.getSelection());
449         }
450     },
451
452     /**
453      * Returns the last selected record.
454      */
455     getLastSelected: function() {
456         return this.lastSelected;
457     },
458
459     getLastFocused: function() {
460         return this.lastFocused;
461     },
462
463     /**
464      * Returns an array of the currently selected records.
465      * @return {Array} The selected records
466      */
467     getSelection: function() {
468         return this.selected.getRange();
469     },
470
471     /**
472      * Returns the current selectionMode. SINGLE, MULTI or SIMPLE.
473      * @return {String} The selectionMode
474      */
475     getSelectionMode: function() {
476         return this.selectionMode;
477     },
478
479     /**
480      * Sets the current selectionMode. SINGLE, MULTI or SIMPLE.
481      */
482     setSelectionMode: function(selMode) {
483         selMode = selMode ? selMode.toUpperCase() : 'SINGLE';
484         // set to mode specified unless it doesnt exist, in that case
485         // use single.
486         this.selectionMode = this.modes[selMode] ? selMode : 'SINGLE';
487     },
488
489     /**
490      * Returns true if the selections are locked.
491      * @return {Boolean}
492      */
493     isLocked: function() {
494         return this.locked;
495     },
496
497     /**
498      * Locks the current selection and disables any changes from
499      * happening to the selection.
500      * @param {Boolean} locked
501      */
502     setLocked: function(locked) {
503         this.locked = !!locked;
504     },
505
506     /**
507      * Returns <tt>true</tt> if the specified row is selected.
508      * @param {Record/Number} record The record or index of the record to check
509      * @return {Boolean}
510      */
511     isSelected: function(record) {
512         record = Ext.isNumber(record) ? this.store.getAt(record) : record;
513         return this.selected.indexOf(record) !== -1;
514     },
515
516     /**
517      * Returns true if there are any a selected records.
518      * @return {Boolean}
519      */
520     hasSelection: function() {
521         return this.selected.getCount() > 0;
522     },
523
524     refresh: function() {
525         var me = this,
526             toBeSelected = [],
527             oldSelections = me.getSelection(),
528             len = oldSelections.length,
529             selection,
530             change,
531             i = 0,
532             lastFocused = this.getLastFocused();
533
534         // check to make sure that there are no records
535         // missing after the refresh was triggered, prune
536         // them from what is to be selected if so
537         for (; i < len; i++) {
538             selection = oldSelections[i];
539             if (!this.pruneRemoved || me.store.indexOf(selection) !== -1) {
540                 toBeSelected.push(selection);
541             }
542         }
543
544         // there was a change from the old selected and
545         // the new selection
546         if (me.selected.getCount() != toBeSelected.length) {
547             change = true;
548         }
549
550         me.clearSelections();
551
552         if (me.store.indexOf(lastFocused) !== -1) {
553             // restore the last focus but supress restoring focus
554             this.setLastFocused(lastFocused, true);
555         }
556
557         if (toBeSelected.length) {
558             // perform the selection again
559             me.doSelect(toBeSelected, false, true);
560         }
561
562         me.maybeFireSelectionChange(change);
563     },
564
565     /**
566      * A fast reset of the selections without firing events, updating the ui, etc.
567      * For private usage only.
568      * @private
569      */
570     clearSelections: function() {
571         // reset the entire selection to nothing
572         this.selected.clear();
573         this.lastSelected = null;
574         this.setLastFocused(null);
575     },
576
577     // when a record is added to a store
578     onStoreAdd: function() {
579
580     },
581
582     // when a store is cleared remove all selections
583     // (if there were any)
584     onStoreClear: function() {
585         if (this.selected.getCount > 0) {
586             this.clearSelections();
587             this.maybeFireSelectionChange(true);
588         }
589     },
590
591     // prune records from the SelectionModel if
592     // they were selected at the time they were
593     // removed.
594     onStoreRemove: function(store, record) {
595         var me = this,
596             selected = me.selected;
597
598         if (me.locked || !me.pruneRemoved) {
599             return;
600         }
601
602         if (selected.remove(record)) {
603             if (me.lastSelected == record) {
604                 me.lastSelected = null;
605             }
606             if (me.getLastFocused() == record) {
607                 me.setLastFocused(null);
608             }
609             me.maybeFireSelectionChange(true);
610         }
611     },
612
613     /**
614      * Gets the count of selected records.
615      * @return {Number} The number of selected records
616      */
617     getCount: function() {
618         return this.selected.getCount();
619     },
620
621     // cleanup.
622     destroy: function() {
623
624     },
625
626     // if records are updated
627     onStoreUpdate: function() {
628
629     },
630
631     // @abstract
632     onSelectChange: function(record, isSelected, suppressEvent) {
633
634     },
635
636     // @abstract
637     onLastFocusChanged: function(oldFocused, newFocused) {
638
639     },
640
641     // @abstract
642     onEditorKey: function(field, e) {
643
644     },
645
646     // @abstract
647     bindComponent: function(cmp) {
648
649     }
650 });