Upgrade to ExtJS 4.0.0 - Released 04/26/2011
[extjs.git] / src / grid / feature / Grouping.js
1 /**
2  * @class Ext.grid.feature.Grouping
3  * @extends Ext.grid.feature.Feature
4  * 
5  * This feature allows to display the grid rows aggregated into groups as specified by the {@link Ext.data.Store#groupers}
6  * specified on the Store. The group will show the title for the group name and then the appropriate records for the group
7  * underneath. The groups can also be expanded and collapsed.
8  * 
9  * ## Extra Events
10  * This feature adds several extra events that will be fired on the grid to interact with the groups:
11  *
12  *  - {@link #groupclick}
13  *  - {@link #groupdblclick}
14  *  - {@link #groupcontextmenu}
15  *  - {@link #groupexpand}
16  *  - {@link #groupcollapse}
17  * 
18  * ## Menu Augmentation
19  * This feature adds extra options to the grid column menu to provide the user with functionality to modify the grouping.
20  * This can be disabled by setting the {@link #enableGroupingMenu} option. The option to disallow grouping from being turned off
21  * by thew user is {@link #enableNoGroups}.
22  * 
23  * ## Controlling Group Text
24  * The {@link #groupHeaderTpl} is used to control the rendered title for each group. It can modified to customized
25  * the default display.
26  * 
27  * ## Example Usage
28  * 
29  *     var groupingFeature = Ext.create('Ext.grid.feature.Grouping', {
30  *         groupHeaderTpl: 'Group: {name} ({rows.length})', //print the number of items in the group
31  *         startCollapsed: true // start all groups collapsed
32  *     });
33  * 
34  * @ftype grouping
35  * @author Nicolas Ferrero
36  */
37 Ext.define('Ext.grid.feature.Grouping', {
38     extend: 'Ext.grid.feature.Feature',
39     alias: 'feature.grouping',
40
41     eventPrefix: 'group',
42     eventSelector: '.' + Ext.baseCSSPrefix + 'grid-group-hd',
43
44     constructor: function() {
45         this.collapsedState = {};
46         this.callParent(arguments);
47     },
48     
49     /**
50      * @event groupclick
51      * @param {Ext.view.Table} view
52      * @param {HTMLElement} node
53      * @param {Number} unused
54      * @param {Number} unused
55      * @param {Ext.EventObject} e
56      */
57
58     /**
59      * @event groupdblclick
60      * @param {Ext.view.Table} view
61      * @param {HTMLElement} node
62      * @param {Number} unused
63      * @param {Number} unused
64      * @param {Ext.EventObject} e
65      */
66
67     /**
68      * @event groupcontextmenu
69      * @param {Ext.view.Table} view
70      * @param {HTMLElement} node
71      * @param {Number} unused
72      * @param {Number} unused
73      * @param {Ext.EventObject} e
74      */
75
76     /**
77      * @event groupcollapse
78      * @param {Ext.view.Table} view
79      * @param {HTMLElement} node
80      * @param {Number} unused
81      * @param {Number} unused
82      * @param {Ext.EventObject} e
83      */
84
85     /**
86      * @event groupexpand
87      * @param {Ext.view.Table} view
88      * @param {HTMLElement} node
89      * @param {Number} unused
90      * @param {Number} unused
91      * @param {Ext.EventObject} e
92      */
93
94     /**
95      * @cfg {String} groupHeaderTpl
96      * Template snippet, this cannot be an actual template. {name} will be replaced with the current group.
97      * Defaults to 'Group: {name}'
98      */
99     groupHeaderTpl: 'Group: {name}',
100
101     /**
102      * @cfg {Number} depthToIndent
103      * Number of pixels to indent per grouping level
104      */
105     depthToIndent: 17,
106
107     collapsedCls: Ext.baseCSSPrefix + 'grid-group-collapsed',
108     hdCollapsedCls: Ext.baseCSSPrefix + 'grid-group-hd-collapsed',
109
110     /**
111      * @cfg {String} groupByText Text displayed in the grid header menu for grouping by header
112      * (defaults to 'Group By This Field').
113      */
114     groupByText : 'Group By This Field',
115     /**
116      * @cfg {String} showGroupsText Text displayed in the grid header for enabling/disabling grouping
117      * (defaults to 'Show in Groups').
118      */
119     showGroupsText : 'Show in Groups',
120
121     /**
122      * @cfg {Boolean} hideGroupedHeader<tt>true</tt> to hide the header that is currently grouped (defaults to <tt>false</tt>)
123      */
124     hideGroupedHeader : false,
125
126     /**
127      * @cfg {Boolean} startCollapsed <tt>true</tt> to start all groups collapsed (defaults to <tt>false</tt>)
128      */
129     startCollapsed : false,
130
131     /**
132      * @cfg {Boolean} enableGroupingMenu <tt>true</tt> to enable the grouping control in the header menu (defaults to <tt>true</tt>)
133      */
134     enableGroupingMenu : true,
135
136     /**
137      * @cfg {Boolean} enableNoGroups <tt>true</tt> to allow the user to turn off grouping (defaults to <tt>true</tt>)
138      */
139     enableNoGroups : true,
140     
141     enable: function() {
142         var me    = this,
143             view  = me.view,
144             store = view.store,
145             groupToggleMenuItem;
146             
147         if (me.lastGroupIndex) {
148             store.group(me.lastGroupIndex);
149         }
150         me.callParent();
151         groupToggleMenuItem = me.view.headerCt.getMenu().down('#groupToggleMenuItem');
152         groupToggleMenuItem.setChecked(true, true);
153         view.refresh();
154     },
155
156     disable: function() {
157         var me    = this,
158             view  = me.view,
159             store = view.store,
160             groupToggleMenuItem,
161             lastGroup;
162             
163         lastGroup = store.groupers.first();
164         if (lastGroup) {
165             me.lastGroupIndex = lastGroup.property;
166             store.groupers.clear();
167         }
168         
169         me.callParent();
170         groupToggleMenuItem = me.view.headerCt.getMenu().down('#groupToggleMenuItem');
171         groupToggleMenuItem.setChecked(true, true);
172         groupToggleMenuItem.setChecked(false, true);
173         view.refresh();
174     },
175
176     getFeatureTpl: function(values, parent, x, xcount) {
177         var me = this;
178         
179         return [
180             '<tpl if="typeof rows !== \'undefined\'">',
181                 // group row tpl
182                 '<tr class="' + Ext.baseCSSPrefix + 'grid-group-hd ' + (me.startCollapsed ? me.hdCollapsedCls : '') + ' {hdCollapsedCls}"><td class="' + Ext.baseCSSPrefix + 'grid-cell" colspan="' + parent.columns.length + '" {[this.indentByDepth(values)]}><div class="' + Ext.baseCSSPrefix + 'grid-cell-inner"><div class="' + Ext.baseCSSPrefix + 'grid-group-title">{collapsed}' + me.groupHeaderTpl + '</div></div></td></tr>',
183                 // this is the rowbody
184                 '<tr id="{viewId}-gp-{name}" class="' + Ext.baseCSSPrefix + 'grid-group-body ' + (me.startCollapsed ? me.collapsedCls : '') + ' {collapsedCls}"><td colspan="' + parent.columns.length + '">{[this.recurse(values)]}</td></tr>',
185             '</tpl>'
186         ].join('');
187     },
188
189     getFragmentTpl: function() {
190         return {
191             indentByDepth: this.indentByDepth,
192             depthToIndent: this.depthToIndent
193         };
194     },
195
196     indentByDepth: function(values) {
197         var depth = values.depth || 0;
198         return 'style="padding-left:'+ depth * this.depthToIndent + 'px;"';
199     },
200
201     // Containers holding these components are responsible for
202     // destroying them, we are just deleting references.
203     destroy: function() {
204         var me = this;
205         
206         delete me.view;
207         delete me.prunedHeader;
208     },
209
210     // perhaps rename to afterViewRender
211     attachEvents: function() {
212         var me = this,
213             view = me.view,
214             header, headerId, menu, menuItem;
215
216         view.on({
217             scope: me,
218             groupclick: me.onGroupClick,
219             rowfocus: me.onRowFocus
220         });
221         view.store.on('groupchange', me.onGroupChange, me);
222
223         me.pruneGroupedHeader();
224
225         if (me.enableGroupingMenu) {
226             me.injectGroupingMenu();
227         }
228
229         if (me.hideGroupedHeader) {
230             header = view.headerCt.down('gridcolumn[dataIndex=' + me.getGroupField() + ']');
231             headerId = header.id;
232             menu = view.headerCt.getMenu();
233             menuItem = menu.down('menuitem[headerId='+ headerId +']');
234             if (menuItem) {
235                 menuItem.setChecked(false);
236             }
237         }
238     },
239     
240     injectGroupingMenu: function() {
241         var me       = this,
242             view     = me.view,
243             headerCt = view.headerCt;
244         headerCt.showMenuBy = me.showMenuBy;
245         headerCt.getMenuItems = me.getMenuItems();
246     },
247     
248     showMenuBy: function(t, header) {
249         var menu = this.getMenu(),
250             groupMenuItem  = menu.down('#groupMenuItem'),
251             groupableMth = header.groupable === false ?  'disable' : 'enable';
252             
253         groupMenuItem[groupableMth]();
254         Ext.grid.header.Container.prototype.showMenuBy.apply(this, arguments);
255     },
256     
257     getMenuItems: function() {
258         var me                 = this,
259             groupByText        = me.groupByText,
260             disabled           = me.disabled,
261             showGroupsText     = me.showGroupsText,
262             enableNoGroups     = me.enableNoGroups,
263             groupMenuItemClick = Ext.Function.bind(me.onGroupMenuItemClick, me),
264             groupToggleMenuItemClick = Ext.Function.bind(me.onGroupToggleMenuItemClick, me)
265         
266         // runs in the scope of headerCt
267         return function() {
268             var o = Ext.grid.header.Container.prototype.getMenuItems.call(this);
269             o.push('-', {
270                 itemId: 'groupMenuItem',
271                 text: groupByText,
272                 handler: groupMenuItemClick
273             });
274             if (enableNoGroups) {
275                 o.push({
276                     itemId: 'groupToggleMenuItem',
277                     text: showGroupsText,
278                     checked: !disabled,
279                     checkHandler: groupToggleMenuItemClick
280                 });
281             }
282             return o;
283         };
284     },
285
286
287     /**
288      * Group by the header the user has clicked on.
289      * @private
290      */
291     onGroupMenuItemClick: function(menuItem, e) {
292         var menu = menuItem.parentMenu,
293             hdr  = menu.activeHeader,
294             view = this.view;
295
296         delete this.lastGroupIndex;
297         this.enable();
298         view.store.group(hdr.dataIndex);
299         this.pruneGroupedHeader();
300         
301     },
302
303     /**
304      * Turn on and off grouping via the menu
305      * @private
306      */
307     onGroupToggleMenuItemClick: function(menuItem, checked) {
308         this[checked ? 'enable' : 'disable']();
309     },
310
311     /**
312      * Prunes the grouped header from the header container
313      * @private
314      */
315     pruneGroupedHeader: function() {
316         var me         = this,
317             view       = me.view,
318             store      = view.store,
319             groupField = me.getGroupField(),
320             headerCt   = view.headerCt,
321             header     = headerCt.down('header[dataIndex=' + groupField + ']');
322
323         if (header) {
324             if (me.prunedHeader) {
325                 me.prunedHeader.show();
326             }
327             me.prunedHeader = header;
328             header.hide();
329         }
330     },
331
332     getGroupField: function(){
333         var group = this.view.store.groupers.first();
334         if (group) {
335             return group.property;    
336         }
337         return ''; 
338     },
339
340     /**
341      * When a row gains focus, expand the groups above it
342      * @private
343      */
344     onRowFocus: function(rowIdx) {
345         var node    = this.view.getNode(rowIdx),
346             groupBd = Ext.fly(node).up('.' + this.collapsedCls);
347
348         if (groupBd) {
349             // for multiple level groups, should expand every groupBd
350             // above
351             this.expand(groupBd);
352         }
353     },
354
355     /**
356      * Expand a group by the groupBody
357      * @param {Ext.core.Element} groupBd
358      * @private
359      */
360     expand: function(groupBd) {
361         var me = this,
362             view = me.view,
363             grid = view.up('gridpanel'),
364             groupBdDom = Ext.getDom(groupBd);
365             
366         me.collapsedState[groupBdDom.id] = false;
367
368         groupBd.removeCls(me.collapsedCls);
369         groupBd.prev().removeCls(me.hdCollapsedCls);
370
371         grid.determineScrollbars();
372         grid.invalidateScroller();
373         view.fireEvent('groupexpand');
374     },
375
376     /**
377      * Collapse a group by the groupBody
378      * @param {Ext.core.Element} groupBd
379      * @private
380      */
381     collapse: function(groupBd) {
382         var me = this,
383             view = me.view,
384             grid = view.up('gridpanel'),
385             groupBdDom = Ext.getDom(groupBd);
386             
387         me.collapsedState[groupBdDom.id] = true;
388
389         groupBd.addCls(me.collapsedCls);
390         groupBd.prev().addCls(me.hdCollapsedCls);
391
392         grid.determineScrollbars();
393         grid.invalidateScroller();
394         view.fireEvent('groupcollapse');
395     },
396     
397     onGroupChange: function(){
398         this.view.refresh();
399     },
400
401     /**
402      * Toggle between expanded/collapsed state when clicking on
403      * the group.
404      * @private
405      */
406     onGroupClick: function(view, group, idx, foo, e) {
407         var me = this,
408             toggleCls = me.toggleCls,
409             groupBd = Ext.fly(group.nextSibling, '_grouping');
410
411         if (groupBd.hasCls(me.collapsedCls)) {
412             me.expand(groupBd);
413         } else {
414             me.collapse(groupBd);
415         }
416     },
417
418     // Injects isRow and closeRow into the metaRowTpl.
419     getMetaRowTplFragments: function() {
420         return {
421             isRow: this.isRow,
422             closeRow: this.closeRow
423         };
424     },
425
426     // injected into rowtpl and wrapped around metaRowTpl
427     // becomes part of the standard tpl
428     isRow: function() {
429         return '<tpl if="typeof rows === \'undefined\'">';
430     },
431
432     // injected into rowtpl and wrapped around metaRowTpl
433     // becomes part of the standard tpl
434     closeRow: function() {
435         return '</tpl>';
436     },
437
438     // isRow and closeRow are injected via getMetaRowTplFragments
439     mutateMetaRowTpl: function(metaRowTpl) {
440         metaRowTpl.unshift('{[this.isRow()]}');
441         metaRowTpl.push('{[this.closeRow()]}');
442     },
443
444     // injects an additional style attribute via tdAttrKey with the proper
445     // amount of padding
446     getAdditionalData: function(data, idx, record, orig) {
447         var view = this.view,
448             hCt  = view.headerCt,
449             col  = hCt.items.getAt(0),
450             o = {},
451             tdAttrKey = col.id + '-tdAttr';
452
453         // maintain the current tdAttr that a user may ahve set.
454         o[tdAttrKey] = this.indentByDepth(data) + " " + (orig[tdAttrKey] ? orig[tdAttrKey] : '');
455         o.collapsed = 'true';
456         return o;
457     },
458
459     // return matching preppedRecords
460     getGroupRows: function(group, records, preppedRecords, fullWidth) {
461         var me = this,
462             children = group.children,
463             rows = group.rows = [],
464             view = me.view;
465         group.viewId = view.id;
466
467         Ext.Array.each(records, function(record, idx) {
468             if (Ext.Array.indexOf(children, record) != -1) {
469                 rows.push(Ext.apply(preppedRecords[idx], {
470                     depth: 1
471                 }));
472             }
473         });
474         delete group.children;
475         group.fullWidth = fullWidth;
476         if (me.collapsedState[view.id + '-gp-' + group.name]) {
477             group.collapsedCls = me.collapsedCls;
478             group.hdCollapsedCls = me.hdCollapsedCls;
479         }
480
481         return group;
482     },
483
484     // return the data in a grouped format.
485     collectData: function(records, preppedRecords, startIndex, fullWidth, o) {
486         var me    = this,
487             store = me.view.store,
488             groups;
489             
490         if (!me.disabled && store.isGrouped()) {
491             groups = store.getGroups();
492             Ext.Array.each(groups, function(group, idx){
493                 me.getGroupRows(group, records, preppedRecords, fullWidth);
494             }, me);
495             return {
496                 rows: groups,
497                 fullWidth: fullWidth
498             };
499         }
500         return o;
501     },
502     
503     // adds the groupName to the groupclick, groupdblclick, groupcontextmenu
504     // events that are fired on the view. Chose not to return the actual
505     // group itself because of its expense and because developers can simply
506     // grab the group via store.getGroups(groupName)
507     getFireEventArgs: function(type, view, featureTarget) {
508         var returnArray = [type, view, featureTarget],
509             groupBd     = Ext.fly(featureTarget.nextSibling, '_grouping'),
510             groupBdId   = Ext.getDom(groupBd).id,
511             prefix      = view.id + '-gp-',
512             groupName   = groupBdId.substr(prefix.length);
513         
514         returnArray.push(groupName);
515         
516         return returnArray;
517     }
518 });