Add form fragments dynamically

A new feature of Rails is the ability of manipulating related models.  While it’s great, dynamically adding / removing nested model entries is not an established way.  However, it’s already solved by Eloy Duran.  His method was for Prototype and default form builder, which had to be converted to HAML, JQuery and Formtastic one for me.  Here’s how.

First, set up the nested objects. Let’s say we have a page scaffold created, and added a translation model:

app/models/page.rb:

class Page < ActiveRecord::Base
  has_many :translations
  accepts_nested_attributes_for :translations, :allow_destroy => true,
    :reject_if => proc { |a| a["lang"].blank? }
end

app/models/translation.rb:

class Translation < ActiveRecord::Base
  belongs_to :page
end

(note, for real obtrusive experience, you should create views and controller for Translations too)

Prepare the layout file, and put this to the header:

app/views/layouts/application.html.haml

=javascript_include_tag 'jquery', 'application'
-javascript_tag do
  =yield(:jstemplates)

Then, fill :jstemplates with pre-parsed forms:

app/helpers/application_helper.rb:

module ApplicationHelper
  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.semantic_fields_for(method, options[:object], :child_index => "NEW_RECORD") do |f|
      render(:partial => options[:partial], :locals => { options[:form_builder_local] => f })
    end
  end                                                                                                                               

  def generate_template(form_builder, method, options = {})
    escape_javascript generate_html(form_builder, method, options)
  end
end

So far it’s not a big difference from Eloy’s solution (wrt. semantic_fields_for).  Let’s create the views now (I leave index and show views to your imagination):

app/views/pages/new.html.haml:

- title "New page"
= render :partial => 'form'

%p= link_to 'Back', pages_path

As you can see, I use Ryan Bates’ nifty_generators (or at least nifty_layout helpers).  You know what?  Imagine how my edit page looks like.  But here comes the meat:

app/views/pages/_form.html.haml:

- semantic_form_for setup_page(@page) do |f|
  - content_for :jstemplates do
    = "var translations='#{generate_template(f, :translations)}'"
  - f.inputs :name => "Page details" do
    = f.input :tag, :label => "Short name"
    - f.inputs :name => "Translations", :id => "translations" do
      - f.semantic_fields_for :translations do |t|
        = render :partial => "translation", :locals => { :f => t }
  - f.buttons do
    %li = link_to 'Add translation', '#translations', :class => 'add_nested_item', :rel => '#translations ol'
    = f.commit_button

app/views/pages/_translation.html.haml (let’s assume the :lang parameter comes from another model):

=f.input :lang, :label => "Language"
=f.input :title
=f.input :body
- unless f.object == nil || f.object.new_record?
  =f.input :_delete, :as => :boolean

The real nifty feature lies in contents_for block, which saves an empty partial form with object reflection. The other feature is the link, which contains two interesting parameters for the javascript: it’s href and rel parameters.

For real obtrusive solution, we should specify new_page_translation_path(@page, :anchor => "translations") , which would be translated to /pages/:id/translations/new#translations, which would be good as both an URL and a parameter for our script.

We have another parameter: rel tells which DOM object it relates to. Remember, Formtastic generates accessible forms just like the one in A List Apart, eg. a form contains a number of fieldsets, and ordered lists in them.

Alright, let’s connect them!

public/javascripts/application.js:

replace_ids = function(s) {
  var new_id = new Date().getTime()
  return s.replace(/NEW_RECORD/g, new_id)
}

$(document).ready(function() {
  $(".add_nested_item").click(function() {
    var template = eval($(this).attr("href").replace(/.*#/, ''))
    $($(this).attr("rel")).append(replace_ids(template))
    return false
  })
})

And it works!