140 Characters of awesome
A few days ago I tweeted a line of code and decided it was wonderful enough to warrant further explanation. While the code itself may have fit into 140 characters, the examples and use cases go on and on. Because of a few architectural decisions made by Dojo long ago, these 140 characters are powerful and flexible as well as mindlessly simple to use.
One thing a dojo.NodeList does not have by default in Base (dojo.js) is a way to fetch and inject content into a node (or nodes). This isn't necessarily an oversight, there are several ways this can be accomplished. In fact it is so simple an operation with so many possible edge cases it may even not be worth implementing. Here I present to you my own take on this super simple concept, built by using standard Base (dojo.js) functionality.
The function will be called grab(). It will grab a url, and inject it into some nodes. Here is the first iteration of this idea:
dojo.extend(dojo.NodeList, { grab: function(url){ dojo.xhr("GET", { url:url }) .addCallback(this, function(response){ this.addContent(response, "only"); }); return this; } });
We're simply mixing a new function in dojo.NodeList.prototype via dojo.extend, giving all instances of a NodeList this method. The plugin calls "return this;" to allow further chaining. Ajax operations by default are asynchronous
We work with NodeLists via dojo.query, so to use this code now we simply query the dom and load some content via Ajax:
dojo.addOnLoad(function(){ dojo.query("#header").grab("header.html"); });
That is all well and good and works brilliantly until you need to a) load non-html content b) issue an xhrPost instead of get or c) configure the xhr call in any way. No worries, with just a few more bytes we can add all of that functionality piggybacking on the behavior of dojo.Deferred and objects.
First, we'll add support for mixing in extra parameters into the object passed to the XHR call. Currently, we're only passing { url: url }, giving the Ajax call an endpoint to fetch. dojo.xhr accepts a lot of options in this magic object, so allowing a developer/user to mix in their own custom information here is as simple as calling dojo.mixin with an optional parameter. mixin() is safe like this, in that if extraArgs is undefined or null nothing happens. The url:url aspect is still retained, and any args passed as extraArgs are overwritten into the call.
dojo.extend(dojo.NodeList, { grab: function(url, extraArgs){ dojo.xhr("GET", dojo.mixin({ url:url }, extraArgs)) .addCallback(this, function(response){ this.addContent(response, "only"); }); return this; } });
This now allows us to mix extra items into the Ajax call like timeout, sync, error handlers and so on. Back to the nature of Ajax calls being asynchronous: if we issue a grab() call on some node, the next items in the chain will not apply to the new content until an undetermined point in the future. We can overcome this limitation by passing { sync:true } to the Ajax call, making the operation synchronous and thus making the following chains not execute until after the transfer is complete.
dojo.addOnLoad(function(){ dojo.query("#content").grab("/article/12345", { sync: true }) .find("p.title").onclick(function(e){ alert(this.innerHTML); }); });
If we had omitted the { sync:true } here, the find() call would be querying the dom of the node with id="content", though the content of /article/12345 url would not yet be in the container. We don't have to use synchronous Ajax here, it is just a possibility. For instance, we could have connected the click handler to the #content NodeList and delegated from the bubble, or used plugd's connectLive plugin to simple connect to "p.title" and delegated that way.
We can use the extraArgs in so many ways it's ridiculous. What if you wanted to know if the loading error'd out for some reason:
dojo.query("#content").grab("/foo.html", { error: function(e){ console.warn("error fetching foo.html"); } });
Or, more importantly, what if you wanted to load content other than plain HTML. Perhaps you have a JSON object to load in. This is entirely possible because of the way dojo.Deferred works. When we call addCallback() on the Deferred chain in the plugin we're really registering the last chain. Anything we mix in to the Ajax call directly is executed before the final callback which calls this.addContent(). The secret is: the return value from a previous callback is passed to the next callback in the chain. So if we register a load: callback in the extraArgs we are permitted a pre-processing step for whatever content comes in. We simply need to supply an alternate handleAs to tell Ajax to process as JSON, and the load callback to process the JSON and return HTML so addContent works.
dojo.query("ul > li").grab("person.json", { handleAs:"json", load: function(data){ // again, broken string for a broken wordpress highlight. return dojo.replace("<" + "p>{username} - {age} / {sex}</" + "p>", data); } });
The [new in Dojo 1.4] function dojo.replace does simple template substitution. In the above example, data.username is substituted into {username}, and the full HTML is returned to the second callback for this Ajax call, then injected into the appropriate node(s). In version prior to Dojo 1.4 you can use dojo.string.replace to accomplish mostly the same goals (though the templating is done differently, ${username} would need to be substituted). Above will inject a paragraph into each of the matched list items with some data. If our data came back as an array, we could simply make up the list items as well.
dojo.query("ul#mainlist").grab("tweets.json", { handleAs:"json", load:function(data){ var response = []; dojo.forEach(data, function(item){ // string intentionally broken up because wordpress highlight breaks it. response.push(dojo.replace("<l" + "i><" + "p>{username} - {age}</" + "p></l" + "i>", data)); }); return response.join(""); } });
Here we're building up a string of list-items to be injected into the unordered list with id="mainlist". When the full response is joined and returned, that value is processed by addContent and injected appropriately.
The last little piece of the puzzle is to assume you don't want to issue and xhrGet call. There are several HTTP verbs to use, each with their own purpose: PUT, DELETE, or POST come to mind. With a simple adjustment to the grab function we can allow the developer to optionally overwrite this as well:
dojo.extend(dojo.NodeList, { grab: function(url, extraArgs, method){ dojo.xhr(method || "GET", dojo.mixin({ url:url }, extraArgs)) .addCallback(this, function(response){ this.addContent(response, "only"); }); return this; } });
Now, if we pass a method parameter the default value of "GET" is ignored. Otherwise the defaults take over. Again utilizing the extraArgs "mixin", we can make an Ajax POST request and send along custom data:
dojo.query("#login").grab("/user/login", { content:{ name: dojo.byId("username").value, pass: dojo.byId("password").value } }, "POST");
One implementation detail I don't like about grab is the forced emptying of the target node. In the addCallback function where we call addContent with a parameter of "only" we are forcefully emptying out the nodes before injecting the new content. Unfortunately, this is the only behavior we've permitted here. It would be trivial to omit to "only" (and less bytes, too) and require the developer to call empty() manually before injecting new content if that behavior was desired. By default then we'd be adding the content "last", which is the default addContent position. Let's rework the grab function to handle this:
dojo.extend(dojo.NodeList, { grab: function(url, extraArgs, method){ dojo.xhr(method || "GET", dojo.mixin({ url:url }, extraArgs)).addCallback(this, "addContent"); return this; } });
It got even smaller! the "(scope, method)" pattern used in addCallback above is found throughout Dojo, and is super handy. That function "pair" (this.addContent) will be passed whatever value is returned from the Ajax call still, in the first position. The difference is we are not permitted to pass a position (without lamba's in JavaScript). Now, to inject some content into a node and empty it we must do that manually:
dojo.query("#foo").empty().grab("bar.html");
Without the empty(), "bar.html" content would be appended to the node rather than replace the content. I have yet to decide which behavior I like better. Opinions? I'll need to decide that before adding this functionality to Base Dojo proper. It currently lives in plugd, along with a slew of other new functionality for Dojo 1.4.
One last thing to do to grab function: there is no point in sending off the request if no nodes were found in the query. A simple check on the .length of the NodeList will prevent any unnecessary XHR calls from taking place:
dojo.extend(dojo.NodeList, { grab: function(url, extraArgs, method){ this.length && dojo.xhr(method || "GET", dojo.mixin({ url:url }, extraArgs)).addCallback(this, "addContent"); return this; } });
And there you have it, 140 characters of awesome. (it may be a bit more with the longhand variables, pre-shrinkage. Also the inclusion of the extend call and so on add a few bytes, but in plugd's base.js these were already there for the other plugins provided, so is moot)
