3

The example code below is a contrived example of an attempt at a form object where it is probably overkill to utilize a form object. Nonetheless: it shows the issue I am having:

I have two models: a User and an Email:

# app/models/user.rb
class User < ApplicationRecord
  has_many :emails
end

# app/models/user.rb
class Email < ApplicationRecord
  belongs_to :user
end

I want to create a form object which creates a user record, and then creates three associated email records.

Here are my form object classes:

# app/forms/user_form.rb
class UserForm 
  include ActiveModel::Model

  attr_accessor :name, :email_forms

  validates :name, presence: true

  def save
    if valid?
      persist!
      true
    else
      false
    end
  end

  private

  def persist!
    puts "The Form is VALID!"
    puts "I would proceed to create all the necessary objects by hand"

    user = User.create(name: name)
    email_forms.each do |email|
      Email.create(user: user, email_text: email.email_text)
    end
  end
end

# app/forms/email_form.rb
class EmailForm 
  include ActiveModel::Model

  attr_accessor :email_text, :user_id

  validates :email_text, presence: true

  def save
    if valid?
      persist!
      true
    else
      false
    end
  end

  private

  def persist!
    puts "The Form is VALID!"
    # DON'T THINK I WOULD PERSIST DATA HERE
    # INSTEAD DO IT IN THE user_form
  end
end

Notice: the validations on the form objects. A user_form is considered to be invalid if it's name attribute is blank, or if the email_text attribute is left blank for any of the email_form objects inside it's email_forms array.

For brevity: I will just be going through the new and create action of utilizing the user_form:

# app/controllers/user_controller.rb
class UsersController < ApplicationController

  def new
    @user_form = UserForm.new
    @user_form.email_forms = [EmailForm.new, EmailForm.new, EmailForm.new]
  end

  def create
    @user_form = UserForm.new(user_form_params)

    if @user_form.save
      redirect_to users_path, notice: 'User was successfully created.' 
    else
      render :new
    end 
  end

  private

  def user_form_params
    params.require(:user_form).permit(:name, {email_forms: [:_destroy, :id, :email_text, :user_id]})
  end
end

Lastly: the form itself:

# app/views/users/new.html.erb
<h1>New User</h1>

<%= render 'form', user_form: @user_form %>
<%= link_to 'Back', users_path %>

# app/views/users/_form.html.erb
<%= form_for(user_form, url: users_path) do |f| %>
  <% if user_form.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(user_form.errors.count, "error") %> prohibited this user from being saved:</h2>

      <ul>
      <% user_form.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= f.label :name %>
    <%= f.text_field :name %>
  </div>

  # MESSY, but couldn't think of a better way to do this...
  <% unique_index = 0 %>
  <% user_form.email_forms.each do |email_form| %>
    <div class="field">
      <%= label_tag "user_form[email_forms][#{unique_index}][email_text]", "Email Text" %>
      <%= text_field_tag "user_form[email_forms][#{unique_index}][email_text]" %>
    </div>
    <% unique_index += 1 %>
  <% end %>


  <div class="actions">
    <%= f.submit %>
  </div>
<% end %>

The form does render:

picture of rendered form with input

And here is the form's html:

html of the form

I go to submit the form. Here is the params hash:

Parameters: {"utf8"=>"✓", "authenticity_token"=>”abc123==", "user_form"=>{"name"=>"neil", "email_forms"=>{"0"=>{"email_text"=>"test_email_1"}, "1"=>{"email_text"=>"test_email_2"}, "2"=>{"email_text"=>""}}}, "commit"=>"Create User form"}

What should happen is the form should be re-rendered and nothing persisted because the form_object is invalid: All three associated emails must NOT be blank. However: the form_object thinks it is valid, and it blows up in the persist! method on the UserForm. It highlights the Email.create(user: user, email_text: email.email_text) line and says:

undefined method `email_text' for ["0", {"email_text"=>"test_email_1"}]:Array

Clearly there are a couple things going on: The nested validations appear to not be working, and I am having trouble rebuilding each of the emails from the params hash.

Resources I have already examined:

  • This Article seemed promising but I was having trouble getting it to work.
  • I have attempted an implementation with the virtus gem and the reform-rails gem. I have pending questions posted for both of those implementations as well: virtus attempt here and then reform-rails attempt here.
  • I have attempted plugging in accepts_nested_attributes, but was having trouble figuring out how to utilize that with a form object, as well as a nested form object (like in this code example). Part of the issue was that has_many and accepts_nested_attributes_for do not appear to be included in ActiveModel::Model.

Any guidance on getting this form object to do what is expected would be very much appreciated! Thanks!

Community
  • 1
  • 1
Neil
  • 4,578
  • 14
  • 70
  • 155
  • 1
    Have you looked at accepts_nested_attributes_for ? http://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html ... that plus the gem `cocoon` basically takes all the hard work out of handling nested attributes. – SteveTurczyn Mar 21 '17 at 14:48
  • @SteveTurczyn yes I have looked at `accepts_nested_attributes_for`. I was having a ton of trouble figuring out how to implement `accepts_nested_attributes_for` in a form object. I updated my question to express that an attempt was made for that route as well. – Neil Mar 21 '17 at 14:59
  • Seriously, look at 'cocoon'. It makes it all easy. If you have a problem implementing 'cocoon' post a question using the tag `ruby-on-rails` and `cocoon-gem` The gem is here... https://github.com/nathanvda/cocoon – SteveTurczyn Mar 21 '17 at 15:25
  • I'm having the same issue, User Model has a aasociated model Address. How to design the formobject, if the address record empty or not. If address record empty it should render the address field or it should not display. – prdk0 Mar 11 '19 at 07:26

1 Answers1

3

Complete Answer

Models:

#app/models/user.rb
class User < ApplicationRecord
  has_many :emails
end

#app/models/email.rb
class Email < ApplicationRecord
  belongs_to :user
end

Controller:

#app/controllers/users_controller.rb
class UsersController < ApplicationController

  def index
    @users = User.all
  end

  def new
    @user_form = UserForm.new
    @user_form.emails = [EmailForm.new, EmailForm.new, EmailForm.new]
  end

  def create
    @user_form = UserForm.new(user_form_params)
    if @user_form.save
      redirect_to users_path, notice: 'User was successfully created.'
    else
      render :new 
    end 
  end


  private

  def user_form_params
      params.require(:user_form).permit(:name, {emails_attributes: [:email_text]})
  end
end

Form Objects:

#app/forms/user_form.rb
class UserForm
  include ActiveModel::Model

  attr_accessor :name, :emails

  validates :name, presence: true
  validate  :all_emails_valid


  def emails_attributes=(attributes)
    @emails ||= []
    attributes.each do |_int, email_params|
      email = EmailForm.new(email_params)
      @emails.push(email)
    end
  end

  def save
    if valid?
      persist!
      true
    else
      false
    end
  end

  private

  def persist!
    user = User.new(name: name)
    new_emails = emails.map do |email_form|
      Email.new(email_text: email_form.email_text)
    end
    user.emails = new_emails
    user.save!
  end

  def all_emails_valid
    emails.each do |email_form|
      errors.add(:base, "Email Must Be Present") unless email_form.valid?
    end
    throw(:abort) if errors.any?
  end
end


app/forms/email_form.rb
class EmailForm 
  include ActiveModel::Model

  attr_accessor :email_text, :user_id
  validates :email_text, presence: true
end

Views:

app/views/users/new.html.erb
<h1>New User</h1>

<%= render 'form', user_form: @user_form %>
<%= link_to 'Back', users_path %>


#app/views/users/_form.html.erb
<%= form_for(user_form, url: users_path) do |f| %>

  <% if user_form.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(user_form.errors.count, "error") %> prohibited this User from being saved:</h2>

      <ul>
      <% user_form.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= f.label :name %>
    <%= f.text_field :name %>
  </div>


  <%= f.fields_for :emails do |email_form| %>
    <div class="field">
      <%= email_form.label :email_text %>
      <%= email_form.text_field :email_text %>
    </div>
  <% end %>


  <div class="actions">
    <%= f.submit %>
  </div>
<% end %>
Neil
  • 4,578
  • 14
  • 70
  • 155