Nested Forms with Ruby on Rails 4.2

22.01.2015 by Stefan Wintermeyer

Thumbnail of the video.

In this screencast I show how to create nested forms. A nested form is a form within an other form. The aim is to generate a nicer user experience because the user doesn’t have to jump back and forth between parent and child forms. To show the effect I create a database of hotels which have different room categories. A hotel should be editable in one form which includes all the room categories.

New project

We start a new Rails project with the name travel-agency and cd into that.

rails new travel-agency
cd travel-agency

A scaffold for Hotel and RoomCategory

We need the following two scaffolds for this example project.

rails g scaffold Hotel name
rails g scaffold RoomCategory name hotel:references
rake db:migrate

Associations

Because a hotel and a room category needs a name we add validation for that in the models. While we are there we add a to_s method which prints that name and add the needed associations.

app/models/hotel.rb

class Hotel < ActiveRecord::Base
  has_many :room_categories, dependent: :destroy
  validates :name,
            presence: true
  def to_s
    name
  end
end

app/models/room_category.rb

class RoomCategory < ActiveRecord::Base
  belongs_to :hotel
  validates :name,
            presence: true
  def to_s
    name
  end
end

Seed data

Let’s include two example hotels with a couple of room categories in the seeds.

db/seeds.rb

ritz_carlton = Hotel.create(name: 'The Ritz-Carlton New York, Central Park')
mandarin_oriental = Hotel.create(name: 'Mandarin Oriental, New York')
['City View Guestroom', 'Deluxe Parkview Room'].each do |rc|
  ritz_carlton.room_categories.create(name: rc)
end
['City View Room', 'Skyline View Room', 'Hudson River View Room'].each do |rc|
  mandarin_oriental.room_categories.create(name: rc)
end

Start the Rails server

Reset the database which runs the seeds automatically and start Rails.

rake db:reset
rails s

Hotel Show View

We want to list all room categories for a hotel in the show view of that hotel.

app/views/hotels/show.html.erb

<p id="notice"><%= notice %></p>
<p>
  <strong>Name:</strong>
  <%= @hotel.name %>
</p>
<h2>Room Categories</h2>
<% if @hotel.room_categories.any? %>
  <ul>
  <% @hotel.room_categories.each do |room_category| %>
    <li><%= link_to room_category, room_category %></li>
  <% end %>
  </ul>
<% else %>
  <p>none available</p>
<% end %>
<%= link_to 'Edit', edit_hotel_path(@hotel) %> |
<%= link_to 'Back', hotels_path %>

Hotel Form

Now we use the fields_for Helper within the Hotel form to add fields for the room_categories of that hotel (code line 7). Have a look at ActionView::Helpers::FormHelper Documentation for more examples.

app/views/hotels/_form.html.erb

 1 <div class="field">
 2   <%= f.label :name %><br>
 3   <%= f.text_field :name %>
 4 </div>
 5 
 6 <h2>Room Categories</h2>
 7 <%= f.fields_for :room_categories do |room_category| %>
 8   <div class="room_category_fields">
 9     <div class="field">
10       <%= room_category.label :name %><br>
11       <%= room_category.text_field :name %>
12     </div>
13   </div>
14 <% end %>

accepts_nested_attributes_for

But that form doesn’t show any nested attributes. To achieve that we have to add accepts_nested_attributes_for in the hotel model.

app/models/hotel.rb

class Hotel < ActiveRecord::Base
  has_many :room_categories, dependent: :destroy
  accepts_nested_attributes_for :room_categories
  validates :name,
            presence: true
  def to_s
    name
  end
end

Change the name of hotel categories

Any change within the nested attributes doesn’t work.

Let’s have a look at the console output while we update a hotel category name within the hotel form:

Unpermitted parameters: room_categories_attributes

The room_categories_attributes are not in the controller strong parameters white list. This is a security feature of Rails 4 (have a look at the Strong Parameters plugin docu for more information about strong parameters).

app/controllers/hotels_controller.rb

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_hotel
      @hotel = Hotel.find(params[:id])
    end
    # Never trust parameters from the scary internet, only allow the white list through.
    def hotel_params
      params.require(:hotel).permit(:name, room_categories_attributes: [ :id, :name ])
    end
end

Add New Room Categories in the Hotel Edit View

It probably would be useful to add an empty room category in the edit view just to be able to add one when ever you edit a hotel. For that we have to add a @hotel.room_categories.build into the edit method of the hotel controller.

app/controllers/hotels_controller.rb

# GET /hotels/1/edit
def edit
  @hotel.room_categories.build
end

But that triggers a side effect of our validation. Because of the name validation in the hotel_category model we can not save an empty name - it is just not valid. We can not change the validation but we can add a reject_if to the accepts_nested_attributes_for to reject an empty name attribute.

app/models/hotel.rb

class Hotel < ActiveRecord::Base
  has_many :room_categories, dependent: :destroy
  accepts_nested_attributes_for :room_categories,
                                reject_if: proc { |attributes| attributes['name'].blank? }
  validates :name,
            presence: true
  def to_s
    name
  end
end

Now let’s add the same functionality to the hotel new form by adding @hotel.room_categories.build to the new method.

app/controllers/hotels_controller.rb

# GET /hotels/new
def new
  @hotel = Hotel.new
  @hotel.room_categories.build
end

BTW: We could DRY this code by creating an after_filter for the edit and new method to run a @hotel.room_categories.build.

Destroy a Room Category in the Hotel Form

Now we are able to edit and create nested room categories in the hotel form. But we have no way to destroy a room category in that form. We need to add allow_destroy: true to the accepts_nested_attributes_for for that functionality.

app/models/hotel.rb

class Hotel < ActiveRecord::Base
  has_many :room_categories, dependent: :destroy
  accepts_nested_attributes_for :room_categories,
                                reject_if: proc { |attributes| attributes['name'].blank? },
                                allow_destroy: true
  validates :name,
            presence: true
  def to_s
    name
  end
end

For that to work we have to add a room_category.check_box :_destroy field in the hotel form.

app/views/hotels/_form.html.erb

<div class="field">
  <%= f.label :name %><br>
  <%= f.text_field :name %>
</div>
<h2>Room Categories</h2>
<%= f.fields_for :room_categories do |room_category| %>
  <div class="room_category_fields">
    <div class="field">
      <%= room_category.label :name %><br>
      <%= room_category.text_field :name %>
      <%= room_category.check_box :_destroy %>
      <%= room_category.label :_destroy, 'remove' %>
    </div>
  </div>
<% end %>

And of course we have to add this attribute in the hotel controller strong parameter whitelist.

app/controllers/hotels_controller.rb

def hotel_params
  params.require(:hotel).permit(:name, room_categories_attributes: [ :id, :name, :_destroy ])
end

Now we can add, edit and remove room categories to in the hotel from.

Rails Ruby Forms