ExtJS Tree Grid

Wir mussten kürzlich eine Benutzeroberfläche implementieren, die ein editierbares Grid mit einer Baumstruktur enthielt. Natürlich richteten wir unsere Aufmerksamkeit zunächst auf das Ext.ux.tree.TreeGrid, das offiziell mit ExtJS 3.1 veröffentlicht wurde. Leider erreicht diese Komponente nicht den hohen Standard, den ExtJS-Komponenten wie Ext.grid.GridPanel, Ext.tree.TreePanel oder die gesamte Layout-Engine setzen. Schlimmer noch: Es fehlt jegliche Dokumentation – nur eine Randnotiz (oder ein deutlicher Wink an die Entwickler 😉
In unserem Anwendungsfall erwiesen sich insbesondere das Column-Model, das auf dem Column-Model der Ext.list.ListView basiert und die Implementierung editierbarer Felder ziemlich schwierig und unschön machte, sowie einige Probleme bei der Berechnung der Spaltenbreiten und die fehlende Unterstützung für automatisch expandierende Spalten (die letzten beiden Punkte konnten wir durch „Hacken“ der entsprechenden Methoden beheben) als echter Show-Stopper. Also begannen wir, andersherum zu denken: Warum nicht die aktuellen, hochwertigen Komponenten Ext.grid.GridPanel und Ext.grid.EditorGridPanel verwenden und so erweitern, dass sie Baumstrukturen darstellen können?
Auf Basis der komponentenbasierten Struktur (Panel-Komponente, Store, Column-Model, View und Selection-Model) des Ext.grid.GridPanel stellten wir fest, dass es ausreichen sollte, einen eigenen Store zu implementieren, der baumartige Strukturen einlesen kann, sowie eine eigene View, die das Rendern von Zeilen und Zellen anpasst, um all diese Tree-Features abzubilden. Die Implementierung verlief dann auch recht reibungslos, auch wenn wir einige größere Codefragmente aus den überschriebenen Basisklassen kopieren mussten. Der folgende Code ist keineswegs vollständig und außerhalb unserer eigenen Anwendung vielleicht nicht einmal produktionsreif (da er das Hinzufügen, Entfernen oder Umsortieren von Knoten noch nicht unterstützt), aber wir denken, dass dieser Ansatz deutlich flexibler und brauchbarer ist als das aktuelle Ext.ux.tree.TreeGrid. Vielleicht geben die ExtJS-Entwickler diesem Ansatz ja mal eine Chance…
Der erste Schritt bestand darin, eine Baumstruktur in eine Menge von Records umzuwandeln, die von der Grid-Komponente verwendet werden können:
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;
}
});Damit können wir einfach eine baumartige Struktur über das data-Attribut unseres Grid-Stores übergeben. Die Hauptarbeit beim Rendern des Baums übernimmt unsere eigene Implementierung einer 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"> </div>',
'<div class="x-grid3-resize-proxy"> </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 = ' ';
}
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) : [];
}
});Hier passiert die eigentliche Arbeit (auch wenn eine ganze Menge Code aus der Originalimplementierung kopiert werden musste). Beachten Sie, dass wir derzeit CSS-Klassen des Grids selbst, des Trees und des TreeGrids verwenden – auch das ist außerhalb unserer Umgebung nicht wirklich produktionsreif. Zusammen mit ein paar CSS-Ergänzungen können wir nun ganz einfach unser eigenes Tree-Grid aufsetzen:
/**
* 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
}]
});