Blog

Drying your views with DSL’s

Repetition often creeps into our views, and having a way to simplify them makes them easier to update. Enter Domain Specific Languages (DSL's); using a specific set of context-aware methods allows us to focus on the specifics of our domain. This becomes critical if our domain becomes large or complex. Even when this happens, we try to avoid having too many fields on any particular model. Sometimes, however, the simplest solution is to, temporarily, allow models to grow. At the same time it is important that your code remain manageable. In this example, though, we're not going to work with a large model, we'll work with a small one, as an example:

class Example
    #fields: name:string display:boolean number_of_views:integer --timestamps
    def created_on_date
        created_on.to_date
    end
end

In order to prevent our 'large' model's views from becoming unreadably large we're only going to display fields which have values and boolean fields which are true. This will allow us to organize our views, and shorten them so that we can begin to evaluate how to break our large model up. We'll have to manage formatting requirements for dates and such, so our system needs to be flexible enough that we can customize the output.

Beginning with what not to do

Imagine how you might build out your view:

<h3>Example Details</h3>
<dl>
  <% if @example.name.present? %>
    <dt>Name</dt>
    <dd><%= @example.name %></dd>
  <% end %>

  <% if @example.display %>
    <dt>Display</dt>
    <dd>Yes</dd>
  <% end %>

  <% if @example.number_of_views.present? %>
    <dt>Number of Views</dt>
    <dd><%= number_with_delimiter @example.number_of_views %></dd>
  <% end %>

  <dt>Created On</dt>
  <dd><%= l created_on_date %></dd>
</dl>

Not only is this code verbose, cluttered, and mostly unreadable, it requires lots of inline tweaks as you work your way down. It's not too bad, since it's only a few fields, but as we get a lot more, it will be less readable. Worst of all, most of the time you'll be copying and pasting most of this which can lead to mistakes and typos of many kinds. You'll probably have to be working with this code again at some point, so lets see how much better we can do.

Desired Results

This is just a contrived example. You might have as many as 20 fields in your large model, so we need to find a better way of marking this up:

  <%= display_for @example do |d| %>
    <h3>Example Details</h3>
    <dl>
      <%= d.display :name %>
      <%= d.display_if_true :display %>
      <%= d.delimit :number_of_views %>
      <%= d.localize :created_on_date, "Created On" %>
    </dl>
  <% end %>

Not only is this more readable, it easily allows you to standardize your presentation across multiple views and models. In addition, the helper stores your context (the model instance) and allows you to simplify references to specific fields and to work with an implied scope, as with rails form helpers.

Implementation

We're going to walk through the steps of building out this DSL by building an object that will host our context instance, provide several methods, and delegate to the main helpers as-needed.

Lets look at how we'll be creating our DSL instance. First, we look at the initialize method of our DisplayBlock object:

class DisplayBlock
  def initialize model, helper
    @model = model
    @helper = helper
  end
end

The object's methods will need to delegate out to our context and to our main helpers, so we're going to store references to them in our instance. We'll need to get those from our helper method:

module ApplicationHelper
  def display_for model, &block
    capture(DisplayBlock.new(model, self), &block)
  end
end

capture is a helper method provided by rails for stripping a block of erb from the parsing flow so that we can inject into it, reuse it, or drop it from the view. We'll be using this extensively(1).

Next, we'll need to implement the basic display-limiting code into a method. We're just going to call the base method display

  def display field, label=nil, &block
    if @model.public_send(field).present?
      content = if block_given?
        @helper.capture(@model.public_send(field), &block) 
      else
        @model.public_send(field)
      end

      disp_label = label || @model.human_attribute_name(field)

      @helper.content_tag(:dt, disp_label) + @helper.content_tag(:dd, content)
    end
  end

This allows us several different versions, depending on our needs.

display :name will show our name (with the label 'Name') as long as name has a value display :created_on_date, "Created On" will show the created_on_date with the label "Created On"

Lastly, we can provide a field with a block to further-customize a field's display:

display :created_on_date do |f|
  I18n.l f, format: :long_date
end

I18n.l is defined in the internationalization library(2) for ActiveSupport. You can customize your date formatting using your language YAML files (e.g. en.yml)

Now that we have the basic functionality, we can provide some shorthands for common methods:

  def delimit field, label=nil, &block
    display field, label do |f|
      @helper.number_with_delimiter value(f,&block)
    end
  end

  def currency field, label, &block
    display field, label do |f|
      @helper.number_to_currency value(field, &block)
    end
  end

  def localize field, label, *localize_args, &block
    display field, label do |f|
      @helper.l f, *localize_args
    end
  end  

We have a couple of cases where we want slightly different conditions for displaying fields and blocks. We can refactor some of the components in display to built variants of our main method:

  def display field, label=nil, &block
    field_value = @model.public_send(field)
    if field_value.present?
      content = value(field_value,&block)

      line(disp_label(field, label), disp_content.to_s + addon.to_s)
    end
  end

  def value(field_value, &block)
    if block_given?
      @helper.capture(field_value, &block)
    else
      field_value
    end
  end

  def disp_label(field, label)
    label || @model.human_attribute_name(field)
  end

  def line(dt, dd)
    @helper.content_tag(:dt, dt) + @helper.content_tag(:dd, dd)
  end

Now we can dynamically inject our context into existing methods, handle the boolean fields by only displaying them if true (or False), customize output, and apply conditions across multiple fields

  def display_if_true field, label, &block
    if value(@model.public_send(field),&block) == true
      line(disp_label(field, label),"Yes")
    end
  end

  def display_if_all *fields, &block
    if fields.all? {|f| @model.public_send(f).present? }
      @helper.capture(self, &block)
    end
  end

These are just a few basic examples of ways you can write shorthands that powerfully clean up your views and DRY up your markup. As an exercise, I've provided a test example. Think about how you might write a conditional section header that only shows up when the fields it applies to are present. Consider what you might rewrite (and tests) to only show the header if more than 2 are present.

class DummyHelper
  include ActionView::Helpers::TranslationHelper
  include ActionView::Helpers::TagHelper
  include ActionView::Helpers::UrlHelper
  include ActionView::Helpers::TextHelper
  include ActionView::Helpers::NumberHelper

  attr_accessor :output_buffer

  def display_for model, &block
    capture(DisplayBlock.new(model, self), &block)
  end
end

class DummyModel
  include ActiveModel

  attr_accessor :name
  attr_accessor :display
  attr_accessor :number_of_uses
end

describe DisplayBlock do
  let!(:model) { DummyModel.new }
  let!(:helper) { DummyHelper.new }

  subject { described_class.new(model, helper) }

 describe 'section' do
    context "with all blank fields" do
      it "should be blank" do
        section = subject.section :details, :h3 do |d|
          d.display :name
        end

        section.should be_blank
      end
    end

    context "with fields some present" do
      it "should show the header and field" do
        model.name = "Example 1"

        section = subject.section :details, :h3 do |d|
          d.display :name
        end

        section.should == "<h3>Details</h3><dl><dt>Name</dt><dd>Example 1</dd></dl>"
      end
    end
  end
end

Summary

When it comes to applying lots of conditional logic in your views, there are a limited number of ways to do that flow control. Using a DSL can help you to avoid repetitive idioms in your views, eliminate copying and typing errors, and adds context to a repetitive implementation. You can use objects to encapsulate your methods and delegate out to your helpers as-needed without too much trouble. Being able to test your DSL, though complicated, is also useful in making sure your implementation can handle a variety of use-cases and helps to document the DSL.