Monday, January 22, 2007

Cascading Drop-downs in Rails

We were looking for a solid example of cascading dynamic drop-down select lists to use in our rails application, and found the web sorely lacking in solid examples. We found a very good start at http://www.railsweenie.com/forums/2/topics/767 but it wasn't complete enough, or didn't work entirely. So my very good buddy and co-worker Sheri and I figured this out, and got it working for our app, and wanted to document it here in the hopes that it helps someone else. Here's the nutshell version:

It's probably already there, but make sure this line is in your standard_layout.rhtml:

<%= javascript_include_tag :defaults %>

Add this function def to application_helper.rb:

def update_select_box( target_dom_id, collection, options={} )
# Set the default options
options[:text] ||= 'name'
options[:value] ||= 'id'
options[:include_blank] ||= true
options[:clear] ||= []
pre = options[:include_blank] ? [['','']] : []
out = "update_select_options( $('" << onclick="BLOG_clickHandler(this)" class="blsp-spelling-error" id="SPELLING_ERROR_5">dom_id.to_s << "'),"
out << "#{(pre + collection.collect{ |c| [c.send(options[:text]), c.send(options[:value])]}).to_json}" << ","
out << "#{options[:clear].to_json} )"
end

This calls update_select_options which needs to go into application.js:

function update_select_options( target, opts_array, clear_select_list ) {

if( $(target).type.match("select" ) ){ // Confirm the target is a select box

// Remove existing options from the target and the clear_select_list
clear_select_list[clear_select_list.length] = target // Include the target in the clear list

for( k=0;k <>
obj = $(clear_select_list[k]);
if( obj.type.match("select") ){
len = obj.childNodes.length;
for( var i=0;i <>
}
}

// Populate the new options
for(i=0;i <>
o = document.createElement( "option" );
o.appendChild( document.createTextNode( opts_array[i][0] ) );
o.setAttribute( "value", opts_array[i][1] );
obj.appendChild(o);
}
}
}

Add something like this to the form.rhtml (changing the name of the observable field as appropriate):

<%= observe_field 'item[facility_id]', :frequency => 0.5,
:update => 'location_id', :url =>
{ :controller => 'item', :action=> 'refreshLocation' },
:with => "'facility_id=' + escape(value)" %>

Add something like this to the controller:

def refreshLocation
@facilities = Facility.find(:all)
@facility = Facility.find(params[:facility_id])
@locations = Location.find_all_by_facility_id(params[:facility_id])
render :update do |page|
page << text =""> :description} )
end
end


This tidbit in the form.rhtml is the ultimate target of all this work (this is the drop down we want to refresh)






<%= select_tag "item[location_id]", options_from_collection_for_select(@locations,:id,:description) %>



If I missed any code attributions from the various sources we pieced this together from, I'm sorry. Write me and I'll make good and give attribution where appropriate.

If you have any questions about this, post 'em and I'll take my best shot at answering.

And finally, thanks Sheri! Couldn't have done it without you!

2 comments:

Anonymous said...

I got a set of cascading select boxes working after going through your example (thanks!), but wondered if you could explain what all the "clear" option stuff is about? That part I don't really get, and I still need to mess around with it more to fully understand what's going on.

Jeremy Pruitt said...

So, I'm an 8 year perl coder, 10 year sysadmin, and after 2 years in management I have to write the first forum post of my career asking for help. :)

Seriously, though, it's amazing how such a common task has almost no "right way" to do it in such a thriving, active community.

Anyways, on to my problem, of which I am 100% sure is my doing.


What's this?:
render :update do |page|
page << text =""> :description} )
end


I (think) I understand that it allows you to dump some rubyish javascript into the page, but does that actually run without throwing an error? I just get:

syntax error, unexpected '}', expecting kEND


Also I get that it updates the div tag with the id location_id, right?:
:update => 'location_id'


But does that mean I should just wrap the select box in a div tag with location_id as the id? I tried that and my select box is replaced with:
try { text =""> :description} ) } catch (e) { alert('RJS error:\n\n' + e.toString()); alert('text =\"\"> :description} )'); throw e }

And that's only if I try to quote the "render :update" I mentioned above like so:

render :update do |page|
page << text =""> :description} )
end


Thanks so much in advance!