Jul 13 2009

Introducing liteGrid, a lightweight jQuery grid plug-in

Category: JavaScript | jQueryMatt @ 04:49

At my day job, we’re basically stuck re-implementing Excel in a web environment.  (Sidenote: Rob tells me that this is absolutely the correct way to build applications and all web applications should try to mimic desktop applications as much as possible.  I don’t like it, but it’s the cards we’ve been dealt, and we have to make it work.)  Right now, we’re using jqGrid with ASP.NET MVC, and we’ve got things working at a rudimentary level. However, we’ve found ourselves tripping more and more over jqGrid bugs, limitations, and just plain *pain* when trying to extend it.  My take on jqGrid is that it’s too heavy to be flexible, plus it’s not a perfect match for what we’re trying to build in this application.  It’s actually becoming a barrier to progress.  Though there are alternatives, there is nothing that we’ve found that has all the functionality we need.  So, I have now been tasked with creating a new plug-in from scratch that is flexible enough to support our needs.  I have dubbed this effort liteGrid (the name sucks because there are 10,000 table plug-ins, and all the good names have been taken).

First, here the requirements I laid out for myself as I built liteGrid.  Some of these are based on application needs, but many are just based on design principles that I felt were important:

  • Must support tree-grid functionality.  You should be able to expand and collapse rows.
  • Must support flexible editing.  Items in the grid should be editable, and it should be very clean and easy to customize how things are edited.
  • Easy to customize.  If you want to change something on the grid, it should be easy and straightforward.
  • Easy to handle formatting.  You should have complete control over how values are rendered.
  • Completely decoupled data model.  The underlying data model should be distinct from what’s rendered.
  • AJAX support.  At a minimum, loading and saving data should be doable with AJAX.
  • Simple.  The grid itself should contain very little code.
  • Event-driven.  The grid should be loosely-coupled and use events to pass messages.  I was inspired by this article on custom events in jQuery.
  • jQuery UI support.  I hate coming up with themes for anything as I have no artistic inclination (at all), so this is me passing the buck. I’m still not sure how feasible this is as I’m a complete newb on jQuery UI, but hopefully they have something I can leverage.

Before we go any further …

DISCLAIMER

I do not consider myself to be a JavaScript or jQuery guru (yet).  This is also a work in-progress. I fully expect it to undergo major changes as additional functionality is added.  I’m not claiming (yet) that it’s the best grid out there, just that I like the way it is shaping up.  Still, all feedback is welcomed at this point, so feel free to throw rocks at it.  Oh, and this may/may not eventually be released in final form.  It depends on how my employer wants to handle it.  This code is not free and open-source yet, so read it at your own risk (though I really don’t think anyone is going to care). 

Getting Started

There is a “right” way to build a jQuery plug-in.  And this is it.  I used Starter to generate my skeleton plug-in, which saved me quite a bit of time.  From there, I modeled the core of liteGrid based loosely on how ASP.NET data grids work.  The grid raises events when certain things happen, which interested parties can then listen for and respond to.  I wanted to keep the core extremely simple and light, so I also built in support for pluggable modules that hook into the grid to provide additional functionality.  The tree-grid, which I’ll show in a future post, is implemented a liteGrid module.  Also, liteGrid supports a data provider model.  By default, it uses a null provider that returns no data, but you can drop in any objection you want to serve as the provider.  By default, liteGrid requires only a single provider method, but as you will see in future posts, liteGrid modules may add additional data provider requirements.

Alright, so code time.  Let’s look at the plug-in as a whole before we dive in to the interesting methods:

(function($) {

    // Declare namespace if not already defined
    if (!$.inrad) {
        $.inrad = new Object();
    }

    //This is the actual liteGrid plug-in class.
    $.inrad.liteGrid = function(el, options) {
        // To avoid scope issues, use 'base' instead of 'this'
        // to reference this class from internal events and functions.
        var base = this;

        // Access to jQuery and DOM versions of element
        base.$el = $(el);
        base.el = el;

        // Add a reverse reference to the DOM object
        base.$el.data("inrad.liteGrid", base);

        //This actually performs the initialization.
        base.init = function() {
            ...
        }

        //Rendes the actual table.  This can be overriden by modules.
        base.render = function() {
            ...
        }

        //Renders the specified row.
        base.renderRow = function(dataItem, index) {
            ...
        }

        //Builds a row that can be inserted into the table.  columnBound
        //events are raised, and the formatter is used to format cell
        //values.
        base.buildRow = function(dataItem) {
            ...
        }

        //Inserts a row for a data item after the specified row
        //that's already in the table.
        base.insertRowAfter = function(dataItem, existingRow) {
            ...
        }

        base.init();
    }


    $.inrad.liteGrid.defaultOptions = {
        columns: [],
        dataProvider: new NullDataProvider(),
        modules: [],
        missingValue: "",
        rowIdColumn: "ID"
    }


    // This is the actual plug-in function.  It creates and returns
    // a new instance of the plug-in.  
    $.fn.inrad_liteGrid = function(options) {
        return this.each(function() {
            (new $.inrad.liteGrid(this, options));
        });
    }

    // This function breaks the chain, but returns
    // the inrad.liteGrid if it has been attached to the object.
    $.fn.getinrad_liteGrid = function() {
        return this.data("inrad.liteGrid");
    }

})(jQuery);

Aside from the JavaScript added by jQuery Starter, there’s not really much going on.  liteGrid is a simple object with only a few methods: init, render, renderRow, buildRow, and insertRowAfter.  I expect this list to grow a little bit as I find common functionality that feels like it belongs on the core object, but still, this is far from a complicated object. 

Let’s look at the options that are supported right now: columns, dataProvider, modules, missingValue, and rowIdColumn.  Columns is simply an array of objects that defines the columns to be rendered.  Note that modules can modify this definition to add new columns as needed, as we’ll see in the tree-grid module.  Next is the data provider.  By default, this is a null provider that just returns an empty array.  Users of liteGrid should supply their own provider or use one of the available providers that will “ship” when this thing is finished (including an AJAX provider).  Next is the array of add-on modules for the grid.  These are initialized by liteGrid and can do all sorts of crazy things.  Next we have the value to substitute for rows that are missing a value for a column.  This may be a bit of YAGNI creeping in, so I might remove it later.  Finally, we have the name of the column (we’re talking data model column) that contains the unique identifier for the row.  This should probably renamed to “rowIdProperty” for clarity.  Basically, the underlying data objects that are returned by the grid’s data provider must expose a unique identifier, and this is the name of that identifier.

Alright, time for some code.  Let’s dig in to the init method first:

base.init = function() {

    base.options = $.extend({}, $.inrad.liteGrid.defaultOptions, options);

    //Initialize all modules!  Modules might, for example, add
    //new columns (such as the expander column), add decorators to
    //the providers, subscribe to events, or do other fun things.
    $(base.options.modules).each(function() {
        console.log("Initializing %s...", this.constructor.name);
        //TODO: Add error-handling!
        this.initialize(base, base.options);

        console.log("Finished!");
    });

    base.render();
    base.$el.trigger("tableRendered");
}

There is the standard jQuery stuff for handling options.  After that, all the modules are initialized.  This is done prior to *anything* else happening because modules can do whatever they want to the grid.  They can override methods, they can change options, they can register event handlers, whatever.  Next, we render the grid, and fire a custom event on the actual table element itself that signifies that rendering is complete.  Pretty simple!

Next up is the render method:

//Rendes the actual table.  This can be overriden by modules.
base.render = function() {
    //Clear any existing table markup
    base.$el.html("");

    //Build the header.
    var headerRow = $("<tr></tr>");
    $("<thead></thead>").append(headerRow).appendTo(base.$el);

    $(base.options.columns).each(function() {
        headerRow.append("<th>" + (this.header || this.field) + "</th>");
    });

    //Grab data and add the rows.
    var dataArray = base.options.dataProvider.getData();

    $(dataArray).each(function(index) {

        base.renderRow(this, index);
    });

    //The table has been rendered, so trigger the event.
    base.$el.trigger("tableUpdated", base);
}

This method does a lot, so it defers a lot of logic to helpers where it can.  First, it wipes out any existing HTML content, and builds a header row for the table based on the column definitions.  Next, the data items are retrieved from the configured provider, and a row is rendered for each table.  Finally, an event is raised to signify that the grid has changed in some way.

Next is our helper to render a row.   I’ll also throw in a related method that builds the actual row:

//Renders the specified row.
base.renderRow = function(dataItem, index) {

    var row = base.buildRow(dataItem);

    base.$el.append(row);
}

//Builds a row that can be inserted into the table.  columnBound
//events are raised, and the formatter is used to format cell
//values.
base.buildRow = function(dataItem) {

    //Spit out values for each of the columns
    var row = $("<tr></tr>");

    //Add a class containing the ID
    row.addClass("row-id-" + dataItem[base.options.rowIdColumn]);

    //Bind the actual data item to the row so that we can get it later.
    row.data("dataItem", dataItem);

    $(base.options.columns).each(function() {
        var column = this;

        //Format the value.
        var value = (dataItem[column.field] || base.options.missingValue));

        var element = "<td>" + value + "</td>";
        element = $(element).appendTo(row);

        base.$el.trigger("columnBound", [column, element]);
    });

    //Raises the "rowBound" event on the table.
    //TODO: Can we hand the user the row's index?  Probably not...
    base.$el.trigger('rowBound', [row, dataItem]);

    return row;
}

renderRow simply calls buildRow to build up the DOM tree for the row, then appends it to the table.  buildRow is a little more complicated.  First, the new row is tagged with a special class that stores the row’s ID.  The jQuery data function is also used to store the raw dataItem with the row, making it easy to grab the raw data later.  Next, a cell is rendered for each column.  Note that the data item may define fields beyond the column specifications, it is completely flexible in this regard.  If the data item doesn’t define the column, the missing value is used instead.  When it’s all finished, an event is triggered, again on the table element, that allows interested parties to see what’s going on.

The last method our grid exposes is a helper for modules to use.  It inserts a row after an existing row in the table:

base.insertRowAfter = function(dataItem, existingRow) {

    //Create the row.
    var row = base.buildRow(dataItem);

    //Insert it.
    row.insertAfter(existingRow);

    //The table has been changed, so fire the event.
    //TODO: Do we *really* want to fire this after every insert?  Would
    //it be better to have a way to supress the event being fired, and
    //allow modules to trigger the event?
    base.$el.trigger("tableUpdated", base);

    return row;
}

It uses the buildRow function, insuring that all our events are fired, adds it to the DOM, and triggers an event indicating that the table has changed.

And that’s all there is to it!  How do we put this grid into action?  Here’s a simple example:

$(function() {
    //Subscribes to events just to make sure they work.
    $("#myTable").bind("rowBound", function() {
        //alert("Received rowBound event!");
    })
    .bind("columnBound", function(event, column, element) {
        //element.html(column.field + ": " + element.html());
    })
    .bind("tableRendered", function(event) {
        //Do some table-specific processing?
    });

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

This tells the plug-in to convert the table with ID #myTable to a liteGrid.  We are passing in a custom data provider for testing:

function MockDataProvider() {
    this.getData = function() { 
        return [
            {ID:1, Name:"Name1", Value:"Value1", Cost:1234, HasChildren:true},
            {ID:2, Name:"Name2", Value:"Value2", Cost:12345, HasChildren:true}
        ];
    };
    
    //Gets child data from the server.
    this.getChildData = function(parentId) {
        if (parentId == 1) {
            return [
                { ID: 3, Name: "Child1", Value: "Value3", Cost: 1234, HasChildren:true },
                { ID: 4, Name: "Child2", Value: "Value4", Cost: 1234, HasChildren:false }
            ];
        }
        else if (parentId == 2) {
            return [
                { ID: 5, Name: "Child1", Value: "Value3", Cost: 1234 },
                { ID: 6, Name: "Child2", Value: "Value4", Cost: 1234 }
            ];
        }
        else if (parentId == 3) {
            return [
                { ID: 7, Name: "Child1", Value: "Value3", Cost: 1234 },
                { ID: 8, Name: "Child2", Value: "Value4", Cost: 1234 }
            ];
        }
        else { 
            return [];
        }
    }
}

Note that this provider actually has a few things that are required by the tree-grid module, which I’ll show in the next post, but liteGrid doesn’t care.  It simply looks for matches between your column definitions and the fields on your data items, and skips anything that doesn’t match.

There you have it, liteGrid.  Before I end this post, let’s look at a simple module that stripes the rows in the table:

function StripifyModule() {
    var base = this;
    
    //Registers for events that signal that the table needs to be 
    //re-stripified.
    base.initialize = function(liteGrid, options) {

        //Register for the events we care about.
        liteGrid.$el.bind("tableUpdated", function(event, table) {

            //Remove even/odd classes from everything
            table.$el.find("tbody tr").removeClass("even odd");

            table.$el.find("tbody tr:even").addClass("even");
            table.$el.find("tbody tr:odd").addClass("odd");
        });
    }
}

By simply subscribing to events, this module can now apply stripes to the table anytime a row is added or removed.  This is a very simple example, and the true power of this functionality won’t really be obvious until the next post, when I show the TreeGridModule.

Alright, so, comment away.  What do you think?  Any suggestions for improving things?

Tags:

blog comments powered by Disqus