Wednesday, January 24, 2007

capture, binding and other diabolical machinations

For the South Bend Ruby Group I volunteered to talk about form builders for February. At the time of volunteering, I knew close to nothing about form builders. I decided to take the upcoming meeting as a reason for learning about the builders. And now I'm hooked.

But that is not the point of this particular post. What I'm talking about is the bizarre magic involved in the form builder. In a project that I've been working on we have a need for rendering lists both in tables and in lists. However we'd really like to re-use the view logic in generating the list. Its pretty much a matter of hot-swapping the tags appropriately.


<% list_for(@list) do | l | %>
<% l.caption 'Hello World' %>
<% l.header_row(:id => 'list_head') do %>
<%= l.header_cell 'title' %>
<%= l.header_cell 'edit' %>
<%= l.header_cell 'delete' %>
<% end %>
<% l.row( :class => 'spam', :id => 'hot_dog') do | list_item | %>
<%= l.cell l.title %>
<%= l.cell 'edit_link' %>
<%= l.cell 'delete_link' %>
<% end %>
<% end %>


We opted to create a builder class. You call :list_for, and can optionally pass a builder class (like the one below). Of particular note there is use of bindings and captures. I'm still a bit dazzled by what all is going on (and wrote the tests to make sure it all works). Below is my understanding of what is going on.

1) There is the :concat method (as defined in the ActionView::Helpers::TextHelper module). We are taking the results of first parameter and appending it to the proc's binding. The proc's binding is the result of the stuff in that proc. In the case of the above :list_for its all of the text generated :list_for's block.

2) There is the :capture method (as defined in ActionView::Helpers::CaptureHelper module). This little critter packages up the input block as text. I tried using block.call but this didn't work as intended (I got double the block text). If I didn't want the option of passing options to each of the elements, I could conceptually simplify the method by doing a manual opening of a tag, block.call, then manually closing the tag. This could be a performance piece that I will implement at a later point (though I'll need a Hash#to_markup_attributes method).



def list_for(object, *args, &block)
raise ArgumentError, "Missing block" unless block_given?
options = args.last.is_a?(Hash) ? args.pop.symbolize_keys! : {}
builder = options.delete(:builder) || ListTableBuilder
builder.new(object, self, options, &block)
end

# <table>
# <caption>
# <tr>
# <th />
# </tr>
# <tr>
# <td />
# </tr>
# </table>
class ListTableBuilder

# Welcome to the insanely bizarre initialize function
def initialize(collection, template, options, &proc)
@collection, @template, @proc = collection, template, proc
@options = options.reverse_merge( :class => 'list', :id => 'list')
@template.concat( @template.content_tag('table', @template.capture(self, &proc) , @options ), @proc.binding)
end

# :options are applied to content_tag('td')
def cell(text, options = {})
@template.content_tag('td', text, options)
end

# :options are applied to content_tag('th')
def header_cell(title, options = {})
@template.content_tag('th', title, options)
end

# :options are applied to content_tag('caption')
def caption(text, options={})
@template.concat( @template.content_tag('caption', text, options), @proc.binding )
end

# :options are applied to content_tag('tr')
def header_row(options={}, &block)
raise ArgumentError, "Missing block" unless block_given?
@template.concat( @template.content_tag('tr', @template.capture(&block), options) , block.binding)
end

# Options
# The options will be added to each row.
# :class will append and not destroy the internal classes
# :id will be used as a prefix for the internal id generation
def row(options={}, &block)
raise ArgumentError, "Missing block" unless block_given?
options.symbolize_keys!
@collection.each do | c |
row_options = options.reverse_merge( :class => '' )
row_options[:class] += ' ' + @template.cycle('odd_row', 'even_row')
row_options[:id] += '_' + @template.send(:dom_id, c)
@template.concat( @template.content_tag('tr', @template.capture(c, &block ), row_options), block.binding )
end
end
end

2 comments:

jnunemaker said...

Very cool stuff. I'm looking forward to the form builder stuff at the next group.

Jeremy said...

Yes. I need to work on what I'm going to present.