#197 Nested Model Form Part 2
- Download:
- source codeProject Files in Zip (121 KB)
- mp4Full Size H.264 Video (17.1 MB)
- m4vSmaller H.264 Video (12.4 MB)
- webmFull Size VP8 Video (34.2 MB)
- ogvFull Size Theora Video (23.6 MB)
In the previous episode we showed you how to create a form that could handle multiple nested models. In the application we’ve created for this we have a Survey
model each Survey
having many Questions
and each Question
many Answers
.
In the Survey
and Question
models we’ve used accepts_nested_attributes_for
to allow us to create, edit and destroy nested records through a single model.
As our application currently stands if we want to remove a question or an answer we have to use a checkbox. Also we have no way of adding new questions or answers through the form. In this episode we’ll fix these problems by using JavaScript to alter the form so that we can use links to create and destroy models dynamically.
The JavaScript we’ll write involves some DOM manipulation so we’ll use the Prototype library to make this easier. To add the Prototype code to our application we’ll add the following line to the <head> section of our application’s layout file.
<%= javascript_include_tag :defaults, :cache => true %>
If you prefer using jQuery then you can do so and we’ll show the equivalent jQuery code at the end of the episode.
Adding Links to Remove Answers
We’ll tackle the easiest part first: replacing the checkboxes we use to remove questions and answers with links. Let’s look at the answers first.
The code that renders each answer is held in a partial called answer_fields
and looks like this:
<p> <%= f.label :content, "Answer" %> <%= f.text_field :content %> <%= f.check_box :_destroy %> <%= f.label :_destroy, "Remove" %> </p>
In the code above is the _destroy
checkbox that we check when we want an answer to be destroyed. We’re going to replace it with a hidden field whose value will be set when the “remove” link is clicked. This way we can still mark an answer for deletion.
We’ll replace the label in the code above with a link and make use of Rails’ link_to_function
to create a link that fires a JavaScript function when clicked. With the hidden field and the link the partial code will now look like this:
<p class="fields"> <%= f.label :content, "Answer" %> <%= f.text_field :content %> <%= f.hidden_field :_destroy %> <%= link_to_function "remove", "remove_fields(this)" %> </p>
When the “remove” link next to an answer is clicked it will fire a JavaScript function called remove_fields
, passing in the link as an argument so that we can use it as a reference element to find the other elements related to the answer. There’s no direct way get at all those fields so we’ve added a class name to the enclosing paragraph element so that we can find them more easily.
Next we’ll need to write that remove_fields
function. We’ll do this in the application.js
file as this is one of the files that is automatically included on our pages because we’ve included the JavaScript :defaults
.
function remove_fields(link) { $(link).previous("input[type=hidden]").value = "1"; $(link).up(".fields").hide(); }
This function performs two actions. First it uses Prototype’s previous
function to find the first previous hidden field relative to the link that called the function which is the _destroy field and sets its value to 1
so that the answer will be marked as deleted. It then uses up
to work its way up the DOM tree from the link until it finds an element with a class of fields
, which is the class name we gave to the paragraph element that wraps the answers fields, and hides it so that the answer is hidden.
If we reload the survey page now we’ll see a link against each answer.
If we click some of the links the value of the _destroy
hidden field will be set to 1
for those answers so that they are marked to be deleted and the their form fields are hidden.
Note though that we’re not using AJAX to post back updated form values when the link is clicked so that although we’re hiding the removed answers immediately the database won’t be updated until we submit the form. When we do submit the form the answers will be removed and we’ll see so on the survey’s show
page.
Removing Questions
Now that we can remove answers through links we’ll move on to the questions. The way to remove a question is basically the same as the way we remove an answer and so we can reuse some of the code we wrote earlier.
As we did with the answers we’re going to replace a checkbox and a label with a hidden field and a link so we’ll take that part of the code from the answer_fields
partial and put it in a new helper method called link_to_remove_fields
, passing in the text we want to appear in the link and the form variable f
.
<p class="fields"> <%= f.label :content, "Answer" %> <%= f.text_field :content %> <%= link_to_remove_fields "remove", f %> </p>
We’ll now write that method in the application_helper
file.
# Methods added to this helper will be available to all templates in the application. module ApplicationHelper def link_to_remove_fields(name, f) f.hidden_field(:_destroy) + link_to_function(name, "remove_fields(this)") end end
The methods that create form fields return strings, so we can concatenate the HTML generated by f.hidden_field
and link_to_function
methods and return them to the partial.
We can use our new method in the question_fields
partial too.
<div class="fields"> <p> <%= f.label :content, "Question" %> <%= link_to_remove_fields "remove", f%><br /> <%= f.text_area :content, :rows => 3 %><br /> </p> <% f.fields_for :answers do |builder| %> <%= render 'answer_fields', :f => builder %> <% end %> </div>
As our JavaScript remove_fields
function looks for an element with a class of fields
when hiding a question or answer we’ve wrapped the whole partial in a div
element with that class name so that when the “remove” link for a question is clicked the question will be hidden along with its answers.
If we look at the edit
page for a survey now and click the a question’s “remove” link the question will be removed along with its answers and when we submit the form the question and its answers will be removed from the survey.
Adding Questions and Answers
Now for the difficult part: adding new questions and answers. We want links on the form that will create new form fields dynamically when they are clicked. What makes this difficult is that the JavaScript will need to have access to a blank set of fields so that it can create a new question or answer when a link is clicked.
To do this we’ll write a new method in the application helper called link_to_add_fields
.
We’ll be able to use this method whenever we need to show a link to add the fields for a new question or answer on the form. The code for the method will look like this:
def link_to_add_fields(name, f, association) new_object = f.object.class.reflect_on_association(association).klass.new fields = f.fields_for(association, new_object, :child_index => "new_#{association}") do |builder| render(association.to_s.singularize + "_fields", :f => builder) end link_to_function(name, h("add_fields(this, \"#{association}\", \"#{escape_javascript(fields)}\")")) end
The method takes three arguments: name
, which will be the link’s text; f
, the form builder object and association
, which in this case will be either “questions” or “answers”.
The first line of the method creates a new instance of that new association class, i.e. a new Question
or Answer
. This means that we have a template object that we can use to create the new form fields.
The second part of the code builds a string of the form fields for that object so that we can insert them into the javascript function that will add them to the form when a link is clicked. It does this by calling the appropriate partial code, passing in the form builder. The only really new thing here is the :child_index
we set. We do this so that we have something to refer to for creating the fields for the new question or answer. In the JavaScript we’ll replace the name of this with a unique value which will be based on the current time. That way, every time we create a new question or answer it will have a unique index so that it can be identified when we submit the form.
Finally we use the link_to_function
method again, passing in the name of the link and a call to a JavaScript function called add_fields
to which we pass the link, the name of the association and a string containing the escaped form fields.
Now we can go back to the JavaScript and write the add_fields
function.
function add_fields(link, association, content) { var new_id = new Date().getTime(); var regexp = new RegExp("new_" + association, "g"); $(link).up().insert({ before: content.replace(regexp, new_id) }); }
This function takes the three arguments we mentioned earlier: the link that was clicked, the name of the association and a string containing the HTML for the form fields. The first thing this function does is create a new id for the form elements. If we create multiple new questions or answers we don’t want them to have the same index value as they will be considered the same model when they’re inserted. We use the current time to make the id unique and then use a regular expression to replace the “new_question” or “new_answer” string in the form fields with that new unique id. That done we insert the string of form fields into the appropriate place in the DOM.
That’s the difficult part over. All we need to do now is add the links themselves. In the question_fields
partial we’ll add a link to add a new answer using link_to_add_fields
, passing in :answers as the name of the association as a question has many answers.
<div class="fields"> <p> <%= f.label :content, "Question" %> <%= link_to_remove_fields "remove", f %><br /> <%= f.text_area :content, :rows => 3 %><br /> </p> <% f.fields_for :answers do |builder| %> <%= render 'answer_fields', :f => builder %> <% end %> <p><%= link_to_add_fields "Add Answer", f, :answers %></p> </div>
We can then to do a similar thing in the survey form to add an “Add Question” link.
<% form_for @survey do |f| %> <%= f.error_messages %> <p> <%= f.label :name %><br /> <%= f.text_field :name %> </p> <% f.fields_for :questions do |builder| %> <%= render 'question_fields', :f => builder %> <% end %> <p><%= link_to_add_fields "Add Question", f, :questions %> <p><%= f.submit "Submit" %></p> <% end %>
If we reload the survey page now we’ll see the links for adding a new question or answer and when we click one of them a new field will appear on the form.
A new, blank answer field appears on the form when we click the “Add Answer” link.
If we fill in the new answer field above with “jQuery” and submit the form then the new answer will be added.
So, we’ve reached our goal and now have a form on which we can dynamically add or remove fields and have the database updated appropriately when the form is submitted.
Alternate jQuery Code
The JavaScript code we’ve used in this episode works with the Prototype library. If you prefer using jQuery, the code should look like this:
function remove_fields(link) { $(link).prev("input[type=hidden]").val("1"); $(link).closest(".fields").hide(); } function add_fields(link, association, content) { var new_id = new Date().getTime(); var regexp = new RegExp("new_" + association, "g"); $(link).parent().before(content.replace(regexp, new_id)); }
This code is very similar to the Prototype code we’ve been writing.
Some of you reading this might be a little upset that the JavaScript we’ve use isn’t unobtrusive. While an unobtrusive solution is always preferable there isn’t really one for our problem that was simple enough to present in this episode. Ryan Bates is working on a plugin called nested_form that will make use of jQuery to handle nested forms in an unobtrusive way. This plugin is still in early development so if you think you’ll need something like this keep checking there to see how it is coming along.