This is a topic that seems to get asked a lot and answered in so many different ways. I haven’t really found an answer that I considered full and easy to follow. Also, most answers were give for rails 3. So I decided I would share my implementation.
I have made this project available in GitHub, look at the last commit to see all the changes made to the empty project.
First off, let me explain what I am trying to do here:
I have a model called Job and this Job can have many Tasks. I want to be able to add and/or remove such tasks from the edit/new form of the Jobs controller.
Now in my php days I would do this by setting up a task template and storing it in a javascript variable. I would attach an event to an Add Task button that would read the template, replace the id and place it on a target Div. The good news is that we can follow the same approach in rails.
In rails we can take advantage of nested forms and and make the matter very easy. The only part that is a bit hard is generating the task template. To keep in the spirit of Rails and the fellows that answered this question before, we are going to keep a lot of the logic as helper.
In a nutshell this is what we are going to do:
Generate our Models Job and Task
Generate our Jobs Controller
Adding helper methods to generate our task template, link to add new task and link to remove task
Adding the Javascript (I use jQuery in this exercise) to handle the remove and add new task actions
Generate our views for Jobs
First Our Models
Our Job model will look like this
class Job < ActiveRecord::Base has_many :tasks, :dependent => :destroy accepts_nested_attributes_for :tasks, :reject_if => lambda { |a| a[:name].blank? }, :allow_destroy => true end
and out task model
class Task < ActiveRecord::Base belongs_to :job end
So on the Job Model, we are telling it that it can have many tasks
has_many :tasks, :dependent => :destroy
we are also telling it that it can accept attributes for task but to ignore any with a blank name and that we can pass a destroy flag as a parameter.
accepts_nested_attributes_for :tasks, :reject_if => lambda { |a| a[:name].blank? }, :allow_destroy => true
the task model is self explanatory. So basically that is all we need on our models to make this work
The Job Controller
There are two things we need to have in our controller. First, we need to allow fields for tasks to pass through. We do this by modifying our job_params method. Note "tasks_attributes" and that we are allowing :_destroy. Second we are including a helper "helper :jobs" this is so we can easily generate add/remove links and our task template
class JobsController < ApplicationController helper :jobs ... your code here... def job_params params.require(:job).permit(:id,:title, :tasks_attributes => [:id, :name,:_destroy]) end end
The Child Partial
We will use this partial to render existing children and to generate our child template
<%= f.hidden_field :_destroy, :class => 'removable' %> <%= f.label :name %> <%= f.text_field :name %> <%= remove_child_button "Remove"%>
Notice the hidden field _destroy and its class removable. we will be using this field to delete the child when we are editing the Job
I will talk more later about the remove_child_button. This is one of the helpers I am discussing next.
The Helper
now we are going to define 3 helpers two for creating link and one for generating our template
we could do this directly in the view but doing it this way keeps things cleaner. I am making this methods generic so they can be used with any model by just passing the resource name.
First, the Add Child Link
I ask for a name to display to the user, the association (our child model) and a target for where we want to append our new task
this way we can place this link anywhere. is up to you to style the span so it looks and feels like a link or button
def add_child_button(name, association,target)
content_tag(:spam,"#{name}".html_safe,
:class => "add_child",
:"data-association" => association,
:target => target)
end
Now we add the Remove Child Link
On this one, we pass a display name
def remove_child_button(name)
content_tag(:div,"Remove".html_safe,
:class => "remove_child")
end
These two links are very simple. later, we will be attaching event handlers on the javascript with jquery
And finally for the child template
def new_fields_template(f,association,options={}) options[:object] ||= f.object.class.reflect_on_association(association).klass.new options[:partial] ||= association.to_s.singularize+"_fields" options[:template] ||= association.to_s+"_fields" options[:f] ||= :f tmpl = content_tag(:div,:id =>"#{options[:template]}") do tmpl = f.fields_for(association,options[:object], :child_index => "new_#{association}") do |b| render(:partial=>options[:partial],:locals =>{:f => b}) end end tmpl = tmpl.gsub /(? var #{options[:template]} = '#{tmpl.to_s}' ".html_safe end
The first section is so we can set some default options that can be overwritten if necessary.
We use reflection to generate a new object based on our association parameter.This is so we can use the handy fields for method with the form builder object f.
tmpl = content_tag(:div,:id =>"#{options[:template]}") do tmpl = f.fields_for(association,options[:object], :child_index => "new_#{association}") do |b| render(:partial=>options[:partial],:locals =>{:f => b}) end end
We are capturing this output and putting inside a div tag. I will explain later why we use a div here.
Finally, we remove all line breaks from the generated output and return it as a string assigned to a variable in a script tag
tmpl = tmpl.gsub /(? var #{options[:template]} = '#{tmpl.to_s}' ".html_safe
I like this way better than other methods proposed on the web because it makes the template easily available to JS and it makes sure that template doesn't interfere with the form that this method is called from.
The Javascript
I am going to include this in the application.js file but it could easily be added to a separate josb.js file.
For clarification I am surrounding everything with a $(function(){ CODE HERE });
First, the Add Child click event
$('.add_child').click(function() { var association = $(this).attr('data-association'); var target = $(this).attr('target'); var regexp = new RegExp('new_' + association, 'g'); var new_id = new Date().getTime(); var Dest = (target == '') ? $(this).parent() : $('#'+target); Dest.append(window[association+'_fields'].replace(regexp, new_id)); return false; });
you should be able to follow this pretty easily. I use association for two things, first to generate a regex that is used to replace the temporary id with a randomly generated id in the template and to find the template itself in the document.
I am using window[association+'_fields']
to find the template using the association.
I use the target to define my Dest element. This is where the new field will be appended to, if no target is given then I default to the parent element.
If you think about what this does, it is actually very basic. We find our template, we set a new randomly generated Id, we then attach it to the document.
Now, the remove child click event
Since the remove links are sometimes generated dynamically, we need to set up the event as a delegate. I really don't like appending delegates to the document directly but for keeping the exercise generic I will make an exception.
$(document).delegate('.remove_child','click', function() { $(this).parent().children('.removable')[0].value = 1; $(this).parent().hide(); return false; });
Now, there is one assumption I am making here. It is that the fields are surrounded by a grouping element in this case a Div. We add a div to the child template so we are covered there, we just need to remember to add the div when we display existing children in the form.
So the first thing we do is locate the parent, and from the parent we find the first child with class .removable an set its value to 1.
$(this).parent().children('.removeable')[0].value = 1;
This allows us to delete this child when we save the form.
Finally, we hide the parent to give the user some feedback. we return false to keep things tidy. That's all.
The Views
Now we are ready to use the helper methods in our view. I have extracted the form part of the edit and new views into a partial called _form.html.erb
this is to keep things DRY.
first we add this inside the form_for method
<%= add_child_button "New Task",:tasks,"#tasks" %> <%= new_fields_template f, :tasks %>
The first is our add new child button, notice how the last parameter #tasks is the Id for the container shown bellow. the second part is our template that the button will use
Then we render existing children using the _task_fields partial. don't forget to surround the partial in a div so our remove button works correctly, I will be working on fixing this later so we don't need the div
<%= f.fields_for :tasks do |builder| %><%= render "task_fields", :f => builder %><% end %>
and that's all you need. This post ran pretty long but the code is not that long at all basically you need 3 files to add/modify in your existing project. the application.js, the jobs_helper.js and the _task_fields partial. if you want to see this working just clone this example or look at this commit for the changes
Leave a Reply