8

UPDATED: I am trying to add/remove form fields to a nested form involving multiple models. I have seen the "Dynamic Forms" railscast by Ryan Bates and I have referred to this article using the Cocoon Gem. Following that article has made everything work perfectly except for the child_index. The child_index is present only on the first :kid input field (:name) and the first :pet input fields (:name and :age). Then it goes back to an authenticity token for the fields being added.

I've removed all of the JS and helper methods and instead I'm using some of the Cocoon methods that has built in JS.

I fixed the problem where clicking "Add" would add two fields instead of one by removing the = javascript_include_tag :cocoon from the application.html.erb file.

I have tried adding jQuery and form helpers but I'm not sure I entered the code correctly.

(I have changed the model objects to make the relationships more clear)

parent.rb file:

class Parent < ActiveRecord::Base

has_many :kids
has_many :pets, through: :kids # <<<<<< ADDED KIDS USING 'through:'

kid.rb file:

class Kid < ActiveRecord::Base

belongs_to :parent
has_many :pets
accepts_nested_attributes_for :pets, reject_if: :all_blank, allow_destroy: true
validates :name, presence: true

pet.rb file:

 class Pet < ActiveRecord::Base

 belongs_to :kid

 validates :name, presence: true

 validates :age, presence: true

This is my _form.html.erb file:

 <%= form_for @parent do |f| %>
  <% if @parent.errors.any? %>
   <div class="alert alert-danger">
    <h3><%= pluralize(@student.errors.count, 'Error') %>: </h3>

         <ul>
            <% @student.errors.full_messages.each do |msg| %>
                <li><%= msg %></li>
            <% end %>
         </ul>
    </div>
 <% end %>

   <div class="inline">
     <div>
        <%= f.fields_for :kids do |kid| %>
         <%= render 'kid_fields', f: kid %>
        <% end %>
           <div>
            <%= link_to_add_association "Add Kid", f, :kids, id: 'add_kid',
            'data-association-insertion-method' => 'before',
            'data-association-insertion-traversal' => 'closest' %>
           </div>
        <% end %>   
     </div>


    </div>
        <div class="form-actions">
            <%= f.submit 'Create Parent', class: 'btn btn-primary' %>
        </div>

<% end %>

This is my _kid_fields.rb file:

    <div class="nested-fields">

     <div class="kid-fields inline">
      <%= f.hidden_field :_destroy, class: 'removable' %>
      <%= f.text_field :name, class: 'form-control', placeholder: 'Kid's Name', id: 'kid-input' %>
        <div>
         <%= link_to_remove_association 'Remove Kid', f %>
        </div>


        <%= f.fields_for :pets do |pet| %>
         <%= render 'pet_fields', f: pet %>
        <% end %>
      </div>    
      <div>
       <%= link_to_add_association "Add Pet", f, :pets, id: 'add_pet',
            'data-association-insertion-method' => 'before' %>
      </div>
    </div>

This is my _pet_fields.rb file:

    <div class="nested-fields">
     <div class="pet-fields">
      <%= f.hidden_field :_destroy, class: 'removable' %>
      <%= f.text_field :name, placeholder: 'Pet Name', id: 'pet-name-input' %>
      <%= f.text_field :age, placeholder: 'Pet Age', id: 'pet-age-input' %>  
      <%= link_to_remove_association 'Remove Pet', f, id: 'remove_pet' %>
     </div>  
    </div>
BB123
  • 215
  • 3
  • 10

2 Answers2

10

when I click the "Remove Student" it removes every field above that link

This is a well known issue with the particular RailsCast you're following (it's outdated). There's another here:

enter image description here

The problem comes down to the child_index of the fields_for references.

Each time you use fields_for (which is what you're replicating with the above javascript functionality), it assigns an id to each set of fields it creates. These ids are used in the params to separate the different attributes; they're also assigned to each field as an HTML "id" property.

Thus, the problem you have is that since you're not updating this child_index each time you add a new field, they're all the same. And since your link_to_add_fields helper does not update the JS (IE allows you to append fields with exactly the same child_index), this means that whenever you "remove" a field, it will select all of them.


The fix for this is to set the child_index (I'll give you an explanation below).

I'd prefer to give you new code than to pick through your outdated stuff to be honest.

I wrote about this here (although it could be polished a little): Rails accepts_nested_attributes_for with f.fields_for and AJAX

There are gems which do this for you - one called Cocoon is very popular, although not a "plug and play" solution many think it is.

Nonetheless, it's best to know it all works, even if you do opt to use something like Cocoon...


fields_for

To understand the solution, you must remember that Rails creates HTML forms.

You know this probably; many don't.

It's important because when you realize that HTML forms have to adhere to all the constraints imposed by HTML, you'll understand that Rails is not the magician a lot of folks seem to think.

The way to create a "nested" form (without add/remove) functionality is as follows:

#app/models/student.rb
class Student < ActiveRecord::Base
   has_many :teachers
   accepts_nested_attributes_for :teachers #-> this is to PASS data, not receive
end

#app/models/teacher.rb
class Teacher < ActiveRecord::Base
   belongs_to :student
end

Something important to note is that your accepts_nested_attributes_for should be on the parent model. That is, the model you're passing data to (not the one receiving data):

Nested attributes allow you to save attributes on associated records through the parent

#app/controllers/students_controller.rb
class StudentsController < ApplicationController
   def new
      @student = Student.new
      @student.teachers.build #-> you have to build the associative object
   end

   def create
      @student = Student.new student_params
      @student.save
   end

   private

   def student_params
      params.require(:student).permit(:x, :y, teachers_attributes: [:z])
   end
end

With these objects built, you're able to use them in your form:

#app/views/students/new.html.erb
<%= form_for @student do |f| %>
   <%= f.fields_for :teachers |teacher| %>
       <% # this will replicate for as many times as you've "built" a new teacher object %>
        <%= teacher.text_field ... %>
   <% end %> 
   <%= f.submit %>
<% end %>

This is a standard form which will send the data to your controller, and then to your model. The accepts_nested_attributes_for method in the model will pass the nested attributes to the dependent model.

--

The best thing to do with this is to take note of the id for the nested fields the above code creates. I don't have any examples on hand; it should show you the nested fields have names like teachers_attributes[0][name] etc.

The important thing to note is the [0] - this is the child_index which plays a crucial role in the functionality you need.


Dynamic

Now for the dynamic form.

The first part is relatively simple... removing a field is a case of deleting it from the DOM. We can use the child_index for that, so we first need to know how to set the child index etc etc etc...

#app/models/Student.rb
class Student < ActiveRecord::Base
    def self.build #-> non essential; only used to free up controller code
       student = self.new
       student.teachers.build
       student
    end
end

#app/controllers/students_controller.rb
class StudentsController < ApplicationController
   def new
      @student = Student.build
   end

   def add_teacher
      @student = Student.build
      render "add_teacher", layout: false
   end

   def create
      @student = Student.new student_params
      @student.save
   end

   private

   def student_params
      params.require(:student).permit(:x, :y, teachers_attributes: [:z])
   end
end

Now for the views (note you have to split your form into partials):

#app/views/students/new.html.erb
<%= form_for @student do |f| %>
   <%= f.text_field :name %>
   <%= render "teacher_fields", locals: {f: f} %>
   <%= link_to "Add", "#", id: :add_teacher %>
   <%= f.submit %>
<% end %>

#app/views/_teacher_fields.html.erb
<%= f.fields_for :teachers, child_index: Time.now.to_i do |teacher| %>
   <%= teacher.text_field ....... %>
   <%= link_to "Remove", "#", id: :remove_teacher, data: {i: child_index} %>
<% end %>

#app/views/add_teacher.html.erb
<%= form_for @student, authenticity_token: false do |f| %>
   <%= render partial "teacher_fields", locals: {f:f}
<% end %>

This should render the various forms etc for you, including the fields_for. Notice the child_index: Time.now.to_i -- this sets a unique ID for each fields_for, allowing us to differentiate between each field as you need.

Making this dynamic then comes down to JS:

#config/routes.rb
resources :students do 
   get :add_teacher, on: :collection #-> url.com/students/get_teacher
end

Using this route allows us to send an Ajax request (to get a new field):

#app/assets/javascripts/.....coffee
$ ->

   #Add Teacher
   $(document).on "click", "#add_teacher", (e) ->
      e.preventDefault();

      #Ajax
      $.ajax
        url: '/students/add_teacher'
        success: (data) ->
           el_to_add = $(data).html()
           $('#subscribers').append(el_to_add)
        error: (data) ->
           alert "Sorry, There Was An Error!"

   #Remove Teacher
   $(document).on "click", "#remove_teacher", (e) ->
      e.preventDefault();

      id = $(this).data("i")
      $("input#" + i).remove()
Community
  • 1
  • 1
Richard Peck
  • 76,116
  • 9
  • 93
  • 147
  • 1
    Would I just do the same thing for Class as I did for Teacher? I have a version somewhat working although I _do_ need to have a child_index involved somewhere because I think it's just assigning an authenticity token rather than an id. – BB123 Oct 07 '15 at 20:05
  • So I've followed [this example](https://hackhands.com/building-has_many-model-relationship-form-cocoon/) using the [Cocoon Gem](https://github.com/nathanvda/cocoon) and everything seems to be working just fine except whenever I click the "Add" link it adds two fields rather than just one. I think it may be something going on with my controller but I'm not sure. I'm also not using any JS or Helper methods. My main concern is that the fields aren't giving me a child_index. Rather it's still giving me what I think is an authenticity token (a bunch of random numbers). @richpeck – BB123 Oct 09 '15 at 19:53
  • It will likely be a turbolinks issue. Try refreshing the page and doing it again - if it continues to send two requests, we'll have to figure out your JS bindings – Richard Peck Oct 10 '15 at 06:49
  • Should I uninstall the turbolinks gem? I feel like I just need to write some JS to assign a child_index to each field being added. – BB123 Oct 11 '15 at 20:16
  • Noo don't uninstall Turbolinks, it's actually really useful if you use it properly – Richard Peck Oct 11 '15 at 21:50
  • for `child_index`, is it really reliable to use the current second in unix time? (`Time.now.to_i`)? Isnt it entirely possible for **1)** Fields within the same `teacher` attributes to be indexed with different `child_index`, and/or, **2)** fields within different `teacher` instances to have the same `child_index` (assuming the association added many instead of one)? – Todd Jun 05 '16 at 21:16
  • 1
    Cant edit my comment now, but - for example, Ive just built this locally, and if I click `#add_teacher` 3x in one second, I get 3 new rows with the same exact index. This is a realistic use case for any user. In the example case, what if someone was adding 3 `Teacher` associations to their `Student`, and simply clicked add 3x? I guess the solution is to add some random number to the time as well. – Todd Jun 05 '16 at 22:32
  • Interesting, never had this issue but yes it's definitely a use case. Since the time is meant to provide random number for `id`, you should append a random number to the generated time as you suggested – Richard Peck Jun 06 '16 at 06:40
-1
add this in your js.coffe file
$(document).on 'click', 'form .remove_', (event) ->
$(this).prev('input[type=hidden]').val('1')
$(this).closest('fieldset').hide()
event.preventDefault()

$(document).on 'click', 'form .add_teacher', (event) ->
event.preventDefault()
time = new Date().getTime()
regexp = new RegExp($(this).data('id'), 'g')
$(this).before($(this).data('fields').replace(regexp, time))
Ramesh
  • 1
  • You need to explain how to implement the solution and principally the logic to follow to fix the problem. No only copy and paste code. https://github.com/ryanb/railscasts-episodes/blob/master/episode-196/revised/questionnaire-after/app/assets/javascripts/surveys.js.coffee – alexventuraio Sep 03 '16 at 04:09