41

I'm attempting to get over the hurdle of dynamic form fields in Rails -- this appears to be something the framework doesn't handle very gracefully. I'm also using jQuery in my project. I have jRails installed, but I'd much rather write the AJAX code unobtrusively where possible.

My forms are fairly complex, two or three levels of nesting are not unusual. The problem I'm having is generating the correct form ids, since they are so dependant on the form builder context. I need to be able to dynamically add new fields or delete existing records in a has_many relationship, and I am completely at a loss.

Every example I've seen so far has been ugly in one way or another. Ryan Bates' tutorial requires RJS, which results in some pretty ugly obtrusive javascript in the markup, and seems to have been written before nested attributes. I've seen a fork of that example with unobtrusive jQuery, but I just don't understand what it's doing, and haven't been able to get it working in my project.

Can somebody provide a simple example of how this is done? Is this even possible while respecting the RESTful convention of the controllers?


Andy has posted an excellent example of deleting an existing record, can anybody provide an example of creating new fields with the correct attributes? I haven't been able to figure out how to do this with nested forms.

2
  • Are you having issues writing unobtrusive ajaxified jQuery in general or is it specific to nested forms? If you want to go unobtrusive getting rid of jRails is a good start. Commented Nov 9, 2009 at 23:09
  • I need to be able to support nested forms, but I'm trying to grasp the basics right now. I have jRails installed, but I'm not using it; like I said I want to do it the unobtrusive way, and I'd rather not have to learn another DSL when I'm perfectly good at javascript. Commented Nov 10, 2009 at 0:10

6 Answers 6

53

Since nobody has offered an answer to this, even after a bounty, I've finally managed to get this working myself. This wasn't supposed to be a stumper! Hopefully this will be easier to do in Rails 3.0.

Andy's example is a good way of deleting records directly, without submitting a form to the server. In this particular case, what I'm really looking for is a way to dynamically add/remove fields before doing an update to a nested form. This is a slightly different case, because as the fields are removed, they aren't actually deleted until the form is submitted. I will probably end up using both depending on the situation.

I've based my implementation on Tim Riley's complex-forms-examples fork on github.

First set up the models, and make sure they support nested attributes:

class Person < ActiveRecord::Base has_many :phone_numbers, :dependent => :destroy accepts_nested_attributes_for :phone_numbers, :reject_if => lambda { |p| p.values.all?(&:blank?) }, :allow_destroy => true end class PhoneNumber < ActiveRecord::Base belongs_to :person end 

Create a partial view for the PhoneNumber's form fields:

<div class="fields"> <%= f.text_field :description %> <%= f.text_field :number %> </div> 

Next write a basic edit view for the Person model:

<% form_for @person, :builder => LabeledFormBuilder do |f| -%> <%= f.text_field :name %> <%= f.text_field :email %> <% f.fields_for :phone_numbers do |ph| -%> <%= render :partial => 'phone_number', :locals => { :f => ph } %> <% end -%> <%= f.submit "Save" %> <% end -%> 

This will work by creating a set of template fields for the PhoneNumber model that we can duplicate with javascript. We'll create helper methods in app/helpers/application_helper.rb for this:

def new_child_fields_template(form_builder, association, options = {}) options[:object] ||= form_builder.object.class.reflect_on_association(association).klass.new options[:partial] ||= association.to_s.singularize options[:form_builder_local] ||= :f content_tag(:div, :id => "#{association}_fields_template", :style => "display: none") do form_builder.fields_for(association, options[:object], :child_index => "new_#{association}") do |f| render(:partial => options[:partial], :locals => { options[:form_builder_local] => f }) end end end def add_child_link(name, association) link_to(name, "javascript:void(0)", :class => "add_child", :"data-association" => association) end def remove_child_link(name, f) f.hidden_field(:_destroy) + link_to(name, "javascript:void(0)", :class => "remove_child") end 

Now add these helper methods to the edit partial:

<% form_for @person, :builder => LabeledFormBuilder do |f| -%> <%= f.text_field :name %> <%= f.text_field :email %> <% f.fields_for :phone_numbers do |ph| -%> <%= render :partial => 'phone_number', :locals => { :f => ph } %> <% end -%> <p><%= add_child_link "New Phone Number", :phone_numbers %></p> <%= new_child_fields_template f, :phone_numbers %> <%= f.submit "Save" %> <% end -%> 

You now have the js templating done. It will submit a blank template for each association, but the :reject_if clause in the model will discard them, leaving only the user-created fields. Update: I've rethought this design, see below.

This isn't truly AJAX, since there isn't any communication going on to the server beyond the page load and form submit, but I honestly could not find a way to do it after the fact.

In fact this may provide a better user experience than AJAX, since you don't have to wait for a server response for each additional field until you're done.

Finally we need to wire this up with javascript. Add the following to your `public/javascripts/application.js' file:

$(function() { $('form a.add_child').click(function() { var association = $(this).attr('data-association'); var template = $('#' + association + '_fields_template').html(); var regexp = new RegExp('new_' + association, 'g'); var new_id = new Date().getTime(); $(this).parent().before(template.replace(regexp, new_id)); return false; }); $('form a.remove_child').live('click', function() { var hidden_field = $(this).prev('input[type=hidden]')[0]; if(hidden_field) { hidden_field.value = '1'; } $(this).parents('.fields').hide(); return false; }); }); 

By this time you should have a barebones dynamic form! The javascript here is really simple, and could easily be done with other frameworks. You could easily replace my application.js code with prototype + lowpro for instance. The basic idea is that you're not embedding gigantic javascript functions into your markup, and you don't have to write tedious phone_numbers=() functions in your models. Everything just works. Hooray!


After some further testing, I've concluded that the templates need to be moved out of the <form> fields. Keeping them there means they get sent back to the server with the rest of the form, and that just creates headaches later.

I've added this to the bottom of my layout:

<div id="jstemplates"> <%= yield :jstemplates %> </div 

And modified the new_child_fields_template helper:

def new_child_fields_template(form_builder, association, options = {}) options[:object] ||= form_builder.object.class.reflect_on_association(association).klass.new options[:partial] ||= association.to_s.singularize options[:form_builder_local] ||= :f content_for :jstemplates do content_tag(:div, :id => "#{association}_fields_template", :style => "display: none") do form_builder.fields_for(association, options[:object], :child_index => "new_#{association}") do |f| render(:partial => options[:partial], :locals => { options[:form_builder_local] => f }) end end end end 

Now you can remove the :reject_if clauses from your models and stop worrying about the templates being sent back.

Sign up to request clarification or add additional context in comments.

8 Comments

Awesome answer. The only thing I tweaked was to move the remove link around a bit such that I could .remove() the fields rather than .hide(). This way I can use Cucumber + Selenium to make sure they're gone. Thanks so much for this!
@matschaffer I've actually changed this to use Mustache.js templates instead of storing them in the DOM. If your front-end is this sophisticated I would encourage looking into that.
This is an attitude, to take time to answer for the rest to know, thanks a lot Adam!
@Ramon unfortunately, this is totally out-of-date now :-/ One of these days I need to update it to explain how to use templates.
@DGM yeah, this is how I'm doing it now.
|
3

Based on your answer to my comment I think handling deletion unobtrusively is a good place to start. I'll use Product with scaffolding as an example, but the code will be generic so it should be easy to use in your application.

First add a new option to your route:

map.resources :products, :member => { :delete => :get } 

And now add a delete view to your Product views:

<% title "Delete Product" %> <% form_for @product, :html => { :method => :delete } do |f| %> <h2>Are you sure you want to delete this Product?</h2> <p> <%= submit_tag "Delete" %> or <%= link_to "cancel", products_path %> </p> <% end %> 

This view will only be seen by users with JavaScript disabled.

In the Products controller you'll need to add the delete action.

def delete Product.find(params[:id]) end 

Now go to your index view and change the Destroy link to this:

<td><%= link_to "Delete", delete_product_path(product), :class => 'delete' %></td> 

If you run the app at this point and view the list of products you'll be able to delete a product, but we can do better for JavaScript enabled users. The class added to the delete link will be used in our JavaScript.

This will be a rather large chunk of JavaScript, but it's important to focus on the code that deals with making the ajax call - the code in the ajaxSend handler and the 'a.delete' click handler.

(function() { var originalRemoveMethod = jQuery.fn.remove; jQuery.fn.remove = function() { if(this.hasClass("infomenu") || this.hasClass("pop")) { $(".selected").removeClass("selected"); } originalRemoveMethod.apply(this, arguments); } })(); function isPost(requestType) { return requestType.toLowerCase() == 'post'; } $(document).ajaxSend(function(event, xhr, settings) { if (isPost(settings.type)) { settings.data = (settings.data ? settings.data + "&" : "") + "authenticity_token=" + encodeURIComponent( AUTH_TOKEN ); } xhr.setRequestHeader("Accept", "text/javascript, application/javascript"); }); function closePop(fn) { var arglength = arguments.length; if($(".pop").length == 0) { return false; } $(".pop").slideFadeToggle(function() { if(arglength) { fn.call(); } $(this).remove(); }); return true; } $('a.delete').live('click', function(event) { if(event.button != 0) { return true; } var link = $(this); link.addClass("selected").parent().append("<div class='pop delpop'><p>Are you sure?</p><p><input type='button' value='Yes' /> or <a href='#' class='close'>Cancel</a></div>"); $(".delpop").slideFadeToggle(); $(".delpop input").click(function() { $(".pop").slideFadeToggle(function() { $.post(link.attr('href').substring(0, link.attr('href').indexOf('/delete')), { _method: "delete" }, function(response) { link.parents("tr").fadeOut(function() { $(this).remove(); }); }); $(this).remove(); }); }); return false; }); $(".close").live('click', function() { return !closePop(); }); $.fn.slideFadeToggle = function(easing, callback) { return this.animate({opacity: 'toggle', height: 'toggle'}, "fast", easing, callback); }; 

Here's the CSS you'll need too:

.pop { background-color:#FFFFFF; border:1px solid #999999; cursor:default; display: none; position:absolute; text-align:left; z-index:500; padding: 25px 25px 20px; margin: 0; -webkit-border-radius: 8px; -moz-border-radius: 8px; } a.selected { background-color:#1F75CC; color:white; z-index:100; } 

We need to send along the auth token when we make POST, PUT or DELETE. Add this line under your existing JS tag (probably in your layout):

<%= javascript_tag "var AUTH_TOKEN = #{form_authenticity_token.inspect};" if protect_against_forgery? -%> 

Almost done. Open up your Application controller and add these filters:

before_filter :correct_safari_and_ie_accept_headers after_filter :set_xhr_flash 

And the corresponding methods:

protected def set_xhr_flash flash.discard if request.xhr? end def correct_safari_and_ie_accept_headers ajax_request_types = ['text/javascript', 'application/json', 'text/xml'] request.accepts.sort!{ |x, y| ajax_request_types.include?(y.to_s) ? 1 : -1 } if request.xhr? end 

We need to discard flash messages if it's an ajax call - otherwise you'll see flash messages from the "past" on your next regular http request. The second filter is also required for webkit and IE browsers - I add these 2 filters to all of my Rails projects.

All that's left is the destroy action:

def destroy @product.destroy flash[:notice] = "Successfully destroyed product." respond_to do |format| format.html { redirect_to redirect_to products_url } format.js { render :nothing => true } end end 

And there you have it. Unobtrusive deleting with Rails. It seems like a lot of work all typed out, but it's really not that bad once you get going. You might be interested in this Railscast too.

3 Comments

That's very helpful, thank you. Could you post an example of how you create new form fields?
Having a 'delete' route using GET? Isn't that asking for trouble?
You can call it whatever you want (for example in one of my applications it's called 'confirm_delete'), the GET request doesn't do an actual delete. The point is that you have a view that contains the delete form and asks the user to confirm the deletion.
2

By the way. rails has changed a bit so you can not longer use _delete, now use _destroy.

def remove_child_link(name, f) f.hidden_field(:_destroy) + link_to(name, "javascript:void(0)", :class => "remove_child") end 

Also I found it easier to just remove html that is for new records... so i do this

$(function() { $('form a.add_child').click(function() { var association = $(this).attr('data-association'); var template = $('#' + association + '_fields_template').html(); var regexp = new RegExp('new_' + association, 'g'); var new_id = new Date().getTime(); $(this).parent().before(template.replace(regexp, new_id)); return false; }); $('form a.remove_child').live('click', function() { var hidden_field = $(this).prev('input[type=hidden]')[0]; if(hidden_field) { hidden_field.value = '1'; } $(this).parents('.new_fields').remove(); $(this).parents('.fields').hide(); return false; }); }); 

1 Comment

Good catch, looking at my codebase I actually made this change myself but forgot to update the post.
2

FYI, Ryan Bates now a has a gem that works beautifully: nested_form

1 Comment

Is this gem still being maintained? I mean is it safe to use actually? I see the Latest commit 1b0689d on Dec 1, 2013. If not, any other option than cocoon?
1

I created an unobtrusive jQuery plugin for dynamically adding fields_for objects for Rails 3. It is very easy to use, just download the js file. Almost no configuration. Just follow the conventions and you're good to go.

https://github.com/kbparagua/numerous.js

It is not super flexible but it will do the job.

1 Comment

I have a question related to your numerous.js plugin. I'm posting it here because it's this easiest way to signal it to you and perhaps others may run into the same issue and be following this question. stackoverflow.com/q/13783927/439756
1

I've successfully (and rather painlessly) used https://github.com/nathanvda/cocoon to dynamically generate my forms. Handles associations elegantly and the documentation is very straightforward. You can use it together with simple_form, too, which was particularly useful for me.

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.