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:
- Ruby 1.8.6
- Rails 2.3.5
- The Haml gem.
- Ryan Bates’ nifty generators.’
- SQLite database.
Get Started
To create the app:
rails dynalists cd dynalistsNo 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 --hamlWe’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 startNext, 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:
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. 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.
= 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.
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 endBasically, 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 = [] endAll 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",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 …bject => @person
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 --"
An article by









[...] 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…