Dynamic Duos - Dependent Select Menus with JQuery and Rails
The first time I tried to apply “dynamic selection” within a Rails form - where selecting an option in one select field dynamically updates the menu options in a second select field, I had a surprisingly hard time with it. I’d watched the Railscast and read multiple tutorials, but just couldn’t crack the code.
Problem was, I was trying to use a collection of model attributes in the first collection_select
menu and a collection of model instances in the second menu, organized using the grouped_collection_select
helper. What I learned after much trial, error and a deep dive into the documentation, was that the menu options should both be collections of model instances, where the models are associated through a join table. <– tldr: central lesson of this post.
I could have benefitted from more explicit discussion of those mechanisms, one with a slightly more complex example than countries and states. Towards that end, let’s walk through the steps I took to apply this solution in my Rails application, Parkster.
SECTIONS:
- Correcting the schema
- Updating the seeds file
- Adding appropriate ActiveRecord associations to models
- Updating the form partial
- Adding jQuery, a nice alternative to Railscasts’ CoffeeScript solution
Pre-Refactor Setup
All code here is from my Rails app Parkster, a Meetup-esque app that allows users to organize games in NYC parks with friends. You can check out the full source code here.
Before the refactor, my schema was structured as follows:
You’ll notice that Parks have an #activity
attribute (ex. “Baseball Field”, “Basketball Court”), and Games have a #game_category
attribute (ex. “Baseball”, “Basketball”). Hopefully some readers’ smell test sensors are pinging here already…
The Game form lives in its own partial that’s rendered from both the create.html.erb
and edit.html.erb
. I originally utilized the SimpleForm gem with ActionView form_for helper.
<div class="game-form">
<%= simple_form_for(@game) do |f| %>
<div class="input-group input-group-lg">
<span class="input-group-addon"><%= f.label "Activity" %></span>
<%= f.select :game_category, collection: @game.game_categories.keys, class: "form-control", placeholder: "Baseball" %>
</div>
<div class="input-group input-group-lg">
<span class="input-group-addon"><%= f.label :location %></span>
<%= f.select :park_id, label: false, collection: Park.all.order("name"), class: "form-control" %>
</div>
<%= f.submit %>
<% end %>
</div>
Finally, the seeds file made a simple JSON call to NYC Open Data’s Socrata API, creating Park instances and persisting them to the database.
# seeds.rb
require 'open-uri'
json = JSON.load(open("https://data.cityofnewyork.us/resource/e4ej-j6hn.json"))
json.each do |park|
Park.create(
name: park['name'],
location: park['location'],
facility: park['type']
) unless park['type'] == 'Bathroom' # filter out parks without game facilities
end
Step 1: Update schema
We noticed above that there’s a relationship between Park #activity
and Game #game_category
, so first thing is to extract this connection into its own model and associated join table.
Run two commands from the terminal:
rails g migration CreateActivities name:string
rails g migration CreateActivityParks park_id:integer activity_id:integer
Step 2: Update seeds file
Activities and ActivityParks are derived from the Park #facility attribute, so we can efficiently create and persist instances of all three models on the fly in our seeds file. Given the limited number of unique activities/facilities, I used a simple switch statement on JSON park['type']
data.
# seeds.rb
require 'open-uri'
json = JSON.load(open("https://data.cityofnewyork.us/resource/e4ej-j6hn.json"))
json.each do |park|
park = Park.create(
name: park['name'],
location: park['location'],
facility: park['type']
) unless park['type'] == 'Bathroom' # filter out parks without game facilities
park_activity = ''
park_facility = Park.all.last.facility # most recently-created Park's facility
case park_facility
when 'Ice Skating Rinks'
park_activity = 'Ice Skating'
when 'Bocce Courts'
park_activity = 'Bocce'
when 'Basketball Courts'
park_activity = 'Basketball'
when 'Tennis Courts'
park_activity = 'Tennis'
when 'Football Fields'
park_activity = 'Football'
when 'Baseball Fields'
park_activity = 'Baseball'
when ''
park_activity = 'Other'
end
activity = Activity.find_or_create_by(:name => park_activity)
ActivityPark.create(:park_id => park.id, :activity_id => activity.id)
end
Step 3: Add ActiveRecord associations
The updated model associations are fairly straight-forward.
A Park has one Activity, through ActivityParks.
class Park < ActiveRecord::Base
has_one :activity_park
has_one :activity, through: :activity_park
has_many :games
has_many :reservations, through: :games
end
An Activity has many Parks, through (many) ActivityParks.
class Activity < ActiveRecord::Base
has_many :activity_parks
has_many :parks, through: :activity_parks
end
An ActivityPark belongs to one Park and one Activity.
class ActivityPark < ActiveRecord::Base
belongs_to :activity
belongs_to :park
end
Our migrations, associations, and seeds file are all properly updated, so now’s a good time to run rake db:reset
(shortcut for db:drop, db:create, db:schema:load, db:seed
). Now we have a schema we can work with:
Step 4: Update form partial
At this point, it was easiest for me to remove SimpleForm and use the built-in ActiveView form_for helper instead. The first select menu should use the collection_select
FormOptionsHelper:
# ActionView::Helpers::FormOptionsHelper#collection_select
collection_select(
object, method, collection, value_method, text_method, options={}, html_options={}
)
We want this menu to list each Activity by name, and send the user’s selection to the GamesController’s #create
or #update
action as part of game_params
, specifically params['game']['game_category']
. So we’ll set the method/attribute as #game_category
, collection as Activity.order(:name)
, and value_method
as #name
. I included the Bootstrap “form-control” class as part of the html_options
hash for styling purposes.
The second select menu options need to be grouped by Park #activity, so we’ll use the grouped_collection_select
FormOptionsHelper:
# ActionView::Helpers::FormOptionsHelper#grouped_collection_select
grouped_collection_select(
object, method, collection, group_method, group_label_method, option_key_method, option_value_method, options={}, html_options={}
)
This menu should list each Park by name, grouped by associated Activity, and send the user’s selection to the GamesController’s #create
or #update
action as part of game_params
, specifically params['game']['park_id']
. We’ll set the method/attribute as park_id
, collection as Activity.all
, and group by group_method #parks
(i.e. all the parks that are associated with that instance of Activity
).
<div class="game-form">
<%= form_for(@game) do |f| %>
<div class="input-group input-group-lg">
<span class="input-group-addon"><%= f.label "Activity" %></span>
<%= f.collection_select :game_category, Activity.order(:name), :name, :name, include_blank: 'Select an activity...', class: "form-control" %>
</div>
<div class="input-group input-group-lg">
<span class="input-group-addon"><%= f.label :location %></span>
<%= f.grouped_collection_select :park_id, Activity.all, :parks, :name, :id, :name, include_blank: 'Select a park...', class: "form-control" %>
</div>
<%= f.submit %>
<% end %>
</div>
Final Step: Add jQuery
On document load, we want to add a listener to the first select menu, which form_for
has given the helpful id of #game_game_category
. If that menu’s value changes (e.g. if a user makes a selection), it’ll trigger a function that grabs the selected option’s value and passes it to the second select menu, #game_park_id
, as a filter, so the menu’s optgroup
automatically filters to show just the Parks associated with the user’s selection.
$(function(){
filterParksList();
})
function filterParksList(){
var parks = $('#game_park_id').html();
$('#game_game_category').change(function(){
var selectedGameCategory = $('#game_game_category :selected').text();
var optgroup = "optgroup[label='"+ selectedGameCategory + "']";
var parkOptions = $(parks).filter(optgroup).html();
if(selectedGameCategory != 'Other'){
$('#game_park_id').html(parkOptions);
}
});
}
I made a conscious decision to leave the “Other” Activity category available to users, and in the case when “Other” is selected, we’d want users to be able to choose from all NYC parks. That’s why the jQuery doesn’t filter #game_park_id
’s optgroup
for that case.
That’s the full refactor. Your select menus are now working in tandem, filtering on change like a truly dynamic duo. Hope it was helpful - let me know in the comments below!