#196 Nested Model Form (revised)
- Download:
- source codeProject Files in Zip (104 KB)
- mp4Full Size H.264 Video (22 MB)
- m4vSmaller H.264 Video (12 MB)
- webmFull Size VP8 Video (15.4 MB)
- ogvFull Size Theora Video (26.4 MB)
Currently we can only edit each survey’s name. We want to change this application so that we’re able to edit a survey’s questions and answers on the same form. The tricky part of this is that questions and answers are separate models. A Survey
has many Questions
, a Question
has many Answers
and we have an Answer
model that belongs to a Question
. This means managing three different models through a single form.
Fortunately Rails provides a method called accepts_nested_attributes_for
which allows us to manage associated records in a form. If we want to manage questions in our survey form we have to add a call to this method in our Survey
model. As we’re using attr_accessible
in our model we also need to modify this and add the plural name of the associated model followed by _attributes
so that these attributes can be set.
class Survey < ActiveRecord::Base attr_accessible :name, :questions_attributes has_many :questions accepts_nested_attributes_for :questions end
In the view template for our form we just have a field for the survey’s name
. We’ll add a field for each question’s content
attribute like this:
<div class="field"> <%= f.label :name %><br /> <%= f.text_field :name %> </div> <%= f.fields_for :questions do |builder| %> <fieldset> <%= builder.label :content, "Question " %><br/> <%= builder.text_area :content %> </fieldset> <% end %>
We add fields for the Question
model with fields_for
, passing in the name of the association and a block. A form builder is passed to the block and the block is called for each of the survey’s questions. We wrap each question in a fieldset
element and render a label
and a text_area
containing its content. When we reload our survey form now we’ll have a field for editing each question.
If we make a change to one or more of the questions these changes will be saved and the survey’s questions will be updated.
Removing Questions
It would be nice to have a way to remove questions too. One way to do this is to have a checkbox against each question to mark the question for deletion. We’ll add one underneath each text area along with a label. The key here is to call the checkbox _destroy
as this will automatically remove the question if the checkbox is checked.
<%= f.fields_for :questions do |builder| %> <fieldset> <%= builder.label :content, "Question " %><br/> <%= builder.text_area :content %><br/> <%= builder.check_box :_destroy %> <%= builder.label :_destroy, "Remove Question" %> </fieldset> <% end %>
Destroying records for nested attributes is disabled by default but we can enable it by adding the allow_destroy
option to accepts_nested_attributes_for
and setting it to true
.
class Survey < ActiveRecord::Base attr_accessible :name, :questions_attributes has_many :questions accepts_nested_attributes_for :questions, allow_destroy: true end
Reloading the page now will show the checkboxes and if we check one then submit the form it will delete that question.
Editing Answers
We still need a way to edit each question’s answers in our form so we’ll modify it to display them underneath each question. Essentially we need to repeat the same process but this time for questions and their answers. First we’ll modify the Question
model and add a call to accepts_nested_attributes_for
and add answers_attributes
to attr_accessible
.
class Question < ActiveRecord::Base attr_accessible :content, :survey_id, :answers_attributes belongs_to :survey has_many :answers accepts_nested_attributes_for :answers, allow_destroy: true end
In the view template we need to add another call to fields_for
for answers, nested under the questions. Before we do that we’ll move some of the view code into a partial that we’ll call question_fields
to help clean things up. We’ll pass the builder to this partial as an f
option.
<%= f.fields_for :questions do |builder| %> <%= render 'question_fields', f: builder %> <% end %>
We can now use this option in our new partial, along with a call to fields_for
to render out the answers for the question. This will call a new answer_fields
partial that we’ll write next.
<fieldset> <%= f.label :content, "Question " %><br/> <%= f.text_area :content %><br/> <%= f.check_box :_destroy %> <%= f.label :_destroy, "Remove Question" %> <%= f.fields_for :answers do |builder| %> <%= render 'answer_fields', f: builder %> <% end %> </fieldset>
The answer_fields
partial will render an answer in a text_field
.
<fieldset> <%= f.label :content, "Answer" %> <%= f.text_field :content %> <%= f.check_box :_destroy %> <%= f.label :_destroy, "remove" %> </fieldset>
When we reload the edit page now we’ll see the answers for each question and if we edit some of them the changes are saved and the survey updated.
Adding and Removing Questions and Answers Through JavaScript
The next thing we’ll show you is how to add and remove question and answer records through JavaScript. We’ll focus on removing records first. Instead of using a checkbox to select the records we want to remove we’ll have a ‘remove’ link that will remove that record when it’s clicked. First we’ll replace the _destroy
checkbox in the answer_fields
partial with a hidden field so that we can modify its value with JavaScript. We’ll also replace the label with a link that we’ll give a class so that we can reference it later.
<fieldset> <%= f.label :content, "Answer" %> <%= f.text_field :content %> <%= f.hidden_field :_destroy %> <%= link_to "remove", '#', class: "remove_fields" %> </fieldset>
We’ll add the JavaScript (actually CoffeeScript) code in surveys.js.coffee
.
jQuery -> $('form').on 'click', '.remove_fields', (event) -> $(this).prev('input[type=hidden]').val('1') $(this).closest('fieldset').hide() event.preventDefault()
In this file we check that the DOM is loaded then listen to the click
event for any elements in the form with a class of remove_fields
. We use on here, rather than calling click
directly is because we’ll have to insert remove_fields
links dynamically later on. When that event fires we find the hidden field just before the link and set its value to 1
. We then use closest
to find the fieldset
that wraps the whole answer and hide it. Finally we use event.preventDefault()
to stop the link’s default behaviour from firing. When we reload the page now we’ll have a “remove” link next to each answer. Clicking one of the links will hide that answer and when we submit the form that answer will be removed from the question.
We could apply the same approach to the “Remove Question” checkbox but we’ll leave it as-is so that our example app shows both approaches. Instead we’ll focus next on adding questions and answers through a link and some JavaScript and here’s where things get a little trickier. We list the answer fields inside the question partial and we’ll add a link to add an answer underneath the existing answers. This link needs a lot of logic associated with it so we’ll create a helper method to generate it. There are a number of variables we’ll have to pass in to this method including the form builder and the name of the association we’re trying to add to.
<fieldset> <%= f.label :content, "Question " %><br/> <%= f.text_area :content %><br/> <%= f.check_box :_destroy %> <%= f.label :_destroy, "Remove Question" %> <%= f.fields_for :answers do |builder| %> <%= render 'answer_fields', f: builder %> <% end %> <%= link_to_add_fields "Add Answer", f, :answers %> </fieldset>
We’ll create this helper method on the application helper.
module ApplicationHelper def link_to_add_fields(name, f, association) new_object = f.object.send(association).klass.new id = new_object.object_id fields = f.fields_for(association, new_object, child_index: id) do |builder| render(association.to_s.singularize + "_fields", f: builder) end link_to(name, '#', class: "add_fields", data: {id: id, fields: fields.gsub("\n", "")}) end end
The first thing this method does is build an instance of that association record. We take the form builder’s object, in this case a Question
instance, and call the association method on that, which will be answers, call klass
on that to get the Answer
class and finally call new on that to create a new Answer
. In the next line we grab that object’s fields_for
on the form builder, just like we did in the partial, passing in the association and the answer instance we created so that it builds fields for the answer and we pass the unique id as the child_index
. We then render the partial for this association, in this case the _answer_fields
partial and finally we generate a link with a class of add_fields
and give it some data-
attributes including the unique identifier and all the field data we generated.
In our CoffeeScript file we can now add some code to handle these links.
$('form').on 'click', '.add_fields', (event) -> time = new Date().getTime() regexp = new RegExp($(this).data('id'), 'g') $(this).before($(this).data('fields').replace(regexp, time)) event.preventDefault()
In this code we listen for the click
event on any links with a class of add_fields
. When this event fires we store the current time then generate a regular expression based on the link’s data-id
attribute, which contains the unique identifier we created in our helper method. We then insert the new fields before the link by fetching the encoded HTML from the data-fields
attributes and replacing the unique identifier with the current time. The way each field will have a unique index based on the current time.
Reloading the page now shows the new link and clicking on it adds a new answer field. Entering a value in this new field and submitting the form will add the new answer to the question.
We can do the same thing for questions. First we’ll add a link to add a question in our template immediately after the questions are rendered.
<%= f.fields_for :questions do |builder| %> <%= render 'question_fields', f: builder %> <% end %> <%= link_to_add_fields "Add Question", f, :questions %>
Reloading the page now will show the “Add Question” link and clicking it will add a textfield where we can add a new question. This new question will have an “Add Answer” link and we can add as many answers to the new question as we like.
Submitting the form will create the new question and its answers.
What we’ve built here will work well for creating surveys as well. We can create a new survey and give it as many questions and answers as we like.