Rails 3 Remote Links and Forms Part 2: Data-type (with jQuery)

18 January 2011

Continued from Rails 3 Remote Links and Forms: A Definitive Guide.

Since writing the Rails 3 Remote Links & Forms Definitive Guide, one question keeps coming up:

How can we make our remote links and forms retrieve JS.ERB, instead of an HTML partial?

In the last article, we requested an HTML partial to be inserted into the page by our AJAX callbacks. But what if we want JavaScript to be executed? Or JSON or XML to be parsed? Or plain text to be displayed?

Spoiler Alert: this article concludes with a complete working example using js.erb.

Equal parts Rails & jQuery

First, we must understand the :remote => true process in Rails 3. It's equal parts Rails and jQuery magic. But don't worry, it's very little magic, bundled into a 4-step process:

  1. Rails tells jQuery, "Hey, bind your ajaxy goodness to this sweet little link/form," with the :remote => true option.
  2. jQuery hi-jacks the element's click/submit event and binds it to the .ajax() method. Now that element submits via AJAX instead of loading a new page in the browser.
  3. Rails receives the AJAX web request when the element is clicked/submitted and responds with some content.
  4. jQuery receives the response content with the .ajax() method that hi-jacked our element, and provides callbacks for us to handle the response in the page.

In this article, we're exploring the different ways we can specify the format of the AJAX response and handle it accordingly, which actually spans all 4 steps above.

Steps 1 & 2: Setting the data-type

When the browser sends web requests back and forth between the browser and the server, part of the request/response header can specify the format of the content. When loading a page in the browser, the content type is typically inferred from the extension in the URL. jQuery, though, can directly set the data-type desired in the AJAX request header.

jQuery allows dataType parameter

jQuery's .ajax() method provides an optional parameter called dataType to specify the desired data-type of the response. This allows jQuery to specify the format type in the request's HTTP Accept header, and then to encapsulate the response content in the appropriate data object for easier manipulation.

As of jQuery 1.4, if you do not specify the data-type of the response, jQuery will actually inspect the MIME type header of the response and make an "intelligent guess" as to the data-type, changing the data-type of the response object on-the-fly.

There's more information on the .ajax() documentation page, but the basic types are:

dataTypeBehavior
"xml" Returns an XML document that can be processed by jQuery.
"html" Returns HTML as plain text, but evaluates any <script> tags included in the markup.
"script" Evaluates the response as JavaScript and returns it as plain text.
"json" Evaluates the response as JSON and returns a JavaScript object.
"jsonp" Loads response in a JSON block using JSONP.
"text" Returns a plain text string.

Rails.js sets the dataType parameter

The Rails UJS driver sets our AJAX dataType from the data-type attribute we specified on our remote link or form. If we didn't specify data-type explicitly for that element, then the default data-type is used from our global $.ajaxSettings. If we haven't set that either, then a generic request is sent that will accept any type of response.

dataType = element.attr('data-type') || ($.ajaxSettings && $.ajaxSettings.dataType);

Older versions of the UJS driver would default to a data-type of 'script' rather than send a generic request. While this seemed like a sensible default, it would cause our Rails app to throw an exception if we didn't have format.js defined in our controller action.

Newer versions of the UJS driver simply leave jQuery's default dataType of '*/*'. This tells the server, "Give me whatever you've got." However, this would make the controller respond with the first format that happens to be listed in the Responder (see next section). So if format.html is listed before format.js, the app will respond with the HTML response (which means it will try redirecting for POST or DELETE method AJAX requests). This isn't ideal either.

So in the newest versions, we figured out how to set the default, such that it tells the server, "I'd prefer JS, but I'll take whatever you've got." Now, if format.js is defined at all in the available Responder formats, JS will be returned. If not, the controller will then respond with the first format listed. (See the discussion thread here.)

// Simplified for clarity
if (dataType === undefined) {
  xhr.setRequestHeader('accept', '*/*;q=0.5, text/javascript');
}

Step 3: Rails responds (from the controller)

Now the AJAX request has been made, with the desired data-type specified in the header. Our Rails app receives the request, routes it to the appropriate controller action, and renders a response.

Our controller decides what content to render as the response, and how to format it. The respond_with and respond_to methods from the Rails Responder class inspect the Accept header of the request (set by dataType) to render the appropriate response. [2]

For object-based data-types, like JSON and XML, we'd typically serialize and return an object in the requested format. And this is exactly what the Responder does by default. And for content-based data-types like JS or HTML, we would usually render a js.erb or html.erb file.

Technically we could also have a custom json.erb or xml.erb template to render a custom data object as well. Responder will look for these templates and render them if they're there.

Step 4: jQuery handles the response

From the last article, we bind our response-handling jQuery code to the ajax:success, ajax:error, and ajax:complete callbacks.

With our new understanding of the various data-types and how to set them, we can now modify our response callbacks to handle our desired data-type. In the last article, our callback bindings handled an HTML response.

Handling an HTML request:

$('#i-want-html')
  .bind('ajax:success', function(evt, data, status, xhr){
    var $this = $(this);

    // Append response HTML (i.e. the comment partial or helper)
    $('#comments').append(xhr.responseText);

    // Clear out the form so it can be used again
    $this.find('input:text,textarea').val('');

    // Clear out the errors from previous attempts
    $this.find('.errors').empty();

  })
  .bind('ajax:error', function(evt, xhr, status, error){

    // Display the errors (i.e. an error partial or helper)
    $(this).find('.errors').html(xhr.responseText);

  });

We can easily change this now to handle a JSON response for example.

Handling a JSON request:

$('#i-want-json')
  .bind('ajax:success', function(evt, data, status, xhr){
    var $this = $(this);

    // do something with 'data' response object

    $this.find('input:text,textarea').val('');
    $this.find('.errors').empty();
  })
  .bind('ajax:error', function(evt, xhr, status, error){
    var responseObject = $.parseJSON(xhr.responseText),
        errors = $('<ul />');

    $.each(responseObject, function(){
      errors.append('<li>' + this + '</li>');
    })

    $(this).find('.errors').html(errors);
});

And finally, to handle a JS request, we don't actually need to bind any callback functions. Remember if dataType: 'script', jQuery automatically executes the response JavaScript on the page.

Handling a JS request:

// Nothing!

If our response wasn't automatically executed for us, our handler might look something like this:

$('#i-want-js').bind('ajax:complete', function(evt, xhr, status){
  eval(xhr.responseText);
});

Notice we'd bind to the generic ajax:complete callback, instead of the ajax:success|error callbacks. This is because our success/error handling is in the js.erb file.

The above handler isn't exactly the same as the automatically-executed function. The function above would execute our script in the context of the $('#i-want-js') element, while jQuery automatically executes our script in the context of $(window).

 

Now let's put it all together.

Working example using js.erb

Just like the last article, we'll create a comment and submit it via AJAX. Except this time, we'll respond with js.erb.

In the controller, we'll add the ability to respond to requests via HTML, JS, or JSON.

comments_controller.rb

class TestCommentsController < ApplicationController
  respond_to :html, :js
  ...
  def create
    @comment = Comment.new( params[:comment] )

    flash[:notice] = "Comment successfully created" if @comment.save
    respond_with( @comment, :layout => !request.xhr? )
  end
end

If you're new to the Rails 3 responder, the above is equivalent to:

class TestCommentsController < ApplicationController
 ...
  def create
    @comment = Comment.new( params[:comment] ) 

    respond_to do |format|
      if @comment.save
        flash[:notice] = "Comment successfully created"
        format.html { redirect_to(@comment), :layout => !request.xhr? }
        format.js { render :js => @comment, :status => :created, :location => @comment, :layout => !request.xhr? }
      else
        format.html { render :action => "new", :layout => !request.xhr? }
        format.js { :js => @comment.errors, :status => :unprocessable_entity }
     end
  end
end

Now, we'll set up our index page to list all comments, and include our AJAX form to create a new comment. We don't need to include any data-type HTML5 attribute in our form, since the Rails UJS will prefer JS by default.

index.html.erb

<div id="comments"></div>

<%= form_for :comment, :remote => true, :html => { :id => 'new-comment-form' } do |f| %>
  <%= f.text_area(:body) %>
  <div class="errors"></div>
  <%= f.submit %>
<% end %>

And finally, we'll need to create our js.erb template which will execute and insert our response into the page.

create.js.erb

var el = $('#new-comment-form');

<% if @comment.errors.any? %>

  // Create a list of errors
  var errors = $('<ul />');

  <% @comment.errors.full_messages.each do |error| %>
    errors.append('<li><%= escape_javascript( error ) %></li>');
  <% end %>

  // Display errors on form
  el.find('.errors').html(errors);

<% else %>

  // We could also render a partial to display the comment here
  $('#comments').append("<%= escape_javascript( 
      simple_format( @comment.body ) 
    ) %>");

  // Clear form
  el.find('input:text,textarea').val('');
  el.find('.errors').empty();

<% end %>

That's it!

Again, we don't need to bind to any of our AJAX callbacks here. It just works.

Sometimes, the JavaScript response does not execute as it should, and instead just returns the response as a string. This usually means there is some malformed JavaScript somewhere in the response. It's annoying, but it won't throw any visible JavaScript errors from the automatically-executed response.

See Rails js.erb Remote Response not Executing.

Additional Resources

About the author:

Steve Schwartz // Owner of Alfa Jango Web-based Software, creator of RateMyStudentRental & LeadNuke, engineer, hacker, rubyist, guitarist, aspiring racecar driverist.



Comments are loading...