Sunday, January 23, 2011

jQuery : Creating a simple table sorting widget

In this article, I am going to create a very simple widget for sorting a html table using jQuery. In this article, we will see how to create a bit more complex widget as compared to the one that I had discussed earlier in my post jQuery : Creating a custom widget.

I am going to call my new widget a 'tablesort'.

First the HTML.

My HTML table comprises of three columns, and contains a list of 4 of my favorite movies. In the first column, i provide the movie name, the second column is my own rating of the movie and the third column is the year in which the movie was released.

Here is our table

Favorite Movies My Rating Year Of Release
October Sky 9.6 1999
Forrest Gump 9.5 1994
Matrix 9.9 1999
Pursuit Of Happiness 10 2006


Okey, lets us first see what features we will include in our tablesort widget.

  1. A user can should be able to make any table sortable which follows more or less the above html structure.
  2. A user should be able to specify which columns can be used as a sort criteria for sorting the rows.
  3. Sorting of both strings and numeric values should be handled. (This is a simple widget, and i am not handling sorting of complex data types like dates because date formats can vary a lot and i still need to figure out how to handle that sort of stuff. Any suggestions on how to do that efficiently would be great.)
  4. The user should be able to reverse the sorting on a particular column, when clicked upon consecutively.


Now lets see how our formal requirements translate in technical terms.

  1. Since our table contains a 'thead' and a 'tbody', we first need a way to associate a column header with all the columns under it. i.e. Association of a 'th' element with a set of 'td' elements.
  2. Secondly, we need to provide a means for the user to specify the columns to be sorted and add the sorting functionality to only those columns. We also need to provide a visual cue to the user to identify the sortable columns.
  3. When a column header is clicked on, the data in the associated 'td' elements should be compared and used as a basis to sort the 'td' elements. Then based upon the ordering of the 'td' elements, we will update the DOM for the table and rearrange the table rows.


I ususally create a prototype object for the widget first and then initialize the widget instead of doing both of them at the same time. Lets first begin by creating the options object

var tablesortPrototype=(
options:{
   header:'thead',
   body:'tbody',
   sortableColumns:[0],
   sortableClass:'sortable'
  }
};

I have explicitly created the header and body variables so as to allow the user a bit of flexibilility of using CSS selectors as well for identifying the head and body elements. The next two variables are to specify the default settings for the sortable columns. We will used a 0 based index, as is common in most javascript functions. So, if the user does not specify anything, the table will be sortable based upon the first column. The next variable is for the css class that can be used for styling the sortable column.

Now lets do the widget initialization. The code for it is pretty simple and i will be doing it in the _create() function of the widget.

_create:function(){
   console.log('create');
   var widget=this;
   var $domTable=widget.element;
   var options=widget.options;
   
   var $headings=$('th',options.header);
   
   //For each column index as specified in the sortable columns, add a click event handler
   //and the necessary css
   $.each(options.sortableColumns,function(index){
   
    var columnIndex=this;
    $headings.eq(columnIndex).click(function(){
     //call event handler for click event
    })
    .addClass(options.sortableClass);
    
   });
  }

As seen in the above code, i have initialized a few local variables to reduce the amount of typing that i may have to do. You dont actually need to do it, but i find it pretty handy because not only does it eliminate unnecessary typing, but it also eliminates extra function calls and helps your scripts run a tad faster.

So, what I have done in the above code is, fetch all the headers, and for each numeric value specified as in the sortableColumns variable, I add a click event handler and a css class.

Okey, right now, our code is pretty useless without the click event handler. Now, lets create another private function _toggleSort that handles sorting of the columns. This function will handle sort toggling, i.e. ascending order and descending order for the clicked column. Here is my empty sort toggling function

_toggleSort:function(columnIndex,srcDomHeader){}

and here is how I will call it in my click event handler

var columnIndex=this;
    $headings.eq(columnIndex).click(function(){
     widget._toggleSort(columnIndex, this);
    })
    .addClass(options.sortableClass);

Okey, now that we have created our function, lets try to break down the code for adding the functionality one by one.

First, i will declare local variables for the javascript objects the way I did so in the _create function

var widget=this;
   var $domTable=widget.element;
   var options=widget.options;

All the following code has comments that would explain what is being doe.

//Get the column which has to be sorted, i.e. get all the td elements of that particular column
   var column=$(options.body,$domTable).find('tr>td:nth-child('+(columnIndex+1)+')');

The column variable is a jQuery object. I need to extract the td objects from it and keep it in an array to allow for sorting and reverse ordering.

//Convert the jQuery object into an array
   var columnArray=[];
   $.each(column,function(index){
    columnArray[index]=$(this);
   });

In this widget, since we want the sorting to be reversed on consecutive clicks, i am going to use a small technique to save resorting every time the header is clicked. What I will do is to store a sortstatus when an unsorted column header is clicked. I will do this using the data() function of jQuery. So, if a header is clicked, and it does not already have the sortstatus value or if it is set to false, we will sort the data. If the value is set to true, then it indicates that this is a consecutive click, and all we need to do is to reverse current order of the rows.

But what this also means is that when a column header is clicked upon, i must clear the sortstatus on the other column headers so that they can be resorted when clicked upon.

This is how I do it

//Get the heading
   var $currentHeading=$(srcDomHeader);
   
   if($currentHeading.data('sortstatus')){
    //If the column is already sorted, just reverse the rows
    columnArray.reverse();
   }
   else{
    //If not already sorted
    
    //Clear the sort status on all the other columns
    $currentHeading.siblings().data('sortstatus',false);

//More code for sorting goes here
}

Once in the else, my next task will be to identify the data types for the columns. As mentioned earlier, i will handle only string and numeric values here. I save the identified datatype as a property on the columnArray variable itself.

//Determine the datatype for the column(whether string or numeric)
    $.each(columnArray,function(index){
     if(isNaN($(this).text())){
      columnArray.dataType='string';
      return false;
     }
    });
    
    if(columnArray.dataType!='string'){
      columnArray.dataType='number';
    }

My next task is to sort the data, based upon the datatype for the data in the column. So, in the following code, i will cast the data to numbers wherever applicable, and then sort the 'td' elements.

//Sort the array using Arrays.prototype.sort by passing a custom function
    columnArray.sort(function(a,b){
     var value1='';
     var value2='';
     if(columnArray.dataType=='string'){
      value1=$(a).text();
      value2=$(b).text();
     }
     else{
      value1=parseFloat($(a).text());
      value2=parseFloat($(b).text());
     }
     return (value1<value2)?-1:(value1==value2?0:1);
    });

Once this is done, my column array now contains the selected 'td' elements in a sorted order.

Now i must add the sorting status to the header

//Set the sort status on the current column heading, 
    //which can be used later to toggle the sorting
    $currentHeading.data('sortstatus',true);


All i now need to do is to manipulate the rows of the respective 'tr' elements for the sorted 'td' elements.

//Append the rows to the table body for each td, thereby sorting them
   $.each(columnArray,function(index){
    var column=$(options.body,$domTable).append($(this).parent());
   });

Once that is done, i can safely creae my widget with the following code

$.widget('ui.tablesort',tablesortPrototype);

Here is the code for the entire widget as one big blob!

(function($){
 var tablesortPrototype={
  options:{
   header:'thead',
   body:'tbody',
   sortableColumns:[0],
   sortableClass:'sortable'
  },
  _create:function(){
   console.log('create');
   var widget=this;
   var $domTable=widget.element;
   var options=widget.options;
   
   var $headings=$('th',options.header);
   
   //For each column index as specified in the sortable columns, add a click event handler
   //and the necessary css
   $.each(options.sortableColumns,function(index){
   
    var columnIndex=this;
    $headings.eq(columnIndex).click(function(){
     widget._toggleSort(columnIndex, this);
    })
    .addClass(options.sortableClass);
    
   });
  },
  _init:function(){
   console.log('init');
  },
  _toggleSort:function(columnIndex,srcDomHeader){
   
   var widget=this;
   var $domTable=widget.element;
   var options=widget.options;
   
   //Get the column which has to be sorted, i.e. get all the td elements of that particular column
   var column=$(options.body,$domTable).find('tr>td:nth-child('+(columnIndex+1)+')');
   
   //Convert the jQuery object into an array
   var columnArray=[];
   $.each(column,function(index){
    columnArray[index]=$(this);
   });
   
   //Get the heading
   var $currentHeading=$(srcDomHeader);
   
   if($currentHeading.data('sortstatus')){
    //If the column is already sorted, just reverse the rows
    columnArray.reverse();
   }
   else{
    //If not already sorted
    
    //Clear the sort status on all the other columns
    $currentHeading.siblings().data('sortstatus',false);
    
    //Determine the datatype for the column(whether string or numeric)
    $.each(columnArray,function(index){
     if(isNaN($(this).text())){
      columnArray.dataType='string';
      return false;
     }
    });
    
    if(columnArray.dataType!='string'){
      columnArray.dataType='number';
    }
    
    //Sort the array using Arrays.prototype.sort by passing a custom function
    columnArray.sort(function(a,b){
     var value1='';
     var value2='';
     if(columnArray.dataType=='string'){
      value1=$(a).text();
      value2=$(b).text();
     }
     else{
      value1=parseFloat($(a).text());
      value2=parseFloat($(b).text());
     }
     return (value1<value2)?-1:(value1==value2?0:1);
    });
    
    //Set the sort status on the current column heading, 
    //which can be used later to toggle the sorting
    $currentHeading.data('sortstatus',true);
    
   }
   
   //Append the rows to the table body for each td, thereby sorting them
   $.each(columnArray,function(index){
    var column=$(options.body,$domTable).append($(this).parent());
   });
  }
 };
 
 $.widget('ui.tablesort',tablesortPrototype);
 
})(jQuery);


Here is how I set up my tablesort widget on my table

$(function(){
   $('#movies').tablesort({sortableColumns:[0,1]});
  });

Yay! Now we have a very very simple table sorter in less than 100 lines of code. jQuery makes it so easy!

Try out the code on your own table or on the table that I showed earlier, click on the column headings and see the effect. Of course, if there is a bug, let me know. If not, I would welcome suggestions on how this can be enhanced, or improved, or simplified.

After all, we all love a KISS(Keep It Simple, Stupid)!

Happy Programming :)
Signing Off
Ryan

No comments: