Monday, March 5, 2007

table_for erb template

Background:

Rails provides a form_for method which can be used in a view to generate an html form. When this is used, there is no need to specify the <form> tags and, when combined with the convenience methods such as text_field, select and radio_button, very little html needs to be handwritten.

At my current project we create a lot of CRUD interfaces. We found that we were often creating simple tables displaying attribute name-value pairs for a given object in the 'Read' view. Inspired by the Rails Recipes 'tabular_form_for' template, my coding pair and I decided to create a table_for template.

For example:

Let's say an Aeroplane object has two attributes:

:name, :string
:model_number, :string

An rhtml view for displaying the Aeroplane object might look like this:

<table>
<tr>
<th>Aeroplane Name</th> <td><%=h @aeroplane.name %></td>
</tr>
<tr>
<th>Model Number</th> <td><%=h @aeroplane.model_number %></td>
</tr>
</table>

<%= link_to 'Edit', :action => 'edit', :id => @aeroplane %> |
<%= link_to 'Back', :action => 'list' %>

We want to remove as much of that html code as possible. A syntax like this would be nice:
     
<% table_for @aeroplane do |t| %>
<%= t.print :name %>
<%= t.print :model_number %>
<% t.link_to 'Edit', url_for(:action => 'edit', :id => @aeroplane) %>
<% t.link_to 'Back', url_for(:action => 'list') %>
<% end %>

We put the 'table_for' method in application_helper.rb, so that it is available to all templates. It creates a TableForContext object to perform all the work for us, yields this object back to the caller, and wraps any output generated in 'table' tags.

def table_for(object, &block)
concat('<table>', block.binding)
context = TableForContext.new(object, block.binding)
yield context
concat(context.links + '</table>', block.binding)
end

The 'print' method in TableForContext is straight-forward:

def print(field)
"<tr>" + "<th>" + field.to_s.humanize + "</th>" + "<td>#{@object.send(field)}</td></tr>"
end

We want the links to be side-by-side in a separate row at the bottom. You'll notice in the view that the result of the 'print' method is output directly (it is wrapped in <%= %> tags), whereas the 'link_to' method is not (it is wrapped in <% %> tags). The links are concatenated together to be output at the end:

def link_to(link_name, url)
@links << ' ' unless @links.empty?
@links << class =""> 'button')", @view_binding)
end

All that is left is some customization options. The ones we have required at so far are:

- custom wrapping of the attribute value in other html (eg making the value link to another page).
- custom headers (ie not just the attribute name humanized)
- custom css classes on the table headers

Let's say that an aeroplane has an associated Manufacturer object. We add a manufacturer_id attribute to aeroplane (and associated belongs_to and has_many declarations).

Let's say that the associated Manufacturer object has two attributes:

:name, :string
:address, :string

And that in the Aeroplane view we want to display the aeroplane's manufacturer name as a link to the manufacturer 'Show' view. Let's also say that we want a custom label on our Aeroplane 'name' attribute, and a class called "header" on all of our table headers.

With some changes to our 'table_for' and 'print' methods we should be able to accommodate the following syntax:

<% table_for @aeroplane, :header_class => "header" do |t| %>
<%= t.print :name, :label => 'Super Happy Aeroplane Name:' %>
<%= t.print :manufacturer do |manufacturer, aeroplane|
link_to manufacturer.name, manufacturer_url(:id => @aeroplane.manufacturer_id)
end %>
<%= t.print :model_number %>
<% t.link_to 'Edit', edit_aeroplane_url(:id => @aeroplane) %>
<% t.link_to 'Back', aeroplanes_url %>
<% end %>

The 'table_for' method now looks like this:

def table_for(object, options={}, &block)
concat('<table>', block.binding)

context = TableForContext.new(object, block.binding, options)
yield context

concat(context.links + '</table>', block.binding)
end

The constructor for TableForContext becomes:

def initialize(object, view_binding, options={})
@object = object
@view_binding = view_binding
@links = ''
@options = options
end

And the 'print' method now looks like:

def print(field, options={})
label = options[:label].nil? ? field.to_s.humanize : options[:label]
if block_given?
content = yield @object.send(field), @object
else
content = @object.send(field)
end
"<tr>" + th(label) + "<td>#{content}</td></tr>"
end

We have added a private method 'th' which adds the header class, if specified:

def th(content)
html = '<th'
if @options[:header_class]
html << " class=\"#{@options[:header_class]}\""
end
html << ">#{content}</th>"
end

2 comments:

Jim McSlim said...

Dude, check out Streamlined. I think it does what you are doing plus a bunch more. Despite a bit of slippery start they seem to have a lot more momentum now. There are some screencasts on their website demonstrating it in detail.

Shane Harvie said...

Yeah, I've been meaning to look at it again. Stuart Halloway, one of the guys that wrote Streamlined took me for some training a while back. He's a really smart guy, but it wasn't quite ready when I looked at it. So thanks, I'll check it out again.