Saturday, March 22, 2008

Enhanced error reporting for complex forms

Ryan Bates has a great series of RailsCasts about complex forms, in which multiple models are on one form. The problem is, errors are not easy to report and identify. But a clever viewer of the casts came up with a great solution which I took a little further.

The problem with the enhancement by ChrisFF was that the error message was including the top-level models "Invalid #{child}" message, which is totally useless once you have the actual error message for the child. So I added a bit of code to delete the top-level model error:

[UPDATE: June 24, 2008]
I've updated this code to cull out repeating association errors. Old code is at bottom of this post.


## add to model (can be in protected section)
def after_validation()
return if errors.empty?
associations_harvested = []
associations = self.class.reflect_on_all_associations.collect(&:name)
errors.each do |field, useless_message|
next unless associations.index(field.to_sym) && !associations_harvested.include?(field)
associations_harvested << field
Array(send(field.to_sym)).each do |assoc_object|
next if assoc_object.valid?
assoc_object.errors.each_full do |useful_message|
errors.add_to_base "#{assoc_object.class.name}: #{useful_message}"
end
end
# calls our ActiveRecord::Errors#delete monkey patch to get rid of useless message
errors.delete field
end
end


## add to config/initializers/monkey_patches.rb
class ActiveRecord::Errors
def delete(attribute)
@errors.delete attribute.to_s
end
end


(This could be packaged as a slimline plugin.)

I also used recipe #30 from the Advanced Rails Recipes book, "Keep Forms DRY and Flexible" to create an errors handling form that rocks. Using CSS, I have a form that can highlight the error field and provides a nice error message right next to the field. Very nice looking.


[UPDATE: June 24, 2008]
I've updated the above code to cull out repeating association errors. For reference, here is the old code -- DO NOT USE THIS CODE!


NOTE: DO NOT USE THIS CODE!
def after_validation
return if errors.empty?
# Get an array of associations
associations = self.class.reflect_on_all_associations.collect(&:name)
# Iterate through the errors
errors.each do |field,message|
# If the field of an error is really an association, then the 'validates_associated' found an error
next unless associations.index(field.to_sym)
# Iterate through the objects in the association looking for the invalid ones
[self.send(field)].flatten.each do |association|
if association and !association.valid?
# add the error messages of the associated object to my error messages
association.errors.each_full do |msg|
self.errors.add_to_base "#{association.class.name}: #{msg}"
end
end
errors.delete field
end
end
end

4 comments:

lundie said...

Hi Kevin,

Thanks for your help with this. I am having problems getting it to work in a one to many relationship.

Example:

Application has_many References, in which a Reference name is required.

My view lists 6 different, and if a name is not put in any, we get 36 different Reference: Name can't be blank messages.

From what I can see, the code loops through each initial error messages (which there are 6 for references) and then loops through six more times for each reference in the application.

I am however, lacking the necessary skills to fix it:)

Any help is appreciated!

lundie said...

Hi Kevin,

I found an issue with the new code that was posted. If an object has multiple associations, one with a has_many, and one as a has_one, then the code fails, because it tries to loop through the has one.

I attempted to fix it, but my code is not very dry:

def after_validation()
return if errors.empty?
associations_harvested = []
associations = self.class.reflect_on_all_associations.collect(&:name)
errors.each do |field, useless_message|
next unless associations.index(field.to_sym) && !associations_harvested.include?(field)
associations_harvested << field
if send(field.to_sym).class == [].class
send(field.to_sym).each do |assoc_object|
next if assoc_object.valid?
assoc_object.errors.each_full do |useful_message|
errors.add_to_base "#{assoc_object.class.name}: #{useful_message}"
end
end
else
assoc_object = send(field.to_sym)
next if assoc_object.valid?
assoc_object.errors.each_full do |useful_message|
errors.add_to_base "#{assoc_object.class.name}: #{useful_message}"
end
end
# calls our ActiveRecord::Errors#delete monkey patch to get rid of useless message
errors.delete field
end
end

Thanks for your help!

Ryan Lundie

Greg Hauptmann said...

thanks for posting the update - I notice that when I have more than one association record error message the text is the same (i.e. doesn't distinguish) - did you find the same? Wondering how you handled this.

Greg Hauptmann said...

thanks for posting the update - I notice that when I have more than one association record error message the text is the same (i.e. doesn't distinguish) - did you find the same? Wondering how you handled this.