// feature idea to enable Ajax loading and then the content // cache would actually make sense. Should we dictate that they use // data or support raw html as well? /** * Plugin (ptype = 'rowexpander') that adds the ability to have a Column in a grid which enables * a second row body which expands/contracts. The expand/contract behavior is configurable to react * on clicking of the column, double click of the row, and/or hitting enter while a row is selected. */ Ext.define('Ext.grid.plugin.RowExpander', { extend: 'Ext.AbstractPlugin', lockableScope: 'normal', requires: [ 'Ext.grid.feature.RowBody', 'Ext.grid.feature.RowWrap' ], alias: 'plugin.rowexpander', rowBodyTpl: null, /** * @cfg {Boolean} expandOnEnter * <tt>true</tt> to toggle selected row(s) between expanded/collapsed when the enter * key is pressed (defaults to <tt>true</tt>). */ expandOnEnter: true, /** * @cfg {Boolean} expandOnDblClick * <tt>true</tt> to toggle a row between expanded/collapsed when double clicked * (defaults to <tt>true</tt>). */ expandOnDblClick: true, /** * @cfg {Boolean} selectRowOnExpand * <tt>true</tt> to select a row when clicking on the expander icon * (defaults to <tt>false</tt>). */ selectRowOnExpand: false, rowBodyTrSelector: '.x-grid-rowbody-tr', rowBodyHiddenCls: 'x-grid-row-body-hidden', rowCollapsedCls: 'x-grid-row-collapsed', addCollapsedCls: { before: function(values, out) { var me = this.rowExpander; if (!me.recordsExpanded[values.record.internalId]) { values.itemClasses.push(me.rowCollapsedCls); } }, priority: 500 }, /** * @event expandbody * **Fired through the grid's View** * @param {HTMLElement} rowNode The <tr> element which owns the expanded row. * @param {Ext.data.Model} record The record providing the data. * @param {HTMLElement} expandRow The <tr> element containing the expanded data. */ /** * @event collapsebody * **Fired through the grid's View.** * @param {HTMLElement} rowNode The <tr> element which owns the expanded row. * @param {Ext.data.Model} record The record providing the data. * @param {HTMLElement} expandRow The <tr> element containing the expanded data. */ setCmp: function(grid) { var me = this, rowBodyTpl, features; me.callParent(arguments); me.recordsExpanded = {}; // <debug> if (!me.rowBodyTpl) { Ext.Error.raise("The 'rowBodyTpl' config is required and is not defined."); } // </debug> me.rowBodyTpl = Ext.XTemplate.getTpl(me, 'rowBodyTpl'); rowBodyTpl = this.rowBodyTpl; features = [{ ftype: 'rowbody', lockableScope: 'normal', recordsExpanded: me.recordsExpanded, rowBodyHiddenCls: me.rowBodyHiddenCls, rowCollapsedCls: me.rowCollapsedCls, setupRowData: me.getRowBodyFeatureData, setup: me.setup, getRowBodyContents: function(record) { return rowBodyTpl.applyTemplate(record.getData()); } },{ ftype: 'rowwrap', lockableScope: 'normal' }]; if (grid.features) { grid.features = Ext.Array.push(features, grid.features); } else { grid.features = features; } // NOTE: features have to be added before init (before Table.initComponent) }, init: function(grid) { var me = this, reconfigurable = grid, view, lockedView; me.callParent(arguments); me.grid = grid; view = me.view = grid.getView(); // Columns have to be added in init (after columns has been used to create the headerCt). // Otherwise, shared column configs get corrupted, e.g., if put in the prototype. me.addExpander(); // Bind to view for key and mouse events // Add row processor which adds collapsed class me.bindView(view); view.addRowTpl(me.addCollapsedCls).rowExpander = me; // If the owning grid is lockable, then disable row height syncing - we do it here. // Also ensure the collapsed class is applied to the locked side by adding a row processor. if (grid.ownerLockable) { // If our client grid is the normal side of a lockable grid, we listen to its lockable owner's beforereconfigure reconfigurable = grid.ownerLockable; reconfigurable.syncRowHeight = false; lockedView = reconfigurable.lockedGrid.getView(); // Bind to locked view for key and mouse events // Add row processor which adds collapsed class me.bindView(lockedView); lockedView.addRowTpl(me.addCollapsedCls).rowExpander = me; // Refresh row heights of expended rows on the locked (non body containing) side upon lock & unlock. // The locked side's expanded rows will collapse back because there's no body there reconfigurable.mon(reconfigurable, 'columnschanged', me.refreshRowHeights, me); reconfigurable.mon(reconfigurable.store, 'datachanged', me.refreshRowHeights, me); } reconfigurable.on('beforereconfigure', me.beforeReconfigure, me); if (grid.ownerLockable && !grid.rowLines) { // grids without row lines can gain a border when focused. When they do, the // stylesheet adjusts the padding of the cells so that the height of the row // does not change. It is necessary to refresh the row heights for lockable // grids on focus to keep the height of the expander cells in sync. view.on('rowfocus', me.refreshRowHeights, me); } }, beforeReconfigure: function(grid, store, columns, oldStore, oldColumns) { var expander = this.getHeaderConfig(); expander.locked = true; columns.unshift(expander); }, /** * @private * Inject the expander column into the correct grid. * * If we are expanding the normal side of a lockable grid, poke the column into the locked side */ addExpander: function() { var me = this, expanderGrid = me.grid, expanderHeader = me.getHeaderConfig(); // If this is the normal side of a lockable grid, find the other side. if (expanderGrid.ownerLockable) { expanderGrid = expanderGrid.ownerLockable.lockedGrid; expanderGrid.width += expanderHeader.width; } expanderGrid.headerCt.insert(0, expanderHeader); }, getRowBodyFeatureData: function(record, idx, rowValues) { var me = this me.self.prototype.setupRowData.apply(me, arguments); rowValues.rowBody = me.getRowBodyContents(record); rowValues.rowBodyCls = me.recordsExpanded[record.internalId] ? '' : me.rowBodyHiddenCls; }, setup: function(rows, rowValues){ var me = this; me.self.prototype.setup.apply(me, arguments); // If we are lockable, the expander column is moved into the locked side, so we don't have to span it if (!me.grid.ownerLockable) { rowValues.rowBodyColspan -= 1; } }, bindView: function(view) { if (this.expandOnEnter) { view.on('itemkeydown', this.onKeyDown, this); } if (this.expandOnDblClick) { view.on('itemdblclick', this.onDblClick, this); } }, onKeyDown: function(view, record, row, rowIdx, e) { if (e.getKey() == e.ENTER) { var ds = view.store, sels = view.getSelectionModel().getSelection(), ln = sels.length, i = 0; for (; i < ln; i++) { rowIdx = ds.indexOf(sels[i]); this.toggleRow(rowIdx, sels[i]); } } }, onDblClick: function(view, record, row, rowIdx, e) { this.toggleRow(rowIdx, record); }, toggleRow: function(rowIdx, record) { var me = this, view = me.view, rowNode = view.getNode(rowIdx), row = Ext.fly(rowNode, '_rowExpander'), nextBd = row.down(me.rowBodyTrSelector, true), isCollapsed = row.hasCls(me.rowCollapsedCls), addOrRemoveCls = isCollapsed ? 'removeCls' : 'addCls', ownerLock, rowHeight, fireView; // Suspend layouts because of possible TWO views having their height change Ext.suspendLayouts(); row[addOrRemoveCls](me.rowCollapsedCls); Ext.fly(nextBd)[addOrRemoveCls](me.rowBodyHiddenCls); me.recordsExpanded[record.internalId] = isCollapsed; view.refreshSize(); // Sync the height and class of the row on the locked side if (me.grid.ownerLockable) { ownerLock = me.grid.ownerLockable; fireView = ownerLock.getView(); view = ownerLock.lockedGrid.view; rowHeight = row.getHeight(); row = Ext.fly(view.getNode(rowIdx), '_rowExpander'); row.setHeight(rowHeight); row[addOrRemoveCls](me.rowCollapsedCls); view.refreshSize(); } else { fireView = view; } fireView.fireEvent(isCollapsed ? 'expandbody' : 'collapsebody', row.dom, record, nextBd); // Coalesce laying out due to view size changes Ext.resumeLayouts(true); }, // refreshRowHeights often gets called in the middle of some complex processing. // For example, it's called on the store's datachanged event, but it must execute // *after* other objects interested in datachanged have done their job. // Or it's called on column lock/unlock, but that could be just the start of a cross-container // drag/drop of column headers which then moves the column into its final place. // So this throws execution forwards until the idle event. refreshRowHeights: function() { Ext.globalEvents.on({ idle: this.doRefreshRowHeights, scope: this, single: true }); }, doRefreshRowHeights: function() { var me = this, recordsExpanded = me.recordsExpanded, key, record, lockedView = me.grid.ownerLockable.lockedGrid.view, normalView = me.grid.ownerLockable.normalGrid.view, normalRow, lockedRow, lockedHeight, normalHeight; for (key in recordsExpanded) { if (recordsExpanded.hasOwnProperty(key)) { record = this.view.store.data.get(key); lockedRow = lockedView.getNode(record, false); normalRow = normalView.getNode(record, false); lockedRow.style.height = normalRow.style.height = ''; lockedHeight = lockedRow.offsetHeight; normalHeight = normalRow.offsetHeight; if (normalHeight > lockedHeight) { lockedRow.style.height = normalHeight + 'px'; } else if (lockedHeight > normalHeight) { normalRow.style.height = lockedHeight + 'px'; } } } }, getHeaderConfig: function() { var me = this; return { width: 24, lockable: false, sortable: false, resizable: false, draggable: false, hideable: false, menuDisabled: true, tdCls: Ext.baseCSSPrefix + 'grid-cell-special', innerCls: Ext.baseCSSPrefix + 'grid-cell-inner-row-expander', renderer: function(value, metadata) { // Only has to span 2 rows if it is not in a lockable grid. if (!me.grid.ownerLockable) { metadata.tdAttr += ' rowspan="2"'; } return '<div class="' + Ext.baseCSSPrefix + 'grid-row-expander"></div>'; }, processEvent: function(type, view, cell, rowIndex, cellIndex, e, record) { if (type == "mousedown" && e.getTarget('.x-grid-row-expander')) { me.toggleRow(rowIndex, record); return me.selectRowOnExpand; } } }; } });