Last Updated: February 02, 2019
·
29.4K
· sslotsky

Nested forms with ActiveModel::Model objects

ActiveModel::Model is an excellent way to make objects behave like ActiveRecord. One potential use for this is if we want to make use of form data that doesn't necessarily persist to an object, but we want to keep our controllers clean. A great example can be found on this thoughtbot blog post from about a year ago.

One thing that I think many have found lacking, however, is has_many and accepts_nested_attributes_for functionality. I found myself in this boat recently when I wanted to use these form objects to create multiple records. How can I have a form that can create arbitrarily many records and have validation errors appear for each record I'm trying to create, without associations?

Like many, I began looking for gems to solve my problem, but after a day or so of searching for the perfect solution, I found the missing key. If an object with a one-to-many association is instantiated with a params hash, and that hash has a key for the association, Rails will call the <association_name>_attributes= method on that object. So, we need to define that on our form object.

class ContactListForm
  include ActiveModel::Model

  attr_accessor :contacts

  def contacts_attributes=(attributes)
    @contacts ||= []
    attributes.each do |i, contact_params|
      @contacts.push(Contact.new(contact_params))
    end
  end
end

Now we need a form for this model. Keeping it simple:

<div>
  <%= form_for @contact_list, url: contacts_path, method: :post do |f| %>
    <%= f.fields_for :contacts do |c| %>
      <%= c.text_field :first_name %>
      <%= c.text_field :last_name
      <%= c.text_field :phone %>
    <% end %>

    <p>
      <%= f.submit "Submit" %>
    </p>
  <% end %>
</div>

Of course our object doesn't have any contacts yet, so our controller will need to make sure that the form has at least one fields_for block to render by giving it one on initialization

class ContactsController < ApplicationController
 def new
    @contact_list = ContactListForm.new(contacts: [Contact.new])
  end
end

My controller also needs a create action for when the form is submitted.

class ContactsController < ApplicationController
  def create
    @contact_list = ContactListForm.new(params[:contact_list_form])

    if @contact_list.save
      flash[:notice] = "Created contacts"
      redirect_to root_path
    else
      flash[:notice] = "There were errors"
      render :action => 'new'
    end
  end
end

If all forms were valid, then all contacts were submitted to the database. But what about validation errors for each contact on the form? First I took some good advice from a StackOverflow post to just redefine the error_messages method that was deprecated back in Rails 3

class CustomFormBuilder < ActionView::Helpers::FormBuilder
    def error_messages
      return unless object.respond_to?(:errors) && object.errors.any?

      html = ""
      html << @template.content_tag(:span, "Please correct the following errors.")
      html << object.errors.full_messages.map { |message| @template.content_tag(:li, message) }.join("\n")

      @template.content_tag(:ul, html.html_safe)
    end
end

ActionView::Base.default_form_builder = CustomFormBuilder

Now just a small update to my form:

<%= f.fields_for :contacts do |c| %>
  <%= c.error_messages %>
  <%= c.text_field :first_name %>
  <%= c.text_field :last_name
  <%= c.text_field :phone %>
<% end %>

And I'm good to go.

For my next post, we'll add a link to the form that will add inputs for another contact, so that we may submit an arbitrary number of contacts. Please stay tuned!

13 Responses
Add your response

Great stuff. I assume you also implemented #save in your ContactListForm but just left that out of the post?

over 1 year ago ·

Thank you!

over 1 year ago ·

Loved that article, helped me a lot, thanks.

over 1 year ago ·

This just saved my ass. I was about to nuke everything that I've been working on with a form object since Saturday morning. Literally the only example of a form object with a has_many association that I've found on the entire Interwebz.

over 1 year ago ·

Do you place validations in your model or in your form object? For example, let's say that each Contact has a number of validations. Where would those validations live? How would you access those validations from your ContactsListsForm?

over 1 year ago ·

Good question! If you include ActiveModel::Validations you can write the same validators as you would with ActiveRecord.

However, in this case, our form is just a collection of Contact objects, which are ActiveRecord and have their own validations. When I save the ContactListForm, it attempts to save all the contacts. In doing so, each contact has its error_messages available.

over 1 year ago ·

There is nothing wrong with accepts_nested_attributes_for. This is what you should use in your typical case. My post describes a non-typical case. ContactListForm is not an ActiveRecord object, it is an object that includes ActiveModel::Model, which does not support accepts_nested_attributes_for.

over 1 year ago ·

How can I write a validations for nested resource? Contacts, in this case.
Could you show how you have implemented the save method

over 1 year ago ·

The validation could simply be contacts.all?(&:valid?). Contact defines its own validation of course.

over 1 year ago ·

Thank you! You helped me a lot!

over 1 year ago ·

I have done what you have described, but just some name changes. When I submit the form, I get this error: ActiveModel::ForbiddenAttributesError in CampaignsController#create and this part is marked as the error: @campaigns.push(Campaign.new(campaign_params))

What have I done wrong?

Thanks in advanced!

over 1 year ago ·

Does this handle existing records like ActiveRecord does? I mean:
- Update records for attributes that include an id attribute
- Create records for attributes that DON'T include an id
- Delete all the rest

over 1 year ago ·

Great stuff. Thanks. Interested for the next post :)

over 1 year ago ·