Thursday, November 13, 2008

Multiple asynchronous jQuery forms OR Making jQuery objects work for you

There are times when I find the need to put multiple forms on a page. Lets say for example I have a couple of surveys for my users. I want them to be able to participate in all of the surveys, but I don't want to make them hit the back button to fill out the subsequent forms. This, of course, can be done pretty easily with with ajax. Using both jQuery and the Forms Plugin, this is just a single line of javascript.

$("form").ajaxForm();

That was easy enough. All my forms will submit via ajax now. While this will work, it isn't quite enough. Users like feedback, and right now they wont even know that anything happened.

I like to use a submit button (html button as opposed to input type="submit") that has an image that changes to a spinner when the form is submitted. I also disable the buttons while the form is submitting. (There is a nice tutorial on styling buttons this way.)

The forms plugin gives us a nice beforeSubmit() function. It is similar to jQuery's ajax beforeSend(), but provides different input parameters. I will take advantage of the second parameter here -- the form object. This will swap out the submit button's image with a spinner image and disable the buttons before the XHR request is sent.

var spinner = new Image();
spinner.src = "/path/to/spinner.gif";

$("form").ajaxForm({
  beforeSubmit: function(data, $form, opts){
    $form.find('button').attr('disabled', 'disabled');
    $form.find('button img').replaceWith($(spinner).clone());
  }
});

Okay, great. We don't want to spin forever though, so we should change the images back when the request is done processing. I reset my buttons in the complete callback. You can use the success callback if you'd like, but if the form submission returns an error code then your button will be spinning and disabled forever.

The problem with both the 'complete' and the 'success' callbacks is they don't give you the form object like the beforeSubmit callback does. If you just have one form on the page this isn't really a big issue because you can just use jQuery to select the form again via $('form') within the callback. I have multiple forms on my page though. If the second form is submitted before the first form completes, then both forms will be reset when the first one finishes.

I need a way to know which form submission is completing inside the callback. I can do this by storing the form in the jQuery.ajax() options object. Inside any of the callback functions 'this' refers to the options object, so it actually quite convenient.

var spinner = new Image();
spinner.src = "/path/to/spinner.gif";

$("form").ajaxForm({
  beforeSubmit: function(data, $form, opts){
    this.$form = $form;
    this.$buttonImage = $form.find('button img');
    $form.find('button').attr('disabled', 'disabled').find('img').replaceWith( $(spinner).clone() );
  },
  complete: function(){
    this.$form.find('button').removeAttr('disabled').find('img').replaceWith(this.$buttonImage);
  }
});

I do think it is a little shady to be modifying jQuery objects. If for some reason they decide to add a $form property in the future and I upgrade my jQuery then obviously my code will overwrite that property. I think the benefits outweigh the risk though.

In my fully tricked out forms, I'll even add my own functions to the object to make things easier. Here is code for some forms that have both a save and cancel button and has server side validations. Fields that are invalid are passed back in the response. This will also display both success and error messages after submission. I have our server generate a 422 (Unprocessable Entity) on invalid forms. jQuery doesn't automatically extract the response data for in the error call back, but it provides the XHR object so it is pretty easy to extract it myself.

$("form").ajaxForm({
  resetForm: true,
  beforeSubmit: function(data, $form){
    this.$form = $form;
    this.$saveButton = $form.find('button.save');
    this.$saveButtonImage = this.$saveButton.find('img');
    this.$saveButton.attr('disabled', 'disabled').find('img').replaceWith($(loadingImage).clone());
  },
  clearMessages: function(){
    this.$form.find('.invalid').andSelf().removeClass('invalid');
    this.$form.find('.errors, .success').remove();
  },
  complete: function(){
    this.$saveButton.removeAttr('disabled').find('img').replaceWith(this.$saveButtonImage)
  },
  success: function(){
    this.clearMessages();
    this.$form.prepend('<div class="success"><p class="heading">Your submission has been recorded.</p></div>');
    this.$form.find(".success")[0].scrollIntoView();
  },
  error: function(XHR){
    this.clearMessages();
    var $form = this.$form;

    if(XHR.status == 422){
      var data = eval("(" + XHR.responseText + ")");

      if(typeof(data['invalid_fields']) != "undefined") {
        var error_box = '<div class="errors"><p class="heading">There were errors processing your submission.</p><ol>';

        $.each(data['invalid_fields'], function(id, errors){
          $form.find('.form_field_'+id).addClass('invalid');
          $.each(errors, function(){
            error_box += '<li>' + this + '</li>';
          });
        });

        error_box += '</ol></div>';
        $form.prepend(error_box);
        $form.find(".errors")[0].scrollIntoView();
      }
    }
  }
});

3 comments:

  1. That's a great trick and you could make it a little less shady by using the .data() function rather then modifying the object's internals.

    http://docs.jquery.com/Internals/jQuery.data

    i.e.

    beforeSubmit: function(data, $form){
    this.data('form_obj', $form)
    // ...
    }

    and;

    complete: function(){
    this.$saveButton.removeAttr('disabled').find('img').replaceWith(this.$saveButtonImage)
    this.removeData("form_obj");
    },

    ReplyDelete