#88 Dynamic Select Menus (revised)
- Download:
- source codeProject Files in Zip (126 KB)
- mp4Full Size H.264 Video (16.8 MB)
- m4vSmaller H.264 Video (8.8 MB)
- webmFull Size VP8 Video (10.8 MB)
- ogvFull Size Theora Video (20.8 MB)
Below is a form for creating a new Person record. This form has two dropdown menus, one for selecting the person’s country and one for their state or province.
This second menu contains every state, province or county for every country in the world so it’s very long which makes it difficult to select the correct option. This form would be much better if the second menu was filtered when the user selected a country from the first so that only the relevant items were shown.
Grouping The State Options
Let’s start by taking a look at the template that renders this form.
<h1>New Person</h1> <%= form_for @person do |f| %> <div class="field"> <%= f.label :name %><br /> <%= f.text_field :name %> </div> <div class="field"> <%= f.label :country_id %><br /> <%= f.collection_select :country_id, Country.order(:name), :id, :name, include_blank: true %> </div> <div class="field"> <%= f.label :state_id, "State or Province" %><br /> <%= f.collection_select :state_id, State.order(:name), :id, :name, include_blank: true %> </div> <div class="actions"><%= f.submit %></div> <% end %>
The two dropdown menus are populated by using collection_select
which is the usual way of populating a belongs_to
association in a form. In our application a Person
belongs both to a Country
and a State
.
We want to filter out the states that are shown depending on the Country
that is selected and we’ll do this all on the client with JavaScript, but right now there’s no way for JavaScript to work our which states belong to a given country. We’ll need to give a little more information about the states and a good way to do this is through a grouped menu.
Rails provides a grouped_collection_select
method which will do just this for us. This method takes quite a few arguments so it’s worth taking a look at the documentation to see exactly how it works. Bear in mind though that if you’re using form_for
and calling this method through a form builder that the first argument isn’t used and needs to be left out.
We’ll change our state collection_select
to use grouped_collection_select
instead.
<%= f.grouped_collection_select :state_id, Country.order(:name), :states, :name, :id, :name, include_blank: true %>
When we reload the page now the states are grouped by their country.
This not only provides a nicer user experience but it means that there’s enough information in the dropdown list for JavaScript to determine which states belong to each country.
Adding The JavaScript
We can now start writing the code needed to write the filter which we’ll write in CoffeeScript.
jQuery -> states = $('#person_state_id').html() console.log(states) $('#person_country_id').change -> country = $('#person_country_id :selected').text() options = $(states).filter("optgroup[label=#{country}]").html() console.log(options) if options $('#person_state_id').html(options) else $('#person_state_id').empty()
In this script we first check that the DOM has loaded. Once it has we get the states dropdown by its id
, which is person_state_id
, and copy its HTML (including all the options it holds) into a variable. This is so that we have a copy of the full contents of the dropdown when we change it.
We need to change the states that are displayed whenever the user changes the country dropdown so we’ve added a function that runs when its change event fires. In this function we get the text of the selected item using jQuery’s :selected
selector and store it in a country
variable.
Next we need to filter the states so that only the selected country’s states are shown. We can use filter
here to get the states for the selected country. The states
variable holds the countries and states as a string of HTML a part of which is shown below so finding the correct optgroup
by its label and getting its contents will filter the list as we want.
<optgroup label="Australia"> <option value="173">Australian Capital Territory</option> <option value="174">Northern Territory</option> <option value="175">New South Wales</option> <option value="176">Queensland</option> <option value="177">South Australia</option> <option value="178">Tasmania</option> <option value="179">Victoria</option> <option value="180">Western Australia</option> </optgroup>
If any options are found we populate our state dropdown with them, otherwise we empty it.
We can try our form out now to see if it works. When we select a country, say Australia in the first dropdown, the second will now show just the states for that country. If we select a country with no states the second dropdown will be empty.
It would be better if the second dropdown disappeared completely when the user selects a country that has no states. This is an easy fix to implement.
jQuery -> $('#person_state_id').parent().hide() states = $('#person_state_id').html() console.log(states) $('#person_country_id').change -> country = $('#person_country_id :selected').text() options = $(states).filter("optgroup[label=#{country}]").html() console.log(options) if options $('#person_state_id').html(options) $('#person_state_id').parent().show() else $('#person_state_id').empty() $('#person_state_id').parent().hide()
The second dropdown will now be hidden by default and only shown when the user selects a country that has states.
Escaping Country Names
A country with special characters in its name, especially one with a single quote in its name, say Côte d’Ivoire, could cause a problem with our JavaScript code as we’re embedding the name directly into a jQuery selector. It’s sensible to escape these names so we’ll alter our CoffeeScript code.
jQuery -> $('#person_state_id').parent().hide() states = $('#person_state_id').html() console.log(states) $('#person_country_id').change -> country = $('#person_country_id :selected').text() escaped_country = country.replace(/([ #;&,.+*~\':"!^$[\]()=>|\/@])/g, '\\$1') options = $(states).filter("optgroup[label=#{escaped_country}]").html() console.log(options) if options $('#person_state_id').html(options) $('#person_state_id').parent().show() else $('#person_state_id').empty() $('#person_state_id').parent().hide()
Our dynamic menu will now work with any country name we select.
That’s it for this episode. It’s interesting to compare the code we’ve written here with the code from the original episode 88 where we generated JavaScript dynamically. This solution is less obtrusive and works even when the user has JavaScript disabled in their browser.