Pages and Subpages
Goals
In this lesson we’re adding a hierarchy to our pages. Instead of a single pool of pages with a navigation button for each, we want to have subpages as well, which don’t appear in the top navigation bar but are listed in second-level navigation on their parent page.
Please note that, while we’ve tried to make these notes complete, they aren’t the full tutorial; that’s in the screencast, which you can access via the link on the left.
Setup
We begin with the code with which we ended Lesson 14. These zip files contain the beginning and ending states of the code:
- Learning Rails example app code as of the end of Lesson 14
- Learning Rails example app code as of the end of Lesson 15
Model association
To associate subpages with their parent pages, we could create a subpage model, and then we could write in the page.rb model:
has_many :subpages
And in the subpage.rb model:
belongs_to :page
But to do it this way creates a lot of duplication, since the subpage model would need to behave just like the page model. So we use what’s called a self-referential association: a page object has many page objects, and a page can have (belong to) a parent. This requires a more complex declaration in the page.rb model, as we’ll see, but once that’s done, the self-referential model works the same as it would if subpage was a separate model.
Extending the Page table
We need to add some fields to the Page model. As with any change to the database structure, we create new migration, with whatever name we’d like:
script/generate migration AddSubpages
These three lines in the “up” method create the new fields:
def self.up add_column :pages, :parent_id, :integer add_column :pages, :navlabel, :string add_column :pages, :position, :integer end
And the corresponding three lines in the “down” method allow us to set the database back to its prior condition, should we want to roll back the database:
def self.down remove_column :pages, :parent_id remove_column :pages, :navlabel remove_column :pages, :position end
Once we’ve created and saved the migration file, we apply the migration:
rake db:migrate
Defining the association
In the Page class, we need to specify the relationship between parent pages and subpages. We have a field, parent_id, that we created in the previous migration to serve as the foreign key for the association. Since this is a self-referential association, the default naming schemes don’t apply, and we need to explicitly specify the class name and foreign key field name:
has_many :subpages, :class_name => 'Page', :foreign_key => 'parent_id'
The has_many declaration allows us to then write, typically in our controllers, page.subpages, to retrieve all the pages that have the current page as their parent.
Since this is a self-referential association, the “belongs_to” side of the relationship also goes in the page model:
belongs_to :parent, :class_name => 'Page', :foreign_key => 'parent_id'
This declaration allows us to then write page.parent to find the parent page, if there is one.
Writing Custom Finders
Until now, we’ve mostly used standard find methods directly in our controllers. It’s a better design practice, however, to push logic for finding into the model. By writing a custom method to serve as a special kind of find, we can encapsulate more of how the page model works in that single file.
Here’s a method to provide the complete set of navigation tabs:
def self.find_main Page.find(:all, :conditions => ['parent_id IS NULL'], :order => 'position') end
And here’s a variant that only shows tabs for site visitors (i.e., no pages that have their “admin” attribute set):
def self.find_main_public Page.find(:all, :conditions => ["parent_id IS NULL and admin != ?", true], :order => 'position') end
Creating main navigation tabs
Now we need to update the method that tells the layout what tabs to display. We created this method in an earlier lesson and put it in controllers/application.rb, which is “mixed in” to every other controller. Let’s modify get_pages_for_tabs in application_controller to use the new finds:
def get_pages_for_tabs
if logged_in?
@tabs = Page.find_main
else
@tabs = Page.find_main_public
end
end
Using the navlabel text
We have a new attribute in the page model for the navigation button text, so let’s change the line of code in views/layouts/application.rhtml.erb from:
<li><%= link_to page.title, view_page_path(page.name) %></li>
to use the new attribute:
<li><%= link_to page.navlabel, view_page_path(page.name) %></li>
Providing access to the new attributes
We need to update the admin forms for the Page scaffold to let us set and modify the new attributes. Instead of changing both new and edit views (in views/pages), we create a form partial that is used for both the new and edit views.
Copy the guts out of either the new or edit view:
<p>
<b>Name</b><br />
<%= f.text_field :name %>
</p>
<p>
<b>Title</b><br />
<%= f.text_field :title %>
</p>
<p>
<b>Body</b><br />
<%= f.text_area :body %>
</p>
<p>
<b>Admin?</b><br />
<%= f.check_box :admin %>
</p>
Paste this into a new file in views/pages, called “_form.rhtml.erb”. The underscore identifies it as a partial.
Now, in both the new and edit views, all this text can be replaced with:
<%= render :partial => 'form', :locals => {:f => f} %>
To create the HTML pop-up menu for choosing the parent page, we use the collection_select method:
<p>
<b>Parent Page</b><br />
<%= f.collection_select :parent_id, Page.find(:all), :id, :title, :include_blank => true %>
</p>
Add a field to specify the position:
<p> <b>Position</b><br /> <%= f.text_field :position, :size => '3' %> </p>
And a field for the nav label:
<p> <b>Nav Label</b><br /> <%= f.text_field :navlabel %> </p>
Creating subpages
Now we can use the page admin interface to set the navlabel and position for each of the existing pages.
Then let’s create some subpages. For our example we create two pages, Services and Products, that have About Us as their parent page.
The main navbar should still show only the main pages, labeled and sorted according to our new navlabel and position attributes.
Creating second-level navigation links
To create the second-level menu, we need to find the subpages in the viewer controller’s show method, by adding the following between the two existing lines:
@subpages = @page.subpages
At the top of views/viewer/show, we’ll add a simple list of the any subpages:
<% unless @subpages.empty? %>
<div id='subnav'>
<ul>
<% for page in @subpages %>
<li><%= link_to page.navlabel, view_page_path(page.name) %></li>
<% end %>
</ul>
</div>
<% end %>
And some minimal styling for this div, to put in the stylesheet:
#subnav {
width: 200px;
float: left;
border-right: 1px solid black;
margin-right: 20px;
}
The About Us page now shows links for its two subpages, and clicking on those links displays those pages.
Bugfix: there’s been a spurious <div> tag after the navigation links in the application layout, which we’ve deleted in the code for this lesson.
Wrapping up
We now have a usable two-level page structure. Before putting this into real use, we’d want better styling for the level-two navigation links, and some indication of what the parent page is when we’re on a subpage.
In our next lesson, we’ll take are of a few of these lingering details, preparing to move on to the contact form and resources page in later lessons.
Comments on This Lesson
From: Walter Date: 07/18/08 04:04 AM
Subject: Tracking down a bug in Parent.id in pages#index.html.erb
I’m getting the Parent_id off by one (+1) even though I have @pages[page.parent_id.to_i – 1].name in the parent column of pages#index.html.erb. I had to rollback my db since it developed some error when a page was set to a blank index then deleted. Should it fail it will fail the entire index and the db then has to be reset. Oddly if I go to Edit the page, the correct parent page is selected and is not offset. How is the parent_id.name used in the drop down list differ from the view? Maybe I’m getting a conflict? As always thanks for terrific screencasts.
From: Erwin Odendaal Date: 07/06/08 06:06 AM
Subject: different stylesheet
Easier than expected… Added: <% if @page.parent %> <%= stylesheet_link_tag ‘sub’ %> <% end %> to the head-section of application.html.erb
From: Erwin Odendaal Date: 07/02/08 08:08 AM
Subject: different stylesheets
I want to link another stylesheet to the subpages. So when a page has a parent it needs to call the style sub.css (for example). But I just can’t figure it out. :-( I presume you need to check if there’s no parent_id?
From: Michael Slater Date: 06/09/08 09:09 AM
Subject: collection select syntax
Alexei, yes, it’s a small bit of rails magic. Whenever you use a form_for block, you get the form context from the block variable, f. Since we wrote f.collection_select, we don’t specify the page object; it is implicitly specified through the block variable.
From: Alexei Date: 06/09/08 01:01 AM
Subject: collection_select
I looked down at different wikis and official API for rails and seems like the syntax for collection_select is collection_select(:page, :parent_id, Page.find(:all), :id, :title, :include_blank => ‘true’) Does your variant (without first :page) uses some rails magic?
From: Erik Date: 05/29/08 01:13 PM
Subject: Menus
Loved the lesson as always. One other thing you could add as a ‘to-do’ is to somehow generate drop-down menus for the subpages. This is a frequently used feature in many sites and would be a helpful addition to a rails site as well. I would imagine using javascript to invoke a mouseover event that would display a bit of html that would show the buttons below the parent tabs.
From: Tim Morgan Date: 05/21/08 02:14 PM
Subject: Lesson feedback
Hi guys
Thanks again for another great installment.
My comments.
===
Last time we did a migration, we were careful to initialize the new fields. Accordingly, I added the following to my ‘up’ method.
Page.find(:all).each do |page|
page.update_attributes :position => page.id, :navlabel => page.title
end
===
It would seem that self-refering classes would be a pattern. Is there a macro that would generate the following…
has_many :subpages, :class_name => self.name, :foreign_key => 'parent_id' belongs_to :parent, :class_name => self.name, :foreign_key => 'parent_id' from, say self_reference :subpages
===
I refactored ‘get_pages_for_tabs’ / ‘find_main’ as follows:
//application.rb
def get_pages_for_tabs
@tabs = Page.find_main logged_in?
end
// page.rb
def self.find_main( logged_in )
if logged_in
Page.find(:all,
:conditions => ['parent_id IS NULL'], :order => 'position')
else
Page.find(:all,
:conditions => ["parent_id IS NULL and admin != ?", true], :order => 'position')
end
end
===
Navbar is growing into an application level helper. I also refactored the ‘edit’ button into the navabar, so I can find it in a consistant location. Remove it from viewer/show.html.erb altogether and
// application.html.erb
<%- if logged_in? -%>
<li><a href='#' id='edit'>[Edit]</a></li>
<li><%= link_to "Log Out", logout_path %></li>
<%- else -%>
<li><%= link_to "Log In", login_path %></li>
<%- end -%>
But I still get the tooltip ‘edit’ popping up from the in_place_editing plugin. Is there an option to shut this off inside the Javascript?
===
Here’s a question for restful development. In following the development of the database, we are filling it with some testing records. I was wondering: Is it possible to get a data dump from the database, to fill a SQL script to re-initialize? Then I remembered restfull. Using the url ‘pages.xml’ or ‘users.xml’, we get an xml dump of the data. ‘pages.json’ will send formated as json
Is there a simple SQL formatter for this?
===
Again, really enjoying the series! Great work, guys
From: Michael Slater Date: 05/20/08 08:08 AM
Subject: HTML error corrected
Scott, thanks for pointing out the error in the HTML layout. I’ve corrected this in the zip file. As for the Akita technique, this works but it isn’t clear to me that it is much of an improvement.
From: Scott Gardner Date: 05/19/08 01:13 PM
Subject: Correction: Another version of the _form partial
The button name local variable is a hash, so the = should be => new.html.erb :locals => {:button_name => “Create”} edit.html.erb :locals => {:button_name => “Update”}
From: Scott Gardner Date: 05/19/08 12:12 PM
Subject: Another version of the _form partial
I got the idea from the Akita On Rails tutorial to move the entire form to a partial, and then assign the button name to a local variable, e.g.:
_form.html.erb <% form_for(@page) do |f| %>
- ... <% end %>
edit.html.erb
From: Scott Gardner Date: 05/19/08 12:12 PM
Subject: Mismatched divs in source code download > application.html.erb
You are missing a closing div in this file. Starting from the footer id: # ...
Learning Rails Sample Application




From: Nicholas Date: 07/30/08 05:05 AM
Subject: Required a tutrorial on Receiving email
Required a tutrorial on Receiving email , something similar to your add a comment.
looking forward to read it. hope you’ll add it soon. thank you -Nicholas I