We recently had to implement a user interface that included a editable grid displaying a tree-structure. Naturally we first turned our attention to the Ext.ux.tree.TreeGrid which was officially released with ExtJS 3.1. Sadly this component lacks the current high standard set by ExtJS components such as the Ext.grid.GridPanel, Ext.tree.TreePanel or the whole layout engine. Even worse it also lacks any documentation at all – just a side note (or a broad hint to de developers 😉

In our use-case, especially the column-model that’s based on the Ext.list.ListView column-model, which made implementing editable fields quite hard and ugly, and some problems with calculating column widths as well as the lack of support for auto-expanding columns (we could correct the last two things by “hacking” the respective methods) proved to be a real show-stopper, so we started to think the other way ’round: why not use the current high-quality Ext.grid.GridPanel and Ext.grid.EditorGridPanel and extend them to be able to display tree-structures?

On the basis of the componentized structure (panel component, store, column model, view and selection model) of Ext.grid.GridPanel we identified that it should be enough to just implement our own store that’s able to read tree-like-structures and our own view that modifies row and cell rendering to handle alls those tree-features. That said, implementation went quite smooth, even though we had to copy some larger code fragments from the overridden base classes. The following code is by no means complete or perhaps it’s not even production-ready outside our own application (as it doesn’t support adding and removing or reordering nodes yet), but we think that this approach would be much more flexible and usable than using the current Ext.ux.tree.TreeGrid. Perhaps the ExtJS developers could give that approach a try…

The first step was to turn a tree-structure into a set of records usable by the grid component:


tree: null,

constructor: function(meta, recordType) {
Ext.ux.tree.TreeReader.superclass.constructor.call(this, meta, recordType || meta.fields);
},

load: function(node){
if (node.attributes.children){
var cs = node.attributes.children;
for (var i = 0, len = cs.length; i < len; i++){
var cn = node.appendChild(this.createNode(cs[i]));
this.load(cn);
}
}
},

createNode: function(attr){
var node = new Ext.data.Node(attr);
node.expanded = (attr.expanded === true);
return node;
},

/**
* Create a data block containing Ext.data.Records from a tree.
*/
readRecords: function(o) {

var root = this.createNode({
text: 'Root',
id: 'root',
children: o
});
this.tree = new Ext.data.Tree(root);
this.load(root);

var f = this.recordType.prototype.fields;
var records = [];
root.cascade(function(node) {
if (node !== root) {
var record = new this.recordType(this.extractValues(node, f.items), node.id);
record.node = node;
record.depth = node.getDepth();
records.push(record);
}
}, this);

return {
success : true,
records : records,
totalRecords : records.length
};
},

/**
* type-casts a single node
*/
extractValues : function(node, fields) {
var f, values = {};
for(var j = 0; j < fields.length; j++){
f = fields[j];
var v = node.attributes[f.mapping];
values[f.name] = f.convert((v !== undefined) ? v : f.defaultValue, node);
}
return values;
}
});

This allows us to simple hand over a tree-like structure within the data attribute of our grid store. The major work of rendering the tree was handed over to our own implementation of a grid view:


Ext.ux.tree.GridView = Ext.extend(Ext.grid.GridView, {

useArrows: true,

staticTree: false,

constructor: function(config) {
this.emptyIcon = Ext.BLANK_IMAGE_URL;

Ext.ux.tree.GridView.superclass.constructor.call(this, config);
this.templates = {};
this.templates.master = new Ext.Template(
'<div class="x-grid3 tq-treegrid" hidefocus="true">',
'<div class="x-grid3-viewport">',
'<div class="x-grid3-header">',
'<div class="x-grid3-header-inner">',
'<div class="x-grid3-header-offset" style="{ostyle}">{header}</div>',
'</div>',
'<div class="x-clear"></div>',
'</div>',
'<div class="x-grid3-scroller">',
'<div class="x-grid3-body ', (this.useArrows ? 'x-tree-arrows' : this.lines ? 'x-tree-lines' : 'x-tree-no-lines') , '" style="{bstyle}">{body}</div>',
'<a href="#" class="x-grid3-focus" tabIndex="-1"></a>',
'</div>',
'</div>',
'<div class="x-grid3-resize-marker">&#160;</div>',
'<div class="x-grid3-resize-proxy">&#160;</div>',
'</div>'
);

this.templates.row = new Ext.Template(
'<div class="x-grid3-row {alt}" style="{tstyle}">',
'<table class="x-grid3-row-table" border="0" cellspacing="0" cellpadding="0" style="{tstyle}">',
'<tbody class="x-tree-node">',
'<tr>{cells}</tr>',
(this.enableRowBody ? '<tr class="x-grid3-row-body-tr" style="{bodyStyle}"><td colspan="{cols}" class="x-grid3-body-cell" tabIndex="0" hidefocus="on"><div class="x-grid3-row-body">{body}</div></td></tr>' : ''),
'</tbody>',
'</table>',
'</div>'
);
this.templates.treeCell = new Ext.Template(
'<td class="tq-treegrid-col x-grid3-col x-grid3-cell x-grid3-td-{id} {css}" style="{style}" tabIndex="0" {cellAttr}>',
'<div ext:tree-node-id="{nodeId}" class="x-grid3-col-{id} x-tree-node-el x-unselectable" unselectable="on" {attr}>',
'<div class="tq-treegrid-icons">',
'<span class="x-tree-node-indent">{nodeIndent}</span>',
'<img src="', this.emptyIcon, '" class="x-tree-ec-icon x-tree-elbow {nodeTreeIconCls}" />',
'<img src="', this.emptyIcon, '" class="x-tree-node-icon {nodeIconCls}" unselectable="on" />',
'</div>',
'<div class="x-grid3-cell-inner" unselectable="on" {attr}>',
'{value}',
'</div>',
'</div>',
'</td>'
);
},

getChildIndentUI: function(node) {
var indentBuffer = [];
var parentNode = node.parentNode;
while (parentNode){
if (!parentNode.isRoot){
if (!parentNode.isLast()) {
indentBuffer.unshift('<img src="'+this.emptyIcon+'" class="x-tree-elbow-line" />');
} else {
indentBuffer.unshift('<img src="'+this.emptyIcon+'" class="x-tree-icon" />');
}
}
parentNode = parentNode.parentNode;
}
return indentBuffer.join("");
},

getTreeNodeIcon: function(node) {
var treeIcon = node.isLast() ? "x-tree-elbow-end" : "x-tree-elbow";
if (node.hasChildNodes() && !this.staticTree){
if (node.expanded){
treeIcon += "-minus";
} else {
treeIcon += "-plus";
}
treeIcon += ' tq-tree-node-control';
}
return treeIcon;
},

// private
doRender: function(cs, rs, ds, startRow, colCount, stripe){
// buffers
var rowBuffer = [];
var cellBuffer;
var cell;
var cellTemplate;
var cellProperties = {};
var rowProperties = {
tstyle: 'width:'+this.getTotalWidth()+';'
};
var record;
var depthBuffer = [];
var hasTreeCol;

for (var j = 0, len = rs.length; j < len; j++){
record = rs[j];
cellBuffer = [];
hasTreeCol = false;
var rowIndex = (j+startRow);
for (var i = 0; i < colCount; i++){
cell = cs[i];
cellProperties.id = cell.id;
cellProperties.css = (i === 0) ? 'x-grid3-cell-first ' : (i == (colCount - 1) ? 'x-grid3-cell-last ' : '');
cellProperties.attr = cellProperties.cellAttr = '';
cellProperties.value = cell.renderer.call(cell.scope, record.data[cell.name], cellProperties, record, rowIndex, i, ds);
cellProperties.style = cell.style;
if (Ext.isEmpty(cellProperties.value)){
cellProperties.value = '&#160;';
}
if (this.markDirty && record.dirty && Ext.isDefined(record.modified[cell.name])){
cellProperties.css += ' x-grid3-dirty-cell';
}

if (cell.scope.treeCol && !hasTreeCol && record.node) {
hasTreeCol = true;
var node = record.node;

cellProperties.nodeIndent = this.getChildIndentUI(node);
cellProperties.nodeIconCls = node.attributes.iconCls || '';

cellProperties.nodeTreeIconCls = this.getTreeNodeIcon(node);

cellTemplate = this.templates.treeCell;
} else {
cellTemplate = this.templates.cell;
}
cellBuffer[cellBuffer.length] = cellTemplate.apply(cellProperties);

}
var alt = [];

if (record.depth && record.node) {
if (depthBuffer.length < record.depth) {
rowBuffer.push('<div class="x-tree-node-ct">');
depthBuffer.push('</div>');
} else {
while (depthBuffer.length > record.depth) {
rowBuffer.push(depthBuffer.pop());
}
}

if (this.staticTree) {
alt.push('tq-treegrid-static');
}

if (node.isLeaf()) {
alt.push('x-tree-node-leaf');
} else if (node.expanded){
alt.push('x-tree-node-expanded');
} else {
alt.push('x-tree-node-collapsed');
}
}

if(stripe && ((rowIndex+1) % 2 === 0)){
alt.push('x-grid3-row-alt');
}
if(record.dirty){
alt.push(' x-grid3-dirty-row');
}

rowProperties.cols = colCount;
rowProperties.nodeId = record.node.id;
rowProperties.cells = cellBuffer.join('');
if (this.getRowClass){
alt.push(this.getRowClass(record, rowIndex, rowProperties, ds));
}
rowProperties.alt = alt.join(' ');

rowBuffer[rowBuffer.length] = this.templates.row.apply(rowProperties);
}

while (depthBuffer.length) {
rowBuffer.push(depthBuffer.pop());
}
return rowBuffer.join('');
},

afterRender: function() {
Ext.ux.tree.GridView.superclass.afterRender.call(this);

if (!this.staticTree) {
this.mainBody.on('click', function(ev, el) {
this.toggleNode(el);
}, this, {
delegate: '.tq-tree-node-control'
});
this.mainBody.on('dblclick', function(ev, el) {
this.toggleNode(el);
}, this, {
delegate: '.x-tree-node-el '
});
}
},

toggleNode: function(el) {
var row = Ext.get(this.findRow(el));
var node = this.grid.getStore().getAt(row.dom.rowIndex).node;

var childCnt = row.next('.x-tree-node-ct', false);
var nodeCtrl = row.child('.tq-tree-node-control', false);

if (childCnt && nodeCtrl) {
if (node.expanded) {
childCnt.enableDisplayMode('block');
childCnt.stopFx();

row.removeClass('x-tree-node-expanded');
nodeCtrl.removeClass(node.isLast() ? "x-tree-elbow-end-minus" : "x-tree-elbow-minus");
row.addClass('x-tree-node-collapsed');
nodeCtrl.addClass(node.isLast() ? "x-tree-elbow-end-plus" : "x-tree-elbow-plus");

childCnt.slideOut('t', {
callback : function(){
row.highlight();
node.expanded = false;
},
scope: this,
duration: .25
});

} else {
childCnt.stopFx();

row.addClass('x-tree-node-expanded');
nodeCtrl.addClass(node.isLast() ? "x-tree-elbow-end-minus" : "x-tree-elbow-minus");
row.removeClass('x-tree-node-collapsed');
nodeCtrl.removeClass(node.isLast() ? "x-tree-elbow-end-plus" : "x-tree-elbow-plus");

childCnt.slideIn('t', {
callback : function(){
childCnt.highlight();
node.expanded = true;
},
scope: this,
duration: .25
});
}
}
},

getRows: function() {
return this.hasRows() ? this.mainBody.query(this.rowSelector) : [];
}

});

That’s where the hard work is done (event though a decent amount of code had to be copied over from the original implementation). Be aware that we currently use CSS classes from the grid itself, from the tree and from the treegrid – that’s also not really production ready outside our environment.
Together with some CSS additions we can easily set up our own tree grid:


/**
* Tree grid
*/
.tq-treegrid .tq-treegrid-col {
border: none;
}

.tq-treegrid .tq-treegrid-icons {
float: left;
}

.tq-treegrid .x-tree-node-el {
line-height: 13px;
padding: 1px 3px 1px 5px;
}

.tq-treegrid .tq-treegrid-static .x-tree-ec-icon {
display: none;
}

.tq-treegrid .tq-treegrid-static .x-tree-node-el {
cursor: default;
}

var treeGrid = new Ext.grid.EditorGridPanel({
renderTo: Ext.getBody(),
autoHeight: true,
width: 400,
columnLines: true,
autoExpandColumn: 'col-name',
view: new Ext.ux.tree.GridView({
useArrows: true,
staticTree: false
}),
store: {
xtype: 'store',
autoDestroy: true,
reader: new Ext.ux.tree.TreeReader({
fields: [{
name: 'id',
mapping: 'id',
type: 'int'
}, {
name: 'name',
mapping: 'name'
}, {
name: 'cost',
mapping: 'cost',
type: 'float'
}]
}),
data: [{
id: 1,
name: 'Company A',
cost: 1000000.00,
leaf: false,
expanded: true,
children: [{
id: 11,
name: 'Department AA',
cost: 800000.00,
leaf: false,
expanded: true,
children: [{
id: 111,
name: 'Thing AAA',
cost: 300000.00,
leaf: true
}, {
id: 112,
name: 'Thing AAB',
cost: 500000.00,
leaf: true
}]
}, {
id: 12,
name: 'Department AB',
cost: 200000.00,
leaf: false,
expanded: true,
children: [{
id: 121,
name: 'Thing ABA',
cost: 50000.00,
leaf: true
}, {
id: 122,
name: 'Thing ABB',
cost: 150000.00,
leaf: true
}]
}]
}, {
id: 2,
name: 'Company B',
cost: 200000.00,
leaf: false,
expanded: true,
children: [{
id: 21,
name: 'Department BA',
cost: 100000.00,
leaf: false,
expanded: true,
children: [{
id: 211,
name: 'Thing BAA',
cost: 50000.00,
leaf: true
}, {
id: 212,
name: 'Thing BAB',
cost: 50000.00,
leaf: true
}]
}, {
id: 22,
name: 'Department BB',
cost: 100000.00,
leaf: false,
expanded: true,
children: [{
id: 221,
name: 'Thing BBA',
cost: 90000.00,
leaf: true
}, {
id: 222,
name: 'Thing BBB',
cost: 10000.00,
leaf: true
}]
}]
}]
},
columns: [{
header: 'Id',
dataIndex: 'id',
width: 30
}, {
header: 'Name',
id: 'col-name',
dataIndex: 'name',
treeCol: true
}, {
header: 'Cost',
dataIndex: 'cost',
width: 80
}]
});