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