Rails Form Object with Virtus: Has_Many Association

Rails Form Object with Virtus: has_many association

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 %>

Alternative for accepts_nested_attributes_for - maybe virtus

Your question implies that you believe accepts_nested_attributes functionality to be a bad thing which is totally not the case and works perfectly well.

I'll start by saying that you don't need an alternative to accepts_nested_attributes_for but I'll cover that at the end of this post.

With reference to the link you provide, it cites nothing about why the poster believes accepts_nested_attributes_for should be deprecated at all and just merely states

in my humble opinion, should be deprecated

Nested attributes are an extremely important concept when considering how to capture multiple records related to a parent in a single form which is not just a Ruby on Rails thing but used in most complex web applications for sending data back to the server from the browser regardless of of the languages used to develop the site.

I'm not criticising the article you point to at all. To me it's just pointing out obvious alternatives to filling up a database backed model with lots of code that is not necessarily related to business logic. The specific example used is merely a coding style preference alternative.

When time is money and the pressure is on and one line of code will do the job versus the 22 lines of code shown in the example my preference in most cases (not all cases) is to use one line of code in a model (accepts_nested_attributes_for) to accept nested attributes posted back from a form.

To answer your question properly is impossible as you have not actually stated why YOU think accepts_nested_attributes_for is not good practice however the simplest alternative is to just extract the params hash attributes in your controller action and handle each record individually inside a transaction.

Update - follow up on comment

I think the author of the linked article argues that, following
oop-paradigms, every object should only read and write its own data.
With accepts_nested_attributes_for, one object however changes some
other objects data.

O.K. Lets clear that up.
Firstly OO paradigms suggest no such thing. Classes should be discreet but they are allowed to interact with other classes. In fact there would be no point to an OO approach in Ruby if this was the case as EVERYTHING in ruby is a class therefore nothing would be able to talk to anything else. Just imagine what would happen if an object that just happens to be an instance of your controller were not able to interact with models or other controllers?

With accepts_nested_attributes_for, one object however changes some
other objects data.

Couple of points on that statement as it's a complex one I'll try to be as brief as possible.

1) Model instances guard the data. In very complex scenarios involving hundreds of tables in any/most other languages (C, Delphi, VB to name a few) a middle tier in a 3 tier solution does just that. In Rails terms a model is a place for business logic and does the job of the middle tier in a 3 tier solution that is normally backed up by stored procedures and views in the RDBMS. Models quite rightly should be able to talk to each other.

2) accepts_nested_attributes_for does not break any OO principles at all. it merely simplifies the amount of code that you would need to write if the method did not exist (as you are finding out). If you accept attributes that are nested inside a params hash for child models all you are doing is allowing the child models to handle that data in the same way that your controller's action would have to do. No business logic is bypassed and you get added benefits.

Lastly

I can afford to care about elegance of code (more than about time)

I can assure you that there is nothing elegant about writing 20 + more lines of code than you need to and adding hundreds of lines more code from a gem where one line of code will do the work for you. As others have stated (including me) accepts_nested_attributes_for is not always an appropriate ActiveRecord method to use and it is a good thing that you are doing by looking at different approaches as ultimately you will be able to make better informed judgements as to when to use built in methods and when to write your own. However I would suggest that to fully understand what is going on (as you state you have the time) you would be better writing your own code to handle form objects and accepts nested attributes alternatives. That way you would find yourself understanding so much more.

Hope that makes sense and good luck with your learning.

UPDATE 2

To finally get to your point and in reference to your own answer plus taking into account of the excellent comments others have made on your own answer form objects backed by the virtus gem is a perfectly reasonable solution especially when dealing with the way data has to be collected. The combination helps to separate the user interface logic from the business logic and so long as you are ultimately passing off the data to the models so that business logic is not bypassed (as you show you are doing exactly this) then you have a great solution.

Just don't rule out accepts_nested_attributes out of hand.

You might also gain some benefit from watching the railscasts by Ryan Bates on form objects.

Rails - How to manage nested attributes without using accepts_nested_attributes_for?

Since Mario commented on my question and asked if I solved it, I thought I would share my solution.

I should say that I'm sure this isn't a very elegant solution, and it's not great code. But it's what I came up with, and it works. Since this question is pretty technical, I'm not posting pseudo-code here - I'm posting the full code for both the Checklist model and the Checklists controller update action (the parts of the code that apply to this question, anyway). I'm also pretty sure my transaction blocks aren't actually doing anything (I need to fix those).

The basic idea is I broke out the update action manually. Rather than relying on update_attributes (and accepts_nested_attributes_for), I manually update the checklist in two phases:

  1. Did the actual checklist object change (a checklist only has a name and description)? If it did, create a new checklist, make the new one a child of the old one, and set the new one up with whatever jobs were added or selected for it.
  2. If the checklist itself didn't change (name and description stayed the same), did the jobs assigned to it change? If they did, archive job assignments that were removed, and add any new job assignments.

There's some "submission" stuff that I think is safe to ignore here (it's basically logic to determine if it even matters how the checklist changed - if there aren't any submissions (records of a checklist's historical data) then just update the checklist in place without doing any of this archiving or adding/subtracting jobs stuff).

I don't know if this will be helpful, but here it is anyway.

Code - checklist.rb (model)

class Checklist < ActiveRecord::Base
scope :archived_state, lambda {|s| where(:archived => s) }

belongs_to :creator, :class_name => "User", :foreign_key => "creator_id"
has_many :submissions
has_many :checklists_jobs, :dependent => :destroy, :order => 'checklists_jobs.job_position'#, :conditions => {'archived_at' => nil}
has_many :jobs, :through => :checklists_jobs
has_many :unarchived_jobs, :through => :checklists_jobs,
:source => :job,
:conditions => ['checklists_jobs.archived = ?', false], :order => 'checklists_jobs.job_position'
has_many :checklists_workdays, :dependent => :destroy
has_many :workdays, :through => :checklists_workdays

def make_child_of(old_checklist)
self.parent_id = (old_checklist.parent_id == 0) ? old_checklist.id : old_checklist.parent_id
self.predecessor_id = old_checklist.id
self.version = (old_checklist.version + 1)
end

def set_new_jobs(new_jobs)
new_jobs.to_a.each do |job|
self.unarchived_jobs << Job.find(job) unless job.nil?
end
end

def set_jobs_attributes(jobs_attributes, old_checklist)
jobs_attributes.each do |key, entry|
# Job already exists and should have a CJ
if entry[:id] && !(entry[:_destroy] == '1')
old_cj = old_checklist.checklists_jobs.archived_state(:false).find_by_job_id(entry[:id])
new_cj = ChecklistsJob.new job_position: old_cj.job_position, job_required: old_cj.job_required
new_cj.checklist = self
new_cj.job = old_cj.job
new_cj.save!
# New job, should be created and added to new checklist only
else
unless entry[:_destroy] == '1'
entry.delete :_destroy
self.jobs << Job.new(entry)
end
end
end
end

def set_checklists_workdays!(old_checklist)
old_checklist.checklists_workdays.archived_state(:false).each do |old_cw|
new_cw = ChecklistsWorkday.new checklist_position: old_cw.checklist_position
new_cw.checklist = self
new_cw.workday = old_cw.workday
new_cw.save!
old_cw.archive
old_cw.save!
end
end

def update_checklists_jobs!(jobs_attributes)
jobs_attributes.each do |key, entry|
if entry[:id] # Job was on self when #edit was called
old_cj = self.checklists_jobs.archived_state(:false).find_by_job_id(entry[:id])
#puts "OLD!! "+old_cj.id.to_s
unless entry[:_destroy] == '1'
new_cj = ChecklistsJob.new job_position: old_cj.job_position, job_required: old_cj.job_required
new_cj.checklist = self
new_cj.job = old_cj.job
new_cj.save!
end
old_cj.archive
old_cj.save!
else # Job was created on this checklist
unless entry[:_destroy] == '1'
entry.delete :_destroy
self.jobs << Job.new(entry)
end
end
end
end
end

Code - checklists_controller.rb (controller)

class ChecklistsController < ApplicationController
before_filter :admin_user

def update
@checklist = Checklist.find(params[:id])
@testChecklist = Checklist.find(params[:id])
@oldChecklist = Checklist.find(params[:id])
@job_list = @checklist.unarchived_jobs.exists? ? Job.archived_state(:false).where( 'id not in (?)', @checklist.unarchived_jobs) : Job.archived_state(:false)

checklist_ok = false
# If the job is on a submission, do archiving/copying; else just update it
if @checklist.submissions.count > 0
puts "HERE A"
# This block will tell me if I need to make new copies or not
@testChecklist.attributes=(params[:checklist])
jobs_attributes = params[:checklist][:jobs_attributes]
if @testChecklist.changed?
puts "HERE 1"
params[:checklist].delete :jobs_attributes
@newChecklist = Checklist.new(params[:checklist])
@newChecklist.creator = current_user
@newChecklist.make_child_of(@oldChecklist)
@newChecklist.set_new_jobs(params[:new_jobs])

begin
ActiveRecord::Base.transaction do
@newChecklist.set_jobs_attributes(jobs_attributes, @oldChecklist) if jobs_attributes
@newChecklist.set_checklists_workdays!(@oldChecklist)
@newChecklist.save!
@oldChecklist.archive
@oldChecklist.save!
@checklist = @newChecklist
checklist_ok = true
end
rescue ActiveRecord::RecordInvalid
# This is a NEW checklist, so it's acting like it's "new" - WRONG?
puts "RESCUE 1"
@checklist = @newChecklist
@jobs = @newChecklist.jobs
checklist_ok = false
end
elsif @testChecklist.changed_for_autosave? || params.has_key?(:new_jobs)
puts "HERE 2"
# Associated Jobs have changed, so archive old checklists_jobs,
# then set checklists_jobs based on params[:checklist][:jobs_attributes] and [:new_jobs]

@checklist.set_new_jobs(params[:new_jobs])

begin
ActiveRecord::Base.transaction do
@checklist.update_checklists_jobs!(jobs_attributes) if jobs_attributes
@checklist.save!
checklist_ok = true
end
rescue ActiveRecord::RecordInvalid
puts "RESCUE 2"
@jobs = @checklist.unarchived_jobs
checklist_ok = false
end
else
checklist_ok = true # There were no changes to the Checklist or Jobs
end
else
puts "HERE B"
@checklist.set_new_jobs(params[:new_jobs])
begin
ActiveRecord::Base.transaction do
@checklist.update_attributes(params[:checklist])
checklist_ok = true
end
rescue ActiveRecord::RecordInvalid
puts "RESCUE B"
@jobs = @checklist.jobs
checklist_ok = false
end
end

respond_to do |format|
if checklist_ok
format.html { redirect_to @checklist, notice: 'List successfully updated.' }
format.json { head :no_content }
else
flash.now[:error] = 'There was a problem updating the List.'
format.html { render action: "edit" }
format.json { render json: @checklist.errors, status: :unprocessable_entity }
end
end
end
end

Code - Checklist form

<%= form_for @checklist, :html => { :class => 'form-inline' } do |f| %>
<div>
<%= f.text_area :name, :rows => 1, :placeholder => 'Name the list...', :class => 'autoresizer checklist-name' %></br>
<%= f.text_area :description, :rows => 1, :placeholder => 'Optional description...', :class => 'autoresizer' %>
</div>

<%= f.fields_for :jobs, :html => { :class => 'form-inline' } do |j| %>
<%= render "job_fields", :j => j %>
<% end %>

<span class="add-new-job-link"><%= link_to_add_fields "add a new job", f, :jobs %></span>
<div class="form-actions">
<%= f.submit nil, :class => 'btn btn-primary' %>
<%= link_to 'Cancel', checklists_path, :class => 'btn' %>
</div>

<% unless @job_list.empty? %>
<legend>Add jobs from the Job Bank</legend>

<% @job_list.each do |job| %>
<div class="toggle">
<label class="checkbox text-justify" for="<%=dom_id(job)%>">
<%= check_box_tag "new_jobs[]", job.id, false, id: dom_id(job) %><strong><%= job.name %></strong> <small><%= job.description %></small>
</label>
</div>
<% end %>

<div class="form-actions">
<%= f.submit nil, :class => 'btn btn-primary' %>
<%= link_to 'Cancel', checklists_path, :class => 'btn' %>
</div>
<% end %>
<% end %>

Rails Form Objects with multiple nested resources

the rails fields_for helper checks for a method in this format: #{association_name}_attributes=

so, if you add this method to CompanyForm:

def users_attributes=(users_attributes)
# manipulate attributes as desired...
@company.users_attributes= users_attributes
end

def users
company.users
end

the fields_for generators will generate the nested users fields for a CompanyForm as if it were a Company. the above could be rewritten as a delegation since nothing is happening in the methods:

  delegate :users, :users_attributes=, :to => :company, :prefix => false, :allow_nil => false

Form Objects in Rails

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 %>


Related Topics



Leave a reply



Submit