Sexy Forms in Rails

Jul 16, 2008

I've been meaning to release this for quite a while now, I've finally got around to packaging it up for some form of public consumption. Basically, I got sick of having to manually create label tags for each of my form inputs. They should be there by default so that users with visual or other impairments have enough additional information to use the site. I'm also lazy, and would have a tendency to forget to put them in otherwise. I also wanted a consistent way to display any additional contextual information, so here it is.

I'll go through the code in detail here, but I'll keep it updated with any changes and available for download at the semantic form builder git repo. So now on to the code.

It started with a custom FormBuilder.

class SemanticFormBuilder < ActionView::Helpers::FormBuilder
  include SemanticFormHelper

  def field_settings(method, options = {}, tag_value = nil)
    field_name = "#{@object_name}_#{method.to_s}" 
    default_label = tag_value.nil? ? "#{method.to_s.gsub(/\_/, " ")}" : "#{tag_value.to_s.gsub(/\_/, " ")}" 
    label = options[:label] ? options.delete(:label) : default_label
    options[:class] ||= "" 
    options[:class] += options[:required] ? " required" : "" 
    label += "<strong><sup>*</sup></strong>" if options[:required]
    [field_name, label, options]
  end

  def text_field(method, options = {})
    field_name, label, options = field_settings(method, options)
    wrapping("text", field_name, label, super, options)
  end

  def file_field(method, options = {})
    field_name, label, options = field_settings(method, options)
    wrapping("file", field_name, label, super, options)
  end

def datetime_select(method, options = {})
    field_name, label, options = field_settings(method, options)
    wrapping("datetime", field_name, label, super, options)
  end

  def date_select(method, options = {})
    field_name, label, options = field_settings(method, options)
    wrapping("date", field_name, label, super, options)
  end

  def radio_button(method, tag_value, options = {})
    field_name, label, options = field_settings(method, options)
    wrapping("radio", field_name, label, super, options)
  end

  def check_box(method, options = {}, checked_value = "1", unchecked_value = "0")
    field_name, label, options = field_settings(method, options)
    wrapping("check-box", field_name, label, super, options)
  end

  def select(method, choices, options = {}, html_options = {})
    field_name, label, options = field_settings(method, options)
    wrapping("select", field_name, label, super, options)
  end

  def time_zone_select(method, choices, options = {}, html_options = {})
    field_name, label, options = field_settings(method, options)
    # wrapping("time-zone-select", field_name, label, super, options)
    select_box = this_check_box = @template.select(@object_name, method, choices, options.merge(:object => @object), html_options)
    wrapping("time-zone-select", field_name, label, select_box, options)    
  end

  def password_field(method, options = {})
    field_name, label, options = field_settings(method, options)
    wrapping("password", field_name, label, super, options)
  end

  def text_area(method, options = {})
    field_name, label, options = field_settings(method, options)
    wrapping("textarea", field_name, label, super, options)
  end

  def submit(method, options = {})
    field_name, label, options = field_settings(method, options.merge( :label => "&#160;"))
    wrapping("submit", field_name, label, super, options)
  end

  def submit_and_cancel(submit_name, cancel_name, options = {})
    submit_button = @template.submit_tag(submit_name, options)
    cancel_button = @template.submit_tag(cancel_name, options)
    wrapping("submit", nil, "", submit_button+cancel_button, options)
  end

  def radio_button_group(method, values, options = {})
    selections = []
    values.each do |value|
      if value.is_a?(Hash)
        tag_value = value[:value]
        label = value[:label]
        help = value.delete(:help)
      else
        tag_value = value
        value_text = value
      end
      radio_button = @template.radio_button(@object_name, method, tag_value, options.merge(:object => @object, :help => help))
      selections << boolean_field_wrapper(
                        radio_button, "#{@object_name}_#{method.to_s}",
                        tag_value, value_text)
    end
    selections    
    field_name, label, options = field_settings(method, options)
    semantic_group("radio", field_name, label, selections, options)    
  end

  def check_box_group(method, values, options = {})
    selections = []
    values.each do |value|
      if value.is_a?(Hash)
        checked_value = value[:checked_value]
        unchecked_value = value[:unchecked_value]
        value_text = value[:label]
        help = value.delete(:help)
      else
        checked_value = 1
        unchecked_value = 0
        value_text = value
      end
      check_box = @template.check_box(@object_name, method, options.merge(:object => @object), checked_value, unchecked_value)
      selections << boolean_field_wrapper(
                        check_box, "#{@object_name}_#{method.to_s}",
                        checked_value, value_text)
    end
    field_name, label, options = field_settings(method, options)
    semantic_group("check-box", field_name, label, selections, options)    
  end
end

Personally I think they are a terribly underused aspect of the Rails framework. Anyway, stepping through what we've got here. We inherit from ActionView::Helpers::FormBuilder and then begin overriding the various tags that will be called within a form_for. All we do for most methods is call field_settings followed by wrapping. In the method field_settings we set the name of the field to the standard rails convention like person_first_name (@object is created for us automatically by form_for so we don't need to worry about it here). Next we set the label text to display for this form input, it will default to something sensible (generally the name of the field you are generating the input for) or you can override it per input by passing :label in as an option. And finally, we check if you have passed :required => true in and if so we set a required class so we can style it differently. All the relevant info gets returned, so we can pass it into the wrapping method.

So where exactly is wrapping? I've moved it out into a helper, as I wanted to use this functionality in regular form_tag calls as well as form_for. So take a look in semantic_form_helper:

module SemanticFormHelper
  def wrapping(type, field_name, label, field, options = {})
    help = %Q{<span class="help">#{options[:help]}</span>} if options[:help]
    to_return = []
    to_return << %Q{ <div class="#{type}-field #{options[:class]}">}
    to_return << %Q{<label for="#{field_name}">#{label}#{help}</label>} unless ["radio","check", "submit"].include?(type)
    to_return << %Q{<div class="input">}
    to_return << field
    to_return << %Q{<label for="#{field_name}">#{label}</label>} if ["radio","check"].include?(type)    
    to_return << %Q{</div></div>}
  end

  def semantic_group(type, field_name, label, fields, options = {})
    help = %Q{<span class="help">#{options[:help]}</span>} if options[:help]
    to_return = []
    to_return << %Q{<div class="#{type}-fields #{options[:class]}">}
    to_return << %Q{<label for="#{field_name}">#{label}#{help}</label>}
    to_return << %Q{<div class="input">}    
    to_return << fields.join
    to_return << %Q{</div></div>}
  end

  def boolean_field_wrapper(input, name, value, text, help = nil)
    field = []
    field << %Q{<label>#{input} #{text}</label>}
    field << %Q{<div class="help">#{help}</div>} if help
    field
  end

  def check_box_tag_group(name, values, options = {})
    selections = []
    values.each do |item|
      if item.is_a?(Hash)
        value = item[:value]
        text = item[:label]
        help = item.delete(:help)
      else
        value = item
        text = item
      end
      box = check_box_tag(name, value)
      selections << boolean_field_wrapper(box, name, value, text)
    end
    label = options[:label]
    semantic_group("check-box", name, label, selections, options)    
  end      
end

There are 3 cases to look at initially. Wrapping a normal input, wrapping a boolean input (like a check box), and wrapping a group of check boxes. For wrapping a normal input, we create a label on the left and insert the field into a div on the right. I've got the boolean one there for the 'remember me?' type of scenario on a login page, where it looked to make more sense to have the label to the right of the checkbox rather than the left. And finally, wrapping a group of elements which isn't handled at all well in the current Rails setup. Overwhelmed yet? Let's just look at how you use it then:

&lt;% form_for @document do |f|
  field_set_tag "Form Details" do %>
    &lt;%= f.date_select :date, :required => true, :help => "date the something happened" %>
    &lt;%= f.text_field :number, :required => true, :help => "the reference number for this thing" %>
    &lt;%= f.select :external_id, [["Choose an option...",""]] + @externals.map{|c| [c.name, c.id]}, :required => true, :label => "options", :help => "select something from the list" %>
    &lt;%= check_box_tag_group "document[other_items][]", @others.map{|u| { :value => u.id, :label => u.description }}, :label => "including these?", :help => "tick the whatever boxes are appropriate for this&#160;thing" %>
    &lt;%= f.text_field :name, :help => "what was Willis talkin' about?" %>
    &lt;%= f.check_box :list, :label => "mailing list", :help => "can we send you a bunch of spam?" %>
    &lt;%= f.submit_and_cancel("save", "cancel") %>
&lt;% end %>

You'll see we can pass in :required => true to visually identify required fields to the user. If we don't like the form name (like the generic 'list' attribute, then we can override with :label =>. And if there is additional contextual help to provide, we've got :help =>. We've also got a pretty way of showing a selection of check boxes with check_box_tag_group, but you'll have to specify the field name yourself. I've also put a convenience method in called submit_and_cancel that takes the text for a submit, and cancel button respectively. Catching the cancel action is up to you though.

And voila! The finished product. Any problems with it, please let me know or fork and fix/pull request on github. I've tested it on Safari 3, Firefox 2/3, and IE7. No idea what it looks like on IE6 just yet.

UPDATE: It's now a plugin, sexy and semantic forms are now available as a plugin

Hi, I'm Glenn! 👋 I've spent most of my career working with or at startups. I'm currently the Director of Product @ Ockam where I'm helping developers build applications and systems that are secure-by-design. It's time we started securely connecting apps, not networks.

Previously I led the Terraform product team @ HashiCorp, where we launched Terraform Cloud and set the stage for a successful IPO. Prior to that I was part of the Startup Team @ AWS, and earlier still an early employee @ Heroku. I've also invested in a couple of dozen early stage startups.