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