Rails - How to manage nested attributes without us

2020-06-04 04:00发布

问题:

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!

回答1:

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



回答2:

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