kvsbfa
Last Updated: May 20, 2017
·
16.6K
· sslotsky
5d5ca4ab2c1b954195153ebf9b7270d3

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!

Say Thanks
Respond

12 Responses
Add your response

13014
Dc01976fdb5397c5026a528c7fbc3590

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

over 1 year ago ·
14056
C08d24086bfc3c74d439b6dbfc651e26

Thank you!

over 1 year ago ·
14931
4e6aa196471a575d9b92752773e51797

Loved that article, helped me a lot, thanks.

over 1 year ago ·
15050
Orange avatar

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 ·
16206
35e30c06d2a7e9f320ab11b287bc6d70

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 ·
16208
5d5ca4ab2c1b954195153ebf9b7270d3

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 ·
16901
5d5ca4ab2c1b954195153ebf9b7270d3

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 ·
17365
None

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 ·
17379
5d5ca4ab2c1b954195153ebf9b7270d3

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

over 1 year ago ·
17764
D9484079a853ae6091a7b3c8e1e92316

Thank you! You helped me a lot!

over 1 year ago ·
28815

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!

8 months ago ·
28944
Jm

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

7 months ago ·