General Interface is an open source project hosted by the Dojo Foundation

Exercise 4 on Editable Grid

This exercise describes how to create an editable grid (similar to a spreadsheet) that allows variable number of rows and columns.

I. Define the control

The data source for the grid is an XML document in Common Data Format (CDF). Each record in the CDF document has an associated on-screen row in the grid. The columns are defined using JSON, which allows you to specify for each column the column label, column width, and CDF attribute to which the column will map.

When users click a cell in the grid, a text input overlays the cell, allowing the grid to be editable. Both before-edit and after-edit model events are exposed, allowing you to intercept, cancel, and modify user edits as they happen. Navigation is supported with the tab, enter, and arrow keys. The header row is fixed, while the remaining rolls scroll when necessary, both horizontally and vertically. The next figure sows the control.

Editable Grid Control

II. Define Common Patterns

The editable grid class takes advantage of the for-each iterator tag. This allows the template to only specify a bare minimum of markup, while generating a variable number of rows and columns. Typically, any time you need to create a control that will display repeating lists of data (such as tables, pick-lists, or grids), you use a for-each iterator.

For example, assume that the following XHTML markup is in your template:

<table>
        <tbody>
            <tr><td><text>hello</text></td></tr>
        </tbody>
    </table>

To paint exactly four rows of data, you can implement the following selector query. Note how the select statement returns an instance of jsx3.util.Iterator.

<table>
        <tbody>
            <for-each select="jsx3.util.List.wrap([1,2,3,4]).iterator()">
                <tr><td><text>hello</text></td></tr>
            </for-each>
        </tbody>
    </table>

Although the above selector hard codes the contents of the iterator (such as [1,2,3,4]), it is common to use a more dynamic approach for your class. For example, if your GUI class implements the jsx3.xml.Cacheable and jsx3.xml.CDF interfaces (as in this example), the class has access to common methods such as getXML. This allows you to paint as many rows as you have CDF records in the XML document used by the control. For example:

<table>
        <tbody>
            <for-each select="this.getXML().selectNodeIterator('//record')">
                <tr><td><text>hello</text></td></tr>
            </for-each>
        </tbody>
    </table>

Beyond simply iterating over a collection of items, the for-each tag also provides a pointer to the active item in the iteration: $$target. For example, assume that you not only want to paint a table row for each record, but you also want to output the value of the jsxtext attribute:

<table>
        <tbody>
            <for-each select="this.getXML().selectNodeIterator('//record')">
                <var id="mytext">$$target.getAttribute('jsxtext')</var>
                <tr><td><text>{mytext}</text></td></tr>
            </for-each>
        </tbody>
    </table>

You may need to nest for-each iterators to handle a variable number of both rows and columns. For example, the class used for this exercise has a variable named items that stores column profile information in the following JSON format:

this.columns = {};
    this.columns.items = [{attribute:"jsxid",caption:"ID",width:"25"},
                             {attribute:"jsxtext",caption:"Name",width:"*"}]

When it is time to paint the component, a corresponding method wraps the array and returns an iterator.

grid.getIterableColumns = function() {
    return jsx3.util.List.wrap(this.columns.items).iterator();
  };

Though useful, nested loops introduce a new challenge in that the $$target variable is intercepted by the nested (inner) for-each. This means that if the inner loop must access the target of the outer loop, you must cache a pointer to the $$target variable before the inner loop begins. For example, note how a custom variable named myrecord is used to do this:

<table>
        <tbody>
            <for-each select="this.getXML().selectNodeIterator('//record')">
                <var id="*myrecord*">$$target</var>
                <tr>
                    <for-each select="this.getIterableColumns()">
                        <var id="mytext">*myrecord*.getAttribute($$target.attribute)</var>
                        <td><text>{mytext}</text></td>
                    </for-each>
                </tr>
            </for-each>
        </tbody>
    </table>

III. Optimize the Template

An important feature of the Template class is its ability to optimize developer code. If, for example, a resize event occurs in the browser, the Template will automatically adjust the size of the control to reflect this change. And if the Template determines that the resize event did not affect the true size of the painted instance, it will exit early to spare the unnecessary processing time. However, there are times when you, the developer, will know of optimizations that the Template class simply cannot handle given that it is a generic tool. For example, the sample class used for this exercise uses a single HTML input in order to make the grid editable. This reduces the amount of HTML needed to create a large, editable grid, since the single input can be overlaid on top of a grid cell when it is time to edit.

The Template engine is capable of positioning the single text input when a given cell receives focus. The handler for the class locates the position of the cell in relation to the HTML element that contains both it and the input mask, using the method getRelativePosition :

var objPos = jsx3.html.getRelativePosition(objDataContainer,objCell);
objPos.target = objCell;

When the true position for the cell is determined, the position is cached (along with a few additional values) and the Template engine is notified of the change:

this._setTargetProfile(objPos);

At this point, any template variable that implements the "beforeedit" trigger is notified to update its cached value. The Template class then automatically applies the updated value to any tag in the template that implements the given variable. For example, consider the following model declarations used by the grid class:

<model>
        <var id="mask_value" triggers="beforeedit">
            return this._getTargetProfile().value;
        </var>
        <var id="mask_left" triggers="beforeedit">
            return isNaN(this._getTargetProfile().L)? -100 :this._getTargetProfile().L;
        </var>
        <var id="mask_top" triggers="beforeedit">
            return isNaN(this._getTargetProfile().T)? -18 :this._getTargetProfile().T;
        </var>
        <var id="mask_width" triggers="beforeedit">
            return this._getTargetProfile().W || 100;
        </var>
        <var id="mask_height" triggers="beforeedit">
            return this._getTargetProfile().H || 18;
        </var>
        <var id="mask_display" triggers="beforeedit afteredit">
            return this._getTargetProfile().display || "none";
        </var>
    </model>

When the model variables are updated, the Template class next attempts to apply these updates to every HTML element that uses one. In this case, the variables mask_value, mask_left, mask_top, mask_width, mask_height, and mask_display are used to update the value, display, and position of the single input box.

The following input definition appears in the template for the class:

<input u:id="txt_editmask" type="text" class="" value="{mask_value}"
        onkeydown="{onkeydown}" onblur="{onblur}"
        style="position:absolute;padding:3 0 0 1;background-color:#ffffff;
        z-index:2;color:blue;left:{mask_left};top:{mask_top};
        width:{mask_width};height:{mask_height};font-family:{$fontname};
        font-size:{$fontsize};display:{mask_display};border:solid 1px gray;" />

Although the template engine simplifies the act of updating the user interface, it can also be inefficient, because the engine does not know in advance which tags implement which variables. When the input mask has been positioned, the engine continues to scan the remainder of the template, in case other HTML elements also use the newly-updated variables. To avoid this, you can tell the engine to exit early when you know that there is no need for it to continue scanning the remaining HTML elements. For example, note how the if tag tells the engine to exit early when there is a flag on the cached profile:

<input u:id="txt_editmask" type="text" class="" value="{mask_value}"
        onkeydown="{onkeydown}" onblur="{onblur}"
        style="position:absolute;padding:3 0 0 1;background-color:#ffffff;
        z-index:2;color:blue;left:{mask_left};top:{mask_top};
        width:{mask_width};height:{mask_height};font-family:{$fontname};
        font-size:{$fontsize};display:{mask_display};border:solid 1px gray;" />
    <if test="this._getTargetProfile().target"><return/></if>

The final class definition for the editable grid is as follows:

jsx3.require("jsx3.gui.Template","jsx3.xml.Cacheable","jsx3.xml.CDF");

jsx3.lang.Class.defineClass("my.Grid",jsx3.gui.Template.Block,[jsx3.xml.Cacheable,jsx3.xml.CDF],function(GRID,grid) {

  //init
  grid.init = function(strName) {
    this.jsxsuper(strName);
  };

 // defaults
  grid.columns = {};
  grid.columns.items = [{attribute:"jsxtext",caption:"Text",width:"*"}];

 // template xml
  grid.getTemplateXML = function() {
    return ['',
    '<transform xmlns="http://gi.tibco.com/transform/" xmlns:u="http://gi.tibco.com/transform/user" version="1.0">' ,
    '  <model>' ,
    '    <var id="mask_value" triggers="beforeedit">return this. getTargetProfile().value;</var>' ,
    '    <var id="mask_left" triggers="beforeedit">return isNaN(this. getTargetProfile().L) ? -100 : this. getTargetProfile().L;</var>' ,
    '    <var id="mask_top" triggers="beforeedit">return isNaN(this. getTargetProfile().T) ? -18 : this. getTargetProfile().T;</var>' ,
    '    <var id="mask_width" triggers="beforeedit">return this. getTargetProfile().W || 100;</var>' ,
    '    <var id="mask_height" triggers="beforeedit">return this. getTargetProfile().H || 18;</var>' ,
    '    <var id="mask_display" triggers="beforeedit afteredit">return this. getTargetProfile().display || "none";</var>' ,
    '  </model>' ,
    '  <template dom="static">' ,
    '    <inlinebox style="position:{$position};left:{$left};top:{$top};width:{$width};height:{$height};margin:{$margin};">' ,
           //scrollable data rows
    '      <div u:id="datarows" style="position:absolute;left:0px;top:20px;width:100%;height:100%-20px;overflow:auto;z-index:1;" onscroll="{onscroll}">',
                          //textbox edit mask
    '        <input u:id="txt_editmask" type="text" class="" value="{mask_value}" onkeydown="{onkeydown}" onblur="{onblur}" ',
    '          style="position:absolute;padding:3 0 0 1;background-color:#ffffff;z-index:2;color:blue;left:{mask_left};top:{mask_top};width:{mask_width};height:{mask_height};font-family:{$fontname};font-size:{$fontsize};display:{mask_display};border:solid 1px gray;" />',
                          //exit the template early if the mask is targeting a cell; when that happens, merely show the mask above and exit early
    '        <if test="this._getTargetProfile().target"><return/></if>' ,
    '        <table u:protected="true" u:id="datarows_table" index="0" onmousedown="{onmousedown}" style="position:absolute;left:0px;top:0px;width:100%;font-size:{$fontsize};font-family:{$fontname};table-layout:fixed;" cellspacing="0" border="0"><tbody u:id="tb_b" u:protected="true">' ,
    '          <for-each select="this.getIterableRecords()">' ,
    '            <var id="rowid">this.getId() + \'_\' + $$target.getAttribute(\'jsxid\')</var>',
    '            <var id="rowtarget">$$target</var>',
    '            <tr id="{rowid}" u:id="tr_b" u:protected="true">',
    '              <for-each select="this.getIterableColumns()">' ,
    '                <var id="celltext">rowtarget.getAttribute($$target.attribute) || "&amp;#160;"</var>',
    '                <var id="columnwidth">$$target.width</var>',
    '                <td u:id="td_b" u:protected="true" style="width:{columnwidth};padding:3px;border-right:solid 1px lightblue;border-bottom:solid 1px lightblue;"><text>{celltext}</text></td>',
    '              </for-each>',
    '            </tr>',
    '          </for-each>',
    '        </tbody></table>',
    '      </div>',
            //fixed header row
    '      <div u:id="header" style="position:absolute;left:0px;top:0px;width:100%;height:20px;border:{myborder|solid 1px gray};z-index:2;background-color:{headerbg};color:{headercolor};overflow:hidden;">',
    '        <table u:protected="true" u:id="header_tbl" style="position:absolute;left:0px;top:0px;width:100%;font-size:{$fontsize};font-weight:bold;table-layout:fixed;" cellpadding="3" cellspacing="0" border="0"><tbody u:id="tb_h" u:protected="true">' ,
    '          <tr u:id="tr_h" u:protected="true">',
    '            <for-each select="this.getIterableColumns()">' ,
    '              <var id="headertext">$$target.caption</var>',
    '              <var id="columnwidth">$$target.width</var>',
    '              <td u:id="td_h" u:protected="true" style="width:{columnwidth};white-space:nowrap;text-align:center;">',
    '                <text>{headertext}</text>',
    '              </td>',
    '            </for-each>',
    '          </tr>',
    '        </tbody></table>',
    '      </div>',
    '    </inlinebox>' ,
    '  </template>' ,
    '</transform>'].join("");
  };

  // 6) define the CONTROLLER functions
  grid.onmousedown = function(objEvent,objGUI) {
    this.onbeforeedit(objEvent,objEvent.srcElement().parentNode,objEvent.srcElement());
  };

 grid.onscroll = function(objEvent,objGUI) {
    //called when the data rows are scrolled
    this.getRenderedBox("header_tbl").style.left = -objGUI.scrollLeft + "px";
  };

 grid.onblur = function(objEvent,objGUI) {
    //handle the primitive event (onblur) with the prototype (instance) event
    this.onafteredit(objEvent,objGUI);
  };

 grid.onkeydown = function(objEvent,objGUI) {
    var objCell = this._getTargetProfile().target;
    var intCellIndex = objCell.cellIndex
    var objRow;

    if((objEvent.enterKey() && !objEvent.shiftKey()) || objEvent.downArrow())  {
      //commit the value; advance edit to the next cell down
      this.onafteredit(objEvent,objGUI);

      //if the current row isn't the last row, apply edit mask to the next row down
      objRow = (objCell.parentNode != objCell.parentNode.parentNode.lastChild) ?
        objCell.parentNode.nextSibling : objCell.parentNode.parentNode.firstChild;
    } else if(objEvent.upArrow() || (objEvent.enterKey() && objEvent.shiftKey())) {
      //commit the value; advance edit to the next cell up
      this.onafteredit(objEvent,objGUI);

      //if the current row isn't the first row, apply edit mask to the next row up;
      objRow = (objCell.parentNode != objCell.parentNode.parentNode.firstChild) ?
        objCell.parentNode.previousSibling : objCell.parentNode.parentNode.lastChild;
    } else if(objEvent.rightArrow())  {
      //commit the value; advance edit to the next cell down
      this.onafteredit(objEvent,objGUI);
      var objRow = objCell.parentNode;
      intCellIndex = objRow.lastChild == objCell ? 0 : intCellIndex+1;
    } else if(objEvent.leftArrow()) {
      //commit the value; advance edit to the next cell up
      this.onafteredit(objEvent,objGUI);
      var objRow = objCell.parentNode;
      intCellIndex = objRow.firstChild==objCell ?objRow.lastChild.cellIndex :
        intCellIndex-1;
    }

    //begin the edit session for the target cell
    if(objRow)
      this.onbeforeedit(objEvent,objRow,objRow.childNodes[intCellIndex]);
  };

 grid._getTargetProfile = function() {
    //when an edit event happens, a target profile is created that describes the context
    return this._jsxtargetprofile || {};
  };

 grid._setTargetProfile = function(objP) {
    //when an edit event happens, a target profile is created that describes the context
    this._jsxtargetprofile = objP;
  };


  grid.onbeforeedit = function(objEvent,objRow,objCell) {
    //use a sleep delay to stop repeated clicks and key strokes from taxing performance
    jsx3.sleep(function() {
        this._onbeforeedit(objEvent,objRow,objRow.childNodes[objCell.cellIndex]);
    },"my.GRID",this);
  };

 grid._onbeforeedit = function(objEvent,objRow,objCell) {
    //get the id for the row that was clicked
    var strCdfId = objRow.getAttribute("id").substr(this.getId().length+1);
    var strAttName = this.columns.items[objCell.cellIndex].attribute;
    var strValue = this.getRecordNode(strCdfId).getAttribute(strAttName);

    //allow the before-edit event to be cancelled
    var objReturn = this.doEvent(jsx3.gui.Interactive.BEFORE_EDIT,
        {objEVENT:objEvent,strID:strCdfId,objCELL:objCell,strVALUE:strValue});
    if(objReturn !== false) {
      //determine information about the target cell being edited (left, top, etc)
      var objThis = this.getRendered(objRow);
      var objDataContainer = this.getRenderedBox("datarows",objThis);
      var objMask = this.getRenderedBox("txt_editmask",objThis);

      //query the system for the location of the target table cell
      var objPos = jsx3.html.getRelativePosition(objDataContainer,objCell);
      //when running on firefox, builds earlier than 3.6.1 have a bug
      if(!(jsx3.getVersion() < "3.6.1" && /fx/.test(jsx3.CLASS_LOADER.getType()))) {
        objPos.L = objPos.L + objDataContainer.scrollLeft;
        objPos.T = objPos.T + objDataContainer.scrollTop;
      }

      objPos.value = strValue || "";
      objPos.display = "block";
      objPos.target = objCell;
      objPos.id = strCdfId;
      objPos.attribute = strAttName;

      //cache the information about the target
      this._setTargetProfile(objPos);
      this.syncProperty("beforeedit",true);
      //give cursor focus to the edit mask (the text input)
      objMask.focus();
    }
  };

 grid.onafteredit = function(objEvent,objGUI) {
    //get the profile object
    var objP = this._getTargetProfile();

    //get the new value entered by the user
    var objReturn;
    if(objP.value != objGUI.value) {
      //allow the edit to be cancelled/modified
      objReturn = this.doEvent(jsx3.gui.Interactive.AFTER_EDIT,
          {objEVENT:objEvent,strID:objP.id,objCELL:objP.target,
          strVALUE:objGUI.value});
      if(objReturn !== false) {
        //update the data model
        this.insertRecordProperty(objP.id,objP.attribute,objGUI.value,false);

        //update the view
        objP.target.innerHTML = jsx3.util.strEmpty(objGUI.value) ?
            "&#160;" : objGUI.value;
      }
    }

    //reset the mask (hide it)
    this._setTargetProfile({value:""});
    this.syncProperty("afteredit",true);

    //fire the final commit event (not cancellable)
    if(objReturn !== false)
      this.doEvent(jsx3.gui.Interactive.AFTER_COMMIT,
         {objEVENT:objEvent,strID:objP.id,objCELL:objP.target,
         strVALUE:\(objReturn != null && objReturn.strVALUE ?
         objReturn.strVALUE : objGUI.value)});
  };

 grid.setColumns = function(arrColumns) {
    //call to change the columns to render for the table. The schema is as follows.:
    this.columns.items = arrColumns;
    //the GI serializer only saves scalar types
    this.encodeColumns(arrColumns);
    this.repaint();
  };

 grid._getColumns = function(arrColumns) {
    return this._columns || "";
  };

 grid.encodeColumns = function(arrColumns) {
    //serialize the columns array as a string so the GI class serializer
    var a = ;
    for(var i=0;i<arrColumns.length;i++) {
      var cur = arrColumns[i];
      a.push("{attribute:\"" + cur.attribute + "\",caption:" +
        jsx3.util.strEscapeJSON(cur.caption) + ",width:\"" + cur.width + "\"}");
    }
    this._columns = "[" + a.join(",") + "]";
  };

 grid.onAfterAssemble = function() {
    //when a serialized object is restored in GI (deserialized), this method is called;
    if(!jsx3.util.strEmpty(this._columns))
      this.columns.items = jsx3.eval(this._columns);
  };

 grid.getIterableRecords = function() {
    var objCDF = this.getXML();
    return objCDF ? objCDF.selectNodeIterator("//record") :
    (new jsx3.util.List()).iterator();
  };

  grid.getIterableColumns = function() {
    return new jsx3.util.List(this.columns.items.length ?
    this.columns.items : ).iterator();
  };

});

Contents

Searching General Interface Docs

Enter labels to add to this page:
Please wait 
Looking for a label? Just start typing.