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: