Saturday 1 May 2010

Using partials as methods

Most of my models have an associated view that lists the records on the index page. Rails generates the code, so generally I do not worry about it, but if I am doing the same thing across a dozen models/controllers, surely it would be better to just do it once? Actually, I am not sure. You do not save any typing, as Rails generates the views (assuming you are in the habit of specifying all your columns from the start), and it is less readable this way. Anyway, let us look at how it can be done, and you can decide for yourself if it is worth while or not.

The way to achieve this is through a partial. Conceptually a partial is just a method that returns a chuck of HTML code You send it a few parameters mapped to the :local key, it processes your code, and returns the HTML. I want my partial accessible from any view, so I created a new folder, app/views/shared, with a partial called _table.rhtml.

I will be invoking my table from within a view with something like this:
<%=
render :partial => 'shared/table', :locals => {
:list => SamplesHelper::WORKSHEET_LIST,
:data => @worksheets,
:links => check_role?('analyst') ? :edit : :show,
}
%>

The render method is sent a hash with a :partial key that maps to the location of the file (without the underscore), and a :locals key with my parameters. This is the standard procedures for partils. Within locals I have chosen to require three parameters that will define how the table is drawn. The first, :data, is simply an array of ActiveRecord::Base objects, that is, the database records that Rails got for me.

The :links parameter determines whether the user sees links to show, to show and edit, or to show, edit and destroy. In this case, I am checking if the user has the "analyst" role; if he does, I want the edit and show links, otherwise just the show links.

Finally, the :list parameter is an array of hashes, which I chose to define in a helper file, and which might look like this:
WORKSHEET_LIST = [
{:heading => "Date", :column => "created_at.format_date"},
{:heading => "Type", :column => :name},
{:heading => "Sample", :column => :sample.number},
]

This will give me three columns, with the given headings, and the values from the given columns (or rather, method calls). Note that the first is a symbol, the others are strings. This will be explained later.

My partial looks like this:
<table align="center">
<tr>
<% list.each do |item| %>
<th><%= item[:heading] %></th>
<% end %>
<th colspan="<%= [:none, :show, :exit, :destroy].index(links) %>"> </th>
</tr>

<% for datum in data %>
<tr class="<%= cycle('odd', 'even') %>">
<% list.each do |item| %>
<td><%= item[:column].is_a?(Symbol) ? h(datum.send(item[:column])) : eval("datum.#{item[:column]}") %></td>
<% end %>
<td><%= link_to 'Show', { :action => :show, :id => datum.id } %></td>
<% unless links == :show %>
<td><%= link_to 'Edit', { :action => :edit, :id => datum.id } %></td>
<% end %>
<% if links == :destroy %>
<td><%= link_to 'Destroy', { :action => :destroy, :id => datum.id }, :confirm => 'Are you sure?', :method => :delete %></td>
<% end %>

</tr>
<% end %>
</table>

For the headings, it iterates through the list, pulling out the :heading value. Then it iterates over the records, and for each record again iterates through the list, this time pulling out the column value. If the :column value is a symbol, the send method is invoked, and the output HTML-escaped. If the :column value is a string, an eval is performed, allowing you to do something more involved (in the example, format a date). Note that this is not HTML-escaped; I have methods that return HTML strings to, for example, highlight values a certain colour, so this preserves that feature. However, you should consider carefully if this is safe in your situation. Note that I coud have defined a formated_created_at method in my model, and invoked that method using a symbol, rather than "created_at.format_date".

The table pads out the headings over the links at the right, and adds only those links that are requested.

As an aside, I was surprised to find that the parameters you send in the :locals hash really are local variables; I expected them to be method calls, like the supposed variables for columns in ActiveRecord.

I added a section at the top of the page that verifies the parameters are there. It just throws an exception if a required parameter is missing, and defines a default for the :links value should that one be missing.
<%
raise RuntimeError.new("list not set for _form") unless defined? list
raise RuntimeError.new("data not set for _form") unless defined? data

links = :show unless defined? links
%>

I think it is important to document your partial, so anyone using it knows what he has to supply in the way of parameters.
<%#
This partial must be sent:
- an array of hashes called "list"
- an array of ActiveRecords in "data"; the records from the database

It can also be sent:
- a value, "links" set to one of: :edit, :show, :destroy (defaults to :show)

The hashes in the array "list" must have a :heading key and a :column key.
The :heading key should map to a string, giving the column name.
The :column key should map to a symbol or string.
If a symbol, then that will be used the send method on the record, and the
output with be HTML-escaped; use this for model column names.
If a string is supplied, it will be used in an eval
method call, and the output will not be HTML-escaped.
%>

The last thing to do is to test your partial. This needs to be done as a functional test, because you need the infrastructure that that implies. This means we need a new action, let us call it _render, which can be defined in the ApplicationController class (but in the test_helper.rb file, so it only exists in your tests). The action firstly executes a string, params[:eval], which would set up any instance variables required for your test. The page is then rendered, using the partial as defined in params[:args].
class ApplicationController
def _render
eval(params[:eval]) unless params[:eval].nil?
render :inline => "<%= render #{params[:args]} %>"
end
end

You should create a new file with your other functional tests. You need to tell Rails which controller it will use (should be okay to use any of them), using the tests method. You may need to load in some records (or you can use fixtures), and you may need to log in.

The meat of the test is the get command, invoking the _render action defined before, with two arguments, the parameters for the partial, and the code for grabbing some ActiveRecords to show (both as strings, please note).
class PartialTest < ActionController::TestCase
tests ChembaseRefsController

def test_table
load_sample_records
login_as 'librarian', @request


get :_render, :args => ":partial => 'shared/table', :locals => {
:list => ChembaseRefsHelper::REFS_LIST,
:data => @chembase_refs,
:links => :destroy,
}", :eval => "@chembase_refs = ChembaseRef.find :all"

doc = REXML::Document.new @response.body
assert_equal ChembaseRef.count(:all) + 1, doc.elements.to_a("table/tr").length#, "#{@response.body}\n"
assert_equal ChembaseRefsHelper::REFS_LIST.length + 1, doc.elements.to_a("table/tr/th").length#, "#{@response.body}\n"
end

The method ends by creating an XML document from the respoonse, and testing the number of columns and rows are what they should be.


Struggling with Ruby: Contents Page

No comments: