Tabbed Panels in Rails, Part 1
May 11, 2008The tabbed panel is one of the most powerful GUI tools: an easy, intuitive way to make more information quickly accessible on a screen. This post is a practical walkthrough of implementing a tabbed panel solution in Rails using a GPL’ed plugin. Part 1 will demonstrate a basic tabbed panel implementation.
I’m developing a custom project management application in Rails for one of my clients. They need to organize large number of tasks for each project, and those tasks are divided into three categories: Action Items, Problem Reports, and Work Orders. The three types are pretty parallel in structure, and in fact I implemented them all in one table called Tasks with Single Table Inheritance.
Here’s an example of tables of the three types of tasks.
The goal is to collapse these three tables into a single tabbed panel, with tabs for Action Items, Work Orders, and Problems reports to quickly switch between the three tables.
Why I’m not using AJAX for this
Many Rails developers would implement this by drawing the tabs and using tab clicks to trigger a reload of the task listing, replacing the old listing with the right kind of tasks using AJAX and link_to_remote. This would work nicely, but would fail to satisfy one of my clients other desires: when a project’s page is printed, they would like all three categories of tasks to display on the page, each in its own table. If we actually render all three tables in a tabbed panel, then we can turn all three on with CSS in a stylesheet set to media=”print”, and solve both problems without having to generate a separate page!
The railstabbedpanel plugin
Several plugins exist that can do tabbed panels in Rails. After glancing at a couple, I picked railstabbedpanel, which is simple and looked like it would suit my needs nicely. The documentation is a bit sparse, but the “simple example” was enough to put together the basic task I needed. Download the archive and drop it in as vendor/plugins/railstabbedpanel.
As it turns out, this plugin needs a little hackery to implement a more advanced feature I want, but we’ll get to that in Part 2.
On to the implementation!
I start by loading the tasks I want to display into three instance variables:
in app/controllers/project.rb:
def show
@project = Project.find(params[:id])
@action_items=@project.action_items
@work_orders=@project.work_orders
@problem_reports=@project.problem_reports
end
To render a set of panels with railstabbedpanel, you pass a block to the tabbed_panel function, which creates a “tab context” object for you to use. The tab context object (which I called “tabctx” as per the example docs for railstabbedpanel) has a function, panel, which itself takes a block - the content you want inside the panel, and a parameter, which is the title you want to appear on your tab. There are plenty of other options you can pass, but we won’t need any of them in part one.
Since I’m going to be making three panels, I’ll put that in a partial called _task_panel, to which I’ll pass the tab context object and the list of tasks to display.
in app/views/project/show.html.erb
<% tabbed_panel do |tabctx| %>
<%= render :partial => "task_panel", :locals => {
:tabctx => tabctx,
:tasks => @action_items,
:title => "Action Items"} %>
<%= render :partial => "task_panel", :locals => {
:tabctx => tabctx,
:tasks => @work_orders,
:title => "Work Orders"} %>
<%= render :partial => "task_panel", :locals => {
:tabctx => tabctx,
:tasks => @problem_reports,
:title => "Problem Reports"} %>
<% end %>
in app/views/project/_task_panel.html.erb:
<% tabctx.panel(title) do %>
<table class='listing'>
<%= render :partial => "task_header_row" %>
<%= render :partial => "task", :collection => tasks %>
</table>
<% end %>
The two partials above render a fairly simple array of
We also need a little help from CSS to get the display right, as indicated by the documentation for railstabbedpanel:
in public/stylesheets/application.css:
.panel_selected {
display: block;
}
.panel_unselected {
display: none;
}
.tab_selected {
background-color: gray;
}
.tab_unselected {
background-color: white;
}
This works — sort of — we get our tab names, and clicking on them will activate the appropriate panels. But it certainly doesn’t look too good@ With just the suggested formatting, railstabbedpanel outputs our tabs as a stack of list items:
Obviously we’d like to improve on this. Let’s take a look at the HTML generated by the railstabbedpanel plugin:
<div class='tabbed_panel' id='tabbed_panel_124'>
<ul class='tab_container' id='tabbed_panel_124_tabs'>
<li class='panel_tab tab_selected ' id='tabbed_panel_124_126_tab'>
<a href="#" onclick="tabbed_panel_124_125('tabbed_panel_124_126');
return false;">Action Items</a>
</li>
<li class='panel_tab tab_unselected ' id='tabbed_panel_124_127_tab'>
<a href="#" onclick="tabbed_panel_124_125('tabbed_panel_124_127');
return false;">Work Orders</a>
</li>
<li class='panel_tab tab_unselected ' id='tabbed_panel_124_128_tab'>
<a href="#" onclick="tabbed_panel_124_125('tabbed_panel_124_128');
return false;">Problem Reports</a>
</li>
</ul>
<ul class='panel_panels' id='tabbed_panel_124_panels'>
<li class='panel_panel panel_selected ' id='tabbed_panel_124_126_panel'>
<table class='listing' id='action_items'>
... etc ....
So what we have is a ul with the class tab_container, with one li for each tab, each of which has class panel_tab and an additional class for either selected or unselected. The panels themselves are in a second ul of class panel_panels, with list items of class panel_panel. That’s fairly clean semantic markup, and in fact this is one of the reasons I chose railstabbedpanel in the first place.
So, let’s add a couple of basic rules to li.panel_tab to float the tabs next to each other, and some extra space at the top of ul.panel_panels. These few extra rules get us much closer to what we want:
li.panel_tab {
list-style: none;
float: left;
padding: 1em;
margin-right: .5em;
}
ul.panel_panels {
clear: both;
}
The result looks like this, which is a nice little functional tabbed panel:
We could play this game with CSS all day trying to get the tab styling just right and testing it in all the relevant web browsers. But as with everything else, some other developer has already struggled through this before, and with Google I dug up this lovely little reference at Adobe. I applied those styles to the containers determined previously by inspecting the HTML generated by the tabbed panel plugin, and tweaked the colors and fonts a bit to match my client’s application. The resulting CSS looks like this:
/*------------ TABBED PANELS -------------------*/
/* many styles from http://labs.adobe.com/technologies/spry/articles/tabbed_panel/ */
.tabbed_panel {
padding: 0px;
clear: both;
width: 100%; /* IE Hack (hasLayout Bug)*/
}
.panel_panel {
padding: 0.5em 0;
}
.panel_selected {
display: block;
}
.panel_unselected {
display: none;
}
.tab_container {
margin: 0px;
padding: 0px;
}
ul.panel_panels {
clear: both;
border-left: solid 1px #CCC;
border-bottom: solid 1px #CCC;
border-top: solid 1px #999;
border-right: solid 1px #999;
background-color: white;
padding: 10px;
}
li.panel_tab {
position: relative;
top: 1px;
float: left;
padding: 4px 10px;
margin: 0px 3px 0px 0px;
font: bold 1.2em sans-serif;
list-style: none;
border-left: solid 1px #CCC;
border-bottom: solid 1px #999;
border-top: solid 1px #999;
border-right: solid 1px #999;
-moz-user-select: none;
-khtml-user-select: none;
cursor: pointer;
}
li.tab_selected {
background-color: white;
border-bottom: 1px solid white;
}
li.tab_unselected {
background-color: #aac;
}
li.tab_unselected:hover {
background-color: #A77;
}
li.panel_tab a {
color: black;
text-decoration: none;
}
li.panel_tab a:hover {
color: #009;
}
Which results in this much improved tabbed panel:

Testing It
As I mentioned in a previous post, I think it’s important for tutorials to explain not just how to write the code, but how to test it as well, so let’s take a look at some tests that make this happen.
To start with, I need to add one small bit to the HTML to make it easily testable. If the tables that contain the task information each have an id specifying which group of tasks they contain, it will be easier to target them with assert_tag. So, I added such an ID to each table, using the name of the title, converted to lowercase with underscores for spaces, making them ‘action_items’ and so forth:
In app/views/project/_task_panel.html.erb
<table class='listing' id='<%= title.gsub(/ /,'_').downcase %>'>
Now in the test case for the show action on a project, I created a method that can look for a table whose id matches such a title. Inside the same function, I look for rows with an id constructed to match that task’s ID number (this is generated by the _task.html.erb partial, which I didn’t show above). And I look for a cell in that row whose text matches the “summary” field of the task. That should be sufficient to determine that each task of the appropriate type has a row in that table, though you can of course test for plenty more tags and attributes if you like.
In test/functional/project_controller_test.rb:
private
# Test that the table containing rows for all registered tasks are correctly
# rendered. The task type should be sent in underscore format, like
# "action_item" or "work_order"
def test_tasks_table(title,tasks)
id = title.gsub(/ /,'_').downcase
table_tag = {:tag=>'table', :attributes=>{:id=>id}}
assert_tag table_tag
#li containing the appropriate title for the tab
assert_tag( { :tag => 'li',
:attributes => { :class => /panel_tab/ },
:content => /#{Regexp.escape(title)}/ } )
# check for all the task rows
tasks.each do |task|
row_tag = { :tag => 'tr', :ancestor => table_tag,
:attributes => { :id => "task_row_#{task.id}" } }
assert_tag row_tag
assert_tag( { :tag => 'td', :parent => row_tag,
:content => /#{task.summary}/ } )
#...test any other important aspects of the tasks' TR row here...
end
end
And then I simply call this for all three task types in the method that tests the “show” action for a project. In this case, @proj_one contains an instance of the project model pulled from fixtures:
In test/functional/project_controller_test.rb:
def test_show
get :show, :id => @proj_one.id
assert_response :success
assert_template 'show'
#assert the project object
assert_not_nil assigns(:project)
assert assigns(:project).valid?
#confirm that tasks row summaries are listed correctly
test_tasks_table("Action Items",@proj_one.action_items)
test_tasks_table("Work Orders",@proj_one.work_orders)
test_tasks_table("Problem Reports",@proj_one.problem_reports)
end
Okay, that works great, now we’ve confirmed that the tables are output correctly. Let’s move on and test the tabs themselves. As we saw above when examining the HTML, the tabs are a set of li.panel_tabs inside a ul.tab_container. The whole thing is contained inside a div.tabbed_panel. We want to confirm that each tab contains text matching the appropriate title. So, one quick helper method an three lines added to our test_show method will test these for us:
Added to test/functional/project_controller_test.rb
def test_show
...
#confirm that the three tabs exist
assert_panel_tab "Action Items"
assert_panel_tab "Work Orders"
assert_panel_tab "Problem Reports"
end
def assert_panel_tab title
ancestor = {:tag => 'div', :attributes => {:class => "tabbed_panel" }}
parent = {:tag => 'ul', :attributes => {:class => "tab_container" }}
assert_tag({:tag => 'li',
:attributes => {:class => /panel_tab/},
:parent => parent,
:ancestor => ancestor,
:content => /#{title}/
})
end
Note that I’m not testing :class => "panel_tab" for the tabs, but using match instead of a string. This is because the actual full class is going to be either class="panel_tab selected" or class="panel_tab unselected", and we want to match the tag in either case.
What’s next?
That’s it for this installment. In Part 2, I’ll explain how to endow the tabs with memory, so that if you click on, say, Problem Reports, and then leave the page, that same tab will still be in front when you come back. In Part 3, I’ll look at some more advanced styling, including making all three tables show nicely on the printout.



Add A Comment