Saturday, 21 November 2009

Ajax in Tapestry (Pt 2)

In my last blog entry, I outlined how to take control of Ajax in Tapestry and pass data into our event handler as a request parameter - useful perhaps, but not very exciting!

In this entry I'm going to build on this knowledge to create an insert zone. By default Tapestry will entirely replace the content of a Zone when we update it - what if we want to add more content to it, eg. add more items to a list? Although this feature is supported by Prototype's insert method, Tapestry doesn't expose it - what we need is an insert zone! Let's start with the following TML:

  • ${item}



more items


Here we've bound a unordered list to a Tapestry zone and inside that we're looping through a collection of items. The component class (not shown) is extremely similar to the class described in the previous blog entry - the only real difference is that we're now dealing with a list of items.

Next we need an event handler that looks like this:
@Inject
private Request request;

Object onMoreItemsEvent() {
int pageNumber = Integer.parseInt(request.getParameter("page"));
setCurrentPage(pageNumber); // this pulls in the next page of items
return listZone.getBody();
}

And now we get to the JavaScript. First of all we need an initMoreItems function:
Tapestry.Initializer.initMoreItems = function(element, zoneId, url) {
element = $(element);
$T(element).zoneId = zoneId;
// add a property to the element - we increment this each time the user clicks 'more items'
element.nextPage = 1;
element.observe("click", function(event) {
Event.stop(event);
var zoneObject = Tapestry.findZoneManager(element);
if (!zoneObject) return;
new Ajax.Request(url, {
method: 'get',
parameters: { "page" : element.nextPage },
onException: Tapestry.ajaxFailureHandler,
onFailure: Tapestry.ajaxFailureHandler,
onSuccess : function (transport) {
zoneObject.processReply(transport.responseJSON, true);
element.nextPage++;
}
});
});
}

Again, extremely similar to the previous blog entry, but here we're using a nextPage property to store our page numbers and we're also passing an insert parameter to the processReply function.

At this point we have a working component ... sort of! Problem is, it doesn't actually do what we set out to do, ie. add further content to the div. To get round this, we need to override the Tapestry functions that actually do the update - processReply + show in Tapestry.ZoneManager. We do this by using JavaScript's prototype keyword:
Tapestry.ZoneManager.prototype.processReply = function(reply, insert) {
Tapestry.loadScriptsInReply(reply, function() {
// In a multi-zone update, the reply.content may be blank or missing.
reply.content && this.show(reply.content, insert);
// zones is an object of zone ids and zone content that will be present
// in a multi-zone update response.
Object.keys(reply.zones).each(function (zoneId) {
var manager = Tapestry.findZoneManagerForZone(zoneId);
if (manager) {
var zoneContent = reply.zones[zoneId];
manager.show(zoneContent, insert);
}
});
}.bind(this));
}

Tapestry.ZoneManager.prototype.show = function(content, insert) {
if (insert) {
this.updateElement.insert(content);
}
else {
this.updateElement.update(content);
}
var func = this.element.visible() ? this.updateFunc : this.showFunc;
func.call(this, this.element);
this.element.fire(Tapestry.ZONE_UPDATED_EVENT);
}

Both these functions have been copied from tapestry.js and then had an insert parameter added to them. They've been modified in a way that means they'll continue to work if called without the parameter but will add more content (by calling insert on the Prototype Element) if the insert parameter is supplied. Of course, it's the show function that we're really interested in, but we need to override processReply because that's what we're calling from our onSuccess handler.

Monday, 9 November 2009

Controlling Ajax in Tapestry

The standard way to do Ajax in Tapestry is with Zones, EventLinks and occasionally Blocks. This can result in a very simple TML file:



Hello ${name}!

Hello World!




Hello Sam


And an equally simple Java class:
@InjectComponent
private Zone helloZone;

@Property
private String name;

Object onHelloEvent(String name) {
this.name = name;
return helloZone.getBody();
}

public boolean isNamePopulated() {
return (name != null) && (name.length() > 0);
}

If that's all you need then job done (and not a line of JavaScript in sight!)

However, I'm finding that real world requirements often require greater control over the implementation details - typically this means we need to write our own JavaScript. Here's how we do it ...

First of all we modify the TML file to use a standard anchor in place of the EventLink:

Hello Sam


Now we need to tell JavaScript what to do with that link. But before we can start writing our JavaScript, we add the following to our Java class:
@Environmental
private RenderSupport renderSupport;

@Inject
private ComponentResources resources;

@Property
private String clientId;

void setupRender() {
clientId = renderSupport.allocateClientId(resources);
}

void afterRender() {
Link helloLink = resources.createEventLink("helloEvent", "Sam");
JSONArray parameters = new JSONArray();
parameters.put(clientId);
parameters.put(helloZone.getClientId());
parameters.put(helloLink.toAbsoluteURI());
renderSupport.addInit("initHello", parameters);
}

This makes use of two methods in Tapestry's rendering lifecycle - setupRender + afterRender. In setupRender we're getting unique client ID for this component. In afterRender we're passing some information to a JavaScript function via Tapestry's JSONArray object - note that one of these parameters is the URI of an EventLink we've created. For this to work, we also need to annotate the class:
@IncludeJavaScriptLibrary("myZone.js")

Finally we get to the JavaScript. What we need is a function that gets called when the page loads and accepts the three parameters we're passing in from the Java class. Here's what it looks like:
Tapestry.Initializer.initHello = function(element, zoneId, url) {
element = $(element);
$T(element).zoneId = zoneId;
element.observe("click", function(event) {
Event.stop(event);
var zoneObject = Tapestry.findZoneManager(element);
if (!zoneObject) return;
zoneObject.updateFromURL(url);
});
}

In fact, all that JavaScript was copied from the linkZone function in tapestry.js - we're doing exactly what Tapestry was doing, but now the JavaScript is under our control. :-)

So now we're in control of the JavaScript, what can we do with it? Pretty much anything we like as it turns out, but for now, let's pass the name in as a standard request parameter. First of all we modify our event handler to look like this:
@Inject
private Request request;

Object onHelloEvent() {
this.name = request.getParameter("name");
return helloZone.getBody();
}

And then we replace zoneObject.updateFromURL with a standard Prototype Ajax request:
// zoneObject.updateFromURL(url);
new Ajax.Request(url, {
method: 'get',
parameters: { "name" : "Sam" },
onException: Tapestry.ajaxFailureHandler,
onFailure: Tapestry.ajaxFailureHandler,
onSuccess : function (transport) {
zoneObject.processReply(transport.responseJSON);
}
});

Note that we are now using GET instead of POST and we have also exposed the onSuccess handler. This can prove very useful when we start implementing more advanced requirements.