Rails Form Object with Virtus: has_many association
Asked Answered
A

3

4

I am having a tough time figuring out how to make a form_object that creates multiple associated objects for a has_many association with the virtus gem.

Below is a contrived example where a form object might be overkill, but it does show the issue I am having:

Lets say there is a user_form object that creates a user record, and then a couple associated user_email records. Here are the models:

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

# models/user_email.rb
class UserEmail < ApplicationRecord
  belongs_to :user
end

I proceed to create a a form object to represent the user form:

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

  attribute :name, String
  attribute :emails, Array[EmailForm]

  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)
    # emails.each do |email_form|
    #   UserEmail.create(user: user, email: email_form.email_text)
    # end
  end
end

One will notice in the UserForm class that I have the attribute :emails, Array[EmailForm]. This is an attempt to validate and capture the data that will be persisted for the associated user_email records. Here is the Embedded Value form for a user_email record:

# app/forms/email_form.rb
# Note: this form is an "Embedded Value" Form Utilized in user_form.rb
class EmailForm
  include ActiveModel::Model
  include Virtus.model

  attribute :email_text, String

  validates :email_text,  presence: true
end

Now I will go ahead and show the users_controller which sets up the user_form.

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

  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 @user, notice: 'User was successfully created.' 
    else
      render :new 
    end
  end

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

The new.html.erb:

<h1>New User</h1>

<%= render 'form', user_form: @user_form %>

And the _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>

  <% unique_index = 0 %>
  <% f.object.emails.each do |email| %>
    <%= label_tag       "user_form[emails][#{unique_index}][email_text]","Email" %>
    <%= text_field_tag  "user_form[emails][#{unique_index}][email_text]" %>
    <% unique_index += 1 %>
  <% end %>

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

Note: If there is an easier, more conventional way to display the inputs for the user_emails in this form object: let me know. I could not get fields_for to work. As shown above: I had to write out the name attributes by hand.

The good news is that the form does render:

rendered form

The html of the form looks ok to me:

html of the form

When the above input is submitted: Here is the params hash:

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

The params hash looks ok to me.

In the logs I get two deprecation warnings which makes me think that virtus might be outdated and thus no longer a working solution for form objects in rails:

DEPRECATION WARNING: Method to_hash is deprecated and will be removed in Rails 5.1, as ActionController::Parameters no longer inherits from hash. Using this deprecated behavior exposes potential security problems. If you continue to use this method you may be creating a security vulnerability in your app that can be exploited. Instead, consider using one of these documented methods which are not deprecated: http://api.rubyonrails.org/v5.0.2/classes/ActionController/Parameters.html (called from new at (pry):1) DEPRECATION WARNING: Method to_a is deprecated and will be removed in Rails 5.1, as ActionController::Parameters no longer inherits from hash. Using this deprecated behavior exposes potential security problems. If you continue to use this method you may be creating a security vulnerability in your app that can be exploited. Instead, consider using one of these documented methods which are not deprecated: http://api.rubyonrails.org/v5.0.2/classes/ActionController/Parameters.html (called from new at (pry):1) NoMethodError: Expected ["0", "foofoo"} permitted: true>] to respond to #to_hash from /Users/neillocal/.rvm/gems/ruby-2.3.1/gems/virtus-1.0.5/lib/virtus/attribute_set.rb:196:in `coerce'

And then the whole thing errors out with the following message:

Expected ["0", <ActionController::Parameters {"email_text"=>"foofoo"} permitted: true>] to respond to #to_hash

I feel like I am either close and am missing something small in order for it to work, or I am realizing that virtus is outdated and no longer usable (via the deprecation warnings).

Resources I looked at:

I did attempt to get the same form to work but with the reform-rails gem. I ran into an issue there too. That question is posted here.

Thanks in advance!

Affiant answered 17/3, 2017 at 15:27 Comment(0)
S
4

I would just set the emails_attributes from user_form_params in the user_form.rb as a setter method. That way you don't have to customize the form fields.

Complete Answer:

Models:

#app/modeles/user.rb
class User < ApplicationRecord
  has_many :user_emails
end

#app/modeles/user_email.rb
class UserEmail < ApplicationRecord
  # contains the attribute: #email
  belongs_to :user
end

Form Objects:

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

  attribute :name, String

  validates :name, presence: true
  validate  :all_emails_valid

  attr_accessor :emails

  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|
      UserEmail.new(email: email.email_text)
    end
    user.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
# "Embedded Value" Form Object.  Utilized within the user_form object.
class EmailForm
  include ActiveModel::Model
  include Virtus.model

  attribute :email_text, String

  validates :email_text,  presence: true
end

Controller:

# app/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

Views:

#app/views/users/new.html.erb
<h1>New User</h1>
<%= render 'form', user_form: @user_form %>


#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 %>
Siu answered 22/3, 2017 at 0:44 Comment(3)
please accept edits and I will gladly mark your answer as the accepted answer. A couple minor changes to make the validations work as intended, and then for convenience: provided code for all major parts. I want to give you credit because your posted answer got me through the part I was stuck.Affiant
It should be noted that one should consider the benefits of using the virtus gem for your form objects. Chances are: you can create your form objects just as easy without adding the virtus gem dependency to your project. See Rails form object implementation hereAffiant
Thanks for this answer. It's just what I've been looking for. I'm a Rails newb though. Could someone explain to me why we need to define emails_attributes=(attributes). What is calling this? I see that if this method isn't defined then @user_form.emails = [EmailForm.new, EmailForm.new, EmailForm.new] doesn't work as expected. Where in Rails is this magic happening?Bozcaada
M
0

You have an issue because you haven't whitelisted any attributes under :emails. This is confusing, but this wonderful tip from Pat Shaughnessy should help set you straight.

This is what you're looking for, though:

params.require(:user_form).permit(:name, { emails: [:email_text, :id] })

Note the id attribute: it's important for updating the records. You'll need to be sure you account for that case in your form objects.

If all this form object malarkey with Virtus gets to be too much, consider Reform. It has a similar approach, but its raison d'etre is decoupling forms from models.


You also have an issue with your form… I'm not sure what you were hoping to achieve with the syntax you're using, but if you look at your HTML you'll see that your input names aren't going to pan out. Try something more traditional instead:

<%= f.fields_for :emails do |ff| %>
  <%= ff.text_field :email_text %>
<% end %>

With this you'll get names like user_form[emails][][email_text], which Rails will conveniently slice and dice into something like this:

user_form: { 
  emails: [
    { email_text: '...', id: '...' },
    { ... }
  ]
}

Which you can whitelist with the above solution.

Mendenhall answered 17/3, 2017 at 16:1 Comment(2)
Thank you for your post. Unfortunately the emails are still not getting passed into the params hash: "user_form"=>{"name"=>"neil", "emails"=>""}. And it still says this: Unpermitted parameter: emailsAffiant
I didn't realize that you weren't getting any values under emails. It's not permitting an empty string because it's now expecting a hash. See updated answer.Mendenhall
M
0

The problem is that the format of the JSON being passed to UserForm.new() is not what is expected.

The JSON that you are passing to it, in the user_form_params variable, currently has this format:

{  
   "name":"testform",
   "emails":{  
      "0":{  
         "email_text":"[email protected]"
      },
      "1":{  
         "email_text":"[email protected]"
      },
      "2":{  
         "email_text":"[email protected]"
      }
   }
}

UserForm.new() is actually expecting the data in this format:

{  
   "name":"testform",
   "emails":[   
       {"email_text":"[email protected]"}, 
       {"email_text":"[email protected]"},  
       {"email_text":"[email protected]"}
   }
}

You need to change the format of the JSON, before passing it to UserForm.new(). If you change your create method to the following, you won't see that error anymore.

  def create
    emails = []
    user_form_params[:emails].each_with_index do |email, i| 
      emails.push({"email_text": email[1][:email_text]})
    end

    @user_form = UserForm.new(name: user_form_params[:name], emails: emails)

    if @user_form.save
      redirect_to @user, notice: 'User was successfully created.' 
    else
      render :new 
    end
  end
Married answered 25/3, 2017 at 15:58 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.