Has Many Through Checkboxes in Rails 3.x, 4.x and 5

Steps

Step 1

Let's generate the category model with name attribute.

rails g model category name:string

Step 2

Create product model and products controller.

$ rails g scaffold product name:string price:decimal

The products table has name and price fields.

Step 3

Define the habtm association in product and category models.

class Product < ApplicationRecord
  has_and_belongs_to_many :categories
end
class Category < ApplicationRecord
  has_and_belongs_to_many :products
end

Step 4

Change the migration file:

class CreateProducts < ActiveRecord::Migration[5.0]
  def change
    create_table :products do |t|
      t.string :name
      t.decimal :price, :precision => 8, :scale => 2

      t.timestamps
    end
  end
end

We specify the precision and scale for the price field. Note: The join table categories_products does not have id column.

Step 5

Create the join table:

rails g migration create_categories_products category_id:integer product_id:integer

The generated migration file looks like this:

class CreateAtegoriesProductsTable < ActiveRecord::Migration[5.0]
  def change
    create_table :categories_products do |t|
      t.integer :category_id
      t.integer :product_id
    end
  end
end

Step 6

Create the tables and populate the database.

rake db:migrate
rake db:seed

We can check the number of categories in the database in the rails console.

rails c
Loading development environment (Rails 5.0.0.beta3)
 > Category.count
   (0.1ms)  SELECT COUNT(*) FROM "categories"
 => 4 

Step 7

To remove existing data and recreate all tables.

$ rake db:drop db:create db:migrate

Step 8

Create sample data to play with in seeds.rb:

p = Product.new(name: 'rug', price: 100)
Product.create(name: 'bowl', price: 20.95)
Product.create(name: 'pillow', price: 90)
Product.create(name: 'light', price: 10.95)

c = Category.create(name: 'Clothes')
Category.create(name: 'Furniture')
Category.create(name: 'Groceries')
Category.create(name: 'Electronics')

p.categories << c
p.save!

Populate the tables.

rake db:seed

Step 9

There are 4 products and 4 categories in the database.

$ rails c
Loading development environment (Rails 5.0.0.beta3)
 Product.count
   (0.1ms)  SELECT COUNT(*) FROM "products"
 => 4 
 > Category.count
   (0.1ms)  SELECT COUNT(*) FROM "categories"
 => 4 

The last product has one category associated to it.

 p = Product.last
  Product Load (0.1ms)  SELECT  "products".* FROM "products" ORDER BY "products"."id" DESC LIMIT ?  [["LIMIT", 1]]
 => #<Product id: 4, name: "rug", price: #<BigDecimal:7fb9b23006d8,'0.1E3',9(18)>, created_at: "2016-03-14 20:29:30", updated_at: "2016-03-14 20:29:30"> 
 > p.categories.to_a
  Category Load (0.1ms)  SELECT "categories".* FROM "categories" INNER JOIN "categories_products" ON "categories"."id" = "categories_products"."category_id" WHERE "categories_products"."product_id" = ?  [["product_id", 4]]
 => [#<Category id: 1, name: "Clothes", created_at: "2016-03-14 20:29:30", updated_at: "2016-03-14 20:29:30">] 

Step 10

In products/index.html.erb list all products on index page with edit and destroy links. Browse to http://localhost:3000/products.

<h1>Products</h1>

<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>Price</th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <% @products.each do |product| %>
      <tr>
        <td><%= product.name %></td>
        <td><%= product.price %></td>
        <td><%= link_to 'Show', product %></td>
        <td><%= link_to 'Edit', edit_product_path(product) %></td>
        <td><%= link_to 'Destroy', product, method: :delete, data: { confirm: 'Are you sure?' } %></td>
      </tr>
    <% end %>
  </tbody>
</table>

<br>

<%= link_to 'New Product', new_product_path %>

Step 11

Clicking on product name takes to product show page. Product show page has Edit and Back links. The app/views/products/show.html.erb looks like this:

<p>
  <strong>Name:</strong>
  <%= @product.name %>
</p>

<p>
  <strong>Price:</strong>
  <%= @product.price %>
</p>

<p>
    In Categories: <%= @product.categories.map(&:name).join(',') %>
</p>

<%= link_to 'Edit', edit_product_path(@product) %> |
<%= link_to 'Back', products_path %>

Step 12

The

In Categories:

value on the product show page is blank.

Step 13

Add to product edit form partial:

<% for category in Category.all %>
<div>
  <%= check_box_tag "product[category_ids][]", category.id, @product.categories.include?(category) %>
  <%= category.name %>
</div>
<% end %>

Step 14

You need to allow the form fields by adding this method:

def product_params
  params.require(:product).permit(:name, :price, :category_ids)
end

Browse to app/views/products/edit/1 and check two categories and click update. You can see the error:

Unpermitted parameter: category_ids 

in the development log file:

Started PATCH "/products/4" for ::1 at 2016-03-14 13:41:24 -0700
Processing by ProductsController#update as HTML
  Parameters: {"utf8"=>"✓", "authenticity_token"=>"Nt7G/2pyg==", "product"=>{"name"=>"rug", "price"=>"100.0", "category_ids"=>["1", "2"]}, "commit"=>"Update product", "id"=>"4"}
  Product Load (0.1ms)  SELECT  "products".* FROM "products" WHERE "products"."id" = ? LIMIT ?  [["id", 4], ["LIMIT", 1]]
Unpermitted parameter: category_ids
   (0.1ms)  begin transaction
   (0.0ms)  commit transaction
Redirected to http://localhost:3000/products/4
Completed 302 Found in 4ms (ActiveRecord: 0.3ms)

To fix the error, change product_params:

def product_params
  params.require(:product).permit!
end

This will display product show page with value for In Categories label populated.

Step 15

In the development log file you can see the selected checkboxes are saved in the database as category records. The parameters sent to the server shows the array of category_ids with values 1, 3 and 4.

Started PATCH "/products/4" for ::1 at 2016-03-14 14:59:07 -0700
Processing by ProductsController#update as HTML
  Parameters: {"utf8"=>"✓", "authenticity_token"=>"UeAoAI9NvAweB/phyRlIe8w==", "product"=>{"name"=>"rug", "price"=>"100.0", "category_ids"=>["1", "3", "4"]}, "commit"=>"Update product", "id"=>"4"}
  Product Load (0.1ms)  SELECT  "products".* FROM "products" WHERE "products"."id" = ? LIMIT ?  [["id", 4], ["LIMIT", 1]]
   (0.0ms)  begin transaction
  Category Load (0.2ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" IN (1, 3, 4)
  Category Load (0.1ms)  SELECT "categories".* FROM "categories" INNER JOIN "categories_products" ON "categories"."id" = "categories_products"."category_id" WHERE "categories_products"."product_id" = ?  [["product_id", 4]]
  SQL (0.5ms)  INSERT INTO "categories_products" ("category_id", "product_id") VALUES (?, ?)  [["category_id", 3], ["product_id", 4]]
   (0.4ms)  commit transaction
Redirected to http://localhost:3000/products/4
Completed 302 Found in 16ms (ActiveRecord: 1.3ms)

After the update, the user is redirected to the product show page.

Started GET "/products/4" for ::1 at 2016-03-14 14:59:07 -0700
Processing by ProductsController#show as HTML
  Parameters: {"id"=>"4"}
  Product Load (0.1ms)  SELECT  "products".* FROM "products" WHERE "products"."id" = ? LIMIT ?  [["id", 4], ["LIMIT", 1]]
  Category Load (0.1ms)  SELECT "categories".* FROM "categories" INNER JOIN "categories_products" ON "categories"."id" = "categories_products"."category_id" WHERE "categories_products"."product_id" = ?  [["product_id", 4]]
  Rendered products/show.html.erb within layouts/application (2.5ms)
Completed 200 OK in 38ms (Views: 35.5ms | ActiveRecord: 0.2ms)

Step 16

Let experiment in the rails console to learn how this works. We can get the first product.

$ rails c
Loading development environment (Rails 5.0.0.beta3)
 p = Product.first
  Product Load (0.2ms)  SELECT  "products".* FROM "products" ORDER BY "products"."id" ASC LIMIT ?  [["LIMIT", 1]]
 => #<Product id: 1, name: "bowl", price: #<BigDecimal:7ff78af69468,'0.2095E2',18(27)>, created_at: "2016-03-14 20:29:30", updated_at: "2016-03-14 20:29:30"> 

The category_ids is empty for this product.

 > p.category_ids
   (0.1ms)  SELECT "categories".id FROM "categories" INNER JOIN "categories_products" ON "categories"."id" = "categories_products"."category_id" WHERE "categories_products"."product_id" = ?  [["product_id", 1]]
 => [] 

The last product we saved has three categories associated to it. Here is the last product.

 > p = Product.last
  Product Load (0.2ms)  SELECT  "products".* FROM "products" ORDER BY "products"."id" DESC LIMIT ?  [["LIMIT", 1]]
 => #<Product id: 4, name: "rug", price: #<BigDecimal:7ff78ac7d790,'0.1E3',9(18)>, created_at: "2016-03-14 20:29:30", updated_at: "2016-03-14 20:29:30"> 

Here is the list of category_ids for this product.

 > p.category_ids
   (0.2ms)  SELECT "categories".id FROM "categories" INNER JOIN "categories_products" ON "categories"."id" = "categories_products"."category_id" WHERE "categories_products"."product_id" = ?  [["product_id", 4]]
 => [1, 4, 3] 

Let's change it.

 > p.category_ids = [2,3]
  Category Load (0.3ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" IN (2, 3)
  Category Load (0.2ms)  SELECT "categories".* FROM "categories" INNER JOIN "categories_products" ON "categories"."id" = "categories_products"."category_id" WHERE "categories_products"."product_id" = ?  [["product_id", 4]]
   (0.0ms)  begin transaction
  SQL (0.3ms)  DELETE FROM "categories_products" WHERE "categories_products"."product_id" = ? AND "categories_products"."category_id" IN (1, 4)  [["product_id", 4]]
  SQL (0.1ms)  INSERT INTO "categories_products" ("category_id", "product_id") VALUES (?, ?)  [["category_id", 2], ["product_id", 4]]
   (0.5ms)  commit transaction
 => [2, 3] 

Notice that we don't need to call save explicitly. The category_ids method is provided by habm association. It saves the categories for this product.

Step 17

Uncheck all the boxes and update. Does it remove categories associated with that product? No. You can see in the development.log file that there is no category records created.

Started PATCH "/products/4" for ::1 at 2016-03-14 15:23:24 -0700
Processing by ProductsController#update as HTML
  Parameters: {"utf8"=>"✓", "authenticity_token"=>"iRTf7sobiLnrSs3HNfW60pSePwkKXY9gCHsFxFp2f2Np+oI7VbQvTvOJOVmM8HOX3b1FAdbaYVyoVRAq0lVJpw==", "product"=>{"name"=>"rug", "price"=>"100.0"}, "commit"=>"Update product", "id"=>"4"}
  Product Load (0.3ms)  SELECT  "products".* FROM "products" WHERE "products"."id" = ? LIMIT ?  [["id", 4], ["LIMIT", 1]]
   (0.0ms)  begin transaction
   (0.4ms)  commit transaction
Redirected to http://localhost:3000/products/4
Completed 302 Found in 4ms (ActiveRecord: 0.7ms)

Step 18

Add:

params[:product][:category_ids] ||=[]

to the update method in products controller like this:

def update
  params[:product][:category_ids] ||=[]
  respond_to do |format|
    if @product.update_attributes(product_params)
      format.html { redirect_to @product, notice: 'Product was successfully updated.' }
      format.json { render :show, status: :ok, location: @product }
    else
      format.html { render :edit }
      format.json { render json: @product.errors, status: :unprocessable_entity }
    end
  end
end

Now it will delete when we unselect the check boxes. If you view the source, you will see:

<input type="checkbox" name="product[category_ids][]" id="product_category_ids_" value="1" />

The id of the checkbox is messed up. You can use dom_id helper to fix it like this:

<%= check_box_tag "product[category_ids][]", category.id, @product.categories.include?(category), {:id => dom_id(category, dom_id(@product)) } %>

This will generate checkbox with id like this:

<input type="checkbox" name="product[category_ids][]" id="product_2_category_1" value="1" />

Step 19

You can make the check boxes for multiple selection like this:

<div>
<%= select_tag "product[category_ids][]", options_from_collection_for_select(Category.all, "id", "name"), :multiple => true %>
</div>

Let's add validation to the product model.

class Product < ApplicationRecord
  has_and_belongs_to_many :categories

  validates_presence_of :name
end

Make the product name blank and update product. Fail saves and the transaction is rolled back. This is correct behavior. You can see in the development.log file that records was inserted into the join table and then it gets rolled back.

Started PATCH "/products/4" for ::1 at 2016-03-14 16:48:20 -0700
Processing by ProductsController#update as HTML
  Parameters: {"utf8"=>"✓", "authenticity_token"=>"zZkOscnXAajkQ==", "product"=>{"name"=>"", "price"=>"100.0", "category_ids"=>["1", "2", "3", "4"]}, "commit"=>"Update product", "id"=>"4"}
  Product Load (0.2ms)  SELECT  "products".* FROM "products" WHERE "products"."id" = ? LIMIT ?  [["id", 4], ["LIMIT", 1]]
   (0.1ms)  begin transaction
  Category Load (0.2ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" IN (1, 2, 3, 4)
  Category Load (0.1ms)  SELECT "categories".* FROM "categories" INNER JOIN "categories_products" ON "categories"."id" = "categories_products"."category_id" WHERE "categories_products"."product_id" = ?  [["product_id", 4]]
  SQL (1.1ms)  INSERT INTO "categories_products" ("category_id", "product_id") VALUES (?, ?)  [["category_id", 1], ["product_id", 4]]
  SQL (0.0ms)  INSERT INTO "categories_products" ("category_id", "product_id") VALUES (?, ?)  [["category_id", 2], ["product_id", 4]]
   (0.5ms)  rollback transaction
  Category Load (0.1ms)  SELECT "categories".* FROM "categories"
  Rendered products/_form.html.erb (3.4ms)
  Rendered products/edit.html.erb within layouts/application (4.8ms)
Completed 200 OK in 49ms (Views: 35.3ms | ActiveRecord: 2.2ms)

Instead of using permit!, we can allow category_ids like this:

def product_params
  params.require(:product).permit(:name, :price, {category_ids: []})
end

Rails 5 Clean Up

Rails 4.x and 5 provide you with collection_check_boxes which simplifies things a bit.

<div class="field">
  <%= f.collection_check_boxes(:category_ids, Category.all, :id, :name, checked: product.categories.map(&:id)) do |b| %>    
    <%= b.check_box %> <%= b.label %>
  <% end %>
</div>

In this case, you don't need params[:product][:category_ids] ||=[] line to handle the empty check box selection. So the update and product_params method looks like this:

def update
  respond_to do |format|
    if @product.update_attributes(product_params)
      format.html { redirect_to @product, notice: 'Product was successfully updated.' }
      format.json { render :show, status: :ok, location: @product }
    else
      format.html { render :edit }
      format.json { render json: @product.errors, status: :unprocessable_entity }
    end
  end
end

def product_params
  params.require(:product).permit(:name, :price, {category_ids: []})
end

There is a minor UI display issue with collection_check_boxes. The check box and the label are on different rows. This requires CSS fix to display the check box and the label on the same row.

Summary

In Rails 3.x apps you had to write quite a bit of code to get the checkboxes working for habtm association. In Rails 4.x and 5, it's a bit simpler to use checkboxes with habtm association.


Related Articles


Create your own user feedback survey