Rails-如何在不使用accepts_nested_attributes_for的情况下管理嵌套属性? [英] Rails - How to manage nested attributes without using accepts_nested_attributes_for?
问题描述
我的问题是我遇到了accepts_nested_attributes_for的局限性,因此我需要弄清楚如何自行复制该功能以获得更大的灵活性. (请参阅下面的内容,这实际上使我感到困惑.)我的问题是:如果我想模仿和增加accepts_nested_attributes_for,我的表单,控制器和模型应该是什么样?真正的诀窍是我需要能够使用现有的关联/属性来更新现有模型和新模型.
我正在构建一个使用嵌套表单的应用程序.我最初将此RailsCast用作蓝图(利用accepts_nested_attributes_for): Railscast 196:嵌套模型表格.
我的应用程序是包含作业(任务)的清单,我让用户更新清单(名称,描述)并以一种形式添加/删除关联的作业.效果很好,但是当我将其合并到应用程序的另一个方面时,就会遇到问题:通过版本控制的历史记录.
我的应用程序很大一部分是我需要记录有关模型和关联的历史信息.我最终滚动了自己的版本( 代码-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])
这就是我遇到accepts_nested_attributes_for限制的地方(很好的记录在这里 .我得到了无法为ID = Y的Model2找到ID = X的Model1"的异常,这基本上是设计好的.
那么,我如何创建多个嵌套模型并以类似于accepts_nested_attributes_for的父模型的形式添加/删除它们,但要靠我自己呢?
我所见过的选择-是最好的选择之一吗? 真正的诀窍是我需要能够使用现有的关联/属性来更新现有模型和新模型.我无法链接它们,因此只给它们命名.
Redtape(在github上) Virtus(也是github)
感谢您的帮助!
由于Mario对我的问题发表了评论,并问我是否解决了这个问题,所以我想我会分享我的解决方案.
我应该说,我确定这不是一个很好的解决方案,也不是很好的代码.但这是我想出的,而且有效.由于此问题是非常技术性的,因此我不在此处发布伪代码-我将发布Checklist模型和Checklists控制器更新操作的完整代码(无论如何,适用于此问题的代码部分).我也很确定我的交易区块实际上没有做任何事情(我需要修复这些问题).
基本思想是我手动执行更新操作.我不依靠update_attributes(和accepts_nested_attributes_for),而是分两个阶段手动更新清单:
- 实际的清单对象是否已更改(清单仅具有名称和描述)?如果是这样,请创建一个新的清单,将新清单设为旧清单,然后为新清单设置添加或选择的所有作业.
- 如果清单本身未更改(名称和描述保持不变),分配给它的作业是否发生了变化?如果有,则存档已删除的作业分配,并添加所有新的作业分配.
我认为这里有些提交"的东西可以忽略不计(确定核对清单的更改是否重要甚至是重要的逻辑-如果没有任何提交(核对清单的历史数据的记录)然后进行更新清单,而无需执行任何此类归档或添加/减去作业的工作.)
我不知道这是否会有所帮助,但这还是可以的.
代码-checklist.rb(模型)
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
代码-checklists_controller.rb(控制器)
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
代码-清单表单
<%= 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 %>
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!
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 %>
这篇关于Rails-如何在不使用accepts_nested_attributes_for的情况下管理嵌套属性?的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!