Sunday, August 6

Drill-down Filtering for AjaxScaffold

A couple weeks ago, I decided to start another little pet project of porting the simple little in-house project-tracking application I had written in Java for my current company over to Ruby on Rails. I did a little leg work and discovered that the AjaxScaffold plug-in offered to save me a lot of wheel reinventing. It's a slick little project and I highly recommend. However, it was missing a critical little feature set I required. I needed three little pieces of functionality tied to the columns of information displayed by the default "list" view:

1. I needed for the values in the columns, certain columns, to be hyperlinked, and when a user clicked on one of the hyperlinked values, the list would refresh/rerender itself and filter out all of the line items that didn't contain the clicked value for the associated column. For example, if I had a column named "Clients" and one of the values in that column was "Acme", then when I clicked on the Acme name the list would hide all of the other lines associated with other clients.

2. I needed for this click-to-filter feature to be reversible. For example, once you had drilled-down into only the Acme values from the Clients column, if you then again clicked on Acme the filter would undo and all of the other lines belonging to other clients would reappear.

3. Finally, I needed for the drill-downs to be aggregated. For example, once I had drilled-down into only the line items with Acme as the Client, I could also drill-down even farther by clicking on Bob in the Representatives column, leaving me with a list that contained only line items with Acme as the Client and Bob as the Representative. Likewise I could re-click Bob or Acme, in any order, to undo that particular column filter.

Clear as mud? I hope my explanation makes sense. Anyhow, AjaxScaffold does not, unfortunately, offer that functionality. Perhaps the author(s) will stumble across this essay and alleviate that situation in the near future. I'll keep my fingers crossed.

Since Ruby and Rails are just an off-hours hobby for me, I'm no expert in either, and I wasn't sure where to even start with this one, so I posted a plea for help over on the Google Groups dedicated to supporting this plug-in. The group doesn't appear to be very active, so I was shocked to get a response from Scott Brittain about ninety minutes later. Scott kindly shared with me his solution to #1 above, and that was all I needed to get the ball rolling. Less than twenty-four hours later, I managed to hack-out #2 and #3 as well.

This probably isn't going to make much sense to those of you that don't use or are unfamiliar with the guts of the code AjaxScaffold generates, but here's how I had to hack it to get the magic I desired.

Guided by Scott's initial clue, it starts in the model. Here is where you can define which values to use as columns in the list displayed to the user, as well as munge each column, which is exactly what I had to do. The setting in my model looks like this now:

@scaffold_columns = [
AjaxScaffold::ScaffoldColumn.new( self, {
:name => "Name",
:eval => "project.name"
} ),
AjaxScaffold::ScaffoldColumn.new( self, {
:name => "Assigned To",
:eval => drill_down_link( 'User', 'user_id' ),
:sort_sql => "projects.user_id"
} ),
AjaxScaffold::ScaffoldColumn.new( self, {
:name => "Deadline",
:eval => "project.deadline"
} ),
AjaxScaffold::ScaffoldColumn.new( self, {
:name => "Status",
:eval => drill_down_link( 'Status', 'status_id' ),
:sort_sql => "projects.status_id"
} ),
]

Most of that is standard AjaxScaffold code; the lines important to my enhancement are the two that look like this:

:eval => drill_down_link( 'User', 'user_id' ),

What's going to happen is that the AjaxScaffold framework is going to, as it renders each of the columns, call "eval" on the member of the ScaffoldColumn instance named, oddly enough "eval". So here is how I inject my magic, by passing in a block of code to be "eval'd". For the sake of brevity and cleanliness, I've defined this block elsewhere in a method named "drill_down_link", and here's what it looks like:

def Project.drill_down_link( model_name, column_name )
# TODO: reverse merge a default parameters hash to aggregate drilldowns
<<EOF
url_for_params = { :controller => 'projects', :action => 'list' }
[ :user_id, :status_id ].each { |param|
if params[param]
unless param.to_s.eql?( '#{column_name}' )
url_for_params.merge!( param => params[param] )
end
end
}
url_for_params.merge!( :#{column_name} => project.#{column_name} ) unless params[:#{column_name}]
link_to( #{model_name}.find( project.#{column_name} ).name, url_for( url_for_params ) )
EOF
end

Yeah. That's pretty ugly. Writing Ruby code that's going to be evaluated by other Ruby code, and worse yet I've got to inject model and variable names with substitutions using the "#{}" mechanism. It sends a chill down my spine, but I've not yet found a more elegant way around it.

So now when these special columns are "eval'd" during rendering of the list, they will display the appropriate value and hyperlink that value back to the listing passing in the parameters and values necessary to filter (or unfilter) the list, as well as propagating the values passed in by the other columns.

Note that this isn't very DRY as I've hard-coded the list of column values "[ :user_id, :status_id ]" here, and I will again in the controller, so there's some room for improvement. But for now, it all works, so I'm content, and I figured I'd go ahead and share it because it will likely not get touched again.

The only piece left is to actually apply the filtering to the list when it renders. That's done in the generated code for the controller in a method named enigmatically "component". Here's the code with my changes emphasized:

def component
@show_wrapper = true if @show_wrapper.nil?
@sort_sql = Project.scaffold_columns_hash[current_sort(params)].sort_sql rescue nil
@sort_by = @sort_sql.nil? ? "#{Project.table_name}.#{Project.primary_key} asc" : @sort_sql + " " + current_sort_direction(params)
clauses = ''
values = {}
[ :user_id, :status_id ].each { |param|
if params[param]
clauses << ( clauses.blank? ? '' : ' AND ' )
clauses << "#{param.to_s} = :#{param}"
values.merge!( param => params[param] )
end
}
conditions = values.empty? ? nil : [ clauses, values ]
# @paginator, @projects = paginate(:projects, :order => @sort_by, :per_page => default_per_page)
@paginator, @projects = paginate( :projects, :order => @sort_by, :per_page => 25, :conditions => conditions )

render :action => "component", :layout => false
end

What I do here is simply build-up a conditionals clause to be passed into the pagination method, and that's it! As you can see here I've hard-coded the parameter list again. Shame on me.

It's not pretty. It's not elegant. It's not DRY. And, it's probably not great Ruby either. But, it works, and perhaps somebody reading this will tell me how to clean it all up.

I am exceedingly grateful to the author(s) of AjaxScaffold and the helpful Scott Brittain from the forums. They, like Rails framework itself, have saved me countless hours of reinventing wheels. Thanks!

No comments: