blog

Ajax Upload Part I: Framed (and jQuery Deferred)

by

upFrame

Inevitably, people want their files on the Internet. If your project is about cute cats, someone will task you with allowing users to upload photos of their cats, videos of their cats, long rambling audio clips in which they attempt to convince their cat to stop attacking the microphone, etcetera. If your project is about the nature and proclivities of mold, someone, somewhere will want to share detailed photographic evidence of their mold problem. The need to upload files is a given.

Despite that, uploading files has long been one of the roughest aspects of interaction with the web – requiring form submission, and the requisite UI-lockup and page reload, or else some form of plugin intervention (such as Flash). This is changing – with the new XMLHttpRequest level 2 spec, coupled with the File API, all supporting browsers will be able to cleanly and neatly retrieve a file from the user’s local file system and upload it to the web – no page reloads, beautifully asynchronous. We will be focusing on just how to take advantage of these new developments in part II.

For now, though, let’s say you have to support an older browser (<= IE9, for instance). You don't want to have to employ Flash – its a security risk (a topic for another post), and not supported by all mobile browsers or devices; but you want to avoid the deleterious standard behaviour of a form submission. Or maybe there's some other need for a form submission that works like an ajax request (I'll get into that later).

Aha, I say – its time for a little iframe trick, then.

The Trick

Situation: We need to submit a form transparently (to the user), and get the result (so that we know when our file has finished uploading, for instance). We don’t want to lock up the UI or reload the page. So, instead of submitting our form within the page, we will submit it within an iframe.

Say you have a form like this:

<form id="uploadForm" action="/upload/url" enctype="multipart/form-data" method="POST">
    <input type="file" name="uploadLocalImage" accept="image/*" />
    <input type="submit" value="Submit" />
</form>

The user selects the file they want to upload, and clicks submit. We want to upload the file, and expect a json response telling us something (new location of the file, number of MB left for uploading files, etc.)

$('#uploadForm').on('submit', function(event){
// Check to see if target is defined before we cancel the default event - this allows us to use the html5 checkValidity function on a form if we so desire, or perform any other checks.
if ($(this).attr('target') === undefined)
{
    event.preventDefault();
    this.checkValidity(); // See <a href="http://www.whatwg.org/specs/web-apps/current-work/multipage/forms.html#dom-form-    checkvalidity">checkValidity</a>; for more details. This probably isn't necessary on a file upload, but might be desirable for other form submissions.
$.transparentSubmit({
    dataType:'json',
    form:this
}).done(function(response){
// Woo, completed successfully! Do something with the response.
    console.log(response);
}).fail(function(e){
// Aww, our call failed. :( Do something with the failure message.
    console.log(e);
});
}
});

Looks pretty simple, right? It’s missing something, though – what’s this $.transparentSubmit thing? It might look familiar if you’re used to $.ajax – we’re using the same nice, neat callback style via $.Deferred.

Aside: $.Deferred

jQuery offers us a very powerful tool for perfoming asynchronous calls, following the Promise pattern, in the form of $.Deferred. jQuery’s ajax implements deferred as a way of offering us chainable callbacks (in the form of .done on success, .fail on failure, .always no matter the result, as well as waiting on multiple sucessful calls with .when, and chaining calls together with .then).

This is all kinds of good, and could fill a post on its own (see this post for example). For now, suffice to say that since we want our transparent form submission to work like an ajax call, it makes perfect sense for it to look and operate like one, too. It opens up neat possibilities – say we need the results of our form submission and a seperate ajax call before we proceed:

$.when($('#myForm').transparentSubmit({}), $.ajax({url:'/ajax/page'}))
.done(function(res1, res2){
// Do something with the responses from both of our asynch calls
});

See the jQuery docs for more details.

Breakdown

So just how does this transparentSubmit thing work, then? Let’s go through it a few pieces at a time.

var defer = $.Deferred(),
options = $.extend({
    dataType:'json'
}, (options || {})),
name = 'target-'+Math.random().toString(36).substring(7),
hiframe = $(''),
form = $(options.form),
cleanup = function(){
    hiframe.remove();
    delete frames[name];
};

At the top we declare our deferred object, get options (which are passed to our plugin function, or else is made an empty object), set a name for the frame, create said iframe, get the form and declare an inline function to do some house-keeping.
Note: name is assigned a value from Math.random converted to a string using base 36 (0-9a-z) to generate a 5 letter random string we append to ‘target-‘. This isn’t strictly necessary – originally, using the same name for more than one frame would cause us problems, but this is resolved by the call in cleanup to delete the named frame. I’ve left it in as a neat little trick that someone might be interested in!

if (!form.length || form[0].tagName !== 'FORM')
{
defer.reject("No form element specified to submit.");
return defer.promise();
}
form.attr('target', name).append(hiframe);

We make sure that form is in the DOM and is a form, or else call reject on our deferred object (triggering any .fail attached to our promise). Then, we set the target attribute on the form to point at our iframe, and append the iframe to the form.

hiframe.on('load', function(){
var res;
form.removeAttr('target');
try{
if (options.dataType.indexOf('json') != -1)
{
    res = frames[name].document.getElementsByTagName("pre")[0].innerHTML;
}
else
{
    res = frames[name].document.getElementsByTagName('body')[0].innerHTML;
}
}catch(e){
    // Failed to receive anything in the body of the frame
    cleanup();
    defer.reject("Failed to receive response in form target "+name+": "+e.message);
    return;
}
cleanup();

Now, we add a listener to the ‘load’ event for the iframe. When that event is triggered, after the upload is finished, or the form has been successfully submitted, we remove the target from the form (so its ready to be checked previous to submission again), and try and get the content returned from the server we submitted to that will have been loaded into the iframe, based on the dataType we set as an option when calling $.transparentSubmit.

if (options.dataType.indexOf('json') != -1)
{
try{
    res = $.parseJSON(res);
}catch(e){
    defer.reject("Failed to parse response into JSON: "+e.message);
    return;
}
}
else if (options.dataType.indexOf('html') != -1)
{
res = $.parseHTML(res);
}
defer.resolve(res);
});
form.submit();
return defer.promise();

If we want to return json or html, then we try and parse it as such. Either way, we can now resolve our promise and return the response. Outside of the load event, after its declared, we make our call to submit the form, which will now target the iframe and submit seamlessly. Finally, we return our deferred promise.

Aside: Other Uses

As mentioned previously, all of this is going away as far as file uploading is concerned as soon as all major browsers (and all the legacy browsers we have to support as web developers) gain support for XMLHttpRequest level 2 and the File API. This technique is useful for more than just file uploading, however. In a project I worked on recently, we wanted to submit billing details to a payment gateway without needing to pass said details through our server, and with the gateway only accepting form submission. This technique was the perfect way to submit said details directly to them, without the negative user experience that would normally come with a form submission.

The Gist

This is a neat technique which I suspect I will still be getting some use out of even after all the major browsers support ajax file uploading. Therefore, I wrote a little jquery plugin (the aforementioned $.transparentSubmit). Feel free to use it, add to it, deride it mercilessly, etc.

/**
 * jQuery plugin for transparent submission of a form using an $.ajax-like interface.
 * Usage Examples:
 * $.transparentSubmit({dataType:'json', form:$('#myForm')})
 * $('#myForm').transparentSubmit({dataType:'html'})
 * Supports Deferred (.done, .fail, .when, etc.)
 */
(function($){
    $.transparentSubmit = function(options){
        var defer = $.Deferred(), // Deferred object whose promise we will hook into when adding .done, etc to calls
            options = $.extend({
                dataType:'json'
            }, (options || {})), // coerce options into being an object, extend defaults
            name = 'target-'+Math.random().toString(36).substring(7), // assign a psuedo-random name to the frame
            hiframe = $('<iframe id="'+name+'" name="'+name+'" src="about:blank" ' +
                'style="width:0;height:0;border:0px solid #fff;"></iframe>'), // create invisible iframe - NOT display:none
            form = $(options.form), // get form, make sure its a jquery object
            cleanup = function(){
                hiframe.remove();
                delete frames[name];
            }; // clean iframe away when we're finished with it
        if (!form.length || form[0].tagName !== 'FORM'){ // if we don't have a form to submit, reject (call .fail)
            defer.reject("No form element specified to submit.");
            return defer.promise();
        }
        form.attr('target', name).append(hiframe); // set target of form to iframe, and append iframe to the form
        // On load event, grab and parse the contents of the iframe
        hiframe.on('load', function(){
            var res;
            form.removeAttr('target');
            try{
                if (options.dataType.indexOf('json') != -1)
                { // browsers will wrap a json return with <pre></pre>
                    res = frames[name].document.getElementsByTagName("pre")[0].innerHTML;
                }
                else
                {
                    res = frames[name].document.getElementsByTagName('body')[0].innerHTML;
                }
            }catch(e){
                // Failed to receive anything in the body of the frame
                cleanup();
                defer.reject("Failed to receive response in form target "+name+": "+e.message);
                return;
            }
            cleanup();
            if (options.dataType.indexOf('json') != -1)
            {
                try{
                    res = $.parseJSON(res);
                }catch(e){
                    defer.reject("Failed to parse response into JSON: "+e.message);
                    return;
                }
            }
            else if (options.dataType.indexOf('html') != -1)
            {
                res = $.parseHTML(res);
            }
            defer.resolve(res); // Finished (call .done)
        });
        form.submit();
        return defer.promise();
    }
/*
* This allows us the option to call $('#myForm').transparentSubmit - as you can see, its just a slightly different form
* to the plugin, which calls our transparentSubmit function as defined above.
*/
    $.fn.transparentSubmit = function(options){
        options.form = this;
        return $.transparentSubmit(options);
    };
})(jQuery);

+ more

Accurate Timing

Accurate Timing

In many tasks we need to do something at given intervals of time. The most obvious ways may not give you the best results. Time? Meh. The most basic tasks that don't have what you might call CPU-scale time requirements can be handled with the usual language and...

read more