Rails Nested Forms using jQuery and SimpleForm

Rails comes with built-in support for allowing a model to accept "nested attributes": where one resource can accept attributes for another (sub)resource. It works out of the box for when a form needs to support just one record of the nested resource. But what if a form needs to accept unlimited records of the nested resource?

For example: Let's assume we are building a site which allows our users to create stock market portfolios. We want to create a single form for each portfolio that will allow the user to handle all the assets (i.e., shares) of the portfolio, so that he can add or remove as many shares as he wants, and all within the parent portfolio form. We don't want to limit the user to adding a single asset to the portfolio. The portfolio form needs to allow the user to add as many assets as he wishes.

How do we do this?

Ryan Bates has a great Railscast on this, but it uses helpers. Here is my shot at creating an easier implementation which only uses jQuery to add and remove sets of the nested resource into the form.

Begin With The Models

Let's begin. We have three models:

  • Stock model: The various stocks on the stock market.
  • Portfolio model: Stock market portfolios. Each portfolio has many assets.
  • Assets model: Each element of the portfolio. For example, if the user has 1000 shares of Apple stock in his portfolio, those 1000 shares are an asset.
class Stock < ActiveRecord::Base
  # e.g. APPL (Apple stock) etc
  has_many :assets
end


class Portfolio < ActiveRecord::Base
  has_many :assets
end


class Asset < ActiveRecord::Base
  belongs_to :stock
  belongs_to :portfolio
end

Adding Nested Attribute Support

Now that our models are ready, we set up the Portfolio model to accept nested attributes of the Asset model.

class Portfolio < ActiveRecord::Base

  belongs_to :user
  has_many :assets

  attr_accessible :title, :assets_attributes
  accepts_nested_attributes_for :assets, allow_destroy: true

end

Then, because we are using SimpleForm, we need to tell our PortfolioController to build out the asset section of the form.

controllers/portfolios_controller.rb
class PortfoliosController < ApplicationController

  def new
    @portfolio = Portfolio.new
    @portfolio.assets.build
  end

  def edit
    @portfolio = current_user.portfolios.find params[:id]
    @portfolio.assets.build
  end

end

Building The Form

We are finally ready to move into our form partial. A standard nested attribute form looks like this.

views/portfolios/_form.html.haml
= simple_form_for @portfolio do |f|
  = f.input :title, label: 'Portfolio Title'

  = f.simple_fields_for :assets do |assets_form|
    = assets_form.association :stock
    = assets_form.input :amount, :input_html => { min: 0 }

  = f.submit

If you load this form, it will load and work fine - but it will only let you add one asset form to the portfolio form. So let's add in some jQuery magic which will let the user duplicate the nested part of the form, so that he can add as many assets as he wants.

First we need to mark the nested part of the form so that jQuery can find it easily. Let's nest it within a class called duplicatable_nested_form and also add a link with a class of duplicate_nested_form which will do the work of duplicating the nested form when it is clicked.

views/portfolios/_form.html.haml
= simple_form_for @portfolio do |f|
  = f.input :title, label: 'Portfolio Title'

  = f.simple_fields_for :assets do |assets_form|
    .duplicatable_nested_form
      = assets_form.association :stock
      = assets_form.input :amount, :input_html => { min: 0 }

  = link_to 'Add Another Asset', '', :class => 'duplicate_nested_form'
  = f.submit

Time to create our jQuery. I am using Coffeescript here.

assets/javascripts/nested_forms.js.coffee
jQuery ($) ->
  $(document).ready ->
    if $('.duplicatable_nested_form').length

      nestedForm = $('.duplicatable_nested_form').last().clone()

First, being that this script is going to be compiled by Rails and loaded on every page of the entire app, I've nested it within an if clause that checks to see if there is a nested attribute form on the loaded page. This prevents the script running on any page that doesn't need it.

Next the script finds the nested section of the form, clones it, and assigns the cloned DOM elements to the nestedForm variable. We are careful to use .last() in this line, $('.duplicatable_nested_form').last().clone(), because when a user comes back to edit a portfolio with many nested assets, SimpleForm will automatically render out all the assets in individual nested forms, and then after them it will create one clean unpopulated nested form for adding a new asset - and it's always the clean unpopulated form we need to clone.

Now let's clone the form when the 'Add Another Asset' link is clicked.

assets/javascripts/nested_forms.js.coffee
$('.duplicate_nested_form').click (e) ->
  e.preventDefault()

  lastNestedForm = $('.duplicatable_nested_form').last()
  newNestedForm  = $(nestedForm).clone()
  formsOnPage    = $('.duplicatable_nested_form').length

  $(newNestedForm).find('label').each ->
    oldLabel = $(this).attr 'for'
    newLabel = oldLabel.replace(new RegExp(/_[0-9]+_/), "_#{formsOnPage}_")
    $(this).attr 'for', newLabel

  $(newNestedForm).find('select, input').each ->
    oldId = $(this).attr 'id'
    newId = oldId.replace(new RegExp(/_[0-9]+_/), "_#{formsOnPage}_")
    $(this).attr 'id', newId

    oldName = $(this).attr 'name'
    newName = oldName.replace(new RegExp(/\[[0-9]+\]/), "[#{formsOnPage}]")
    $(this).attr 'name', newName

  $( newNestedForm ).insertAfter( lastNestedForm )

Whoa! What are those regex thingies doing? If you inspect the source code of a nested form, Rails creates an elaborate nested params hash to handle the nested attributes. Within the keys of the hash, each nested form has an incrementing id, starting at zero, to separate it from the other nested forms. So when we clone the nested form, we also need to increment that id number for each input within the cloned form.

And that is all the above script is doing. It creates a clone of the clone we saved in the nestedForm variable, increments the id numbers of all labels and inputs within the new clone, and then plunks it on the page after the previous nested form.

This works great for allowing us to add as many nested forms as we want. But how do we let users delete those nested resource forms after they fill them in?

Deleting a Nested Resource

We need to add a button or link within each nested form, which, when clicked, will delete that form. It needs to be a dual-purpose link: If the form is dealing with a new record (i.e., an unsaved record), clicking the delete button should simply remove the nested form from the DOM. If however we are using the form on an edit page to edit an already-persisted resource, the delete button needs to actually delete the nested resource from the database. So, let's update our form and our jQuery.

views/portfolios/_form.html.haml
= simple_form_for @portfolio do |f|
  = f.input :title, label: 'Portfolio Title'

  = f.simple_fields_for :assets do |assets_form|
    .duplicatable_nested_form
      = assets_form.association :stock
      = assets_form.input :amount, :input_html => { min: 0 }

      - if assets_form.object.new_record?
        = link_to 'Remove', '', :remote => true, :class => 'destroy_duplicate_nested_form'
      - else
        = link_to 'Remove', portfolio_asset_path(@portfolio, assets_form.object), :method => :delete, :remote => true, :class => 'destroy_duplicate_nested_form'
        = assets_form.input :id, as: :hidden

  = link_to 'Add Another Asset', '', :class => 'duplicate_nested_form'
  = f.submit
assets/javascripts/nested_forms.js.coffee
  $('.destroy_duplicate_nested_form').live 'click', (e) ->
    $(this).closest('.duplicatable_nested_form').slideUp().remove()

We've added a remote link (:remote => true) for removing the form. If the record is new, the link itself doesn't point anywhere and doesn't do anything, but our jQuery catches the link click and removes the form from the DOM.

If the record is persisted, the link is a remote link to the nested form's destroy action, and will delete the nested resource. Simultaneously, the jQuery catches the click event and removes the form from the DOM. But what is the = assets_form.input :id, as: :hidden line doing?

Well, simple_form adds an additional hidden field to each nested form to track the ID number of the nested resource. By default, simple_form will insert this hidden field wherever it wants - usually outside of the '.duplicatable_nested_form' container div. As a result, when our jQuery removes the nested form from the DOM, this hidden field gets left behind. Adding = assets_form.input :id, as: :hidden allows us to tell simple_form where to place this hidden field. We place it within the '.duplicatable_nested_form' container div, so that when a form is deleted, it is deleted cleanly, and nothing is left behind.

Wrapping Up

This jQuery solution is simple, clean, and works well. The coffeescript file can now be dropped into any app for instant nested resource support.

To wrap up, here are the form and jQuery files in their final iterations.

views/portfolios/_form.html.haml
= simple_form_for @portfolio do |f|
  = f.input :title, label: 'Portfolio Title'

  = f.simple_fields_for :assets do |assets_form|
    .duplicatable_nested_form
      = assets_form.association :stock
      = assets_form.input :amount, :input_html => { min: 0 }

      - if assets_form.object.new_record?
        = link_to 'Remove', '', :remote => true, :class => 'destroy_duplicate_nested_form'
      - else
        = link_to 'Remove', portfolio_asset_path(@portfolio, assets_form.object), :method => :delete, :remote => true, :class => 'destroy_duplicate_nested_form'
        = assets_form.input :id, as: :hidden

  = link_to 'Add Another Asset', '', :class => 'duplicate_nested_form'
  = f.submit
assets/javascripts/nested_forms.js.coffee
jQuery ($) ->
  $(document).ready ->
    if $('.duplicatable_nested_form').length

      nestedForm = $('.duplicatable_nested_form').last().clone()

      $(".destroy_duplicate_nested_form:first").remove()

      $('.destroy_duplicate_nested_form').live 'click', (e) ->
        $(this).closest('.duplicatable_nested_form').slideUp().remove()

      $('.duplicate_nested_form').click (e) ->
        e.preventDefault()

        lastNestedForm = $('.duplicatable_nested_form').last()
        newNestedForm  = $(nestedForm).clone()
        formsOnPage    = $('.duplicatable_nested_form').length

        $(newNestedForm).find('label').each ->
          oldLabel = $(this).attr 'for'
          newLabel = oldLabel.replace(new RegExp(/_[0-9]+_/), "_#{formsOnPage}_")
          $(this).attr 'for', newLabel

        $(newNestedForm).find('select, input').each ->
          oldId = $(this).attr 'id'
          newId = oldId.replace(new RegExp(/_[0-9]+_/), "_#{formsOnPage}_")
          $(this).attr 'id', newId

          oldName = $(this).attr 'name'
          newName = oldName.replace(new RegExp(/\[[0-9]+\]/), "[#{formsOnPage}]")
          $(this).attr 'name', newName

        $( newNestedForm ).insertAfter( lastNestedForm )