    // 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 &lt;tr> element which owns the expanded row.
         * @param {Ext.data.Model} record The record providing the data.
         * @param {HTMLElement} expandRow The &lt;tr> element containing the expanded data.
         */
        /**
         * @event collapsebody
         * **Fired through the grid's View.**
         * @param {HTMLElement} rowNode The &lt;tr> element which owns the expanded row.
         * @param {Ext.data.Model} record The record providing the data.
         * @param {HTMLElement} expandRow The &lt;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;
                    }
                }
            };
        }
    });