Add Multiple Nested Attributes through checkboxes

2019-01-23 21:24发布

问题:

3/13 UPDATE:
I've made a small sample project with my models, controller logic and several form versions.



I am building a form where a user can add "Tasks" and "Milestones" together. (ie. Task = 'Vacuum' is inside Milestone = 'clean House'). It's basically a Task/Subtask type model with the parent being 'Milestone' and the child being 'Task'.

Both Tasks and Milestones belong to "Project"....so I am trying to add the Tasks and Milestones through a nested form with an update action. I am thinking the way to go is create a form for each @task_template instance and update multiple forms at once.

My problem is that I am also trying to dynamically set "starter milestones/tasks" through tables called "MilestoneTemplates" and "TaskTemplates"....

The user pulls up the "Add Milestones/Task" page and, depending on their type of project, they see an array of prebuilt tasks(@task_templates) & milestones(@milestone_templates) next to checkboxes. The user then CHECKS the checkbox next to the task or milestone they would like to add. This should create a specific task for the User with a prebuilt @task_template.name, @task_template.description...etc

I cannot get this to even create 1. I am using Rails 4 and I think I have set my strong_params correctly. Below is where I am on this:

Models:

class Task < ActiveRecord::Base
    belongs_to :user
    belongs_to :project
  belongs_to :milestone

class Milestone < ActiveRecord::Base
 belongs_to :project
 belongs_to :user
 has_many :tasks, dependent: :destroy, inverse_of: :milestone
 accepts_nested_attributes_for :tasks, allow_destroy: true

class Project < ActiveRecord::Base
 has_many :milestones, dependent: :destroy
 has_many :tasks, dependent: :destroy
 accepts_nested_attributes_for :tasks, allow_destroy: true
 accepts_nested_attributes_for :milestones, allow_destroy: true

 #the "Starter Milestones & Tasks"

class MilestoneTemplate < ActiveRecord::Base
    has_many :task_templates, dependent: :destroy, inverse_of: :milestone_template

class TaskTemplate < ActiveRecord::Base
     belongs_to :milestone_template,  inverse_of: :task_templates

Controller:

class ProjectsController < ApplicationController

def new_milestones
 @project = Project.find(params[:p])
 @project.milestones.build
 @project.tasks.build
 @milestones_templates = MilestoneTemplate.where(template_id: @project.template_id)
end

def create_milestones
 @project.milestone_ids = params[:project][:milestones]
 @project.task_ids = params[:project][:tasks]
 @milestone = Milestone.new
 @task = Task.new
 @template = Template.find( @project.template_id)
  if @project.update_attributes(project_params)
    redirect_to  view_milestones_path(p: @project.id)
    flash[:notice] = "Successfully Added Tasks & Milestones"
  else
    redirect_to  new_milestones_path(p:  @project.id )
    format.json { render json: @project.errors, status: :unprocessable_entity }
  end
end

def project_params
      params.require(:project).permit( :id, :name,
        milestones_attributes: [:id, {:milestone_ids => []}, {:ids => []}, {:names => []}, :project_id, :user_id,
            :name, :description, :due_date, :rank, :completed, :_destroy,
        tasks_attributes: [:id, {:task_ids => []}, {:names => []},  {:ids => []}, :milestone_id, :project_id,    
          :user_id, :name, :description, :due_date, :rank, :completed,  :_destroy]] )
end
end

Form Test 1:

<%= form_for @project, url: create_milestones_path(p: @project.id) do |f| %>
     <label>Milestones</label><br>
     <div class="row">
       <%= hidden_field_tag "project[names][]", nil %>
       <% @milestones_templates.each do |m| %>
         <%= check_box_tag  "project[names][]", m.name, @milestones_templates.include?(m), id: dom_id(m)%> 
         <%= label_tag dom_id(m), m.name  %>

           <%= hidden_field_tag "project[milestone][names][]", nil %>
           <% m.task_templates.each do |t| %>
             <%= check_box_tag  "project[milestone][names][]", t.name, m.task_templates.include?(t), id: dom_id(t) %> 
             <%= label_tag dom_id(t), t.name  %>
           <% end %>
       <% end %>
     </div>
 <%= f.submit %>

Form Test 2(trying to submit an array of forms):

 <label>Milestones</label><br>
   <%= hidden_field_tag "project[milestone_ids][]", nil %>
   <% @milestones_templates.each do |m| %>
   <div>
      <%= f.fields_for :milestones do |fm|%>
         <%= check_box_tag    "project[milestone_ids][]",  @milestones_templates.include?(m), id: dom_id(m) %> 
         <%= label_tag dom_id(m), m.name  %></div>
      <%= hidden_field_tag :name, m.name %>
      <%= hidden_field_tag "project[milestone][task_ids][]", nil %>

         <% m.task_templates.each do |t| %>
         <%= fm.fields_for :tasks do |ft| %>
               <%= check_box_tag  "project[milestone][task_ids][]", t.name,  m.task_templates.include?(t), id: dom_id(t)%> 
               <%= label_tag dom_id(t), t.name  %>
         <% end %>
         <% end %>
      <% end %>
   <% end %>
   </div>

as per xcskier56's request in the comments, I've added my POST code from Chrome inspector. As you can see, the form isn't even calling the Tasks, just the parent Milestones. The Milestones show up in the form, but the tasks don't....

project[formprogress]:2
project[milestone_ids][]:
project[milestone][names]:true
name:Milestone 1
project[milestone][task_ids][]:
project[milestone][names]:true
name:Milestone 2
project[milestone][task_ids][]:
project[milestone][names]:true
name:Milestone 3
project[milestone][task_ids][]:
project[milestone][names]:true
name:Milestone 4
project[milestone][task_ids][]:

回答1:

I haven't been able to test this code myself, but I have implemented similar code, so the ideas should be correct.

The trick here is using each_with_index, and then passing that index to your fields_for call. This way the each additional milestone_id that you add via a checkbox will be significantly different from the previous. You can find another example of this here.

Using this approach, your form should look something like this:

<%= form_for @project do |f| %>
  <% @milestones_templates.each_with_index do |milestone, index| %>
    <br>
    <%= f.fields_for :milestones, index: index do |fm| %>
      <%= fm.hidden_field :name, value: milestone.name %>
      <!-- Create a checkbox to add the milestone_id to the project -->
      <%= fm.label milestone.name %>
      <%= fm.check_box :milestone_template_id,{}, milestone.id %>
      <br>
      <% milestone.task_templates.each_with_index do |task, another_index| %>
        <%= fm.fields_for :tasks, index: another_index do |ft| %>
          <!-- Create a checkbox for each task in the milestone -->
          <%= ft.label task.name %>
          <%= ft.check_box :task_ids, {}, task.id %>
        <% end %>
      <% end %>
      <br>
    <% end %>
  <% end %>
  <br>
<%= f.submit %>
<% end %>

# Working strong parameters.
params.require(:project).permit(:name, :milestones => [:name, :milestone_ids, :tasks => [:task_ids] ] )

This should output the milestone_template_ids with each of those's task_template_ids nested inside.

Edit: I forgot that if you look at the docs, the check_boxes need another param in the middle f.checkbox :task_ids, task.id => f.checkbox :task_ids, {}, task.id

Now for the meat of the answer. While this form does work, and given enough fiddling I think you could get rails to automagically update your project and through nested attributes, and create everything that you want it to, I don't think this is a good design.

What is a far better design is using a builder class. It is just a PORO (Plain Old Ruby Object). What this will allow you to do is write good tests around the builder. So you can be much more assured that it will always work, and that some change to rails didn't break it.

Here's some pseudo code to get you going:

ProjectsController << ApplicationController

  def update
    @project = Project.find(params[:id])
    # This should return true if everything works, and 
    result = ProjectMilestoneBuilder.perform(@project, update_params)
    if result == false
      # Something went very wrong in the builder
    end
    if result.errors.any?
      #handle success
    else
      # handle failure
      # The project wasn't updated, but things didn't explode.
    end
  end

  private

  def update_params
    params.require(:project).permit(:name, :milestones => [:name, :milestone_ids, :tasks => [:task_ids] ] )
  end
end

In /lib/project_milestone_builder.rb

class ProjectMilestoneBuilder 
  def self.perform(project, params)
    milestone_params = params[:project][:milestones]
    milestone_params.each do |m|
      # Something like this
      # Might be able to use nested attributes for this
      # Milestone.create(m)
    end

    return project.update_attributes(params)
  end
end

In /spec/lib/project_milestone_builder_spec.rb

descibe ProjectMilestoneBuilder do
  # Create a template and project
  let(:template) {FactoryGirl.create :template}
  let(:project) {FactoryGirl.create :project, template: template}

  # Create the params to update the project with. 
  # This will have to have dynamic code segments to get the appropriate milestone_template_ids in there
  let(:params) { "{project: {milestones ..." })

  descibe '#perform' do
    let(:result) { ProjectMilestoneBuilder.perform(project, params) }
    it {expect(result.id).to eq project.id}
    # ...
  end
end

With this pattern, you will end up with a very well encapsulated, easily testable class that will do exactly what you expect it to do. Happy coding.