Rails Lesson #3: Nested Resources

15 Apr 2008

In Lesson #2 we established a relationship between our Recipe model and our Cookbook model. We used the rails console to wire up the recipe record so that it belonged to the cookbook record. In this lesson we’ll make changes to our UI that will allow us to add recipes to cookbooks. First let’s learn a little about controllers.

Controller Actions

Rails uses the URL to direct traffic. All requests are directed to an action of a controller. It’s a good idea to follow RESTful conventions and give your controller a maximum of these 7 actions:

  • index – used to list entities
  • show – used to display a specific entity
  • new – used to display form to create a new entity
  • edit – used to display a form to edit a specific entity
  • create – accessed to create a new entity (No UI)
  • update – accessed to update an entity (No UI)
  • destroy – accessed to delete an entity (No UI)

Rails will direct a request to http://localhost:3000/cookbooks to the “index” action of the cookbooks controller. If you look in the app/controllers/cookbooks_controller.rb file you’ll see seven methods defined; one for each action. The first one is index:

  def index
    @cookbooks = Cookbook.find(:all)

    respond_to do |format|
      format.html # index.html.erb
      format.xml  { render :xml => @cookbooks }
    end
  end

The default behavior is to grab all the Cookbooks and then pass to the view for rendering. In a later lesson we’ll do more with that respond_to part – it basically lets us represent our entities with more than just HTML. Visit http://localhost:3000/cookbooks.xml you’ll see an XML representation of the list of cookbooks instead of HTML. Cool!

When we are requesting HTML rails looks in the app/views/{controller_name}/{action_name}.html.erb or /app/views/cookbooks/index.html.erb If you take a look at that file you’ll see a heading, and code for generating an HTML table. While we have it open, let’s just capitalize the word “cookbooks” in the title.

Nested Resources

What we really want to do is add a list of recipes and a link to add more to this page: http://localhost:3000/cookbooks/1. Rails directs that URL to the “show” action of the cookbooks controller. The show action retrieves a specific cookbook from the database (id = 1 in our case) and passes control to the app/views/cookbooks/show.html.erb file. Before we start editing that file we are going to establish a “route” relationship between them. Open up your config/routes.rb file and add this line right above “map.resources :cookbooks”

map.resources :cookbooks, :has_many => :recipes

Now we are ready to make changes to the app/views/cookbooks/show.html.erb file. The code above tells rails that “recipes” are a nested resource for “cookbooks” – the urls will look like this: /cookbooks/{id}/recipes/{id}. One nice thing is that we don’t need to hand code our urls – rails can do it for us. Change your show.html.erb file so it looks like the code below.

<h2>Cookbook: <%=h @cookbook.title %></h2>
<p>
  <b>Author:</b>
  <%=h @cookbook.author %>
</p>

<h3>Recipes:</h3>
<ul>
<% @cookbook.recipes.each do |recipe| %>
	<li><%= link_to recipe.name, cookbook_recipe_path(@cookbook,recipe) %></li>
<% end %>
</ul>

<br />
<%= link_to "Add new recipe", new_cookbook_recipe_path(@cookbook) %>
<hr style="margin-top:25px;"/>

<%= link_to 'Edit', edit_cookbook_path(@cookbook) %> |
<%= link_to 'Back', cookbooks_path %>

We’ve changed the look slightly and added a list of recipes. We are linking to the recipes using the “cookbook_recipe_path” method to create a new recipe using the “new_cookbook_recipe_path” method – these are provided to us because of the change we made to the routes.rb file. When you click on the recipe name you’ll be taken to this location: /cookbooks/1/recipes/1 and the “Add New” link takes you here: /cookbook/1/recipes/new.

Now that the recipes are showing up at the correct URL we need to make a couple changes so that the new recipes get saved with the correct cookbook ID. First we need to make two changes to the recipes controller. At the top under the class definition add “before_filter :load_parent” and add the private “load_parent” method at the bottom:

class RecipesController < ApplicationController
  before_filter :load_parent

   ... all other actions ...

#all methods defined below "private" are not accessible as actions:
private

  def load_parent
    @cookbook = Cookbook.find(params[:cookbook_id])
  
  end

end # end of class

The before_filter method tells rails not to excecute any actions until after running the methods that are passed in as parameters. That line says “before you do anything, run the ‘load_parent’ method first.” The load_parent method simply retrieves the appropriate category from the database. The “params” hash is created by rails and contains all the parameters available. Rails added the “cookbook_id” parameter by parsing the URL. We don’t have to deal with the details of passing that ID around – rails does the work for us.

We also need to make a small change to the view (recipes/new.html.erb). Change the “form_for” line to the following:

&lt;% form_for([@cookbook,@recipe]) do |f| %&gt;

Before we are able to add new recipes to our cookbook we need to make a change to the “create” action of the recipes controller. You’ll need to code the association for the cookbook (recipe.cookbook = @cookbook) and after saving instead of redirecting to the @recipe - you'll want to redirect as a nested route (redirect_to([cookbook,@recipe]).

  def create
    @recipe = Recipe.new(params[:recipe])
    @recipe.cookbook = @cookbook
    
    respond_to do |format|
      if @recipe.save
        flash[:notice] = 'Recipe was successfully created.'
        format.html { redirect_to([@cookbook,@recipe]) }
        format.xml  { render :xml => @recipe, :status => :created, :location => @recipe }
      else
        format.html { render :action => "new" }
        format.xml  { render :xml => @recipe.errors, :status => :unprocessable_entity }
      end
    end
  end

We should be all set to add recipes to our cookbook. Navigate to http://localhost:3000/cookbooks/1/ then click on “Add new recipe” Fill out the form and click create. VOILA! You have a added a recipe your cookbook. Don’t try clicking the “Edit” and “Back” links: they won’t work yet. That’s it for lesson #3. In Lesson #4 we’ll do some simple AJAX to remove recipes from our cookbooks.

Misc. Cleanup

There are a few issues with our code that don’t really belong as part of the lesson. First let’s fix the “Edit” and “Back” links on the recipe’s show.html.erb.


  • Replace edit_recipe_path(recipe) with edit_cookbook_recipe_path(cookbook,recipe)</li> <li>The back link should go to the cookbook_path(cookbook) instead of recipes_path.

  • Copy that back link to replace the back link in recipes/new.html.erb

  • Make these same changes in recipes/edit.html.erb

  • We need to change the form_for tag on the recipes/edit.html.erb like we did for the “new” view above: form_for([cookbook,recipe])

  • In the update action of the recipes controller we need to redirect to the cookbook/recipe: format.html { redirect_to([cookbook,recipe]) }

There! Now you should be able to click around and add cookbooks and recipes without causing any server errors.