My problem is I've run into limitations of accepts_nested_attributes_for, so I need to figure out how to replicate that functionality on my own in order to have more flexibility. (See below for exactly what's hanging me up.) So my question is: What should my form, controller and models look like if I want to mimmic and augment accepts_nested_attributes_for? The real trick is I need to be able to update both existing AND new models with existing associations/attributes.
I'm building an app that uses nested forms. I initially used this RailsCast as a blueprint (leveraging accepts_nested_attributes_for): Railscast 196: Nested Model Form.
My app is checklists with jobs (tasks), and I'm letting the user update the checklist (name, description) and add/remove associated jobs in a single form. This works well, but I run into problems when I incorporate this into another aspect of my app: history via versioning.
A big part of my app is that I need to record historical information for my models and associations. I ended up rolling my own versioning (here is my question where I describe my decision process/considerations), and a big part of that is a workflow where I need to create a new version of an old thing, make updates to the new version, archive the old version. This is invisible to the user, who sees the experience as simply updating a model through the UI.
Code - models
#checklist.rb
class Checklist < ActiveRecord::Base
has_many :jobs, :through => :checklists_jobs
accepts_nested_attributes_for :jobs, :reject_if => lambda { |a| a[:name].blank? }, :allow_destroy => true
end
#job.rb
class Job < ActiveRecord::Base
has_many :checklists, :through => :checklists_jobs
end
Code - current form (NOTE: @jobs is defined as unarchived jobs for this checklist in the checklists controller edit action; so is @checklist)
<%= simple_form_for @checklist, :html => { :class => 'form-inline' } do |f| %>
<fieldset>
<legend><%= controller.action_name.capitalize %> Checklist</legend><br>
<%= f.input :name, :input_html => { :rows => 1 }, :placeholder => 'Name the Checklist...', :class => 'autoresizer' %>
<%= f.input :description, :input_html => { :rows => 3 }, :placeholder => 'Optional description...', :class => 'autoresizer' %>
<legend>Jobs on this Checklist - [Name] [Description]</legend>
<%= f.fields_for :jobs, @jobs, :html => { :class => 'form-inline' } do |j| %>
<%= render "job_fields_disabled", :j => j %>
<% end %>
</br>
<p><%= link_to_add_fields "+", f, :jobs %></p>
<div class="form-actions">
<%= f.submit nil, :class => 'btn btn-primary' %>
<%= link_to 'Cancel', checklists_path, :class => 'btn' %>
</div>
</fieldset>
<% end %>
Code - snippet from checklists_controller.rb#Update
def update
@oldChecklist = Checklist.find(params[:id])
# Do some checks to determine if we need to do the new copy/archive stuff
@newChecklist = @oldChecklist.dup
@newChecklist.parent_id = (@oldChecklist.parent_id == 0) ? @oldChecklist.id : @oldChecklist.parent_id
@newChecklist.predecessor_id = @oldChecklist.id
@newChecklist.version = (@oldChecklist.version + 1)
@newChecklist.save
# Now I've got a new checklist that looks like the old one (with some updated versioning info).
# For the jobs associated with the old checklist, do some similar archiving and creating new versions IN THE JOIN TABLE
@oldChecklist.checklists_jobs.archived_state(:false).each do |u|
x = u.dup
x.checklist_id = @newChecklist.id
x.save
u.archive
u.save
end
# Now the new checklist's join table entries look like the old checklist's entries did
# BEFORE the form was submitted; but I want to update the NEW Checklist so it reflects
# the updates made in the form that was submitted.
# Part of the params[:checklist] has is "jobs_attributes", which is handled by
# accepts_nested_attributes_for. The problem is I can't really manipulate that hash very
# well, and I can't do a direct update with those attributes on my NEW model (as I'm
# trying in the next line) due to a built-in limitation.
@newChecklist.update_attributes(params[:checklist])
And that's where I run into the accepts_nested_attributes_for limitation (it's documented pretty well here. I get the "Couldn't find Model1 with ID=X for Model2 with ID=Y" exception, which is basically as-designed.
So, how can I create multiple nested models and add/remove them on the parent model's form similar to what accepts_nested_attributes_for does, but on my own?
The options I've seen - is one of these best? The real trick is I need to be able to update both existing AND new models with existing associations/attributes. I can't link them, so I'll just name them.
Redtape (on github)
Virtus (also github)
Thanks for your help!
Your probably gonna want to rip out the complex accepts_nested stuff and create a custom class or module to contain all the steps required.
There's some useful stuff in this post
http://blog.codeclimate.com/blog/2012/10/17/7-ways-to-decompose-fat-activerecord-models/
Particularly point 3
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:
- 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.
- 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 %>