For those just jumping on, liteGrid is a lightweight jQuery grid plug-in that’s based on an event-driven architecture.  The core does practically nothing, but through the power of events, add-on modules can extend it with additional behavior.  In this post, I’ll explain the idea behind the event-driven architecture and modules, and I’ll show you an example that brings nice tree-grid functionality (ala jqGrid) to liteGrid in a simple, easy-to-use manner.

Improvements Since Last Time

Thanks to feedback from Rob, I was able to make a few minor change to the core to clean things up. The big thing is that row IDs are no longer classes but actual element IDs.  I have no clue what I was thinking when I made row-id a class instead of using the attribute that exists specifically for such things, but oh well.

A Little About Events

While objects in the DOM fire a variety of standard events (onclick, onkeypress, etc), jQuery makes it possible to fire custom events, making it easier to build loosely-coupled systems entirely in JavaScript.  For more about custom events, I highly recommend Douglas Neiner’s article.  It’s a doozy, but well worth the read.

So, why did I choose an event-driven architecture for liteGrid?  I wanted something that was loosely-coupled and extremely flexible.  The nice thing about this approach is that any number of add-on modules can plug in to liteGrid’s events and perform whatever actions they want.  As you’ll see, this makes it possible to do some very cool things.  Further, add-on modules themselves can publish their own events, which makes it possible to have add-ons for your add-ons. 🙂

Making liteGrid a tree-grid: TreeGridModule

In the last post, I showed you a simple add-on module that would zebra stripe the rows in the grid.  That’s all well-and-good, but a little simplistic.  Oh, and for those wondering why the rows are restriped in batch like that, there’s actually a really good reason (as we’re about to see): rows can be inserted inside the grid. 🙂

So, what’s a tree-grid?  Here’s a screenshot (snapped from jqGrid’s demonstration page):

treeGrid

The basic idea is that a row can have children.  When a parent is expanded, the child rows should be displayed immediately below it, and there should be some visual indication that the children belong to the parent.  A good tree-grid should support an arbitrary depth of nesting, though this can become a UI problem since indenting things (the most common way to denote parent-child relationships) eats up quite a bit of space.

If that sounds like it is going to be a challenge, you’re right!  How can we bring this functionality to liteGrid?  Let’s look at the module at a high level:

function TreeGridModule() {
    var base = this;

    //Called by liteGrid, this initializes the module and ties it
    //into the liteGrid plumbing.  liteGrid is the actual liteGrid
    //reference.
    base.initialize = function(liteGrid, options) {
        ...
    }

    //Callback that is invoked whenever a row has been bound in the grid.
    base.rowBound = function(event, row, dataItem, index) {
        ...
    }

    //Callback that is invoked whenever a row is collapsed. 
    base.rowCollapsed = function(parentId) {
        ...
    }

    //Used to hide children without actually changing their collapsed status.
    base.recursiveHideChildren = function(parentId) {
        ...
    }

    //Used to show children recursively.  Their collapsed status is not changed, so
    //only children that are expanded are shown.
    base.recursiveShowChildren = function(parentId) {
        ...
    }

    //Callback that is invoked whenever the expando-link is clicked.
    base.rowExpanded = function(parentId) {
        ...
    }
}
TreeGridModule.prototype.defaultOptions = {
    paddingPerLevel: 5
};

All modules must define an initialize function.  This is called by the liteGrid core to initialize the module.  Next, we have a few event handlers.  The first will be tied to liteGrid’s rowBound event.  After that, we have two special event handler that handle the user collapsing and expanding rows.  After that, we have two helper functions that take care of recursively showing/hiding a row’s children (after all, if you collapse a row that has children, and those children have children, shouldn’t they be hidden, too?).  At the very end, we add an object for default options used by TreeGridModule.  By default, we want to add 5 pixels of padding as we indent children under their parents.  As you’ll see, this default can be overriden by specifying the paddingPerLevel property on the liteGrid options.

Let’s dig in to the initialization:

base.initialize = function(liteGrid, options) {

    //Store a reference to the table, that way it can be accessed later, if needed.
    base.liteGrid = liteGrid;

    if (!("getChildData" in options.dataProvider)) {
        alert("Specified data provider is not compatible with TreeGridModule, aborting initialization.");
        return;
    }

    base.dataProvider = options.dataProvider;

    //Add a column to hold the expander.
    options.columns.splice(0, 0, { field: "Expander", header: "X" });

    //Initialize module-specific options
    base.options = $.extend({}, TreeGridModule.prototype.defaultOptions, options);

    //Register to receive rowBound events, that way we can insert the expander.
    liteGrid.$el.bind("rowBound", base.rowBound);
}

First, we grab a reference to the actual liteGrid class so that we can use it in the other functions.  Next, we inspect liteGrid’s configured data provider to make sure it is compatible with the TreeGridModule.  If the provider doesn’t support retrieving child rows, the module initialization is aborted, and liteGrid can go about its business sans-tree-grid.  For simplicities sake, we also store a reference to the configured provider.  Here’s where the magic beings.  First, a new column is added to liteGrid’s column definitions.  Recall that columns are completely decoupled from the underlying data model.  The only way we’ll have an issue is if the data model actually defines a property named “Expander”, in which case we may get strange behavior. Next, we combine TreeGridModule’s default options with liteGrid’s options.  This allows the paddingPerLevel setting to be overriden by specifying a different value when liteGrid is initialized.  Finally, we register for the one event we care about: rowBound.

Recall that rowBound is fired whenever liteGrid has finished populating a row with data and is ready to insert it into the grid.  Here’s what we’re going to do when that happens:

base.rowBound = function(event, row, dataItem, index) {

    //Only add an expander if the row has children.
    if (dataItem.HasChildren === true) {

        //Add the expander
        var expanderCell = row.find("td:first");

        //This is a div because an img won't render without a src attribute.
        var expandImage = $("<div class='expander closed' />")
                        .toggle(
                            function() { base.rowExpanded(dataItem[base.options.rowIdColumn]); },
                            function() { base.rowCollapsed(dataItem[base.options.rowIdColumn]); }
                        );

        expanderCell.append(expandImage);
    }
    //TODO: We might want to show a different icon if the row doesn't have children.
}

TreeGridModule expects the data model to define a HasChildren property, though rows that don’t have children can simply omit the value altogether.  If the row being bound does have children, we add an div (rendered as an image thanks to CSS) to the first cell in the row.  We use jQuery’s toggle event to bind to appropriate event handlers: the first click of the image expands the row, the second collapses it. 

When the expander image for a row is clicked, the rowExpanded callback is invoked.  Get ready for a screenfull of code as this is the most complicated piece of the module!

//Callback that is invoked whenever the expando-link is clicked.
base.rowExpanded = function(parentId) {

    //Grab a reference to the parent, the first row we add goes after it.
    var parent = base.liteGrid.$el.find("tr#row-id-" + parentId);

    //Change the expander image
    var parentExpander = parent.find("td:first div")
            .removeClass("closed").addClass("opened");

    //Determine if the children are already loaded. If they are, we don't need to re-retrieve them.
    if (parent.hasClass("children-loaded")) {

        //Mark the children as shown, they'll be toggled on through the recursive call.
        var children = base.liteGrid.$el.find("tr.child-of-" + parentId).removeClass("hidden").addClass("shown");
        base.recursiveShowChildren(parentId);

        return;
    }

    //Otherwise, we have to load and show the children.

    //Get the padding of the parent, we need it in order to add padding to the children.
    var expanderPadding = parseInt(parentExpander.css("margin-left"));

    //Grab the child data from the provider.
    var dataItems = base.dataProvider.getChildData(parentId);

    //This is the row we're adding the next row after.  
    var targetRow = parent;

    //Add the items to the table
    $(dataItems).each(function() {

        //We want to insert the items in order, so the target for the next
        //iteration is the newly inserted row.
        var newRow = base.liteGrid.insertRowAfter(this, targetRow);

        //Mark the row as a child of the parent
        newRow.addClass("child-of-" + parentId).addClass("shown");

        //Adjust the padding of the child's expander.  It should be more than
        //it's parent.
        newRow.find("td:first div").css("margin-left", (expanderPadding + base.options.paddingPerLevel) + "px");

        //Update target, we want the next row added to go under
        //the row that was just added.
        targetRow = newRow;
    });

    //Mark the parent's children as having been loaded.
    parent.addClass("children-loaded");
}

This handler receives the ID of the row that is being expanded.  First, we grab a reference to this row from the liteGrid reference.  Next, we change the class to indicate that it’s been expanded.  Through the magic of CSS, the image will be swapped to something more appropriate (see the example below).  So far, so simple, but here’s where things get tricky.  We might be re-expanding the row.  The child rows might already be in the table, but hidden.  We check for the marker class “children-loaded” to determine whether we just need to show the children or retrieve them from the data provider.  If the children aren’t present yet, we invoke the provider to get the data items.  We want to insert rows for these items sequentially into the grid immediately after our parent row (that’s what “targetRow” is tracking).  For each item, we simply use liteGrid’s helper method insertRowAfter to add the row.  We mark it with a special class that enables us to find who the row belongs to quickly, and we adjust the padding on the row’s expander image so that it is slightly more indented than its parent was.  Finally, we mark the parent with the “children-loaded” marker class.

What happens if the child rows are already there, but hidden?  We use the parentId value to find the rows children, mark them as shown, and then recursively decide which descendents to show:

//Used to show children recursively.  Their collapsed status is not changed, so
//only children that are expanded are shown.
base.recursiveShowChildren = function(parentId) {

    //Show the children that aren't hidden.
    var children = base.liteGrid.$el.find("tr.shown.child-of-" + parentId).show();

    if (children.length > 0) {
        //Show *their* children recursively.
        children.each(function() {
            base.recursiveShowChildren($(this).data("dataItem")[base.options.rowIdColumn]);
        });
    }
}

The recursive method actually shows all children of the row we just expanded, but note that the selector is slightly more complex now: we’re only selecting children marked with the “shown” class.  This allows us to maintain the shown/hidden state of child rows recursively.  If we didn’t do this, and you expanded/collapsed a top-level element that had expandable children, the expanded/collapsed state of the children would be lost.  Tracking state with classes makes it easy to persist state in nested parent/child scenarios. 

Alright, so now we can expand rows, how do we collapse them?  Remember that our expander image has a toggle event handler, so the second time an expander is clicked, it will trigger the rowCollapsed callback:

//Callback that is invoked whenever a row is collapsed. 
base.rowCollapsed = function(parentId) {

    //Grab a reference to the parent, the first row we add goes after it.
    var parent = base.liteGrid.$el.find("tr#row-id-" + parentId);

    //Change the expander image
    var parentExpander = parent.find("td:first div")
            .removeClass("opened").addClass("closed");

    //Hide the children, and mark them as collapsed. 
    var children = base.liteGrid.$el.find("tr.child-of-" + parentId).removeClass("shown").addClass("hidden").hide();

    //Hide the children recursively.
    base.recursiveHideChildren(parentId);
}

Again, we grab a reference to the actual row being collapsed.  We toggle the class on the expander image (remember that we’re handling the image via CSS for the sake of minimal configuration).  Next, we mark the immediate children as collapsed and hide them, then we recursively hide children of the children:

//Used to hide children without actually changing their collapsed status.
base.recursiveHideChildren = function(parentId) {

    //Hide the children, but don't mark them as collapsed.
    var children = base.liteGrid.$el.find("tr.child-of-" + parentId).hide();

    if (children.length > 0) {
        //Hide *their* children recursively.
        children.each(function() {
            base.recursiveHideChildren($(this).data("dataItem")[base.options.rowIdColumn]);
        });
    }
}

Note that we’re hiding the children without altering their shown/hidden state (which is stored as a class).  This enables to restore them to the correct state when the parent is re-expanded.

Put it all together, and you have a tree-grid.  Here’s how to use it:

//Turn #myTable into a rich table.
$("#myTable").inrad_liteGrid(
    {
        columns: [
            { field: "Name", editable: true },
            { field: "Value", header: "My Value", editable: true },
            { field: "Cost", editable: true, type: "currency" },
            { field: "Other", editable: true }
        ],
        dataProvider: new MockDataProvider(),
        modules: [new TreeGridModule(), new StripifyModule()]
    });

And here’s what it looks like:

liteGrid

I was extremely pleased with how easy it was to add this functionality on top of liteGrid.  The liteGrid core stays nice and light, and the module has a single responsibility: to make rows within the grid expandable.  In the next post, we’ll (probably) look at the inline-editing module that I’m working on now.  It’s more complex, but (so far) I’m very pleased with how clean and flexible it is. 

As always, please let me know what you think!