Nested forms are a bitch. Here are some tips to get it working with Javascript

This article is deprecated.I've marked this article as deprecated. It is either because the gem or plugin discussed in this article is no longer maintained, or I've changed my opinion on it. This article is here only as an historic reference.

Nested Forms

The old subject of nested forms comes back again to hunt me. Rails 2.3 has the new and shiny accepts_nested_attributes_for feature. I like it, but there are some things to take into consideration. Adding a child object through javascript remains a bitch to tackle. So I sat down and wrote some javascript. Here is what I came up with. Not sure if I'm going to release this a plugin though.

First of, build the models. I have a project with many stages:

class Project < ActiveRecord::Base
  validates_presence_of :name
  has_many :stages
  accepts_nested_attributes_for :stages, :allow_destroy => true
end

class Stage < ActiveRecord::Base
  validates_presence_of :title
  belongs_to :project
end

Here is what the form partial for the project looks like:

- form_for @project do |form|
  %p
    = form.label :name
    = form.text_field :name
  #stages
    - form.fields_for :stages do |fields|
      = render :partial => "stage", :locals => { :form => fields }
  %p= partial_button(f, :stage, "Add stage")

And the stage partial:

.stage[form.object]
  %p
    = form.label :title
    = form.text_field :title
  %p= remove_partial(form, "Remove stage")

Ok, so nothing to scary there. Nice clean views. Those two helper methods might be scary though. But apart from that, it's actually quite normal.

Notice that the square brackets used at the first line of the stage partial either adds a class "new_stage" or "stage_X" (where X is the id of an existing stage object).

Let's see what's inside the partial_button method!

def partial_button(form, attribute, link_name)
  returning "" do |out|
    base      = form.object.class.to_s.underscore
    singular  = attribute.to_s.underscore
    plural    = singular.pluralize
    id        = "add_nested_partial_#{base}_#{singular}"
    form.fields_for attribute.to_s.classify.constantize.new do |field|
      html = render(:partial => singular, :locals => { :form => field})
      js   = %|new NestedFormPartial("#{escape_javascript(html)}", { parent:"#{base}", singular:"#{singular}", plural:"#{plural}"}).insertHtml();|
      out << hidden_field_tag(nil, js, :id => "js_#{id}") + "\n"
      out << content_tag(:input, nil, :type => "button", :value => link_name, :class => "add_nested_partial", :id => id)
    end
  end
end

Ok, this looks scary. But it isn't that scary. This method returns a string called out. First of I build some variables, which will be needed as options for the javascript, since javascript doesn't have those cool inflections ActiveSupport has.

Second, I am going to make a fields_for block, which you'll already know what it does. I render the partial and assign it to the html variable. Then I generate some javascript which initiates a new NestedFormPartial object. Finally, I build a hidden field, which contains this javascript as value and a button.

Here's the javascript, you'll need to add:

var NestedFormPartial = Class.create();
NestedFormPartial.prototype = {
  initialize : function(html, options){
    this.newId          = "new_" + new Date().getTime();
    this.html           = html;
    this.parentName     = options["parent"];
    this.singularName   = options["singular"];
    this.pluralName     = options["plural"];
    if (!this.pluralName) this.pluralName = this.singularName + "s";
    this.replaceHtml();
  },
  oldPartialId : function(){
    return this.singularName + "_new";
  },
  oldElementId : function(){
    return this.parentName + "_" + this.singularName + "_";
  },
  oldElementName : function(){
    return this.parentName + "\\[" + this.singularName + "\\]";
  },
  newPartialId : function(){
    return this.singularName + "_" + this.newId;
  },
  newElementId : function(){
    return this.parentName + "_" + this.newPartialId() + "_";
  },
  newElementName : function(){
    return this.parentName + "[" + this.pluralName + "_attributes][" + this.newId + "]";
  },
  replaceFunction : function(pattern, replacement) {
    this.html = this.html.replace(new RegExp(pattern, "g"), replacement);
  },
  replaceHtml : function(){
    this.replaceFunction(this.oldPartialId(),   this.newPartialId());
    this.replaceFunction(this.oldElementId(),   this.newElementId());
    this.replaceFunction(this.oldElementName(), this.newElementName());
  },
  insertHtml : function(){
    $(this.pluralName).insert({ bottom :  this.html });
  },
}

function initPartialButtons() {
  $$(".add_nested_partial").each(function(button, index) {
    Event.observe(button, "click", function(evt) {
      eval($("js_" + button.id).value);
    })
  });
}

Event.observe(window, 'load', initPartialButtons, false);

Ehm, what did I just do there? Well, the most important thing is that some parts of the partial get replaced. There are three problems which need to be addressed:

  • A new object always has the same generated id for input fields. Adding two stages would mean that their ids would be the same and that would mean that the labels wouldn't be clickable (and it wouldn't be valid html).
  • Rails wants "stages_attributes" to be included, when providing a new object, it would be named simple "stage".
  • Rails expects a hash as stages_attributes. We'll need to add some arbitrary key, so it'll turn into a hash.

I generate a new id by using the timestamp and replace the values in my html. When the window loads I find any add_nested_partial class button and eval the value of the hidden field I added earlier, so the scripts gets executed.

As you can see, I did my best to make this as unobtrusive as possible, but going any further made my head hurt.

Finally, the remove_partial method, which I haven't cleaned up yet:

def remove_partial(form, link_name)
  attribute = form.object.class.name.underscore
  if form.object.new_record?
    button_to_function(link_name, "$(this).up('.#{attribute}').remove()")
  else
    form.hidden_field(:_delete) +
    button_to_function(link_name, "$(this).up('.#{attribute}').hide(); $(this).previous().value = '1'")
  end
end

I hope this helps. I found a lot of my initial optimism after hearing about accepts_nested_attributes_for have gone now. It cleans up a lot of code in the model though. I'll keep this post updated when I have some improvements.

Sources:

comments powered byDisqus