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.
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.
To complete the steps in this tutorial, I’m using:
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
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
Edit your three model files to add some associations. They should end up as follows:
class Country < ActiveRecord::Base attr_accessible :name has_many :states has_many :people end
class State < ActiveRecord::Base attr_accessible :name, :country_id belongs_to :country has_many :people end
class Person < ActiveRecord::Base attr_accessible :name, :country_id, :state_id belongs_to :country belongs_to :state end
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.)
- 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"
- 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
- title "State"
%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
- 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"
- 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
- 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
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.
def new @state = State.new @countries = Country.all end
def edit @state = State.find(params[:id]) @countries = Country.all end
def new @person = Person.new @countries = Country.all @states = State.all end
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
and states for the United States:
- North Carolina
and states for Canada:
The country and state forms should appear as follows:
The Default People Form and the ProblemThe 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 SupportThe 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:
Person Form ModificationsNext, modify the person form to:
- encapsulate the states select list within a div
- 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 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.
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.
page.replace_html :states_div, :partial => "states_select_list", bject => @personAll 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 PartialAll 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 --"