Dynamic Select Boxes – Ruby on Rails 3
Over 4 years ago, I wrote a tutorial for doing dynamic (cascading) select boxes.
Still getting comments and emails to this day. Mostly asking how to get this working with rails 3, which has moved from prototype to jquery.
So here's a tutorial for getting 3 select boxes to trigger updates for each other.
First set things up:
# create new rails app | |
rails new dynamic_select_boxes -m https://raw.github.com/RailsApps/rails3-application-templates/master/rails3-haml-html5-template.rb | |
# create models: | |
rails g model genre name:string | |
rails g model artist name:string genre_id:integer | |
rails g model song title:string artist_id:integer | |
rake db:migrate |
I just used the html5 haml twitter bootstrap, etc template. Really useful.
If you need data, here's what I used - put this in your db/seeds.rb file:
3.times do |x| | |
genre = Genre.find_or_create_by_name(:name => "Genre #{x}") | |
3.times do |y| | |
artist = Artist.find_or_create_by_name(:name => "Artist #{x}.#{y}", :genre => genre) | |
3.times do |z| | |
Song.find_or_create_by_title(:title => "Song #{x}.#{y}.#{z}", :artist => artist) | |
end | |
end | |
end |
Next, setup your model associations:
# app/models/artist.rb | |
class Artist < ActiveRecord::Base | |
belongs_to :genre | |
has_many :songs | |
attr_accessible :genre_id, :name, :genre | |
end | |
# app/models/genre.rb | |
class Genre < ActiveRecord::Base | |
attr_accessible :name | |
has_many :artists | |
has_many :songs, :through => :artists | |
end | |
# app/models/songs.rb | |
class Song < ActiveRecord::Base | |
belongs_to :artist | |
attr_accessible :artist_id, :title, :artist | |
end |
Genres have many artists.
Artists have many songs.
Genres have many songs through artists.
I'm just using a home controller to setup variables for the index page as well as setup variables for use in the dynamic updating:
# app/controllers/home_controller.rb | |
class HomeController < ApplicationController | |
def index | |
@genres = Genre.all | |
@artists = Artist.all | |
@songs = Song.all | |
end | |
def update_artists | |
# updates artists and songs based on genre selected | |
genre = Genre.find(params[:genre_id]) | |
# map to name and id for use in our options_for_select | |
@artists = genre.artists.map{|a| [a.name, a.id]}.insert(0, "Select an Artist") | |
@songs = genre.songs.map{|s| [s.title, s.id]}.insert(0, "Select a Song") | |
end | |
def update_songs | |
# updates songs based on artist selected | |
artist = Artist.find(params[:artist_id]) | |
@songs = artist.songs.map{|s| [s.title, s.id]}.insert(0, "Select a Song") | |
end | |
end |
Now the view just has the 3 select boxes and the unobtrusive javascript (triggered onchange) to make the ajax calls for updating:
# app/views/home/index.html.haml | |
= collection_select(nil, :genre_id, @genres, :id, :name, {:prompt => "Select a Genre"}, {:id => 'genres_select'}) | |
%br | |
= collection_select(nil, :artist_id, @artists, :id, :name, {:prompt => "Select an Artist"}, {:id => 'artists_select'}) | |
%br | |
= collection_select(nil, :song_id, @songs, :id, :title, {:prompt => "Select a Song"}, {:id => 'songs_select'}) | |
:javascript | |
$(document).ready(function() { | |
$('#genres_select').change(function() { | |
$.ajax({ | |
url: "#{update_artists_path}", | |
data: { | |
genre_id : $('#genres_select').val() | |
}, | |
dataType: "script" | |
}); | |
}); | |
$('#artists_select').change(function() { | |
$.ajax({ | |
url: "#{update_songs_path}", | |
data: { | |
artist_id : $('#artists_select').val() | |
}, | |
dataType: "script" | |
}); | |
}); | |
}); |
We need our rjs files for updating the select boxes, one for the songs (when artist changes) and one for the artists and songs (when genre changes):
# app/views/home/update_artists.js.haml | |
$('#artists_select').html("#{escape_javascript(options_for_select(@artists))}"); | |
$('#songs_select').html("#{escape_javascript(options_for_select(@songs))}"); | |
# app/views/home/update_songs.js.haml | |
$('#songs_select').html("#{escape_javascript(options_for_select(@songs))}"); |
Our routes are simple:
# config/routes.rb | |
DynamicSelectBoxes::Application.routes.draw do | |
get 'home/update_artists', :as => 'update_artists' | |
get 'home/update_songs', :as => 'update_songs' | |
root :to => "home#index" | |
end |
That's it.
UPDATE: Here's an erb alternative for index.html.
# app/views/home/index.html.haml | |
<%= collection_select(nil, :genre_id, @genres, :id, :name, {:prompt => "Select a Genre"}, {:id => 'genres_select'}) %> | |
<br/> | |
<%= collection_select(nil, :artist_id, @artists, :id, :name, {:prompt => "Select an Artist"}, {:id => 'artists_select'}) %> | |
<br/> | |
<%= collection_select(nil, :song_id, @songs, :id, :title, {:prompt => "Select a Song"}, {:id => 'songs_select'}) %> | |
<script> | |
$(document).ready(function() { | |
$('#genres_select').change(function() { | |
$.ajax({ | |
url: "<%= update_artists_path %>", | |
data: { | |
genre_id : $('#genres_select').val() | |
}, | |
dataType: "script" | |
}); | |
}); | |
$('#artists_select').change(function() { | |
$.ajax({ | |
url: "<%= update_songs_path %>", | |
data: { | |
artist_id : $('#artists_select').val() | |
}, | |
dataType: "script" | |
}); | |
}); | |
}); | |
</script> |
And the js.haml can be converted to js.erb by taking #{...} and converting to <%= ... %> :
# app/views/home/update_artists.js.haml | |
$('#artists_select').html("<%= escape_javascript(options_for_select(@artists)) %>"); | |
$('#songs_select').html("<%= escape_javascript(options_for_select(@songs)) %>"); | |
# app/views/home/update_songs.js.haml | |
$('#songs_select').html("<%= escape_javascript(options_for_select(@songs)) %>"); |