Tuesday, 12 June 2007

Super Cool Smart Column Sorting

So, I wanted to drop in some basic column sorting without having to rip out the innards of something like streamlined. I googled and came across this blogpost on SCSCS - which is so worth it for super-cool simple column sorting...

But I'm just not satisfied by what it can do. As ever, I want more. In this example, I wanted to be able to display neato little up/down arrow widgety thingies on the column heads by giving the th the appropriate "up/down" class - so the user can see which column it's all sorting by.

Arrowy goodness

We want a way to stuff a CSS class into the table's column-heads so we can show the sorty arrows. We need to indicate two things. Firstly, when the data has been sorted by a particular column, we want to indicate "this is the current sort colum". Secondly, we want to be able to indicate the sorting direction of a column (up, down or indifferent).

So the first step is to create (or appropriate) arrow-images. I'm not going to show an example here (you can figure that out for yourself) but I'll assume you have three images: SortUp.gif, SortDown.gif (to show that the current column is sorted up or down), and SortAny.gif (a double-arrow to indicate that the column could be sorted if they want to).

Stylish stuff

Then you need to put something appropriate in your stylesheet. eg:

/* what an ordinary column-head looks like */
.dataTable th {
   background-color: grey;
   color: white;
   padding-left: 15px;
   padding-right: 4px;
}
/* colour for currently sorted column */
.dataTable th.current {
  background-color: black;
  color: white;
}
/* the sorting link */
.dataTable th a {
  color: white;
  text-decoration: none;
  display: block;
  width: 100%;
}
.dataTable th.current a {
  color: grey;
}
/* pretty colours when hovering */
.dataTable th a:hover {
  color: blue;
  background-color: grey;
}
.dataTable th:hover {
  background-color: grey;
}

/* display of up and down arrows for sortable columns */
.dataTable th.up {
  background-image: url("/images/SortUp.gif");
  background-repeat: no-repeat;
  background-position: center left;
}
.dataTable th.down {
  background-image: url("/images/SortDown.gif");
  background-repeat: no-repeat;
  background-position: center left;
}
/* display for "any" sort - eg up and down arrows */
.dataTable th.any {
  background-image: url("/images/SortAny.gif");
  background-repeat: no-repeat;
  background-position: center left;
}

Stuff to note:

  • The "current" column head is a distinctively different colour to a normal column - this makes it stand out.
  • The "hover" colours provide good feedback for users - they pick up the idea that clicking here will probably do something.
  • The anchor within the column head has display:block; and width:100%; this makes the anchor stretch to the full width of the column-head - which is nicer than having to click just the word itself.
  • There's a padding-left on the anchor columns - this gives room for the arrow images. I've specified 15px as it's big enough for the images I use - YMMV.

Classy heads

So now we need to adapt the sort_link method to make it actually use this stuff. But we don't want it just on the link - the class needs to go on the whole column-head. So write a wrapper-function that will generate the whole <th> for you (class and all):

  # generate appropriate class for current sort options
  def sortlink_class(col)
    return "any" unless params[:col] || params[:dir]
    return "current #{params[:dir] == 'up' ? 'up' : 'down'}" if params[:col] == col.to_s
    "any"
  end

  # Generates a column head tag that contains a sort link and is given the
  # appropriate class for the current sorting options.
  def column_sort_link(title, column, options = {})
    content_tag 'th', sort_link(title, column, options), 
                      :class => sortlink_class(column)
  end

  def sort_link(title, column, options = {})
    if options.has_key?(:unless)
      condition = options[:unless] 
      options.delete(:unless)
    end
    new_sort_dir = params[:dir] ==  'down' ? 'up' : 'down'
    link_to_unless condition, title, request.parameters.merge(options.merge(:col => column, :dir => new_sort_dir))
  end

So, the currently sorted column will get something like class="current up", wheras most of the columns will be class="any". You'll also note I updated the badly-named "d" and "c" to "dir" and "col" so that unsuspecting maintenance people might have a clue as to the actual purpose of the options. Self-documenting code being a Good Thing.

Finally, I've modified it to pass through any extra options to the controller by merging the options into the link. This can be used to pass through requests for special sorts, extra parameters etc.

2 comments:

ESESBEE said...

Hi
This is awesome stuff. I have a situation in my RoR project where I have models dependent on each other.

class XYZ < ActiveRecord::Base
belongs_to :zone
belongs_to :keyvalue
belongs_to :category
end

When I display XYZ, the user wants to see zone names from the zone table, etc. They also want to be able to sort the zone names in this view. Can you give me an idea how to approach this sort?

Thanks a lot
Somo

Taryn said...

Hi Esebee,

I implemented this too, but it adds quite a bit of complexity and I wa never truly happy with the results. Mainy because sorting by something other than the actual model meant I needed to sort (and paginate) over a ruby collection, rather than doing my pagination all nicely in ActiveRecord == quicker SQL.

I'm sure there would be an aternative, but we got stuck with the implementation.

In any case what we did required adding a "special_sort" tag for these
columns - which got passed into the sort_link (and thus was picked up by the
controller when the user clicked on them. Display of the column headings was
exactly the same.

The controller then went into the "pull everything out and sort it
afterwards" mode when it received this tag - and it used the name of the
column to figure out what sort to use.

Eg we had and Order which would :belong_to a user, and we'd want to display
a "user name" column. So we'd have:
<%= column_sort_link 'User', :user, :special_sort => true -%>

Then in the model we'd have a list of the allowed special columns vs the
method to use to sort_by:

def special_sorts
{'user' => {:method => lambda {|x| x.user ? x.user.email : '' },
:string => true},
'status' => {:method => lambda {|x| x.status.to_s },
:string => true},
'num_widgets' => {:method => lambda {|x| x.widgets.size } },
}
end


then in the controller it was something along the line of:

if params[:special_sort]
@orders = Order.find(:all)
opts = Order.special_sorts[params[:col]]
@orders.sort do |a,b|
obj_cmp(a,b,dir, opts[:method], opts[:string] ? opts[:string] : false)
end
end

Note I had to separate string and numeric sorting - dpending on what we were sorting... Note also I used the sorting for related objects, and also generated things (eg the "displayable status field").

Hope this helps.