Friday, February 8, 2008

How to dynamically update the contents of one select list from selections in a different list

Rails support for making AJAX requests is great, it's very easy to make simple requests perform actions on the page in the background. A common scenario where you might want to use AJAX to make your pages more dynamic is to repopulate a select list in a form when the user modifies other elements on the form. The built in capabilities of Rails seem to have some problems dealing with select boxes where multiple items can be selected while doing these AJAX requests, however.

I will provide a simple case here where you have two select boxes - one is a list of vehicle types, and the other is a list of vehicles. Instead of having one huge list of all vehicles, you might want the list of vehicles to only show vehicles that are of the selected types. Using AJAX updaters form observers, you can dynamically update the vehicle list based on the type list being changed.

For this to happen, you need to place observe_field Rails helpers in your view for the page, to observe for changes to the field. Typically you can specify here what the URL of the action to call is, but observe_field has some trouble sending parameters for select boxes with multiple selections, so it will instead call a Javascript function. In the Javascript function (which isn't necessary if you aren't using a select multiple), you need to manually make create an Ajax.updater to update the "results" selection box items, creating the query string dynamically based on the selected types. Then you need an action in your controller to respond to the Ajax request, and a view for this action to fill in the correct values to the select box.

I created a simple example with a page where you can select what types of vehicles you want displayed (car, SUV, or truck), and whenever you change the type, the list of vehicles will be updated.

First, add two new actions to your controller. The first is the regular action to get the page for the first time, which I'll call show_list. Within this action, the list of types (from the constant TYPES) gets put into a variable for later loading. The second action, update_list, is what the AJAX request will call to update the vehicle list with selected types. Here is the code for the controller:
# these are just some constants.  Most likely you'll want to find things 
# in your database to return.
TYPES = {"car" => [["Honda Accord", "1"], ["Scion Tc", "2"],
["Ford Focus", "3"], ["BMW M6", "4"]],
"suv" => [["Jeep Wrangler", "5"], ["Ford Explorer", "6"],
["Toyota Highlander", "7"]],
"truck" => [["Ford F150", "8"], ["Dodge Ram", "9"]] }

# this is the action to get the page for the first time.  
def show_list
@types_all = [["Car", "car"], ["SUV", "suv"], ["Truck", "truck"]]
end

# this is the action that the AJAX request will call.  types[] will be
# specified in the parameters list, containing the different vehicle
# types that the user has selected.
def update_list
@vehicles_all = []
if params[:types]
params[:types].each do |t|
@vehicles_all = @vehicles_all.concat(TYPES[t])
end
end
# this part is really important, if you don't tell it not to render the 
# layout, the area where your selection box is will have the whole 
# controller's layout around it.
render :layout => false
end


Next, create a new rhtml file in your controller's view folder called show_list.rhtml. This is the initial page that gets loaded:
<% # this is a function I wrote in my application controller to load a
# Javascript file.  show_list.js must be loaded.
add_javascript_file("show_list", false) -%>

<% # I don't actually have anything to respond to this form, it's just to show
# select boxes.
form_tag({}, {:name => "show_list", :id => "show_list"}) do -%>

<p>Vehicle Types:
<%= select_tag "types[]", options_for_select(@types_all, []),
{:size => 3, :multiple => true, :id => "types[]"} %>
</p>
<% # the observe_field must call a function and not directly call a method
# on the controller.  Select form elements with multiple elements do not
# get correctly passed as parameters to the controller action, so we
# have to manually do it in a Javascript function.
# with any other type of field (including single selects), you can
# specify :url as a parameter and it will call that url.
# if :frequency is not present, a request will be made every time the
# selection is changed.  This isn't recommended, because the user may
# very quickly change selections.  0.5 ensures that a request is made
# at most every half second while the user is changing the selection.
-%>
<%= observe_field "types[]",
:function => "typeChanged('vehicle_list', $F('types[]'))",
:frequency => 0.5 %>

<% # the hourglass will show while the browser is waiting for a response
# from the server, to let the user know something is going on.
-%>
<div style="display:none; text-align:center;" id="hourglass">
<%= image_tag "ani-busy.gif", {:width => 32, :height => 32} %>
</div>

<p>Vehicles from selected types:
<% # the whole select tag must be in a div.  This is what will be updated.
# If the browser is Firefox, you can put the select tag ID as the area
# to update in the AJAX call, and return a list of options in your rhtml,
# but this will NOT work in Internet Explorer.
-%>
<div id="vehicle_list">
<%= select_tag "vehicles[]", "", {:size => 9, :multiple => true} %>
</div>
</p>

<% end -%>

Next, create another new rhtml file in your controller's view folder called update_list.rhtml. This is what gets sent to the specific div section that gets updated whenever the type selection list gets updated:
<% # render just the select tag here
-%>
<%= select_tag "vehicles[]", options_for_select(@vehicles_all, []),
{:size => 9, :multiple => true} %>

Finally, add a javascript function to a file that gets loaded with show_list.rhtml:
function typeChanged(areaToUpdate, typesToSend) {
// Manually create a request string with parameters
var queryStr = "/node/update_list";
var started = false;
if (typesToSend) {
for (var i = 0; i < typesToSend.length; i++) {
// the first type must have ? before it to start the parameters list.
// after that, it should be prepended with & to specify an additional
// parameter.
queryStr += (started ? "&" : "?") + "types[]=" + typesToSend[i];
started = true;
}
}

// before we send the request, show the hourglass.
$('hourglass').show();

// manually send an Ajax request to update the area passed in to the
// function.  Upon completion, hide the hourglass.  You can also specify
// functions to run for onError, onSuccess, and many others.
new Ajax.Updater(areaToUpdate, queryStr,
{onComplete: function() { $('hourglass').hide(); } });
}

This may seem like a lot to add for a simple page, but it's very easy to set all this up and work with much more complicated scenarios.

2 comments:

Anonymous said...

Thanks for the post!

Is there any way in which you could display an additional select box on the fly if there was some extra nested attribute(s) for certain elements of the other select box? An example might be something like wanting to display engine options if they existed and then completely nothing ( including the select box ) if they did not exist.

Brent Sowers said...

Yeah, something like what you described would be pretty easy to do. You'd have to have logic in the update controller function (update_list in my example) to look up the items for the second select list. Then the view for the controller action, after the first select list, you would have a second select list with these options, inside of an if block. Like:
<% if @second_list_options -%>
<%= select_list ...... %>
<% end -%>