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 => " "))
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:
<% form_for @document do |f|
field_set_tag "Form Details" do %>
<%= f.date_select :date, :required => true, :help => "date the something happened" %>
<%= f.text_field :number, :required => true, :help => "the reference number for this thing" %>
<%= f.select :external_id, [["Choose an option...",""]] + @externals.map{|c| [c.name, c.id]}, :required => true, :label => "options", :help => "select something from the list" %>
<%= 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 thing" %>
<%= f.text_field :name, :help => "what was Willis talkin' about?" %>
<%= f.check_box :list, :label => "mailing list", :help => "can we send you a bunch of spam?" %>
<%= f.submit_and_cancel("save", "cancel") %>
<% 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
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.