Topic: Variable Number of Models in One Form (without AJAX)
This tutorial is based off of Ryan's great tutorial by the same name.
The difference with this one is that we will leave all the index generating magic to the client. If your gonna do it via RJS, the browser already needs JS anyways, so we might as well leverage that to it's full potential. Save your server just that little bit.
In any case, let's get started. I'm going to be using the same project has_many tasks model as Ryan did.
I've created a small Prototype class to help us out here, making it as little work as possible. For those of you who are scared of javascript, like I am, it's not so bad really. Though I wont vouch for my ability... some parts of this class was ripped from Blinksale and took on a few adjustments, hope they dont mind. Not that any of it is really complicated or magic.
[code Javascript]# in public/javascripts/application.js
var RowAdder = Class.create();
RowAdder.prototype = {
lineIndex: -1,
initialize: function(objectID, lineHTML, replacements) {
this.lineHTML = lineHTML;
this.objectID = objectID;
this.replacements = replacements;
},
indexedHTML: function() {
var t = new Template(this.lineHTML);
return t.evaluate( $H({id: this.lineIndex--}).merge(this.replacements) );
},
addLine: function() {
new Insertion.Bottom($(this.objectID), this.indexedHTML());
},
deleteLine: function(id) {
Element.remove(id);
}
};[/code]
Let's just start with the controller.
[code Ruby]# in app/controllers/projects_controller.rb
def new
@project = Project.new
end
def edit
@project = Project.find(params[:id])
end[/code]
Now let's setup some partials. We will need two, because I haven't yet figured out a way to use one. Be very careful when looking at these partials - the RowAdder class will be using #{var} syntax for replacements as well, so watch out what is double quoted and what is single quoted. I should probably just change the syntax used for the js template, but that's for another day.
# in app/views/projects/_task_fields.rhtml - the non-js version
<div id="task_<%= index %>">
<% fields_for "tasks[]", task do |f| %>
<p>
<%= f.text_field :name %>
# Remove link will go here
</p>
<% end %>
</div># in app/views/projects/_task_fields_js.rhtml - the js version
# I dont like putting the Task.new here
<div id="task_#{id}">
<% fields_for 'tasks[#{id}]', Task.new do |f| %>
<p>
<%= f.text_field :name %>
# 'Remove' link will go here
</p>
<% end %>
</div>
Now I guess we should use these partials in our views.
# in app/views/projects/new.rhtml
<% form_for :project, :url => { :action => 'create' } do |f| %>
<p>Name: <%= f.text_field :name %></p><h2>Tasks</h2>
<div id="tasks">
</div>
# Our 'Add Another Task' link will go here<p><%= submit_tag 'Create Project' %></p>
<% end %># in app/views/projects/edit.rhtml
<% form_for :project, :url => { :action => 'update' } do |f| %>
<p>Name: <%= f.text_field :name %></p><h2>Tasks</h2>
<div id="tasks">
<% @project.tasks.each do |task| %>
<%= render :partial => 'task_fields', :locals => { :task => task } %>
<% end %>
</div>
# Our 'Add Another Task' link will go here<p><%= submit_tag 'Update Project' %></p>
<% end %>
Now we want to setup our RowAdder object. If the project has no tasks, we will add one row automagically on page load. The RowAdder object accepts 3 parameters. The first one is the id of the element we want to add rows to, in this case, "tasks". The second parameter is the Template, which we will render from our task_fields_js partial. The third is any extra replacements which you want to do on the template, as a Hash. We won't be adding any extra replacements, so we will just pass an empty hash as the last argument.
# in app/views/projects/new.rhtml
<script language="javascript">
Event.observe(window, 'load', function() {
task_adder = new RowAdder('tasks', '<%= escape_javascript(render :partial => 'task_fields_js') %>', '$H({})');
// Automatically add a line because this a new project.
task_adder.addLine();
});
</script># in app/views/projects/edit.rhtml
<script language="javascript">
Event.observe(window, 'load', function() {
task_adder = new RowAdder('tasks', '<%= escape_javascript(render :partial => 'task_fields_js') %>', '$H({})');
// Add a line only if the project has no tasks
<% if @project.tasks.empty? %>
task_adder.addLine();
<% end %>
});
</script>
Our javascript takes care of incrementing (well, decrementing) the index for us. New records get a negative index, existing records keep their positive one. You can use this in the controller.
Now all we need to do is add some 'Add New Task' and 'Remove' links and were good to go.
# in app/views/projects/new.rhtml and app/views/project/edit.rhtml
<%= link_to_function 'Add Another Task', 'task_adder.addLine()' %># in app/views/projects/_task_fields.rhtml
<%= link_to_function 'Remove', "task_adder.deleteLine('task_#{task.id}')" %># in app/views/projects/_task_fields_js.rhtml
# again, beware the quotes, and the quoted out quotes!
<%= link_to_function 'Remove', 'task_adder.deleteLine(\'task_#{id}\')' %>
And that's it for the views, all we need to do is our create and update actions and were finished!
# in app/controllers/projects_controller.rb
def create
@project = Project.new(params[:project])
# It's safe to assume that they are all new tasks, this is a new project
params[:tasks].each_value { |task| @project.tasks.build(task) }
if @project.save
redirect_to :action => 'index'
else
render :action => 'new'
end
enddef update
@project = Project.find(params[:id])
@project.attributes = params[:project]# First, delete records which were 'Removed'
@project.tasks.reject { |task| params[:tasks].include?(task.id.to_s) }.each { |task| task.destroy } if params[:tasks]
if params[:tasks]
params[:tasks].each do |id, task|
# Update existing records first
if id.to_i > 0
@project.tasks.find(id.to_i).attributes = task
else
# New record
@project.tasks.build(task)
end
end
endif @project.valid? && @project.tasks.all?(&:valid?)
@project.save!
@project.tasks.each(&:save!)
redirect_to :action => 'show', :id => @project
else
render :action => 'edit'
end
end
There you have it. Here's the final code.
# in public/javascripts/application.js
var RowAdder = Class.create();RowAdder.prototype = {
lineIndex: -1,initialize: function(objectID, lineHTML, replacements) {
this.lineHTML = lineHTML;
this.objectID = objectID;
this.replacements = replacements;
},indexedHTML: function() {
var t = new Template(this.lineHTML);
return t.evaluate( $H({id: this.lineIndex--}).merge(this.replacements) );
},addLine: function() {
new Insertion.Bottom($(this.objectID), this.indexedHTML());
},deleteLine: function(id) {
Element.remove(id);
}
};
# in app/controllers/projects_controller.rb
def new
@project = Project.new
enddef create
@project = Project.new(params[:project])
# It's safe to assume that they are all new tasks, this is a new project
params[:tasks].each_value { |task| @project.tasks.build(task) }
if @project.save
redirect_to :action => 'index'
else
render :action => 'new'
end
enddef edit
@project = Project.find(params[:id])
end# Bit fat - someone else can refactor
def update
@project = Project.find(params[:id])
@project.attributes = params[:project]# First, delete records which were 'Removed'
@project.tasks.reject { |task| params[:tasks].include?(task.id.to_s) }.each { |task| task.destroy } if params[:tasks]
if params[:tasks]
params[:tasks].each do |id, task|
# Update existing records first
if id.to_i > 0
@project.tasks.find(id.to_i).attributes = task
else
# New record
@project.tasks.build(task)
end
end
endif @project.valid? && @project.tasks.all?(&:valid?)
@project.save!
@project.tasks.each(&:save!)
redirect_to :action => 'show', :id => @project
else
render :action => 'edit'
end
end# in app/views/projects/new.rhtml
<script language="javascript">
Event.observe(window, 'load', function() {
task_adder = new RowAdder('tasks', '<%= escape_javascript(render :partial => 'task_fields_js') %>', '$H({})');
task_adder.addLine();
});
</script><h1>New project</h1>
<%= error_messages_for :project %>
<% form_for :project, :url => { :action => 'create' } do |f| %>
<p>Name: <%= f.text_field :name %></p><h2>Tasks</h2>
<div id="tasks">
</div>
<%= link_to_function 'Add Another Task', 'task_adder.addLine()' %><p><%= submit_tag 'Create Project' %></p>
<% end %># in app/views/projects/edit.rhtml
<script language="javascript">
Event.observe(window, 'load', function() {
task_adder = new RowAdder('tasks', '<%= escape_javascript(render :partial => 'task_fields_js') %>', '$H({})');
<% if @project.tasks.empty? %>
task_adder.addLine();
<% end %>
});
</script><h1>Editing project</h1>
<%= error_messages_for :project %>
<% form_for :project, :url => { :action => 'update', :id => @project } do |f| %>
<p>Name: <%= f.text_field :name %></p><h2>Tasks</h2>
<div id="tasks">
<% @project.tasks.each do |task| %>
<%= render :partial => 'task_fields', :locals => { :task => task } %>
<% end %>
</div>
<%= link_to_function 'Add Another Task', 'task_adder.addLine()' %><p><%= submit_tag 'Update Project' %></p>
<% end %># in app/views/projects/_task_fields.rhtml
<div id="task_<%= task.id %>">
<% fields_for "tasks[]", task do |f| %>
<p>
<%= f.text_field :name %>
<%= link_to_function 'Remove', "task_adder.deleteLine('task_#{task.id}')" %>
</p>
<% end %>
</div># in app/views/projects/_task_fields_js.rhtml
<div id="task_#{id}">
<% fields_for 'tasks[#{id}]', Task.new do |f| %>
<p>
<%= f.text_field :name %>
<%= link_to_function 'Remove', 'task_adder.deleteLine(\'task_#{id}\')' %>
</p>
<% end %>
</div>
Hope this was useful for you guys.