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.git

and 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:migrate

Next 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.

Discuss

Was it good for you, too? Join the discussion »


About the Author

Thumb_diet_coke Michael Petnuch is a graduate math student who enjoys walking on his hands, drinking diet coke, solving math problems, and being silly!