Tuesday, 19 June 2007

Slice and Dice

I've been playing around with sorting and scoping to neatly and easily sort and filter an index page. I'm finally happy with a generic set of scoping/sorting methods I can quickly apply to a new resource. It uses the autoscope plugin I discussed earlier, as well as the smart column sorting I played with.

Scope up

To start with, drop some scoping into your model object - and add the three required methods below (with suitably appropriate modifications for your purposes).

  auto_scope \
       :active => {:find => {:conditions => ['destroyed_at IS NULL']}},
       :archived => {:find => {:conditions => ['destroyed_at IS NOT NULL']}}

  # Available scopes - shouldn't this be available via autoscope?
  def self.scopes
    [:all, :active, :archived]
  end
  # Defaults (put into the model object)
  def self.default_scope
    :active
  end
  def self.default_sort
    '(destroyed_at IS NULL) DESC, login ASC'
  end

Next, drop these methods into your application.rb

  # Generate a quick-and-dirty description of the chosen sort order (for displaying in the template)
  def sort_desc
    "#{params[:dir] == 'down' ? 'descending' : ''} by #{params[:col_name] || params[:col]}"
  end
  # Generates a SQL order-by snippet based on requested sort criteria (or given default). 
  #
  # Adapted from the following blog post:
  # http://garbageburrito.com/blog/entry/447/rails-super-cool-simple-column-sorting
  def sort_order(model, default)
    orderby = "#{params[:col]} #{params[:dir] == 'down' ? 'DESC' : 'ASC'}"
    return sort_desc, orderby
  end

  # Uses model scoping to generate a scoped subset of the required objects
  # for use in the index view.
  # Returns a sorted list of the object .
  # Assumes existance of three methods on the model: 
  # scopes:: returns an array of acceptable scopes for this model
  # default_scope:: returns the scope to use when none has been selected
  # default_sort:: represents an SQL-appropriate string for this model
  #   representing the default way of sorting this model object
  def scoped_search(model, scope)
    scope = model.default_scope unless model.scopes.include?(scope.to_sym)
    sort_desc, orderby = sort_order(model, model.default_sort)
    # a description of the search/sort to display in the view
    filter_desc = "#{scope.to_s} #{model.name.pluralize.downcase} #{sort_desc}"
    return filter_desc, model.find(:all, :order => orderby) if scope.to_sym == :all
    return filter_desc, model.send(scope.to_sym).find(:all, :order => orderby)
  end

Using it is now easy. Drop something like this into your controller and call on it for all your collection-based actions.

  # applies user-specified filters and sorting to the specified collection
  def filter_users
    scope = params[:user_scope].to_sym if params[:user_scope]
    @filter_desc, @users = scoped_search(User,scope)
  end

If you need to paginate, you can always paginate-collection, or add the code into the scoped_search function.

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.

Friday, 8 June 2007

Sliding Door Tabs take 2

So, a while back I wrote a quick widget for making sliding door tabs. But it wasn't enough, I wanted *MORE*. I wanted them to be responsive to conditions.

For example, I have a resource User, and I want the "users" tab to stay open no matter which user page I'm on (index, show, edit etc)... *unless*, of course, it happens also to be the "my account" page (ie I'm viewing myself)... for which I already have a tab. This is a cut above just being "on" if we happen to perfectly match the current url.

So I rewrote the helper methods. Now my tabs come with optional separators (that can be independantly styled), and with the ability to pass in a conditional to override whether or not to show the tab as "on".

  def make_tab(t)
    # use the conditional if one was passed - default to current page 
    cond = t.has_key?(:cond) ? t[:cond] : current_page?(t[:options])
    content_tag "li", link_to(t[:name], url_for(t[:options])),
                             :class => (cond ? 'current' : nil)
  end

  # Builds a tab that is just a separator
  def make_separator
    "<li class='separator'></li>"
  end

  # Make tablist out of a given set of tabs.
  def build_tablist(tabs, do_sep = true)
    list = "<ul>"
    tabs.each_with_index do |t,i|
      list << make_tab(t)
      list << make_separator if do_sep && i != tabs.size - 1
    end
    list << "</ul>"
    list
  end

And an example of use (that assumes existance of restful_auth)

<div id="topnav" class="tabNav">

    <% # standard set of tabs
      tabs = [] 
      if logged_in?
        tabs << {:name => 'dashboard', :options => dashboard_url}
        tabs << {:name => "my account", :options => user_path(current_user)}
        if current_user.is_admin?
          tabs << {:name => "widgets", :options => widgets_path, 
            :cond => (current_controller?(hash_for_widgets_path))}
          tabs << {:name => "users", :options => users_path, 
            :cond => (current_controller?(hash_for_users_path) && 
                      !current_page?(user_path(current_user)))}
        end
        tabs << {:name => 'logout', :options => logout_url}
      else
        tabs << {:name => 'login', :options => login_url}
        tabs << {:name => 'forgot password', :options => forgot_password_url}
      end
-%>
      
  <!-- tabbed browsing of main site areas --> 
  <%= build_tablist tabs, false -%>
</div>

Note that it also uses a convenience function: "current_controller?" This is below and could almost certainly be done better... but it currently serves to check if the given url-options match the current controller.

  # determines if the given set of options contains the current controller
  def current_controller?(options)
    return true if current_page?(options) # trivial case
    if options.respond_to?(:has_key?) && options.has_key?(:controller)
      return options[:controller] == controller.controller_name
    end
    # try matching it - assumes it's a string url
    options =~ /#{controller.controller_name}/
  end