Jul
11
2010

Dynamic Select Lists with Ruby on Rails and AJAX

I use Ruby on Rails in many of my cloud computing consulting engagements. Having come from a DBA/system administrator background, I most often struggle with user interface things like AJAX and CSS. This blog post is about one of the most recent UI challenges I overcame: how to create two related select lists such that when the user selects an item in one list the UI dynamically updates the other select list. I didn’t like any of the other tutorials I found much, so I rolled my own. I hope this post is helpful to others.

The Setup

For this tutorial, the example schema will be three simple tables: people, countries, and states. The goal is to make the New/Edit Person form work so that when the user selects a Country from the select list, the States list automatically updates to list states that are only in that country.

Requirements

To complete the steps in this tutorial, I’m using:

Get Started

To create the app:

rails dynalists
cd dynalists
No database configuration is necessary unless you aren’t using the default SQLite. But we will need to add support for Haml. To do this, edit config/environment.rb and add the following line in the initializer block:
config.gem "haml"
Next, create a simple app layout using the nifty_layout generator:
script/generate nifty_layout --haml

Scaffolding

Now create the three simple tables for the tutorial with the nifty_scaffold generator:

script/generate nifty_scaffold country name:string --haml
script/generate nifty_scaffold state name:string country_id:integer --haml
script/generate nifty_scaffold person name:string country_id:integer state_id:integer --haml
We’re not going to get bogged down in migration editing in this post, so just create and migrate the database to continue.
rake db:create
rake db:migrate

Models

Edit your three model files to add some associations. They should end up as follows:

app/model/country.rb

class Country < ActiveRecord::Base
 attr_accessible :name
 has_many :states
 has_many :people
end

app/model/state.rb

class State < ActiveRecord::Base
 attr_accessible :name, :country_id
 belongs_to :country
 has_many :people
end

app/model/person.rb

class Person < ActiveRecord::Base
 attr_accessible :name, :country_id, :state_id
 belongs_to :country
 belongs_to :state
end

View Modifications

The first version of our views require just a few simple modifications that address the associations between the models. In the form partials, add some select lists for the associations, and in the index and show views, display the names of states and countries where appropriate (rather than IDs). Altogether, the edits are minimal and should take you only a couple of minutes to complete. When you are done, the following views should match these listings. (All of my views use Haml, so be careful with your indentations.)

app/views/states/_form.html.haml

- form_for @state do |f|

 = f.error_messages
 %p
  = f.label :name
  %br
  = f.text_field :name
 %p
  = f.label :name
  %br
  = f.collection_select :country_id, @countries, :id, :name
 %p
  = f.submit "Submit"

app/views/states/index.html.haml

- title "States"
%table
 %tr
  %th Name
  %th Country
 - for state in @states
 %tr
  %td= h state.name
  %td= h state.country.name
  %td= link_to 'Show', state
  %td= link_to 'Edit', edit_state_path(state)
  %td= link_to 'Destroy', state, :confirm => 'Are you sure?', :method => :delete
%p= link_to "New State", new_state_path

app/views/states/show.html.haml

- title "State"

%p

 %strong Name:
 =h @state.name
%p
 %strong Country:
 =h @state.country.name
%p
 = link_to "Edit", edit_state_path(@state)
 |
 = link_to "Destroy", @state, :confirm => 'Are you sure?', :method => :delete
 |
 = link_to "View All", states_path

app/views/people/_form.html.haml

- form_for @person do |f|
 = f.error_messages
 %p
  = f.label :name
  %br
  = f.text_field :name
 %p
  = f.label :country
  %br
  = f.collection_select :country_id, @countries, :id, :name, :prompt => "-- Select a country --"
 %p
  = f.label :state
  %br
  = f.collection_select :state_id, @states, :id, :name, :prompt => "-- Select a state --"
 %p
  = f.submit "Submit"

app/views/people/index.html.haml

- title "People"
%table
 %tr
  %th Name
  %th Country
  %th State
 - for person in @people
 %tr
  %td= h person.name
  %td= h person.country.name
  %td= h person.state.name
  %td= link_to 'Show', person
  %td= link_to 'Edit', edit_person_path(person)
  %td= link_to 'Destroy', person, :confirm => 'Are you sure?', :method => :delete
%p= link_to "New Person", new_person_path

app/views/people/show.html.haml

- title "Person"
%p
 %strong Name:
  =h @person.name
%p
 %strong Country:
 =h @person.country.name
%p
 %strong State:
 =h @person.state.name
%p
 = link_to "Edit", edit_person_path(@person)
 |
 = link_to "Destroy", @person, :confirm => 'Are you sure?', :method => :delete
 |
 = link_to "View All", people_path

Controller Modifications

The new select lists in the state and person form partials reference objects that don’t exist with the existing controllers. To deal with this, edit the following controller/actions so that they provide the necessary data.

app/controllers/states_controller.rb

new action

def new
 @state = State.new
 @countries = Country.all
end

edit action

def edit
 @state = State.find(params[:id])
 @countries = Country.all
end

app/controllers/people_controller.rb

new action

def new
 @person = Person.new
 @countries = Country.all
 @states = State.all
end

edit action

def edit
 @person = Person.find(params[:id])
 @countries = Country.all
 @states = State.all
end

Add Some Lookup Data

Fire up the server.

script/server start
Next, add some countries and states to your database using the URLs /countries and /states. To duplicate subsequent steps in this tutorial, add the following countries:

  • United States
  • Canada

and states for the United States:

  • North Carolina
  • Florida

and states for Canada:

  • Quebec
  • Ontario

The country and state forms should appear as follows:

A new country.

A new state.

The Default People Form and the Problem

The people form is functional as it is now, but users can create illegal country/state combos, as the following figure illustrates.

Not good ...

It would be better if after the user selects a country, the states select list is updated to reflect the states in the selected country. Here’s how I did this.

Add AJAX Support

The final form uses AJAX to implement dynamic changes to the form, specifically the AJAX support provided by Prototype. To add prototype’s framework to the app, add the highlighted line to the head section of app/views/layouts/application.html.haml:
...
%head
 %title
  = h(yield(:title) || "Untitled")
 %meta{"http-equiv"=>"Content-Type", :content=>"text/html; charset=utf-8"}/
 = stylesheet_link_tag 'application'
 = javascript_include_tag 'prototype'
 = yield(:head)
...

Person Form Modifications

Next, modify the person form to:
  • encapsulate the states select list within a div
  • add JavaScript that watches for changes to the country select list. Here’s the modified form.
- form_for @person do |f|
 = f.error_messages
 %p
  = f.label :name
  %br
  = f.text_field :name
 %p
  = f.label :country
  %br
  = f.collection_select :country_id, @countries, :id, :name, :prompt => "-- Select a country --"
 %p
  = f.label :state
  %br
  #states_div
   = f.collection_select :state_id, @states, :id, :name, :prompt => "-- Select a state --"

  = observe_field :person_country_id, :url => {:action => "update_state_div"}, :with => :person_country_id

 %p
  = f.submit "Submit"
To add the necessary JavaScript, the form uses the rails prototype helper observe_field.
  • The first parameter for observe_field specifies the field to watch: in this case, rails builds the form with the select list for countries using the name “person_country_id”, so that’s what the parameter is set to.
  • The second parameter for observe_field specifies the action to perform: in this case, the JavaScript calls the action of the current model’s controller (person_controller) named update_states_div (the next section adds this new action to the controller).
  • The third parameter passes the selected value of the person_country_id select list to the action so that it can use it in its logic.
I’ve skipped over defaults for other parameters of observe_field. See the rails documentation for more information.

People Controller Modifications

The JavaScript in the form expects the app/controllers/people_controller.rb references an action named update_states_div. And here’s that new action to add to this controller:
def update_state_div
 @states = State.find(:all, :conditions => ["country_id = ?", params[:person_country_id]])
 respond_to do |format|
   format.html
   format.js
 end
end
Basically, the action finds the states that correspond to the selected country and uses respond_to to handle JavaScript requests. Because the action does not include the Javascript to render, Rails expects an .rjs template to exist for the called action (update_state_div), which the next section shows. But before continuing, make a slight modification to the new action so that it looks as follows.
def new
 @person = Person.new
 @countries = Country.all
 @states = []
end
All that’s different is @states is assigned an empty array rather than an array of states. That way, the new form won’t have any states for the user to pick from until the user selects a country.

Add the RJS Template

The update_states_div expects a corresponding app/views/people/update_states_div.rjs template file to exist that includes the JavaScript to execute. And here it is, just one line of code:
page.replace_html :states_div, :partial => "states_select_list", :o bject => @person
All that this call does is replace the inner HTML of DOM element with the id states_div with the contents of the partial states_select_list, and passes the current person object along for the ride. That partial doesn’t exist yet. And so …

Add the States Select List Partial

All that the app/views/people/_states_select_list.html.haml partial needs is the HTML to replace the initial select list in the form. And here it is, again just one line of code:
= collection_select :person, :state_id, @states, :id, :name, :prompt => "-- Now select a state --"

The New, Dynamic Form

OK, everything should be working now, and the new form should dynamically update the states list when you select a country.

A good person with a valid country/state combo.

Summary

Once you know what you are doing, Ruby on Rails makes it easy to incorporate AJAX into your app so that you can do interesting things. With just 11 relatively simple lines of new code (two in the form, seven in the controller, a one line partial, and a one line RJS template), the form’s dynamic select lists make it much more user-friendly. Not bad for a database guy, eh?

About the Author: Steve Bobrowski

3 Comments + Add Comment

  • [...] This post was mentioned on Twitter by The Cloud View. The Cloud View said: cool new dynamic select lists tutorial, #ror, #ajax over at http://bit.ly/ajhYjE [...]

  • This is a great tutorial! Thanks.

    To make it even better, I would add two things: - A note insisting on the name convention (especially for the nested forms), in your example person_country_id instead of country_id - Use the partial also in the view file, not only for the AJAX call to keep it DRY

    Gam

  • Thanks for sharing, this is a fantastic blog post.Really thank you! Will read on…

Search