Rails has always had a neglected sibling through it’s entire life, while everything else has been blessed with changes and upgrades. The view layer is still relatively unchanged since rails first release in 2004. There have been numerous attempts to try to update the view layer to de-couple the rendering of parts into various different components that can be reused.
The approach that I’ve seen often involves large numbers of partials strewn throughout the application, with varying numbers of locals passed in. Or worse, lots of duplicated HTML
, all doing very similar things. As the application grows it become increasingly painful to manage, changes are very difficult to persist over the entire application.
The solution I settled upon was to try to change the view layer into a more descriptive part of the application. Instead of looking at a template and wondering what all these elements do. Your view reads more like a story, with your own component language, specific to your application.
From this
<section id="support">
<div class="panel">
<div class="title">
<h2>Panel Title</h2>
</div>
<div class="content">
<p>This is the content of the panel</p>
</div>
</div>
</section>
To this
<section id="support">
<%= render_panel('Panel Title') do %>
<p>This is the content of the panel</p>
<% end %>
</section>
So how do we accomplish all this?
The renderer is made up of two parts, with an optional third piece.
Rails
|-App
| |-Renderers
| | |-panel_renderer.rb
| |-Helpers
| | |-renderer_helper.rb
| |-Views
| | |-Renderers
| | | |-_panel.html.erb*
(* optional)
This is the class that’s responsible for rendering the HTML
, and it looks like this:
class PanelRenderer
attr_reader :title
attr_accessor :content
def initialize(context, title, options={})
@context = context
@title = title
@options = options.reverse_merge default_options
end
def render
@context.render(layout: 'renderers/panel', locals: {renderer: self}) do
content
end
end
def close_box
@context.link_to("×", '#', class: 'close-box') if close?
end
def close?
@options[:close_box]
end
private
def default_options
{
close_box: false
}
end
end
The only API stipulation is that it has to respond to render
.
The module provides the hook into the rails view layer. With a block capture for the custom content of this component
module RendererHelper
def render_panel(title, options={}, &block)
renderer = PanelRenderer.new(title, options)
renderer.content = capture(&block)
renderer.render
end
end
Finally there’s a view that we can yield our content into, of course you don’t have to leverage views through Tilt, you could use the Builder gem to generate the HTML
programmatically.
<div class="panel">
<div class="title">
<h2><%= renderer.title %></h2>
<%= renderer.close_box %>
</div>
<div class="content">
<%= yield %>
</div>
</div>
A valid question, lots of extra code to write, and test, however in the long run, with large applications, that reuse very similar view components, there’s a single point of entry to create these items, plus the logic required to build them.
However the major benefit comes from the ability to user many of these small components to build larger more complex components. Maybe we have a couple of panels inside a set of tabs, we can re-use our panel renderer inside a tab renderer of some description.