Form objects with a variable number of nested forms

I see questions about this pop up pretty regularly on StackOverflow. People want to create a form with variable numbers of nested forms. For example, someone may have a Company model which has_many :offices, and they want a single form which allows users to create their company along with however many offices their company has. Although Rails has accepts_nested_attributes_for, which can be used to solve this problem, I like to use form objects when dealing with complex forms. (For a good introduction to form objects, check out this blog post by Code Climate.)

I learned this pattern for nested forms from a project I was working on a while back. Although it was being used to manage forms where the number of nested forms was set at the beginning, with a few changes, it can be used for forms where the user determines the number of nested forms.

The idea is to create a class for the main form (the company in this case), and a separate class for the nested objects. In the view, a “Add Office” button can be used to append another nested form partial (however many times the user requires), and on submit, the form object iterates through the attributes for each office form row and creates a new Office for each.

I put together a quick example project to make sure the below code works, but it’s just a demonstration of the concepts, so the code isn’t perfect.

In the controller, the form’s #submit method is used to determine whether to redirect to the index page or re-render the form with errors:

app/controllers/companies_controller.rb

  def create
    form = CompanyForm.new

    if form.submit(params[:company_form])
      redirect_to companies_path, notice: "Company created."
    else
      @form = form
      render :new
    end
  end

That’s because #submit handles the Company and Office object validations instead of their respective models. It’s also responsible for creating those objects, when traditionally that would happen on the controller level:

app/forms/company_form.rb

class CompanyForm

  extend ActiveModel::Naming
  include ActiveModel::Conversion
  include ActiveModel::Validations

  attr_reader :name, :employee_count

  with_options presence: true do |required|
    required.validates :name
    required.validates :employee_count
  end

  def persisted?
    false
  end

  def office_rows
    @office_rows ||= [OfficeRow.new] # uhhhh...
  end

  def valid?
    validate_rows = @office_rows.all?(&:valid?)
    validate_form = super()
    validate_rows && validate_form
  end

  def submit(params)
    extract_params(params)
    if valid?
      persist!
      true
    else
      false
    end
  end

  def persist!
    @company = Company.new(name: name, employee_count: employee_count)

    @office_rows.each do |office_row|
      office = create_office(office_row)
      @company.offices << office
    end

    @company.save!
  end

  def extract_params(params)
    @name = params[:name]
    @employee_count = params[:employee_count]
    office_params = params[:office_rows]

    @office_rows = office_params.map do |k, office_attrs|
      OfficeRow.new(office_attrs)
    end
  end

  def create_office(office_row)
    office_row.save!
  end

  class OfficeRow

    attr_accessor :name, :city, :state, :employee_count

    include ActiveModel::Validations

    with_options presence: true do |required|
      required.validates :name 
      required.validates :city 
      required.validates :state 
      required.validates :employee_count
    end

    def initialize(params = {})
      @name = params[:name]
      @city = params[:city]
      @state = params[:state]
      @employee_count = params[:employee_count]
    end

    def save!
      Office.create!(name: name, city: city, state: state, employee_count: employee_count)
    end
  end
end

The #valid? method on the CompanyForm class overrides the #valid? method provided by the included ActiveModel::Validations module. First it calls #valid? on each instance of OfficeRow (where it is not overridden), which runs the validations, and then calls super() to run the CompanyForm validations. If both of those are true, a new company is created, and then new offices are created and associated with the new company. Once all that happens, #submit returns true and the user is redirected to the company index page. If any of the validations fail, the form is re-rendered with errors (just like a traditional #create action).

I ran into an unexpected problem when it came to actually adding new office form partials to the form- even though there were multiple office forms on the page only one set of params was being submitted. Because I was copying the original office form to create the new one, the form fields were identical. Since Rails ignores duplicate parameter names, I had to modify some label and input attributes so they would be recognized as separate fields. I came across a this blog post, which demonstrated using regexes to increment the id numbers in the relevant form element attributes.

In case anyone who wants to play around with the code or experiment with improvements, it can be found here. It doesn’t have any edit / update functionality yet, but I’ll be adding that soon so I can use the code for some more experiments.


Thu 04 Feb