Rails - How to manage nested attributes without using accepts_nested_attributes_for?

JoshDoody picture JoshDoody · Mar 26, 2013 · Viewed 8.7k times · Source

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!

Answer

muttonlamb picture muttonlamb · Mar 27, 2013

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