Learn how to plug in to Rails' form builders to speed up application development and avoid duplication.
March 30, 2021
At Brand New Box, we write a lot of forms using Rails. Each of our projects has a unique look and feel to it which usually means that the HTML of our forms is structured a bit differently across each of our applications. As a developer switching between those projects you would have to learn exactly how the forms are structured and if we ever changed that it would involve us changing every form in the application to the new style. Here is how we leverage Rails' form builders to build our forms using the same API across all of our applications and also have a single place where the structure of the form is defined so that if we want to make a change to the structure everything is automatically updated. This allows our developers can quickly switch between projects and build forms consistently!
The Rails framework provides many helpers for presenting data to users in your applications. One of those is the suite of form helpers which generate HTML elements for capturing input from your users. Specifially, whenever you use
form_for
or
form_with
Rails will give you a form builder which gives you quick access to these helpers. In the picture below, the
f
argument that is yielded to the block is an instance of Rail's default form builder
(
ActionView::FormBuilder
)
This default form builder is a very low level API. You declare every label and field that you want in your form and you mix and match those calls with the HTML structure that you want your form to be rendered with.
This form builder has several of perks.
for
attributes are added to the label elements which is good for accessbility and usability of your form.
But with all of it's perks, there are some disadvantages to the default form builder. Those annoyances mostly center around the amount of boilerplate that you have to write. If you have a specific structure or adding specific classes to your form inputs then you will have to add those classes every time that you call one of the form helpers.
I don't think it's intended that anyone build their large scale application with only using the default Rails form builder. There are ways to get around some of the annoyances. Several people have built libraries dedicated to giving you a better API on top of the default. At Brand New Box we've used Simple Form quite a bit in the past for it's power and flexibility. It allowed us to abstract the structure of our forms and have a single place we could update that. That did come at a bit of a cost as our whole team had to learn the custom DSL that simple form provies you with and anytime that we onboard someone we also had to teach them.
= simple_form_for @user do |f|
= f.input :username
= f.input :password
However Rails gives us another ability, we can customize the default form builder that is used to build the forms. In fact, this is the technique that libraries like Simple Form and Formtastic use! They provide a custom form builder which is what you are using when you are calling methods like
f.input
.
We figured out that we could use that same technique and write our own custom form builder that was all of our own code.
= form_with model: @user, builder: AppFormBuilder do |f|
.form-group
= f.label :username
= f.text_field :username
.form-group
= f.label :password
= f.text_field :password
When using the
builder
argument Rails will yield an instance of the class that is passed in to the form builder. On that custom class we can add whatever methods that we want.
class AppFormBuilder < ActionView::Helpers::FormBuilder | |
delegate :tag, :safe_join, to: :@template | |
def input(method, options = {}) | |
@form_options = options | |
object_type = object_type_for_method(method) | |
input_type = case object_type | |
when :date then :string | |
when :integer then :string | |
else object_type | |
end | |
override_input_type = if options[:as] | |
options[:as] | |
elsif options[:collection] | |
:select | |
end | |
send("#{override_input_type || input_type}_input", method, options) | |
end | |
private | |
def form_group(method, options = {}, &block) | |
tag.div class: "form-group #{method}" do | |
safe_join [ | |
block.call, | |
hint_text(options[:hint]), | |
error_text(method), | |
].compact | |
end | |
end | |
def hint_text(text) | |
return if text.nil? | |
tag.small text, class: "form-text text-muted" | |
end | |
def error_text(method) | |
return unless has_error?(method) | |
tag.div(@object.errors[method].join("<br />").html_safe, class: "invalid-feedback") | |
end | |
def object_type_for_method(method) | |
result = if @object.respond_to?(:type_for_attribute) && @object.has_attribute?(method) | |
@object.type_for_attribute(method.to_s).try(:type) | |
elsif @object.respond_to?(:column_for_attribute) && @object.has_attribute?(method) | |
@object.column_for_attribute(method).try(:type) | |
end | |
result || :string | |
end | |
def has_error?(method) | |
return false unless @object.respond_to?(:errors) | |
@object.errors.key?(method) | |
end | |
# Inputs and helpers | |
def string_input(method, options = {}) | |
form_group(method, options) do | |
safe_join [ | |
(label(method, options[:label]) unless options[:label] == false), | |
string_field(method, merge_input_options({class: "form-control #{"is-invalid" if has_error?(method)}"}, options[:input_html])), | |
] | |
end | |
end | |
def text_input(method, options = {}) | |
form_group(method, options) do | |
safe_join [ | |
(label(method, options[:label]) unless options[:label] == false), | |
text_area(method, merge_input_options({class: "form-control #{"is-invalid" if has_error?(method)}"}, options[:input_html])), | |
] | |
end | |
end | |
def boolean_input(method, options = {}) | |
form_group(method, options) do | |
tag.div(class: "custom-control custom-checkbox") do | |
safe_join [ | |
check_box(method, merge_input_options({class: "custom-control-input"}, options[:input_html])), | |
label(method, options[:label], class: "custom-control-label"), | |
] | |
end | |
end | |
end | |
def collection_input(method, options, &block) | |
form_group(method, options) do | |
safe_join [ | |
label(method, options[:label]), | |
block.call, | |
] | |
end | |
end | |
def select_input(method, options = {}) | |
value_method = options[:value_method] || :to_s | |
text_method = options[:text_method] || :to_s | |
input_options = options[:input_html] || {} | |
multiple = input_options[:multiple] | |
collection_input(method, options) do | |
collection_select(method, options[:collection], value_method, text_method, options, merge_input_options({class: "#{"custom-select" unless multiple} form-control #{"is-invalid" if has_error?(method)}"}, options[:input_html])) | |
end | |
end | |
def grouped_select_input(method, options = {}) | |
# We probably need to go back later and adjust this for more customization | |
collection_input(method, options) do | |
grouped_collection_select(method, options[:collection], :last, :first, :to_s, :to_s, options, merge_input_options({class: "custom-select form-control #{"is-invalid" if has_error?(method)}"}, options[:input_html])) | |
end | |
end | |
def file_input(method, options = {}) | |
form_group(method, options) do | |
safe_join [ | |
(label(method, options[:label]) unless options[:label] == false), | |
custom_file_field(method, options), | |
] | |
end | |
end | |
def collection_of(input_type, method, options = {}) | |
form_builder_method, custom_class, input_builder_method = case input_type | |
when :radio_buttons then [:collection_radio_buttons, "custom-radio", :radio_button] | |
when :check_boxes then [:collection_check_boxes, "custom-checkbox", :check_box] | |
else raise "Invalid input_type for collection_of, valid input_types are \":radio_buttons\", \":check_boxes\"" | |
end | |
form_group(method, options) do | |
safe_join [ | |
label(method, options[:label]), | |
tag.br, | |
(send(form_builder_method, method, options[:collection], options[:value_method], options[:text_method]) do |b| | |
tag.div(class: "custom-control #{custom_class}") { | |
safe_join [ | |
b.send(input_builder_method, class: "custom-control-input"), | |
b.label(class: "custom-control-label"), | |
] | |
} | |
end), | |
] | |
end | |
end | |
def radio_buttons_input(method, options = {}) | |
collection_of(:radio_buttons, method, options) | |
end | |
def check_boxes_input(method, options = {}) | |
collection_of(:check_boxes, method, options) | |
end | |
def string_field(method, options = {}) | |
case object_type_for_method(method) | |
when :date then | |
birthday = method.to_s =~ /birth/ | |
safe_join [ | |
date_field(method, merge_input_options(options, {data: {datepicker: true}})), | |
tag.div { | |
date_select(method, { | |
order: [:month, :day, :year], | |
start_year: birthday ? 1900 : Date.today.year - 5, | |
end_year: birthday ? Date.today.year : Date.today.year + 5, | |
}, {data: {date_select: true}}) | |
}, | |
] | |
when :integer then number_field(method, options) | |
when :string | |
case method.to_s | |
when /password/ then password_field(method, options) | |
# when /time_zone/ then :time_zone | |
# when /country/ then :country | |
when /email/ then email_field(method, options) | |
when /phone/ then telephone_field(method, options) | |
when /url/ then url_field(method, options) | |
else | |
text_field(method, options) | |
end | |
end | |
end | |
def custom_file_field(method, options = {}) | |
tag.div(class: "input-group") { | |
safe_join [ | |
tag.div(class: "input-group-prepend") { | |
tag.span("Upload", class: "input-group-text") | |
}, | |
tag.div(class: "custom-file") { | |
safe_join [ | |
file_field(method, options.merge(class: "custom-file-input", data: {controller: "file-input"})), | |
label(method, "Choose file...", class: "custom-file-label"), | |
] | |
}, | |
] | |
} | |
end | |
def merge_input_options(options, user_options) | |
return options if user_options.nil? | |
# TODO handle class merging here | |
options.merge(user_options) | |
end | |
end |
This is no doubt a very dense 211 lines of code. But the advantage here is that this code lives in our repository and it's normal Rails/Ruby code. Anybody on the team can edit it without having to learn a custom DSL.
Some features that we have built into this custom form builder include
f.input
which generates a label and input for a given method of a model.
= f.input :description, as: :text
f.collection
,
form_group
method in the form builder.
None of these features came for free, they all had to be built. But any developer on our team can look through the code and see how those things got there.
With all of those advantages we can now write the the previous form as follows.
= form_with model: @user, builder: AppFormBuilder do |f|
= f.input :username
= f.input :password
We even often make a special helper for generating a form using our custom builder.
def app_form_for(name, *args, &block)
options = args.extract_options!
args << options.merge(builder: AppFormBuilder)
form_for(name, *args, &block)
end
def app_form_with(model: nil, scope: nil, url: nil, format: nil, **options, &block)
options = options.reverse_merge(builder: AppFormBuilder)
form_with(model: model, scope: scope, url: url, format: format, **options, &block)
end
Now that we have built out that custom form builder we copy it to each of our projects and make per projects customizations to it. The workflow has worked very well for our team. It has given us the right amount of power and flexibility while still being able to onboard new team members quickly.