Rails tip: display association validation errors on fields

In Ruby on Rails it is common to use a (collection) select field to set the value for a belongs_to association. However, using Rails' default form builder, validation errors on the association field are usually not correctly displayed. They are either shown on the form's label or the field, while you usually want both. In this blogpost I show a simple patch to fix this problem and make sure validation errors for associations are shown both on the label and the field itself.

The problem

Suppose we have a Book model, which has a belongs_to association called author for the Author model. We can then create a field in the Book form to set the author using Rails' collection_select helper. However, for this form to function correctly, we need to create a field for the author_id attribute of the book, instead of the author field. This field is for the foreign key column of the association and not for the associated author model directly. This is because the resulting HTML select tag will only contain IDs of the authors and not the actual author models. If we create a form field for the author field directly and submit this form, then we would get an ActiveRecord::AssociationTypeMismatch exception. By creating a field for the author_id field however, Rails knows to expect a number and that it should retrieve the author with the selected ID and assign it. So the code for the author field in the Book form will look like this:

<div class="field">
    <%= f.label :author %>:<br />
    <%= f.collection_select :author_id, Author.all, :id, :name %>
</div>

Now suppose we want to validate the presence of the author, i.e. every book should have an author. At this point, we have a decission to maken, namely, do we validate the presence of the author association or the author_id? As discussed here you should always validate the presence of the association and not the foreign key, which is in our case the author_id. So the code for our Book model with validation will look like this:

class Book < ActiveRecord::Base
  belongs_to :author
  validates :author, presence: true
end

If we now submit our form without selecting an author, we notice something strange: the Rails form builder does not indicate the error on the select for the author (by wrapping a div with class field_with_errors around it). This is because the select field we created is for the author_id and not the author association. It does add the error correctly to the Book's error method and it does show the error on the form's label, but it does not for the field. Due to this behavior, it is not possible to indicate errors on association fields by giving them a red border or something similar. The cause of this problem is simple, Rails sets the error for the association name, while we are displaying a field for the foreign key column (the id of the associated model).

First solution

The simple solution would be to just validate the presence of the foreign key column as well (in this case author_id). However, this solution has the drawback of adding the error double. So when displaying a list of all the error messages, the message for the missing author is displayed twice. This could be fixed by adding a conditional to the second validation like this:

class Book < ActiveRecord::Base
  belongs_to :author
  validates :author_id, presence: true
  validates :author, presence: true, if: -> { author_id.present? }
end

Although this works, it is not ideal. We know need to define validations for associations twice. Furthermore it has additional problems when creating a multilingual app using the I18n gem. Rails automatically translates field names in error messages using the activerecord.attributes.. definition in your YAML files. Since the error is now set on the foreign key column, we now need to add the translation for this column as well, effectively duplicating the translations for associations. Clearly this is not what we want.

The better solution

The better solution is to tackle the problem where it is created: in the Rails form builder. We simply need to alter the form builder so it shows an error on the field for the foreign key column whenever there is an error for the association. After exploring Rails' source code for the FormBuilder, I discovered that the ActiveModelInstanceTag error_wrapping method is responsible for showing errors on form fields. This method uses the object_has_errors? method to determine if the field has an error, which on its turn uses the error_message method. We should alter this method such that for foreign key columns it also returns the errors on the association. The implementation of this method is pretty straightforward:

def error_message
  object.errors[@method_name]
end

We can achieve the desired behavior simply by checking if @method_name ends on _id and if so, also return errors for the method name without the _id suffix. So something like this would do the trick:

def error_message
  if @method_name.end_with?('_id')
    object.errors[@method_name] + object.errors[@method_name.chomp('_id')]
  else
    object.errors[@method_name]
  end
end

This works in the standard situation, but it does not work for associations with a non-standard foreign key column name. Furthermore, it only works for belongs_to assocations and not for has_many or has_and_belongs_to_many associations. Using Rails' reflect_on_association method, we can improve this code and make it more robust. We then override the default implementation of the error_message method using the alias_method_chain construction. The final result looks like this:

# Make sure errors on associations are also set on the _id and _ids fields
module ActionView::Helpers::ActiveModelInstanceTag
  def error_message_with_associations
    if @method_name.end_with?('_ids')
      # Check for a has_(and_belongs_to_)many association (these always use the _ids postfix field).
      association = object.class.reflect_on_association(@method_name.chomp('_ids').pluralize.to_sym)
    else
      # Check for a belongs_to association with method_name matching the foreign key column
      association = object.class.reflect_on_all_associations.find do |a|
        a.macro == :belongs_to && a.foreign_key == @method_name
      end
    end
    if association.present?
      object.errors[association.name] + error_message_without_associations
    else
      error_message_without_associations
    end
  end
  alias_method_chain :error_message, :associations
end

To include this patch in your Rails app, simply put it in an initializer (for example config/initializers/errors_for_associations.rb) and restart your server. I hope this helps you!

Other interesting patches

Two other form builder patches that might interest you:

  1. Use a validation-error class instead of wrapping a fields_with_errors div:
  2. # Add a error class to fields with 'errors' instead
    ActionView::Base.field_error_proc = Proc.new do |html_tag, instance|
      class_attr_index = html_tag.index 'class="'
     
      if class_attr_index
        html_tag.insert class_attr_index+7, 'validation-error '
      else
        html_tag.insert html_tag.index('>'), ' class="validation-error"'
      end
    end

    This patch lets the form builder add a validation-error class on fields with errors instead of wrapping them in a field_with_errors div. This is useful because the div might break your lay-out. Also, it seems a bit overkill for simply display that there was an error. Source: http://stackoverflow.com/a/8380400/2157865

  3. Automatically add a required class to form labels:
  4. # Add a 'required' CSS class to the field label if the field is required
    class ActionView::Helpers::FormBuilder
      def label_with_required_class(method, text_or_options = nil, options = {}, &block)
        if text_or_options && text_or_options.class == Hash
          options = text_or_options
        else
          text = text_or_options
        end
     
        validators = object.class.validators_on(method)
        if validators.map(&:class).include?(ActiveRecord::Validations::PresenceValidator)
          # Classes as array with one item for each class
          classes = options[:class]
          classes = classes.is_a?(String) ? classes.split(' ') : Array(classes)
          classes << 'required'
          options[:class] = classes.uniq
        end
     
        self.label_without_required_class(method, text, options, &block)
      end
      alias_method_chain :label, :required_class
    end

    This patch is based on The Pothoven Post: Self-marking required fields in Rails post. It lets the form builder automatically mark labels for required fields with a required class. So when you have a required field (field with presence validation) and use the form builder's label method, then the resulting label tag will get the class required. You can then mark these labels, for example with an asterisk, using the following CSS:

    label.required:after {
      content: " *";
    }

    The patch has one significant drawback, namely that it does not work for presence validations that have conditionals (if/unless). It simply always adds the required class, even when the presence validation is not active because the condition is not met.

All patches can be 'installed' by placing them in an initializer, or all three in a single initializer. For example in config/initializers/form_builder.rb.

Saus - Quick and easy time tracking

Reactie toevoegen

Reacties

Hey!
Thank you so much, it was very clear and useful.

Thank you so much!, it was very clear and useful.

hey!

Thanks a lot for this! Really useful :)

Great post. It is a much better solution than the various hacks I've been using so far.

Thanks
Hakon

afbeelding van Kevin

You're welcome!