I am using nested GridPanels inside a parent GridPanel’s RowExpander plugin for quite sometime now. And everything was working wonderfully till recently, when I hit a major hiccup.

As you might be knowing, the RowExpander plugin for the GridPanel might refresh the Expander content for a row/multiple rows on various events, including Sorting of the parent GridPanel, loading of new data in the parent GridPanel’s store, moving columns of the parent GridPanel’s store etc. Any of these events would lead to refresh of the entire view of the GridPanel, thus needing to recreate the Expander content for all rows.

In the event of an updation of a single row, just that row’s corresponding Expander content would need to be recreated.

Now, this might be undesirable in many situations. An example could be when the nested Grids have custom data attached to them. This makes recreating grids almost out of question. In my case, I wanted to preserve the existing Grids for performance and just for the sake of doing it.

A quick Google Search threw up a couple of threads on ExtJs forums, that were of help. In particular, the approach presented in the following thread seemed promising to me:
http://www.extjs.com/forum/showthread.php?10311-Nested-grid-within-Expander-grid

The approach basically captured the html content for the Expander rows in the beforerefresh event for the Grid’s view, and restores the html in the refresh event.

I immediately adapted the code (that extended the RowExpander plugin) provided there by ExtJs community member going by the nickname, willf1976. However, for some reason, the extension was not working for me.

After some analysis, I found that the extension would work only when the entire View refreshes (as it handles only the beforerefresh and refresh events for the Grid’s view). An update for a particular row’s record was not taken into consideration. I was updating a particular record, and which was causing the nested GridPanel to disappear in my case.

So, I made some enhancements to that code. Here’s my extension to the RowExpander to enable it to capture & restore Expander content in all situations:

 

{syntaxhighlighter brush: jscript;fontsize: 100; first-line: 1; }Ext.grid.RowExpander.prototype.initOrig = Ext.grid.RowExpander.prototype.init;

/**
* extended version of Ext.ux.grid.RowExpander. This version looks for changes in the grid and saves the html of the expanded rows, it then restores the html back into the grid when the view refreshes.
* Note: For this plug in to work properly you need to encapsulate the elements in each expanded row into 1 parent element. Multiple elements at the top of the expanded rows dom element will result in just the first one being captured and preserved.
*/
Ext.override(Ext.grid.RowExpander, {
/**
* @cfg {String|Null} storeHtmlBy
* This is the attribute of the store record that is used to save and restore the html in the grid. If left null then the id of the store record will be used, if you are going to be using remote sorting you may need to change this value. The property of the record you use will need to be a unique identifier. Defaults to null
*/
storeHtmlBy: null,
/**
* @private internal config {Object} storedHtml
* Used to store the html that will be restored to the grid.
*
*/
storedHtml: {},

//Maintains the associations of Record Keys to the Record’s view index before a refresh/update action occurs.
recordKeyAtRow: {},

//Maintains the expanded & rendered statuses for each Record.
recordStatus: {},

//@private
init: function(grid) {
this.initOrig(grid);
if (this.preserveRowsOnRefresh) {
this.grid.on(‘viewready’, this.markRows, this);
this.grid.on(‘rowsinserted’, this.markRows, this);

var view = this.grid.view;
if (typeof Ext.net == undefined && typeof Coolite == undefined && typeof Ext.net.CommandColumn == undefined) {
//Coolite/Ext.net’s CommandColumn also adds this event. So, dont add it again, if the CommandColumn is present.
view.addEvents(“beforerowupdate”);
view.refreshRow = view.refreshRow.createInterceptor(function(record) {
view.fireEvent(“beforerowupdate”, view, view.grid.store.indexOf(record), record);
});
}

view.on(‘beforerefresh’, this.onBeforeRefresh, this);
view.on(‘refresh’, this.onRefresh, this);
view.on(‘beforerowupdate’, this.saveRowHtml, this);
view.on(‘rowupdated’, this.restoreRowHtml, this);

this.on(‘expand’, this.rowExpanded);
this.on(‘collapse’, this.rowCollapsed);
}
},

getRecordKey: function(record) {
if (!Ext.isEmpty(this.storeHtmlBy)) {
return (record.get(this.storeHtmlBy));
} else {
return (record.id);
}
},

rowExpanded: function(expander, record, body, rowIndex) {
var key = this.getRecordKey(record);
var status = { rendered: true, expanded: true };
this.recordStatus[key] = status;
},

rowCollapsed: function(expander, record, body, rowIndex) {
var key = this.getRecordKey(record);
var status = { rendered: true, expanded: false };
this.recordStatus[key] = status;
},

//@private
onBeforeRefresh: function(view) {
var store = this.grid.getStore(),
n, record;

this.storedHtml = {};

for (n = 0; n < store.data.items.length; n++) {
record = store.getAt(n);
this.saveRowHtml(view, n, record);
}
},

saveRowHtml: function(view, index, record) {
var key = this.getRecordKey(record);

var rowIndex = null, found = false;
//Find the view’s rowIndex for this record before the refresh action happened.
//While saving the Row Html for a Record, we have to find the previous position of the record in the view before
//the refresh action happened.
for (rowIndex in this.recordKeyAtRow) {
if (this.recordKeyAtRow[rowIndex] == key) {
found = true;
break;
}
}

if (found) {
var row = view.getRow(rowIndex);
var body = Ext.DomQuery.selectNode(‘div.x-grid3-row-body’, row);
this.storedHtml[key] = body.innerHTML;
}
},

//@private
onRefresh: function(view) {
var store = this.grid.getStore(),
n, row, record;

for (n = 0; n < store.data.items.length; n++) {
row = view.getRow(n);
if (row) {
record = store.getAt(n);
this.restoreRowHtml(view, n, record);
}
}
},

restoreRowHtml: function(view, index, record) {
var key = this.getRecordKey(record);

//When restoring the Row Html for a Record, the restore has to happen at the current position of the Record after
//the refresh action happened.
var storedBody = this.storedHtml[key];
if (!Ext.isEmpty(storedBody)) {
var row = view.getRow(index);
var body = Ext.DomQuery.selectNode(‘div.x-grid3-row-body’, row);
while (body.hasChildNodes()) {
body.removeChild(body.lastChild);
}

var status = this.recordStatus[key];
if (!status) {
//the row has not been touched.
body.innerHTML = this.tpl.html;
this.collapseRow(row);
} else {
body.innerHTML = storedBody;
if (status.expanded)
this.expandRow(row);
else
this.collapseRow(row);
}
}

this.markRow(view, index, record);
},

//@private
markRow: function(view, index, record) {
var key;
var row = view.getRow(index);
if (row) {
key = this.getRecordKey(record);

this.recordKeyAtRow[index] = key;
}
},

markRows: function() {
var view = this.grid.getView(),
store = this.grid.getStore(),
record, n;

this.recordKeyAtRow = {};

for (n = 0; n < store.data.items.length; n++) {
record = store.getAt(n);
this.markRow(view, n, record);
}
}
});{/syntaxhighlighter}

The code is sure lengthy by any standards.

The following are the major interesting features of the above code:

  1. Instead of creating a new Plugin derived from the RowExpander, the code adds methods to the existing plugin (In my case, I am using this code inside Ext.Net. Extending RowExpander would have forced me to write more routine plumbing code for the server-side which I did not wish to).
  2. The RowExpander should have a custom config option named preserveRowsOnRefresh and the value of this option should be true for the code to be enabled for that instance of the Expander.
  3. The code basically captures and restores the Expander html content in various situations, including full view refreshes, as well as a particular row update for the parent Grid.
  4. You can use the code safely with both ExtJs/Ext.Net. In case of Ext.Net, you only need to make sure that this code is included into the page at an appropriate place. No editing of any server-side or client-side file is needed.

The above code is also attached below. Feel free to adapt and modify it according to your needs. If you face any issues with the code, please use the comment form below to tell me about them.

Credit should go to where its due. A major part of the code has been adpated from this ExtJs thread:
http://www.extjs.com/forum/showthread.php?10311-Nested-grid-within-Expander-grid

In addition, the wonderful Ext.Net core team member vlad, provided some useful insights here:
http://coolite.com/forums/Topic31332-16-1.aspx

When using nested GridPanels or any such ExtJs content inside RowExpander, please take particular care for destroying the nested Grids on events like data reload into store, otherwise you might cause significant memory leaks.

UPDATE:

  • (May 3, 2010) – Rewrote the plugin completely for making it to work correctly in all possible updations to the parent grid, which lead to refresh of the Expander rows. Please check this comment below for details and an example.