field_error_proc
I always wondered how to set bypass ActionView::Base.field_error_proc
on
certain forms while leaving it alone for others. It defaults to wrapping form
fields with div.field_with_errors
, but I sometimes want to handle errors a bit
differently.
In my case, I’m working on a Rails engine that includes a custom form builder. It has custom form error handling and doesn’t rely on the proc at all, so I wanted to bypass it when the developer is using my form builder but leave it alone otherwise.
It was surprisingly involved! My first tries didn’t work out. I’ll go over how I figured out where to look, but feel free to skip the investigation and jump to the solution at the bottom, step #7, or see the end result.
field_error_proc
being called?Working backwards, I did a quick grep (using ripgrep) for that proc and found where it’s being invoked.
# action_view/helpers/active_model_helper.rb
module ActionView::Helpers::ActiveModelInstanceTag
def error_wrapping(html_tag)
if object_has_errors?
Base.field_error_proc.call(html_tag, self)
else
html_tag
end
end
end
I then looked around to see what called #error_wrapping
, but it wasn’t too
helpful—they were tag
and content_tag
(and one other place, but that
isn’t important here), but they aren’t the methods you’re most likely to be used
to using (that would be ActionView::Helpers::TagHelper#tag
).
ActiveModelInstanceTag
being included?I did another quick search which brought me a step closer
# action_view/helpers/tags/base.rb
class ActionView::Helpers::Tags::Base
include Helpers::ActiveModelInstanceTag
end
I then checked the directory to see if I could find the classes that inherit from that, and bingo! A bunch of classes including:
module ActionView::Helpers::Tags
# action_view/helpers/tags/label.rb
class Label < Base
# ...
end
# action_view/helpers/tags/text_field.rb
class TextField < Base
# ...
end
end
TextField
?I searched for TextField
this time and found quite a few results, including:
# action_view/helpers/form_helper.rb
module ActionView::Helpers::FormHelper
def text_field(object_name, method, options = {})
Tags::TextField.new(object_name, method, self, options).render
end
end
text_field
?At this point, I thought I found the last piece; I thought that this was
f.text_field
. This assumption turned out to be incorrect. I jumped into
working on a solution, but I ran into some errors that made me realize I had to
dig a bit further.
I didn’t know where to look next, so I decided to look from the opposite direction.
Rails’s form_for
helper accepts a builder:
option which lets
me define a custom form builder with custom methods that could be, like
f.custom_method
. I figured it was reasonable that that class might define the
method I was looking for.
I found this a little differently from the steps before. I created a custom form builder class like that documentation above mentioned, and I called it. I opened up the Rails console and tried to find where the method was defined.
[1] pry(main)> ls ActionView::Helpers::FormBuilder
Object.methods: yaml_tag
ActionView::Helpers::FormBuilder.methods:
_to_partial_path field_helpers field_helpers= field_helpers?
ActionView::Helpers::FormBuilder#methods:
button fields_for password_field
check_box file_field phone_field
collection_check_boxes grouped_collection_select radio_button
collection_radio_buttons hidden_field range_field
collection_select index search_field
color_field label select
date_field month_field submit
date_select multipart telephone_field
datetime_field multipart= text_area
datetime_local_field multipart? text_field
datetime_select number_field time_field
email_field object time_select
emitted_hidden_id? object= time_zone_select
field_helpers object_name to_model
field_helpers= object_name= to_partial_path
field_helpers? options url_field
fields options= week_field
[2] pry(main)> ActionView::Helpers::FormBuilder.instance_method(:text_field).source_location
=> ["action_view/helpers/form_helper.rb", 1906]
Perfect! Let’s see… some metaprogramming. At least there’s a helpful comment!
# action_view/helpers/form_helper.rb
class ActionView::Helpers::FormBuilder
(field_helpers - [...]).each do |selector|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{selector}(method, options = {}) # def text_field(method, options = {})
@template.send( # @template.send(
#{selector.inspect}, # "text_field",
@object_name, # @object_name,
method, # method,
objectify_options(options)) # objectify_options(options))
end # end
RUBY_EVAL
end
end
Again using the console, I verified that the "text_field"
method that was
being invoked via send
was the first text_field
method we came across in
step 3.
That’s it, we went through everything. Let’s go through what what we found out.
form_for
yields an instance of ActionView::Helpers::FormBuilder
ActionView::Helpers::FormBuilder#text_field
callsActionView::Helpers::FormHelper#text_field
which instantiatesActionView::Helpers::Tags::TextField
which inherits fromActionView::Helpers::Tags::Base
which includes the moduleActionView::Helpers::ActiveModelInstanceTag
which defines the method#error_wrapping
, which checks if there are errors and callsActionView::Base.field_error_proc
Bypassing the proc will be a little hard to accomplish because we lose some
context going through each layer/abstraction. We’ll have to find a way to
communicate from form_for
all the way down to #error_wrapping
.
In Ruby, it’s pretty easy to redefine an instance method on a specific instance without affecting any other instance.
I tried overriding error_wrapping
to have it always return the original HTML
without checking if there was an error.
class CustomFormBuilder < ActionView::Helpers::FormBuilder
def initialize(*)
super
# We're adding this private method to the instance (the inner eval is the
# important one)
@template.class_eval do
private
def ignore_field_error_proc(instance)
instance.class_eval do
def error_wrapping(html_tag)
html_tag
end
end
end
end
# Here, we're defining methods like in: action_view/helpers/form_helper.rb
# As you can see though, the implementation is quite different. Instead
# of calling `@template`'s `#text_field` method, I reimplemented that method
# here to "compress" two of the layers we went through into one
@template.class_eval do
def text_field(object_name, method, options = {})
instance = ActionView::Helpers::Tags::TextField.new(object_name, method, self, options)
ignore_field_error_proc(instance)
instance.render
end
# We'll have to override each form builder method that we care about
end
end
end
Like I mentioned, this didn’t quite work. The custom form builder did do its job, but it leaked and affected all following forms as well.
Since we’re mutating the @template
object, which is shared and passed around
quite a lot, this custom form builder affects all following f.text_field
s.
<%= form_for(@user) do |f| %>
<%= f.text_field(:name) %>
# gives us: <div class="field_with_errors"><input ... /></div>
<% end %>
<%= form_for(@user, builder: CustomFormBuilder) do |f| %>
<%= f.text_field(:name) %>
# gives us: <input ... />
<% end %>
<%= form_for(@user) do |f| %>
<%= f.text_field(:name) %>
# gives us: <input ... /> (even though this isn't using the custom form builder)
<% end %>
Sad. Quite disappointing. I had to try something else.
Looking through the source code, I couldn’t figure out a way to do what I wanted without monkey patching. So I figured that the only way forward was through monkey patching.
module IgnoreFieldErrorProc
def error_wrapping(html_tag)
if Thread.current[:custom_form_builder]
return html_tag
end
super
end
end
ActionView::Helpers::Tags::Base.class_eval do
prepend IgnoreFieldErrorProc
end
class CustomFormBuilder < ActionView::Helpers::FormBuilder
def text_field(*)
Thread.current[:custom_form_builder] = true
super
ensure
Thread.current[:custom_form_builder] = nil
end
# We have to override each form builder method that we care about
end
This worked! Forms using the custom form builder correctly bypassed the error proc, but the default behavior stayed the same.
Here’s what I finally ended up with. It has a little bit of metaprogramming, but the general idea is exactly the same.
I’ve tested this to work on Rails 5.0 through 6.1, but I suspect that the general ideal would work in a wider range of Rails versions.
After I finished, I remembered that there were other popular form builders,
Formtastic and SimpleForm. I took a look. It looks like both do it similar ways;
the library would define a method that temporarily reassigned
field_error_proc
, called the usual form_for
, then reset that proc back to
what it used to be.
module CustomFormFor
def custom_form_for(*args, &block)
original_field_error_proc = ::ActionView::Base.field_error_proc
::ActionView::Base.field_error_proc = -> (html_tag, instance) { html_tag }
form_for(*args, &block)
ensure
::ActionView::Base.field_error_proc = original_field_error_proc
end
end
ActiveSupport.on_load(:action_view) do
include CustomFormFor
end
I do wonder if that’s thread safe. I assume it’s fine, since the aforementioned libraries are quite popular. I have to admit, this is a simpler solution than what I came up with and would require less maintenance.