Friday, May 4, 2007

Dynamic attribute validation for forms

Developing usable web application is a lot more difficult than many people think. Forms are an obvious point of friction for users, so anything you can do to help get the user through the form successfully is a good thing. One of my applications has a fair amount of restrictions on data that the models will accept (though validation restrictions). Users were frustrated after submitting a form and having it rejected due to failed validations. This presented a couple of challenges.

-There are a couple of AR models that are updated or created from the form

-The validation and feedback to the user should occur as each field is entered, and any error messages should be presented close to the offending field

-The validations on the models are somewhat complex

Here is a flexible, but somewhat expensive, solution I came up with and plopped in a controller. Substitute the AR models in your application for Model1, Model2, Model3 in the following code:

private

def validate_input_by_model
error_msgs = Hash::new
[Model1, Model2, Model3].each do |klass|
classname = klass.to_s.downcase
error_msgs[classname] = Hash::new
if params.has_key?(classname)
obj = klass::new(params[classname])
unless obj.valid?
obj.attributes.each do |k,v|
if params[classname].has_key?(k)
error_msgs[classname][k] = obj.errors.on(k)
end
end
end
end
end
error_msgs
end

public

def validate_field
if request.xml_http_request?
error_msgs = validate_input_by_model
render :update do |page|
error_msgs.each_key do |classname|
error_msgs[classname].each do |k,v|
page.inject_error_msg_if_condition "#{classname}_#{k}", v, v
end
end
end
else
render :nothing => true, :status => 401
end
end

An example of the view code:

<% css_form_for :model1, @model1obj, :url => { :controller => 'controller1', :action => 'confirm' } do |@f| %>
<%= @f.text_field :attribute1, :extra => 'Required' %>
<%= validate_remote_by_model 'model1_attribute1' %>
<% css_fields_for :model2, @model2obj do |@model2_f| %>
<%= @model2_f.text_field :attribute1 %>
<%= validate_remote_by_model 'model2_attribute1' %>
<% end %>
<% end %>

The Helper (for the controller associated views) code:

def validate_remote_by_model(id)
validate_local_by_regex(id, /^[^&]*$/, '&amp; characters are not allowed. ') + observe_field(id, :with => ("%s[%s]" % id.split(/_/)), :url => { :action => 'validate_field' })
end

def validate_local_by_regex(id, regex = nil, error_msg = nil)
default_regex, default_error_msg = *@@validations[@@validation_mapping[id]]
regex ||= default_regex
error_msg ||= default_error_msg
function = <<-SCRIPT
if (value.match(#{regex.inspect}))
{ Element.remove('l_#{id}_error'); }
else
{ var prev_error = document.getElementById('l_#{id}_error');
if (prev_error != null) {
Element.update('l_#{id}_error', '#{error_msg}');
} else {
new Insertion.Before('#{id}', '<div class=\"field_error\" id=\"l_#{id}_error\">#{error_msg}</div>');
}
}
SCRIPT
observe_field(id, :function => function)
end

This code is dense, but the object is to make the view code as simple and clear as possible and still be able to use the pleasant form_for (or css_form_for with plugin) helpers.

The helper code is knarly, since it is generating javascript, but its functionality is pretty simple. The field text is first check in the browser using a regular expression to screen for ampersands (see post about RoR/prototype issue), and then an AJAX call is made to validate_field sending the field and its value formatted in the standard ROR form syntax. The RoR form parameter syntax is model[attribute]=value. The controller code takes over from there and does its magic to see if the field value will pass validation when applied to the corresponding model. If validation fails and error messages are generated, the errors are passed back to the AJAX code. The javascript then inserts a new element in the DOM that is adjacent to the form field and contains the error message. The new element is updated or cleared based on the response to subsequent calls.

You can disregard that *@@validations[@@validation_mapping[id]] reference in validate_local_by_regex. It is simply a dictionary of regular expressions and error messages used for other in-browser validations. It is not used in this case.

No comments: