TransFS DevBlog / Article RSS Feed

Jun 26

Nested Forms with Rails 2.3, Helpers, and Javascript Tricks

By josh

Rails 2.3 was released a few months ago with some great features. Perhaps chief among them was an “official” solution to nested models in forms. This is something that the community has been arguing about for some time, and has been solved in various ways.

With the Rails 2.3 solution, we finally have direct support for updating nested attributes in our models. This allows us to add a simple directive to our model that specifies which associations can be mass-assigned. For instance, in our app, we have the following (simplified) model:

class Iso < ActiveRecord::Base
  has_many :emails
  has_many :phones
  accepts_nested_attributes_for :emails, :allow_destroy => true
  accepts_nested_attributes_for :phones, :allow_destroy => true
end


This allows us to update the emails and phone numbers of an “iso”, using nested attributes:

# Add a new child to this iso
@iso.phones_attributes = [ { :name => 'Main', :number => '123-456-7890' } ]
@iso.phones #=> [ <#Phone: name: 'Main', number: '123-456-7890'> ]
@iso.phones.clear
 
# Add two new phones to this iso
@iso.phones_attributes =
  [ { :name => 'Main', :number => '123-456-7890' }, { :name => 'Fax', :number => '555-555-5555' } ]
@iso.save
@iso.phones #=> [ <#Phone: name: 'Main', number: '123-456-7890'>, <#Phone: name: 'Fax', number: '555-555-5555'> ]
 
# Edit the phone (assuming id == 1)
@iso.phones_attributes = [ { :id => 1, :name => 'Customer Support' } ]
@iso.save
  #=> the phone's name is now 'Customer Support'
 
# Edit the fax number (id == 2) and add a new phone
@iso.phones_attributes =
  [ { :id => 2, :number => '666-666-6666' }, { :name => 'Sales', :number => '000-000-0000' } ]
@iso.save
  #=> the fax number is now '666-666-6666' and there's a new phone called 'Sales'
 
# Remove 'Sales' (id = 3)
@iso.phones_attributes = [ :id => 3, '_delete' => '1' } ]
@iso.save
  #=> Sales phone is now deleted

Readers who are paying attention will recognize this example as an exact copy of Ryan’s Daigle’s excellent post on Ryan’s Scraps. (I’ve copied it here for completeness purposes only.)

So, now that we have nested attribute mass-assignment… our Controllers can simply pass on the params hash directly to our ActiveRecord model, and everything works like it should. Excellent.

However, the work isn’t quite done yet. We also need to update our views to properly build the params hash (and strip out any old hacks/plugins that were handling this for us previously!)

Fields_for has been updated in Rails 2.3 to build these nested attributes like we’d expect. Thus, creating views to take advantage of nested models is very easy. Ours might look like this:

<% form_for :iso, setup_iso(@iso), do |f| %>
	<%= f.label :name %><%= f.text_field :name %><br/>
	<% f.fields_for :phones do |phones_form| %>
		<%= render 'phone', :locals=>{:f => phones_form} %>
	<% end %>
<% end %>

And our phone partial looks like:

<div class='phone'>
<%= f.label :name %><%= f.text_field :name %>
<%= f.label :number %><%= f.text_field :number %>
<% if f.object.new_record? %>
	<%= link_to_function "clear", "this.up('.phone').remove()" %>
<% else %>
	<%= f.check_box '_delete' %>
	<%= f.label '_delete', 'Remove' %>
<% end %>
</div>

These forms look almost exactly like “normal” rails… which speaks well of the solution that the community has developed for nested models… but the attentive read might notice one exception: What is with the “setup_iso(@iso)” in the form_for?

The problem is that you need to make sure that the first Phone is built before you iterate over the phones association, if there are no existing phones. Here is what that looks like:

  # Setup the iso for use in a nested form
  def setup_iso(iso)
    returning(iso) do |i|
      i.phones.build if i.phones.empty?
    end
  end

This solves the problem of initializing a new phone, and creating an empty fields for it, when you visit the :create/:edit action of the IsoController. But there remains one additional complication… how do you add new Phones to the Iso? One easy solution is to build a few “empty” Phones at the end of the phones association, all the time. This would give the user some blank fields to fill in if they want to add phones, but if they left them blank, they would be ignored. However, this is really less-than-ideal. Most people prefer (and are used to seeing) a “Add a new Phone Number” link, that triggers some simple javascript that adds the new phone number fields to the form dynamically. So, how can this be done?

Several people have looked at this problem. Alloy has published a really nice example app on Github that demonstrated all of these principles, and includes his own solution to the javascript problem. Over at SudoLogic, Branko wrote a fantastic blog post on which this is heavily based, that includes a refinement to Alloy’s helper logic. So far, the SudoLogic solution is the best I’ve seen.

I implemented this solution, with a single adjustment that was required so that adding multiple new objects doesn’t end up with problems relating to the ID’s of the new objects. Here’s my adjusted version of the “link_to_new_nested_form” helper method:

  #
  # Support methods for nested forms
  #
  def generate_html(form_builder, method, options = {})
    options[:object] ||= form_builder.object.class.reflect_on_association(method).klass.new
    options[:partial] ||= method.to_s.singularize
    options[:form_builder_local] ||= :f
 
    form_builder.fields_for(method, options[:object], :child_index => 'NEW_RECORD') do |f|
      render(:partial => options[:partial], :locals => { options[:form_builder_local] => f })
    end
  end
 
  def link_to_new_nested_form(name, form_builder, method, options = {})
    options[:object] ||= form_builder.object.class.reflect_on_association(method).klass.new
    options[:partial] ||= method.to_s.singularize
    options[:form_builder_local] ||= :f
    options[:element_id] ||= method.to_s
    options[:position] ||= :bottom
    link_to_function name do |page|
      html = generate_html(form_builder,
                    method,
                    :object => options[:object],
                    :partial => options[:partial],
                    :form_builder_local => options[:form_builder_local]
                   )
      page << %{
        $('#{options[:element_id]}').insert({ #{options[:position]}: "#{ escape_javascript html }".replace(/NEW_RECORD/g, new Date().getTime()) });
      }
    end
  end

The idea here is that we construct a new partial view for the Phone, with the child ID set to “NEW_RECORD”. Then, before inserting this pre-generated html into the actual form, a simple javascript replace() substitutes a unique timestamp-based id for “NEW_RECORD”. This ensures that each new Phone record that is inserted into our Iso form gets a unique ID.

Thus, the complete form looks like this:

<% form_for :iso, setup_iso(@iso), do |f| %>
	<%= f.label :name %><%= f.text_field :name %><br/>
	<% f.fields_for :phones do |phones_form| %>
		<%= render 'phone', :f => phones_form %>
	<% end %>
	<%= link_to_new_nested_form "Add a phone", f, :phones %>
<% end %>

I like this solution best, because it relies on a simple addition to our ApplicationHelper… and then the views themselves are nice and clean. It will be interesting to keep an eye on this feature to see how it evolves over the next few months. I wouldn’t be surprised if the rails team addresses this need directly by patching the code with a “sanctioned” solution to adding new nested form partials dynamically via javascript. One open ticket on this topic seems to suggest that people are already working on it… so, perhaps it will get even easier! In the meantime, enjoy the Rails 2.3 nested form features, and let us know if you find any better solutions.

Share and Enjoy:
  • Digg
  • del.icio.us
  • Facebook
  • Reddit
  • Twitter

← Back to blog index

15 Comments

  1. 1

    July 30th, 2009 cheeby says:

    excellent. thank you!

  2. 2

    August 4th, 2009 jamesw says:

    I have been looking at solutions to this problem including all the sources you have cited above plus a superb tutorial from Marsin on the Rails forum http://railsforum.com/viewtopic.php?id=28447
    Multiple childe modls in a dynamic form.
    None have given me a comfortable solution although all work satisfactorily.

    I find your explanation has cleared up a lot of uncertainties for me and I would like to say a big thank you for this.

    My requirement is slightly different to most in that my nested form is too large to display for inline editing and I’m needing a list of related records plus a form that will grow into place if a new record is required or an edit link is used.

    I think that what you have shown me here has given me a seed of an idea that will do the job nicely.

    I’m going to have a play now to see what I can come up with but I really just wanted to say a big thank you for sharing this.

    James

  3. 3

    August 4th, 2009 jamesw says:

    Just spotted a typo in your helper for link_to_new_nested_form

    options[:form_builder_loca] ||= :f
    should be
    options[:form_builder_local] ||= :f
    I think!

    Hope that helps

  4. 4

    August 4th, 2009 jamesw says:

    My first problem is that I’m getting a $ is not a function error, obviously because I’m using jQuery rather than javascript.

    Do you have any recommendations on how this could be done with jQuery?

  5. 5

    Good catch, thanks! I just updated the post.

  6. 6

    Glad you found this helpful. It seems like nested forms are still new enough that best-practices have not been completely documented. The cited blog posts were incredibly helpful to me… and I’ve had a working nested form in production for a few months now.

  7. 7

    I’m not using jQuery… so I can’t say for sure. But a quick Google came up with this:
    http://www.adrogen.com/blog/jquery-conflict-is-not-a-function/
    Hopefully it helps!

  8. 8

    August 13th, 2009 kikan says:

    Very useful, thanks.

    But I run into a little problem : how can I manage to create a nested form in a nested form in a nested form ? I mean, a three level deep nested form.

    When I do this, the link to add a children in the last level is “scrambled”, I think because Javascript is escaped twice or more.

    I saw other people ran into the same problem, but I didn’t find a clue yet.

    Anyone ?

  9. 9

    October 13th, 2009 saboteur says:

    kikan and i are having the same problem. If you have multiple nested forms with link_to then the escaping causes this solution to fail. Any ideas?

  10. 10

    March 21st, 2010 Brad says:

    Not sure if I was doing something else wrong but I was unable to make this work as described here. Changing the line

    {:f => phones_form} %>

    in the form to

    ‘phone’, :locals=>{:f => phones_form} %>

    made it work. Was this a typo or am I doing something wrong (I’m on 2.3.5).

  11. 11

    March 21st, 2010 Brad says:

    Woah, something went very wrong with that post, must have been the html.

    I’ll try again without the pointy brackets.

    I changed

    render ‘phone’, :locals=>{:f => phones_form}

    to

    render :partial => ‘phone’, :locals=>{:f => phones_form}

    and everything was better.

  12. 12

    Thanks Brad!
    Good catch… that was a mistake. I’ve updated the post.

    Your suggestion works fine, but I prefer the cleaner-looking rails 2.3+ way:

    render 'phone', :f=>phones_form

    That should work fine. (they did away with the whole :locals stuff…)

  13. 13

    March 21st, 2010 Brad says:

    Thanks Josh, I’ve gone with your even cleaner code.

    I’ve also struck another problem. The link_to_new_nested_form method creates a function that looks for a div with id = ‘phone’ rather than class = ‘phone’. This is OK but it then doesn’t work with the phone partial.

    My solution was to change the phone partial to div id=’phone’ and the link_to_function code to this.up(‘#phone’).remove(). (I could have used class=’phone’ and id=’phone’ but I thought this was tidier). Unfortunately having multiple divs with the same id strikes me as poor practice so this is less than ideal.

    Is there a way of having the link_to_new_nested_form method find the last div with class ‘phone’ and work on that? My rails just isn’t good enough yet.

    Also, I found that changing the line

    options[:position] ||= :bottom

    to

    options[:position] ||= :after

    produced nicer HTML.

  14. 14

    Hey Brad, no time at the moment to dig into this and figure out what you’re running up against. Probably another bug in the above code, tho I admit that I was pretty sure I had this working in production.

    For the time being, you might want to take a look at these two recent Railscasts by Ryan Bates:
    http://railscasts.com/episodes/196-nested-model-form-part-1
    http://railscasts.com/episodes/197-nested-model-form-part-2

    The second one deals with the javascript side of things, but both are a good overview of the best-practices for doing nested forms in rails 2.3.

    (or you might like the AsciiCasts instead: http://asciicasts.com/episodes/197-nested-model-form-part-2 )

  15. 15

    March 23rd, 2010 Brad says:

    Thanks, I’ll check those out.

Leave a Comment