Topic: Multiple child models in a dynamic form
update July 14, 2009: If you want to validate your child models using "validates_presence_of :parent" this won't work for new records in vanilla Rails 2.3.3, since the parent hasn't been created yet at the time of saving the children. You can circumvent this with the following plugin: http://github.com/h-lame/parental_control/tree/master
disclaimer: This tutorial builds on earlier tutorials by Ryan Bates and others found on this forum and around the net. If you see some stuff of yours in here and want to be credited you'll have to let me know!
also: This is the first tutorial I've written. I'm going to try to be as inclusive as possible and take everything very slowly but there's no guarantee of.. well anything
If you spot any mistakes or a cleaner/better way to handle any part please post a reply for everyone's benefit.
So that out of the way let's get started!
requirements: You'll need Rails version 2.3 at least. The fields_for helpers do exist in earlier versions of Rails but will not function the way they do here. You will also need to include the Prototype javascript libraries. You can do this by adding the following code in the <head> section of your layout:
# app/views/layouts/application.html.erb (or your own layout file)
<html>
<head>
<title>My projects</title>
<%= javascript_include_tag :defaults %>
</head>
# ... etc
---
In Rails 2.3 the multi-model form helpers such as fields_for have become a lot more powerful, so let's see how we can use them to make Ryan's already excellent tutorials even cleaner!
First let's generate some models to work with. We'll go with the old example of a Project that has_many Tasks. We'll give the Project a title and a due_date and each task a name.
script/generate scaffold project title:string due_date:datetime
script/generate model task name:string project_id:integerrake db:migrate
Right that's our database sorted, let's have a look at our models. First we set up the relationships
# app/models/project.rb
class Project < ActiveRecord::Base
has_many :tasks, :dependent => :destroy
# This is new!
accepts_nested_attributes_for :tasks, :allow_destroy => true
end# app/models/task.rb
class Task < ActiveRecord::Base
belongs_to :project
end
Easy enough. The scaffold generator has already set up the basic views do we can go straight to adding tasks to the project form. First let's split the form from new.html.erb into a partial, which we'll put in /app/views/projects/_form.html.haml and let's add the child tasks as well:
# Our edited new.html.erb
<h1>New project</h1><% form_for(@project) do |f| %>
<%= render :partial => 'form', :object => f -%>
<% end %># And _form.html.erb
<%= form.error_messages %>
<p>
<%= form.label :title %><br />
<%= form.text_field :title %>
</p>
<p>
<%= form.label :due_date %><br />
<%= form.datetime_select :due_date %>
</p>
<div id="tasks">
<h3>Tasks</h3>
<% form.fields_for :tasks do |task_form| -%>
<%= render :partial => 'task', :locals => { :form => task_form } %>
<% end -%>
</div>
<p>
<%= form.submit 'Save' %>
</p>
As you can see fields_for is now a method called on the Project's form builder object. Because we defined :tasks as being a nested model in the Projects model above fields_for will render its block once for every child Task found in our Project.
Of course we still need to add the Task partial, for now let's keep it simple:
<div class="task">
<p>
<%= form.label :name %>
<%= form.text_field :name, :size => 15 %>
</p>
</div>
So with all the views sorted let's set up our new action to pre-populate our project with 2 empty tasks:
# app/controllers/projects_controller.rb
def new
@project = Project.new
2.times { @project.tasks.build }
end
Now you're ready for the first test. Start mongrel (or whatever server you're using) and navigate to /projects/new. You should see the Project form as well as 2 Task entries at the bottom.
In fact the form is already operational! The addition of "accepts_nested_attributes" in the project model handles all the rough details of converting the Tasks attributes and checking whether they are new or existing records. Amazing! And if you change your edit.html.erb to use the partial like new.html.erb you'll be able to edit project and tasks as well.
Of course we're stuck with 2 tasks now, so let's start making the form dynamic by adding an "add task" link.
I prefer to keep these in a helper, so go ahead and open app/helpers/projects.rb and add the following method (be careful this won't work properly yet!):
module ProjectsHelper
def add_task_link(form_builder)
link_to_function 'add a task' do |page|
form_builder.fields_for :tasks, Task.new do |f|
page.insert_html :bottom, :tasks, :partial => 'task', :locals => { :form => f }
end
end
end
end
Here comes the tricky part! Adding a single extra record works perfectly but when we add a second it breaks. Why is this? Let's take a look at the rendered html, before we add new tasks:
<div id="tasks">
<h3>Tasks</h3>
<input id="project_tasks_attributes_0_id" name="project[tasks_attributes][0][id]" type="hidden" value="1" />
<div class="task">
<p>
<label for="project_tasks_attributes_0_name">Name</label>
<input id="project_tasks_attributes_0_name" name="project[tasks_attributes][0][name]" size="15" type="text" value="task 1" />
</p>
</div><input id="project_tasks_attributes_1_id" name="project[tasks_attributes][1][id]" type="hidden" value="2" />
<div class="task">
<p>
<label for="project_tasks_attributes_1_name">Name</label>
<input id="project_tasks_attributes_1_name" name="project[tasks_attributes][1][name]" size="15" type="text" value="task 2" />
</p>
</div>
</div>
On closer inspection it looks like fields_for gives every task a serial number by the form helper. Our first record has number 0, the next one number 1, and so on. Because the add_task_link helper we created renders the partial when the page is generated it uses the next available index (2 in our case) for each subsequent time we click on "new task". So when you click it twice both new tasks end up with index 2, this obviously won't fly.
Unfortunately there's no standard Rails way of dealing with this (as far as I'm aware at least), so we'll have to rig something up to make the ids unique. Javascript is our only option because this has to happen client-side.
First we'll add the :child_index option to fields_for, this will let us set the generated index to a string of our choosing. Then we'll use javascript to give it a more or less random id, based on the current date.
Because we have to modify the html as it's inserted into the #tasks div, we'll have to jury rig the content to insert. Sadly that means we can't use the page.insert_html helper rails provides any more.
module ProjectsHelper
def add_task_link(form_builder)
link_to_function 'add a task' do |page|
form_builder.fields_for :tasks, Task.new, :child_index => 'NEW_RECORD' do |f|
html = render(:partial => 'task', :locals => { :form => f })
page << "$('tasks').insert({ bottom: '#{escape_javascript(html)}'.replace(/NEW_RECORD/g, new Date().getTime()) });"
end
end
end
end
Quite a mouthful but actually not that complicated when we look at it closely. All we're doing is inserting the new form fields at the bottom of the #tasks div and replacing all strings "NEW_RECORD" with a unique number, the number of miliseconds since epoch. This ensures we won't accidentally make two records with the same id unless somsone manages to add two tasks in less than a milisecond
Now we simply add the new add_task_link to the form view, under the tasks div:
# app/views/projects/_form.html.erb
<div id="tasks">
<h3>Tasks</h3>
<% form.fields_for :tasks do |task_form| -%>
<%= render :partial => 'task', :locals => { :form => task_form } %>
<% end -%>
</div>
<%= add_task_link(form) %>
Hard to believe but that's the most difficult part done! We can now add new projects with an unlimited number of tasks. We can even edit existing projects and add tasks with impunity!
In all the excitement we could be forgiven for overlooking the final step but we can not delete tasks yet! Thankfully here again fields_for and accept_nested_attributes make our life much easier. Lets add another helper method to the projects_helper:
# Display the remove link for a child form
def remove_task_link(form_builder)
if form_builder.object.new_record?
# If the task is a new record, we can just remove the div from the dom
link_to_function("remove", "$(this).up('.task').remove();");
else
# However if it's a "real" record it has to be deleted from the database,
# for this reason the new fields_for, accept_nested_attributes helpers give us _delete,
# a virtual attribute that tells rails to delete the child record.
form_builder.hidden_field(:_delete) +
link_to_function("remove", "$(this).up('.task').hide(); $(this).previous().value = '1'")
end
end
As you can see all we're doing is either dropping the new div if the Task is a new record. If the Task is already saved to the database all we have to do is add a hidden "_delete" field which indicates the task is to be dropped. To make our view a little prettier we make the _delete field invisible and set it through javascript. If you prefer less javascript you could also use a simple check_box.
The only thing remaining is to add the delete link to the view:
# app/views/projects/_task.html.erb
<div class="task">
<p>
<%= form.label :name %>
<%= form.text_field :name, :size => 15 %>
<%= remove_task_link( form ) %>
</p>
</div>
And that's all! You now have a complex, javascript driven, form that will let you save a project with any number of child tasks all in one go.
Once the tasks are added you can show them in your show view the same way you would with any related objects. Here's a quick example (thanks for reminding me to add this tallp
)
# app/views/projects/show.html.erb
<h2>Tasks</h2>
<ul>
<% for task in @project.tasks %>
<li>
<%=h task.name %>
</li>
<% end %>
</ul>
Addition:
I've seen many questions about forms with child objects two levels deep (maybe a Task has_many Contributors) but this is far more complicated. As you saw in the rendered html above, adding a task to a project is as simple as making the field names "project[task_attributes][0][name]". A contributor's fields would look like this: "project[task_attributes][0][contributor_attributes[0][name]".
This doesn't look too complicated until you start adding tasks and contributors by javascript. Not only do you have the trivial task of making a random id for your contributor but you have to somehow determine from the DOM the parent task's id. Currently I have no good ideas on how to do this without terribly ugly hacky code.
Last edited by marsvin (2009-07-14 18:11:05)
* Multiple child models in a dynamic form