10 Jan, 2010

Published at 11:48PM

Tagged with code, golftrac, navigation, plugin, programming, projects, and rails

This post has 8 comments

Multi-menu tabbed navigation in Rails

I just recently scrapped my old navigation plugin (in several applications) in favor of the new one I wrote. It has allowed me to clean that part of my code up tremendously. I know I’ve posted about it already, but I thought I’d show a real-world example of how I’m using it in Golf Trac.

First, here’s the situation. There are three main areas where navigation comes into play: 1) public navigation 2) sidebar navigation and 3) action-level sub navigation. Here’s what I’m referring to:

Not logged in (1):

Logged in (2 and 3):

The public and sidebar navigation doesn’t really change. But the tabs at the top right (sub navigation) change depending on where you are in the app, as they allow you to navigate to different actions within the same controller.

So here’s how it works. I define my navigation menus in an initializer and reference them by a key. This keeps my controllers and views clean. Also, the new plugin has native support for nested menus and action-level navigation, which is something the old one just wasn’t designed to support.

Here’s a snippet of the actual menu definitions used in Golf Trac.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# config/initializers/navigation.rb
 
RPH::Navigation::Builder.config do |navigation|
  navigation.define :public do |menu|
    menu.item WelcomeController, :path => :hello_path
    menu.item TourController, :text => 'About & Tour'
    menu.item PlansController, :text => 'Plans & Pricing'
  end
  
  navigation.define :sidebar do |menu|
    menu.item DashboardController
    menu.item RoundsController
    menu.item CoursesController
    menu.item FriendsController, 
      :text => Proc.new { |view| view.sidebar_friends_text }
    menu.item SettingsController
    menu.item AdminController, 
      :text => 'Administrator', :path => :administrator_path,
      :if => Proc.new { |view| view.logged_in_as_admin? }
  end
  
  # ...
 
end

As you can see, I’m defining a menu for each of the different situations. You can define as many as you want. It’s worth noting that the order that you list your menu items is the order they’ll be rendered.

So once your menus are defined, use <%= navigation :public %> to render them (:public being the key/name of a menu).

A nice side-effect about this is there’s no sloppy ERB tags to deal with. This is a Ruby file and allows for a natural use of Ruby blocks. That in itself is an improvement. Also, the plugin allows you to actually pass the controller itself as a menu item, rather than ensuring you have proper string matching. As expected, a RoundsController would provide a “Rounds” link that goes to the rounds_path. But as you can see, if you need to override the text or the path, that’s not a problem.

One interesting thing I’d like to point out is the “Friends” link in the sidebar navigation. Looking at the second screen shot above, see how it has that little number (in this case a “1”) next to the text? That indicates that you have 1 new friend request. But more importantly, that also means that the text for that item will be dynamic. Fortunately, that’s not a problem.

If you pass a Proc to the :text option, you will be handed an instance of the view. From there, you can call any helper your heart desires. Here’s what Golf Trac does:

1
2
3
4
5
6
7
8
def friend_requests_count
  return if (requests = current_user.friend_requests).size.zero?
  content_tag :span, requests.size
end
 
def sidebar_friends_text
  ["Friends", friend_requests_count].compact.join(" ")
end

So when you see this:

1
2
menu.item FriendsController, 
  :text => Proc.new { |view| view.sidebar_friends_text }

It’s just a simple helper call.

In a similar fashion, you can determine which menu items should be shown based on the result of some condition. In this case, I have an “Administrator” section that should only be shown if an administrator is logged in. So that’s what this does:

1
2
3
menu.item AdminController, 
  :text => 'Administrator', :path => :administrator_path,
  :if => Proc.new { |view| view.logged_in_as_admin? }

Notice the :if option, here. And that’s just another helper call to:

1
2
3
def logged_in_as_admin?
  !!current_user.administrator?
end

Simple stuff, but it makes a world of difference when you’re trying to hack together some navigation with different rules, conditions, text, paths, etc.

Now, that leaves one final question: how is the action-level sub navigation handled? It’s pretty simple, too. When you’re defining a menu, you can specify that it’s an :action_menu, which simply means that it will check against the current action rather than the current controller.

Note: if you want nested navigation, just pass a block to the menu.item call, defining the menu you want nested. In this case, we don’t want a nested menu, but still want action-level navigation. See the README for more.

So all I do is define the menus I want for each controller, using a key of that controller’s name. Here’s the sub navigation for the RoundsController, for example:

1
2
3
4
5
navigation.define :rounds, :action_menu => true do |menu|
  menu.item :index, :text => 'List', :path => :rounds_path
  menu.item :best, :path => :best_round_path
  menu.item :new, :path => :new_round_path
end

You can name the action-level menus anything you want, but by naming them after the controller in which they correspond, you can do some automatic rendering. For example, here’s what I’m doing:

1
2
3
4
5
def sub_navigation
  navigation controller_name.downcase.to_sym, :class => 'sub_navigation'
rescue RPH::Navigation::InvalidMenuIdentifier
  nil
end

If there’s no menu defined, nothing will happen. You can stick a call to the <%= sub_navigation %> helper in your layout, and it will work automatically. Nothing in your controllers, and no jumbled view code.

Nearly every Rails application has navigation. It’s something you have to deal with over and over again, too. Hopefully you have realized how something like this navigation plugin can greatly help you organize almost any form of navigation in your Rails app.

To install:

1
$ script/plugin install git://github.com/rpheath/navigation.git

Happy coding!

Comments

Bharat Ruparel Sunday, 08 Aug, 2010 Posted at 04:44PM

Hello Ryan,

I have used your Navigation plugin in my open source project at: http://www.github/com/bruparel/file_manager

I am trying to upgrade to your current plugin so that I can move my project to Rails 3 eventually. My plan is to upgrade all the plugins, go to Rails 2.3.8 and then to Rails 3.

I have followed the instructions in your write-up here, but am having trouble coming up with an :admin menu block for the following admin menu snippet shown at the following URL:

http://github.com/bruparel/file_manager/blob/master/app/helpers/application_helper.rb

So I have created a symbol for the :admin menu and in my initializer block am trying to define it as follows:

1
2
3
4
5
6
7
8
9
10
11
RPH::Navigation::Builder.config do |navigation|
  navigation.define :admin do |menu|
    menu.item :clients
    menu.item :folders
    menu.item :documents
    menu.item :client_comments, :text => "Notes"
    menu.item :users
    menu.item :clients do |sub_menu|
      sub_menu.item :client_perms, :path => :client_perms
    end
  end

See the client_perms line above? which corresponds to the code snippet:

1
2
3
x = navigation [:clients, :folders, :documents, {:notes => {:controller => 'client_comments', :action => 'index'}}, 
  :users, {:client_perms => {:controller => 'clients', :action => 'list_perms'}},
  {:folder_perms => {:conroller => 'folders', :action => 'list_perms'}},

So basically, I do not know how to tie a controller action to a menu item other than the default index action. If you need to see my menu in action, there is a link of the demo app deployed on Heroku on my home page.

Please advise on how to write this in your new plugin?

Thank you.

Bharat

Ryan Monday, 09 Aug, 2010 Posted at 09:17AM

Bharat -

With the old navigation plugin, you pass the items directly to the navigation helper (like you’re showing in your x = ... code). However, with the new one, you define the menus in the initializer, and then you just pass the key (in your case, :admin) to the navigation helper.

So to render you :admin menu, you would simply do this:

1
<%= navigation :admin %>

This gives you even more flexibility to put logic around menus, because you could then define all of your menus in the initializer (in your case: :admin, :leader, :eclient, :default) and simply render whichever one you need by passing the appropriate key.

This would allow you to have a method, say, on your User object that returned the role of the user and you would no longer have to worry about menu logic. For example:

1
2
3
4
5
class User < ActiveRecord::Base
  def menu_key_based_on_role
    self.role.to_sym || :default
  end
end

Then, in your layout (or wherever), you could just call:

1
<%= navigation current_user.menu_key_based_on_role %>

And your menus would render automatically. Does that make sense? I think the big point of confusion for you is with the new plugin, you no longer pass each individual item to the navigation helper.

Let me know how it goes.

Bharat Ruparel Tuesday, 10 Aug, 2010 Posted at 08:46PM

Hello Ryan,

Thanks for taking time out to respond to my queries quickly, I really appreciate it. Now that I have had time to play with your new plugin, I like it better already! I have got the basic menu going pretty much as you explain above. However, one thing I am not clear about. If you look at my ApplicationHelper class (http://github.com/bruparel/file_manager/blob/master/app/helpers/application_helper.rb), you will notice that I actually refer to the same controller (but different actions) from different tabs in the top level menu as shown below:

1
2
3
4
5
 if is_admin?
      x = navigation [:clients, :folders, :documents, {:notes => {:controller => 'client_comments', :action => 'index'}},
                      :users, {:client_perms => {:controller => 'clients', :action => 'list_perms'}},
                              {:folder_perms => {:conroller => 'folders', :action => 'list_perms'}},
                      {:admin => {:controller => 'admin', :action => 'instruct'}}]

In here, you will notice that :client refers to ClientsController – index action. whereas: :client_perms refers to ClientsController – list_perms action.

How can this be achieved in your new plugin?

I can easily refactor my design and create another controller ClientPermsController and make the list_perms action the index action of the new controller, if necessary.

Please advise.

Thanks.

Bharat

Ryan Wednesday, 11 Aug, 2010 Posted at 09:14AM

Bharat -

In general, I think it’s a good practice to have clean, focused controllers, with only the 7 RESTful actions (if possible). When you start adding methods like “list_perms” and others, you run the risk of combining concerns and that’s not always good. So my recommendation, as you pointed out, would be to split that out into its own controller: ClientPermsController. And make it the index action.

Remember, if you’re using the new navigation plugin/helper, you don’t pass the navigation items directly to the helper. Instead you define those in the initializer, and simply pass it the key that identifies the menu that was defined.

Translating your code snippet above, you might have something like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

RPH::Navigation::Builder.config do |navigation|
  navigation.define :admin do |menu|
    menu.item ClientsController
    menu.item FilesController
    menu.item DocumentsController
    menu.item ClientCommentsController, :text => 'Notes'
    menu.item UsersController
    menu.item ClientPermsController, :text => 'Client Permissions'
    menu.item FolderPermsController, :text => 'Folder Permissions'
    menu.item AdminController
  end

  navigation.define :leader do |menu|
    # ...
  end

  # more menus ...

end

Then, like I said before, you no longer need that complicated if/else series of menus in your application_helper.rb. All you need that method to do is return the key (i.e. :admin or :leader or whatever) based on the menus you’ve defined in the initializer file.

Hope that helps.

Bharat Ruparel Thursday, 12 Aug, 2010 Posted at 10:51PM

Hello Ryan,

Thanks for your response and advice. I have separated ClientPermsController and FolderPermsControllers out of ClientsController and FoldersController respectively. So that has worked out really well.

I cannot however, figure out the last part, that is: the admin section. If you look at my online demo running at Heroku: (username/password – system/important).

http://empty-fog-47.heroku.com/

Then you will see that it has an admin section which is an empty menu rendering the next level view! I had copied your old plugin’s navigation methoed and created sub_navigation method with minor changes for the second level navigation.

So I tried to adapt you new plugin’s model and created sub_navigation method by copying navigation and changing it slightly. You can see it in my code on the github.

How can I use the new plugin to emulate the second level menu? I tried the nested menu in your new plugin but that breaks my layout completely.

So I created another named menu in the initializer as follows:

1
2
3
4
5
  navigation.define :sub_navigation do |menu|
    menu.item :client_statuses
    menu.item :document_statuses
    menu.item :base_folders
  end

And tried to connect it to the :admin menu defined above it:

1
2
3
4
5
6
7
8
9
10
  navigation.define :admin do |menu|
    menu.item :clients
    menu.item :folders
    menu.item :documents
    #menu.item :client_comments, :text => "Notes"
    menu.item :users
    menu.item :client_perms
    menu.item :folder_perms
    menu.item :admin
  end

The routes file defines a named menu as follows:

1
map.admin 'admin', :controller => 'admin', :action => 'instruct'

Now, in the application layout, I call sub_navigation directly as shown below:

1
2
3
4
5
6
7
8
9
          .block

            .secondary-navigation

              = sub_navigation

              .clear

            .content

But that gives me a sub menu which has the main menu items tacked on to it! Have you tried to create second level menus like this? Please advise. This is the last hurdle to overcome for me in upgrading to your new plugin.

Thanks.

Bharat

Bharat Ruparel Saturday, 14 Aug, 2010 Posted at 10:28AM

Hey Ryan,

I am all set. Please ignore my previous post. Everything works as intended with your new plugin. Thanks for the excellent work. I would like to collaborate with you on some projects if we can.

Regards, Bharat

Bharat Ruparel Sunday, 15 Aug, 2010 Posted at 02:36PM

Hello Ryan,

I have successfully upgraded my application to Rails 2.3.8 but have run into problems with Rails 3 rc upgrade. Here is the error that I am getting:

1
2
3
4
5
6
ActionView::Template::Error (undefined method `navigation' for #<#<Class:0x00000003137f88>:0x00000003131818>):
    3: - elsif (is_leader? || is_staff?)
    4:   = navigation :leader_or_staff
    5: - else
    6:   = navigation :welcome
  app/views/shared/_main_navigation.html.haml:6:in `_app_views_shared__main_navigation_html_haml___1653348847369200762_24408400__302207085200435719'

Have you used your plugin with Rails 3 yet?

Please advise.

Thanks.

Bharat

Ryan Tuesday, 17 Aug, 2010 Posted at 10:02AM

Bharat -

Sorry, but no, I haven’t. I plan to soon, though, and I will let you know as soon as I can. I can’t think of any reason it wouldn’t be recognized from the view in Rails 3. I’ll let you know once I know.

Thanks.

Do you have something to say about this post?
Retype the image to the right Spam Hint: Are You Human? Textile Formatting Tips

or

Ryan Heath | Site Management A Ruby on Rails production.

This site is a Formed Function. Formed Function LLC | @formedfunction | Get in Touch