Wednesday, March 23, 2005

Enhancing RoR's AJAX.Updater

So, RoR version 0.11 came out and has built-in support for AJAX. It’s very cool stuff.

A Small Bug

There was a bug in the gem I installed that may be fixed now… I’m not sure. You can have a look, the bug was in lines 61 & 62 of prototype.js (in your public/javascripts/ folder):

Toggle = {
  display: function() {
    for (var i = 0; i < elements.length; i++) {
      var element = $(elements[i]);
      element.style.display = (element.style.display == 'none' ? '' : 'none');
    }
  }
}

It should look like this:

Toggle = {
  display: function() {
    for (var i = 0; i < arguments.length; i++) {
      var element = $(arguments[i]);
      element.style.display = (element.style.display == 'none' ? '' : 'none');
    }
  }
}

RoR’s Built-in AJAX

I really love what they’ve done with the AJAX support—it’s especially good for a first-cut. However, I’ve been playing with it and have a couple of suggestions… And code, of course.

The main thing that bothers me is the AJAX.Updater replaces the entire innerHTML of the target DOM object. I’d much rather it use an insertAdjacentHTML equivalent. Why not just use insertAdjacentHTML? Simple. Only Internet Explorer supports it. And I don’t know about you, but I avoid IE like the plague. That said, insertAdjacentHTML is an excellent way to add HTML fragments positionally in the DOM.

The Goal

So my goal was to find a way of doing this without having to hack the Rails code… Don’t wanna do it—wouldn’t be prudent.

So, to start off, what do we need? We need a nice cross-browser way of adding content in a fashion that’s similar to IE’s insertAdjacentHTML. I know it’s easy to extend mozilla’s HTMLElement... But Safari doesn’t support that. And I have to have it working on Safari.

Well, long story short, Safari can do the same thing as mozilla using HTML fragments, we just can’t extend the built-in HTMLElement class. Which is OK, really. After looking at the prototype stuff in RoR, I wanted to follow the same approach. Here’s what I came up with:

Insert = {
  before_begin: function( dom, html ) {
    dom = $(dom);
    var df; var r = dom.ownerDocument.createRange();
    r.setStartBefore(dom);
    df = r.createContextualFragment( html );
    dom.parentNode.insertBefore( df, dom );
  },
  after_begin: function( dom, html ) {
    dom = $(dom);
    var df; var r = dom.ownerDocument.createRange();
    r.selectNodeContents(dom);
    r.collapse(true);
    df = r.createContextualFragment( html );
    dom.insertBefore(df, dom.firstChild );
  },
  before_end: function( dom, html ) {
    dom = $(dom);
    var df; var r = dom.ownerDocument.createRange();
    r.selectNodeContents(dom);
    r.collapse(dom);
    df = r.createContextualFragment( html );
    dom.appendChild( df );
  },
  after_end: function( dom, html ) {
    dom = $(dom);
    var df; var r = dom.ownerDocument.createRange();
    r.setStartAfter(dom);
    df = r.createContextualFragment( html );
    dom.parentNode.insertBefore(df, dom.nextSibling);
  }
};

Cool, now we have a nice cross-browser way of adding content to the DOM in a positional way… Now we need to hook up RoR’s AJAX.Updater to use it, instead of the innerHTML approach.

A New Problem

Well, that’s all fine and dandy, but there’s a new problem: We can’t send in extra parameters in the form_remote_tag() helper method. So we have no way of telling it which position we’d like the new content to be added to the DOM.

The Right Way

Well, ideally, I think the form_remote_tag() helper should have a couple of params for handling the content returned from AJAX. Maybe you could set one of the following to true: :replace, :before_begin, :after_begin, :before_end, :after_end. Or, maybe we just have a :content_mode that would define one of the above symbols.

The Now Way

But that’s not important right now, it’s against my goal. I just want a drop-in enhancement.

Since we can’t send any params we want we need another way of telling the AJAX.Updater class how to handle the content, taking into consideration the fact it can be asynchronous… Always a fun little wrinkle.

The only thing I’ve got to work is to set it in javascript on the :loading AJAX event handler. I attach a property to the request object it sends, like so:

<%= form_remote_tag( :url =>; url_for( :action=>;'my_ajax_action' ), ) %>

Using that technique, I just created a custom AJAX.Updater.updateContent method, like this:

Ajax.Updater.prototype.updateContent = function() {
  if( this.request.transport.prepend )
    Insert.after_begin( this.container, this.request.transport.responseText );
  else if( this.request.transport.append )
    Insert.before_end( this.container, this.request.transport.responseText );
else
this.container.innerHTML = this.request.transport.responseText; if (this.onComplete) this.onComplete(this.request);
};

You’ll notice I only support prepending (adding to the beginning of a DOM element) and appending (adding to the bottom of a DOM element). It’d be a simple matter to add the other two types, I just didn’t need them.

The Script

I packaged all of this up into a single file that you can include AFTER you include prototype.js and it will automatically add all of this support.

prototype-ex.js

I’m curious to hear what you think. Plus, I’ve only really tested this on Safari, Firefox (and Camino)... It should work on any DOM compliant browser, but I haven’t gotten around to firing up my PC.

2 comments:

  1. Hi
    I have this IE 6.0.28 bug. I am using the prototype.js (1.31) with spyce
    the javascript is
    new Ajax.Updater("target", "whats.spy?series=money");
    my updater returns and html with contains a form. Works fine on firefox,safari but fails on IE.
    I replace the real code with something basic like
    and this also crashes!!
    Do you think your exentions will help

    ReplyDelete
  2. The latest version of prototype (v1.6) includes a new function that would make this addition you added above unnecessary (insert()).
    Thanks for posting your code above though. Got me to started on rewriting my libraries.

    ReplyDelete