#17 HABTM Checkboxes (revised)
Below is a page from a Rails application that displays a list of products.
A product can belong to a number of categories and there’s a many-to-many association between the
Category models. The product shown below isn’t in any categories and as yet we don’t have a way to assign categories to a product through the user interface. In this episode we’ll add the ability to assign categories through a list of checkboxes when a user edits the product.
The two models are associated using
has_many :through. Here’s the code for
class Product < ActiveRecord::Base has_many :categorizations has_many :categories, through: :categorizations end
A product has many categories through a join model called
class Categorization < ActiveRecord::Base belongs_to :category belongs_to :product end
Category model has a similar
has_many :through relationship with
The technique we’ll show you will also work for a
has_and_belongs_to_many relationship but
has_many :through is much more flexible which is why we’ve chosen to use it. One advantage is gives us is a method for accessing the
category_ids for a product. We can demonstrate this in the console by fetching a product and listing its
> p = Product.first > p.category_ids => 
We can also set category_ids through this method.
> p.category_ids = [1,2] => [1, 2]
This assigns the categories and creates the categorization records too and we can now fetch the categories for our product.
> p.categories => [#<Category id: 1, name: "Board Games", created_at: "2011-12-26 21:22:48", updated_at: "2011-12-26 21:22:48">, #<Category id: 2, name: "Clothing", created_at: "2011-12-26 21:22:48", updated_at: "2011-12-26 21:22:48">]
If we look at our product in the browser now we’ll see the categories we’ve assigned to it.
Adding Categories to The Edit Product Form
We need to modify the edit product form so that we can add and remove categories through it. Here’s the form’s template.
<%= form_for(@product) do |f| %> <% if @product.errors.any? %> <div class="error_messages"> <h2><%= pluralize(@product.errors.count, "error") %> prohibited this product from being saved:</h2> <ul> <% @product.errors.full_messages.each do |msg| %> <li><%= msg %></li> <% end %> </ul> </div> <% end %> <div class="field"> <%= f.label :name %><br /> <%= f.text_field :name %> </div> <div class="field"> <%= f.label :price %><br /> <%= f.text_field :price %> </div> <div class="actions"> <%= f.submit %> </div> <% end %>
The form currently has two fields, one for the product’s
name and one for its
price. We want to add another for the categories and this is how we do it:
<div class="field"> <% Category.all.each do |category| %> <%= check_box_tag "product[category_ids]", category.id %> <%= category.name %><br/> <% end %> </div>
In the code above we loop through the categories and display a checkbox for each one. If we were rendering a single checkbox we could use
f.check_box :category_ids but this won’t work here as
category_ids is an array and we need multiple attributes for each attribute. Instead we’ve gone for a more manual approach and used
check_box_tag. To make this work with multiple checkboxes we need to end the name with a pair of empty square brackets. This tells Rails to bundle up all of the values submitted into an array in the
params hash. When we visit the edit form for a product now the checkboxes are shown.
If we select some options and submit the form the categories will be added.
When we go back to edit the product again, however, the categories that we selected won’t be checked. We can fix this by adding a third argument to the
check_box_tag that so that the checkbox is checked if the product’s
category_id’s include that category.
<% Category.all.each do |category| %> <%= check_box_tag "product[category_ids]", category.id, @product.category_ids.include?(category.id) %> <%= category.name %><br/> <% end %>
When we edit a product now the correct checkboxes are checked.
There’s still a bug with this form, though. If we uncheck the selected checkboxes and submit the form the previously selected categories aren’t removed. This is because unchecked checkboxes don’t have their values submitted back to the server. To get around this problem we can go modify to our form to add a hidden field with the same name as the checkboxes and with a value of
<div class="field"> <%= hidden_field_tag "product[category_ids]", nil%> <% Category.all.each do |category| %> <%= check_box_tag "product[category_ids]", category.id, @product.category_ids.include?(category.id) %> <%= category.name %><br/> <% end %> </div>
Now if none of the checkboxes are checked when the form’s submitted the hidden field is included in the fields posted back to the server and the product has its categories removed.
Clickable Checkbox Labels
There’s one more issue in the edit form that we should resolve. Clicking the label next to a checkbox should act as if we’d clicked that checkbox. It’s easy to wrap the name in a
label tag, but we have to associate the label with a checkbox via its
id and currently each checkbox has the same
id. This is bad practice anyway so first we’ll modify the view template to assign each checkbox a unique id.
<% Category.all.each do |category| %> <%= check_box_tag "product[category_ids]", category.id, @product.category_ids.include?(category.id), id: dom_id(category) %> <%= label_tag dom_id(category), category.name %><br/> <% end %>
To make each checkbox’s
id unique we use the
dom_id method that Rails provides and pass it the category. This will generate an
n is the category’s
id. We can then use a
label_tag to display the category’s name and we can use
dom_id here too to associate the label with the checkbox. Now, when we reload the page, we can click the labels to toggle the checkboxes.
The code we have now works well but it’s a little complicated, especially in the view. We could extract this out into a form builder but this is out of the scope of this episode. Alternatively we could use a gem such as SimpleForm or Formtastic. SimpleForm provides a collection_check_boxes method which lets us do what we’ve done here but in a much more concise method. Formtastic has a similar check_boxes method.