#162 Tree-Based Navigation (revised)
If we’re working with an application that has a lot of content we might want to create a nested menu system with multiple root menu items displayed at the top and each top menu item displaying a sidebar of nested items. We could even add a breadcrumb trail so that the user can see where in the navigation they currently are and get get back to the root items easily.
In this episode we’ll create this project from scratch and we’ll start by creating a new Rails application that we’ll call
$ rails new cms $ cd cms
So, how should we start building our application? We’ll use the user interface to drive the design and to do so we’ll look for hints of resources within it. A good indication is a list, especially if its something whose contents should be stored in the database. The list of pages in the left sidebar is such a thing; we want to be able to manage it through an administrative interface and so we’ll turn it into a resource.
The next question is “what are the attributes for each of the items?”. Each page should have a name and in this case some content, although we might want to add use a URL attribute instead of the content if we want the link to go to a more dynamic page if we don’t have a CMS-type app. The list is displayed in a tree structure so we’ll need to keep track of the place of each item. We could display this tree structure on our own but instead we’ll take advantage of a gem. There are a lot of different gems to choose from which all provide a similar interface but which are quite different in how they’re implemented and how they perform. We’ll use a gem called Ancestry which was covered in more detail back in episode 262. If we look at the README for this gem we can see the list of methods that it provides and some of these will help a lot in building our tree-based navigation, such as
root to get the root node,
ancestors to get a node’s ancestors and
descendants to get a node’s children. We’ll install the gem in the usual way by adding it to the gemfile then running
bundle to install it.
Now we can start building our application. We’ll generate a scaffold for a
Page model to get an interface up and running quickly and give it
$ rails g scaffold page name content:text ancestry:string:index $ rake db:migrate
We now have a scaffold set up where we can create new pages. The new page form has an
ancestry field which is a text field. We’ll replace this with a dropdown list of all the existing pages so the we can select a parent for the new page. We’ll modify the generated template to replace the
text_field with a
<div class="field"> <%= f.label :parent_id %><br /> <%= f.collection_select :parent_id, Page.order(:name), :id, :name, include_blank: true %> </div>
Note that we’ve changed the name to
parent_id. Next we’ll change the
Page model so that it allows the new
parent_id field to be accessible through mass assignment and add a call to
has_ancestry to add all the tree-based behaviour including the
parent_id getter and setter methods.
class Page < ActiveRecord::Base attr_accessible :parent_id, :content, :name has_ancestry end
Now when we create a page we have a select menu, although it currently doesn’t have anything in it. If we create a page then go back to create another one we’ll be able to select the first page we created as its parent.
We’ll quickly create some more pages so that we’ve got some data to work with.
Now we have a number of pages, some of which are nested under others and we have enough data to begin building our navigation with. When we visit a page we want some links at the top that will take us to the different root pages. We add these at the top of the template that displays a single page.
<ul id="menu"> <% Page.roots.each do |page| %> <li><%= link_to page.name, page %></li> <% end %> </ul>
We get the root pages by using
Page.roots which Ancestry provides. For each root page we display a list item containing a link to the page. We’ve already added some CSS so when we reload the page now the navigation appears and looks good.
It would be better if the tab for the current page looked active. We’ll modify the template to add an
active CSS class to the tab whose page is the current one.
<ul id="menu"> <% Page.roots.each do |page| %> <li><%= link_to page.name, page, class: ("active" if @page.root == page) %></li> <% end %> </ul>
Now the tab will appear active if the current page or one of its children is showing.
Creating The Sidebar
Next we’ll work on the sidebar of nested menu items. This will work like the top menu but instead of displaying the root pages we’ll loop through the current page’s children. We can do this by using Ancestry’s
children method. The tricky part here is that we want this menu to be nested so that it includes the children’s children in another list. We can make this work by moving the code that renders the current page’s children into a partial then calling that partial from the
show page. We’ll call this partial
submenu_pages and start by rendering the children of the root page so that the menu is consistent no matter what page we’re on.
<div id="submenu"> <%= render 'submenu_pages', pages: @page.root.children %> </div>
This loops through the pages that are passed to it and renders them out. For pages with children the partial is called again and their children will be rendered too.
<ul> <% pages.each do |page| %> <li> <%= link_to_unless_current page.name, page %> <%= render 'submenu_pages', pages: page.children if page.children.present? %> </li> <% end %> </ul>
When we reload the page now the submenu is displayed on the left side of the page and when we visit a page its link is removed.
Every time this sidebar is rendered out it checks the pages’ children. It would be good if we could avoid the second check for each item as this could affect performance if there are a large number of items. Ancestry provides a method that can help with this called
arrange. This takes an array of nodes and generates a set of nested hashes for the parent and its children. To get this to work we need to change the code that renders the submenu_pages partial. Instead of passing the root page’s
children into this partial we’ll pass its descendants instead.
<div id="submenu"> <%= render 'submenu_pages', pages: @page.root.descendants.arrange %> </div>
Instead of just fetching the immediate children
descendants fetches all the sub-children in one giant array. Calling
arrange on this rearranges it into a set of nested hashes which we can use to get all the data for the left menu without needing to make multiple queries. We’ll need to update our partial as it will now have the children passed to it.
<ul> <% pages.each do |page, children| %> <li> <%= link_to_unless_current page.name, page %> <%= render 'submenu_pages', pages: children if children.present? %> </li> <% end %> </ul>
When we reload the page now it looks just like it did before but it performs better as it’s making fewer database queries.
Adding a Breadcrumb Trail
The last piece of navigation we’ll add to the page is the breadcrumb trail. To render this we want to loop through the current page’s ancestors and Ancestry provides an
ancestors method which will do just this.
<div id="breadcrumbs"> <% @page.ancestors.each do |page| %> <%= link_to page.name, page %> > <% end %> </div>
When we visit a sub-page we’ll see the breadcrumb trail at the top.
We’re pretty much done with the navigation part of our application, but to finish up we’ll clean the page up a little so that it’s more finished. We’ll put the page’s name in an
h1 element so that it’s more visible and use
simple_format for the content so that we can add paragraphs and so on. We’ll remove the ancestry information as this isn’t really relevant and finally we’ll put the page’s name in the
<% content_for :title, @page.name %> <ul id="menu"> <% Page.roots.each do |page| %> <li><%= link_to page.name, page, class: ("active" if @page.root == page) %></li> <% end %> </ul> <div id="submenu"> <%= render 'submenu_pages', pages: @page.root.descendants.arrange %> </div> <div id="breadcrumbs"> <% @page.ancestors.each do |page| %> <%= link_to page.name, page %> > <% end %> </div> <p id="notice"><%= notice %></p> <h1><%= @page.name %></h1> <%= simple_format @page.content %> <%= link_to 'Edit', edit_page_path(@page) %> | <%= link_to 'Back', pages_path %>
To display the title we’ll need to make a change to the layout file.
When we reload the page now it looks much better.
That’s it for this episode. The techniques we’ve show here can be used in any application even if it’s not a CMS-style app. We could rename the
Page model to
MenuItem and give it a
url attribute to link to instead of going to the page’s controller. This way the menu items can still be dynamically generated from the database. If we do this we should think about caching the menu’s data as it won’t change very often. Episode 90 covers this in more detail.