Creating a Navigation System with Rails
According to A List Apart’s Derek Powazek’s article, the hallmarks of a good navigation system lets the user know the three basic things: where they are, where they have been, and where they can go. While there are many methods to achieve this, I decided to go the breadcrumb route.
While breadcrumbs are great, there is no easy was to make them with Rails. The problem is that the breadcrumb trail may span across more than one controller. So how do you model the relationships? Use ActsAsNestedSet. The ActsAsNestedSet plugin allows you to have tree like relationships and retrieve the entire tree with just one SQL query.
First we need to get the plugin:
git clone git://github.com/chris/better_nested_set.gitand create our menu model:
class Menu < ActiveRecord::Base acts_as_nested_set after_save :move_to_proposed_parent def proposed_parent_id @proposed_parent_id end def proposed_parent_id=(parent) @proposed_parent_id = parent end private # After the menu is saved, we move it to the correct location # If the proposed parent is nil, or doesn't exist then move it to the end of the list. # If the proposed parent has children - move it to the right of the last child # If the proposed parent has no children, move it directly under the parent def move_to_proposed_parent unless proposed_parent_id.to_s == parent_id.to_s new_parent = self.class.find_by_id(proposed_parent_id) if new_parent.nil? self.move_to_right_of(self.class.roots.last) else unless new_parent.children.last.nil? self.move_to_right_of(new_parent.children.last) else self.move_to_child_of(new_parent) end end end end end
and finally our migration for the table that will contain our data. Note, in our migration we need to make sure that we create the base menus for whatever model we plan on listing. In this example I create a base menu for the models home, post, and topics. My migration file looks like this.
class CreateMenus < ActiveRecord::Migration def self.up create_table :menus, :force => true do |t| t.string :name, :controller, :action t.references :menuable, :polymorphic => true t.integer :parent_id t.integer :lft, :rgt, :null => false t.integer :lock_version, :default => 0 end add_index :menus, ["name", "parent_id"], :name => "index_menu_on_name_and_parent_id", :unique => false # add base menus home = Menu.create :name => 'Home', :controller => 'home', :action => 'index' posts = Menu.create :name => 'Posts', :controller => 'posts', :action => 'index', :proposed_parent_id => home topics = Menu.create :name => 'Topics', :controller => 'topics', :action => 'index', :proposed_parent_id => posts def self.down drop_table :menus end end
The menu model has pseudo column, proposed_parent_id. This allows us to use an after_save callback, which automatically creates the new menu item as a child of the correct parent. If proposed_parent_id is blank, then the new menu item will become a root menu.
Now that we have the menu table set up, we need to update our models so that it populates the menu table with the newly created information. In each model that you wish to associate a menu item with add the following (I am assuming the model named Topic):
has_one :menu, :as => :menuable, :dependent => :destroy after_create :create_menu private def create_menu parent_menu = Menu.find( :first, :conditions => ['controller = ? and action = ?', 'topics', 'index'] ) new_menu = Menu.new( :name => self.name.titleize, :controller => 'topics', :action => 'show', :proposed_parent_id => parent_menu ) self.menu = new_menu end
Now depending upon how static this model is, you might want to create an after_code callback that would update the menu name if, for instance, the topic name is a mutable attribute. After you have everything to you liking, it’s time to migrate your database:
rake db:migrateNext we create a helper method so that we can access the current menu. We are exploting the fact that Rails sets for us the name of the current controller and action via the global controller variable. We are then able to set the correct submenu page (if appropriate) if we are passed an id as well.
module MenuHelper def current_menu id = params[:id] unless id.nil? Menu.find(:first, :conditions => [ 'controller = ? and action = ? and menuable_id = ?', controller.controller_name, controller.action_name, id ] ) else Menu.find(:first, :conditions => [ 'controller = ? and action = ?', controller.controller_name, controller.action_name ] ) end end end
With that setup, all that is left is for us to create the view to see our navigation. This is were we get to see the power of ActsAsNestedSet. In order to retrieve all of the root menus (in our case the children of Home) you create the following loop:
Menu.root.children.each do |menu| # Do something special with the current selected menu unless menu == current_menu.self_and_ancestors[1] .... # Standard formatting for the rest else .... end end
Notice that I use NestedSet’s self_and_ancestors method on the current_menu, but instead of checking the first item in the array I am actually checking the second. The reason for this is that is home is the parent of every item (according to how I setup my menus) and is therefore the first item in the array. If, on the other hand, you decide that you want your Posts, About, etc… to be the topic level menus along with home you would use self_and_ancestors.first instead.
Now to retrieve the breadcrumbs we do something quite similar. Again we use NestedSet’s anestor method to get all the breadcrumbs. Notice however, that we do not use self_and_ancestors because we want to treat the current menu differently, for example, we want to provide links to all the ancestors. But since we are viewing the current page there is no reason to link to it. Also, notice that we omit breadcrumbs if were are at the home page. This we done as a suggestion from A List Apart, but the choice is up to you.
# Don't display breadcrumbs if we are in the home menu unless current_menu.root? current_menu.ancestors.each do |menu| ... end # Do something with current_menu ... end
All that is left is for you to decide on how you want to style your menus. Web Design Practices presents a good overview of some more popular setups.


