Validating Nested Attributes

I am sure that there’s a better way to do this, but I got this working and I wanted to document it.

I have a rails app with two tables, applicants and references. Applicants have many references and each reference belongs to one applicant. Each applicant is required to submit three references, but may submit a fourth.

app/models/applicants.rb

  has_many  :references, :dependent => :destroy
  accepts_nested_attributes_for :references, :allow_destroy => true

app/models/references.rb

  belongs_to  :applicant

Issue #1. How to set up the applicant form so that it accepts the references.
app/controllers/applicants_controller.rb

  def new
    @applicant = Applicant.new
    # We will accept 4 references at most
    4.times { @applicant.references.build }
  end

app/views/applicants/_form.html.erb

    <%= f.fields_for :references do |builder|%>
  1. <%= render "reference_fields", :f => builder %>
  2. <% end %>

app/views/applicants/_reference_fields.html.erb

<%= f.label :firstname, "Firstname" %>
<%= f.text_field :firstname %>
<%= f.label :lastname, "Lastname" %>
<%= f.text_field :lastname %>
<%= f.label :email, "Email" %>
<%= f.text_field :email %>
<%= f.label :institution, "Institution"%>
<%= f.text_field :institution %>

That’s basically all I have to do to get the values for the references from the applicants form.

Issue #2. References must have all fields filled in to be an acceptable reference.
The easy way to fix this would be to add the validations to the reference model, like this:

validates_presence_of :firstname, :lastname, :email, :institution

Problem! In our form, we have spaces for four references, but we’re only requiring three. Thus, the last reference can be empty. The usual way to fix this is to add this lambda to the applicant model:

  accepts_nested_attributes_for :references, :allow_destroy => true
        :reject_if => lambda { |a| a[:firstname].blank? || a[:lastname].blank? || a[:email].blank? || a[:institution].blank?}

Problem! If the applicant accidentally leaves off one of the fields in the required references, that field will not be shown in the refreshed page. So then, the applicant will no longer have three reference fields.

My Solution (and I’m sure there’s a better way to do this, but I don’t know how).

Write my own validations.

Take the validates_presence_of line out of the reference model.

app/models/applicant.rb

validate :check_if_have_at_least_three_references

  def check_if_have_at_least_three_references
    all_refs = []
    references.each do |reference|
      if (reference.lastname.empty? || reference.firstname.empty? || reference.email.empty? || reference.institution.empty?)
        reference.destroy # If any fields are empty, we're not saving it
      else
        all_refs << reference # Add good references to the array to see how many we get
      end
    end
    errors[:reference] << " : Three references are required" if all_refs.size < 3
  end

Basically, I just look at each reference and if it's valid, I put it in an array. Then, I check that there are at least three values in the array. If not, I add something to its errors object, which is how we mark an object as invalid.

Here's the bit that I'm sure could be improved, but I don't know how.

app/controllers/applicants_controller.rb

  def create
    @applicant = Applicant.new(params[:applicant])

    if @applicant.save
      ApplicantMailer.thanks_for_applying(@applicant).deliver
      @applicant.references.each do |reference|
        unless (reference.firstname.blank? || reference.lastname.blank? || reference.email.blank? || reference.institution.blank?)
          ApplicantMailer.request_for_recommendation(reference).deliver
        end
      end
      redirect_to(root_path, :notice => "A confirmation email has been sent to #{@applicant.email}. Requests for letters of recommendation have been sent to your references.")
    else
      render :action => "new"
    end
  end

For reasons I don't fully understand, I have to check the references here again, to ignore a blank fourth reference record. I had thought that the reference.destroy in my validation would have gotten rid of the empty one, but it doesn't. So my very bad hack is to check it again here. The reason is because we send email to each of the references, asking them to send in a letter of recommendation. Sending email to a blank email address will cause problems, so we delete if anything is blank. This works, so I'm sticking with it for now.

Issue #3. If there is a problem with the reference fields, an error box is not drawn around the fields when the error is shown.

I didn't care so much about drawing around the individual fields of the reference. All I wanted was one box around all the references saying that three were required. (Since this app is for people applying for a postdoctoral position, I'm assuming that they'll understand what the problem is if there's a single box around the references.)

I used a scaffold to set up the applicants table. This gave me a stylesheet with this class.

.field_with_errors {
  padding: 2px;
  background-color: red;
  display: table;
}

I basically copied this class to one called .references_with_errors, that looks like this:

.references_with_errors {
	padding: 2px;
	border-style: solid;
	border-color: red;
}

app/models/applicant.rb

  def reference_errors?
    errors[:reference].empty? ? "" : "references_with_errors"
  end

app/views/applicants/_form.html.erb

	
Please list at least three references.
    <%= f.fields_for :references do |builder|%>
  1. <%= render "reference_fields", :f => builder %>
  2. <% end %>

If I wanted to, I could have changed the classes on the divs in the reference_fields partial to mark them, but I didn't think that was necessary.

Anyway, this works for me. Unfortunately, I have no tests written for this because I'm still learning how to write proper tests. So, my testing scheme is to just try submitting a form again and again with different problems. It doesn't fail any more, so for now I'm happy with how it works.