Not very DRY. Not easy to maintain. Not pretty to look at.
So...What's a good solution that allows me to write the HTML once, and have one place to maintain it, while still being able to use it to dynamically evaluate the template based on parameterized data on both the server and client sides?
Below is a solution that I came up with. (And I would love to hear any better ideas).
For brevity, I'll use a very simple HTML structure in these examples, but in practice I have used this with much more complex structure and logic. Here's what our example table looks like:
<table id="items">
<tr>
<td class="name">Shirt</td>
<td class="desc">White short-sleeved shirt</td>
<td class="price">$5.99</td>
</tr>
<tr>
<td class="name">Pants</td>
<td class="desc">Blue pants with zipper</td>
<td class="price">$25.99</td>
</tr>
</table>
Assume we have this built using Haml, and there are two partials: one for the top-level table, and one for an individual row. E.g.:
items_table.haml:
%table#items
- items.each do |item|
= render :partial => 'item_row', :locals => {:item => item}
item_row.haml:
%tr
%td.name= item.name
%td.desc= item.desc
%td.price= item.price
In the client-side javascript in our top-level Haml partial, assume we have some function that gets called from some user input action (like filling out a form and clicking a button). E.g.:
:javascript
var addItem = function(name, desc, price) {
var html =
"<tr>" +
" <td class=\"name\">" + name + "</td>" +
" <td class=\"desc\">" + desc + "</td>" +
" <td class=\"price\">" + price + "</td>" +
"</tr>";
$('items').insert(html); // using prototype.js
};
OK...so this doesn't seem so bad with such a simple example, but imagine that the HTML structure for a table row is something much more complex, with conditionals based on the item, and maybe even usage of additional sub-partials. This maintenance for this becomes a nightmare.
This first thing I am going to do is change the item_rows.haml partial. Instead of having it actually render the item's values, it's going to render template replacement variables using #{...} syntax. Note, these are NOT interpolated in the partial. We'll do that in a separate phase from rendering:
item_row.haml:
%tr
%td.name #{item_name}
%td.desc #{item_desc}
%td.price #{item_price}
Next, let's make a couple helper methods. The first one returns this partial rendered as a string. (I do this because in the real world, this takes a number of parameters, and adding a helper layer makes things easier to manage than always calling render directly). The second one renders this partial and then interpolates the #{...} strings inside it, based on the properties of the item we want to render -- effectively making this equivalent to rendering the original version of this template.
item_helper.rb:
module ItemHelper
# Make ActionController::Base#render_to_string available.
helper_method :render_to_string
def item_row_template
render_to_string :partial => item_row
end
def item_row(item)
# Get the template.
t = item_row_template
# Define the template replacement variables.
item_name = item.name
item_desc = item.desc
item_price = item.price
# Evaluate the template
eval(t.inspect.gsub("\\#", "#"))
end
end
This, of course, means we change the top-level partial to use this new item_row helper, like so:
items_table.haml:
%table#items
- items.each do |item|
= item_row(item)
At this point, we've got a functionally equivalent server-side. But the big benefit is that now the HTML template returned by the item_row_template helper method can also be used to do the same substitutions on the client side, thanks to the handy Template class in prototype.js. The Haml that defines the javascript addItem method now looks like this:
:javascript
var addItem = function(name, desc, price) {
var html = new Template(
'#{escape_javascript(item_row_template)}'
).evaluate({
item_name : name,
item_desc : desc,
item_price : price
});
$('items').insert(html);
};
Now, when the markup for the item row changes, it only needs to change in one place, and both the server-side and client-side benefit from it. This certainly feels more DRY...but also seems like a hefty price to pay in terms of complexity...