Ajax is a web technology for updating pages without reloading them by using JavaScript to make out-of-band requests - http://en.wikipedia.org/wiki/Ajax_%28programming%29
Using Ajax, Gallery clients with JavaScript will enjoy a more responsive interface by doing minor page updates on the client-side
There are already examples of JavaScript & Ajax in Gallery. I followed this pattern to add Ajax to view & block templates using the JSON PHP & Prototype JavaScript libraries
Both libraries are distributed with Gallery
JSON is convenient because variables can be passed to the client-side both directly through the template using:
var slides = {$Slideshow.slides|@json};
- or through out-of-band Ajax requests:
{* Register template's update function with Ajax.Responders *} Ajax.Responders.register({ldelim}onComplete: function(request, transport, json) {ldelim} if (json != null) {ldelim} RotateBlock.update(json.RotateBlock); {rdelim} {rdelim}{rdelim});
Prototype offers a layer of platform abstraction, so client-side code works on Microsoft & Mozilla clients. It also offers some convenient syntax for common statements - http://www.sitepoint.com/article/painless-javascript-prototype
The first choice is to use the same namespace for server-side template variables & client-side functions & variables
Using a namespace avoids conflicts between client-side functions & variables of two templates included in the same page - just like it avoids conflicts between server-side template variables of two templates included in the same page
Using the same namespace makes understanding client- & server-side data-structures a bit easier - eg. it is easier to remember the name of the client-side variable "RotateBlock.item.id" if it matches the server-side variable "{$RotateBlock.item.id}"
Using the template's name as its namespace is predictable - eg. for "RotateBlock.tpl":
{* Template's client-side variables & functions *} var RotateBlock = {ldelim} {* Submit template's form using Ajax *} submit: function(event) {ldelim} [...] {rdelim}, {* Update template's dynamic elements *} update: function(_RotateBlock) {ldelim} [...] {rdelim} {rdelim};
Write a client-side function to update the template
By collecting code in a function, it can be called to update the template both:
The update function takes one argument: An instance of the namespace containing variables with which to update the template
Writing the update function is basically a process of translating server-side template logic for elements which may get updated into client-side DOM code. Modify templates if necessary, so elements which are initially hidden are nonetheless sent to the client, so they can potentially get displayed later, when the template is updated - eg.
{if !empty($status.warning)} <div class="giWarning"> {foreach from=$status.warning item=warning} {$warning} {/foreach} </div> {/if}
- Becomes:
<div class="giWarning" id="ItemEdit_warning"{if empty($status.warning)} style="display: none"{/if}> {foreach from=$status.warning item=warning} {$warning} {/foreach} </div>
The 'id="ItemEdit_warning"' attribute in this example is also needed by the update function:
[...] Element.hide("ItemEdit_warning"); if (json.status != null && json.status.warning != null) {ldelim} $("ItemEdit_warning").innerHTML = ""; json.status.warning.each(function(warning) {ldelim} $("ItemEdit_warning").innerHTML += json.status.warning; {rdelim}); Element.show("ItemEdit_warning"); {rdelim} [...]
Finally, register the update function with Ajax.Responders so it is called whenever an Ajax response is received - no matter which template made the Ajax request:
{* Register template's update function with Ajax.Responders *} Ajax.Responders.register({ldelim}onComplete: function(request, transport, json) {ldelim} if (json != null) {ldelim} RotateBlock.update(json.RotateBlock); {rdelim} {rdelim}{rdelim});
If the template defines a form which can be handled with Ajax, write a client-side function to submit the form
The submit function takes one argument: The event which triggered the form's submission. This is used to serialize only the submit button which was clicked
In addition to sending an Ajax request, the submit function updates the template or calls other template's update functions to give immediate feedback on the requested action - eg. the rotate button rotates thumbnails on the client-side before the Ajax response is received
Prototype makes building an Ajax request from a form easy. There are only three caveats:
{* Submit template's form using Ajax *} submit: function(event) {ldelim} Form.disable("itemAdminForm"); {* Give immediate feedback if possible *} if (Event.element(event).name == "{g->formVar var="form[action][rotate][counterClockwise]"}") {ldelim} $("ItemAdmin_thumbnail").style.filter = "progid:DXImageTransform.Microsoft.BasicImage(rotation=3)"; {rdelim} else if (Event.element(event).name == "{g->formVar var="form[action][rotate][flip]"}") {ldelim} $("ItemAdmin_thumbnail").style.filter = "progid:DXImageTransform.Microsoft.BasicImage(rotation=2)"; {rdelim} else if (Event.element(event).name == "{g->formVar var="form[action][rotate][clockwise]"}") {ldelim} $("ItemAdmin_thumbnail").style.filter = "progid:DXImageTransform.Microsoft.BasicImage(rotation=1)"; {rdelim} var params = []; Form.getElements("itemAdminForm").each(function(element) {ldelim} {* Don't serialize submit elements which weren't clicked *} if (element.type.toLowerCase() == "submit" && element != Event.element(event)) {ldelim} return; {rdelim} {* Serialize input elements *} var param = Form.Element.serialize(element); {* Set controller request variable to callback controller *} if (element.name == "{g->formVar var="controller"}") {ldelim} param = "{g->formVar var="controller"}={$callbackController}"; {rdelim} {* Ignore empty parameters *} if (param) {ldelim} params.push(param); {rdelim} {rdelim}); new Ajax.Request("{g->url}", {ldelim}method: "post", parameters: params.join("&"), onComplete: function(transport, json) {ldelim} if (json != null) {ldelim} if (json.redirect != null) {ldelim} document.location = json.redirect; throw $break; {rdelim} if (json.delegate != null) {ldelim} document.open(); document.write(transport.responseText); document.close(); throw $break; {rdelim} {* * IE chokes on this * delete $("ItemAdmin_thumbnail").style.filter; *} $("ItemAdmin_thumbnail").style.filter = ; {rdelim} Form.enable("itemAdminForm"); {rdelim}{rdelim}); return false; {rdelim},
Prototype provides Event.element(event) to get either event.target or event.srcElement, depending on the client
As a rule, callback views redirect the client to a new URL by responding with a JSON variable "redirect" whose value is the new URL, without HTML entities
Callback views can also instruct the client to replace the current page contents with the body of the Ajax response by responding with a JSON variable "delegate" whose value is true. This is especially handy for displaying the error landing without storing the GalleryStatus object in the session & redirecting to the error page
However, because document.write is used to replace the page contents, it doesn't behave exactly like redirecting to the error page. Another alternative is to set document.location to a data: + transport.responseText URL, but data: URLs are only supported by Mozilla, Opera, & Konqueror/Safari
In the end, storing the error in the session & redirecting to the error page may be best
Finally, define onclick functions for buttons which trigger Ajax requests. The same form can include both buttons which trigger traditional requests & buttons which trigger Ajax requests. If the client doesn't support Ajax, a traditional request will be triggered
<input class="inputTypeSubmit" type="submit" name="{g->formVar var="form[action][rotate][counterClockwise]"}" value="{g->text text="CC 90°"}" onclick="return ItemAdmin.submit.bindAsEventListener(ItemAdmin)(event)"/>
Return the submit function's return value to avoid triggering traditional requests as well as Ajax requests
Use the Prototype bindAsEventListener function to pass the event which triggered the form's submission to the submit function on both Microsoft & Mozilla browsers
What's left to adding Ajax is on the server-side: A controller to perform Ajax-requested actions & a callback view to respond with variables in JSON format
The controller is easy: It is the same as the controller which responds to traditionally-requested actions, except it delegates to a callback view instead of a traditional view
Until Gallery accepts a "delegate" request variable which instructs controllers which view to delegate to, it is easiest to extend the traditional controller & delegate to a callback view:
class ItemEditCallbackController extends ItemEditController { /** * @see GalleryController::handleRequest */ function handleRequest($form) { list ($ret, $results) = parent::handleRequest($form); if ($ret) { $jsonService = new Services_JSON(); header('X-JSON: ' . $jsonService->encode(array('delegate' => true))); return array($ret->wrap(__FILE__, __LINE__), null); } unset($results['redirect']); $results['delegate'] = array('view' => 'core.ItemAdminCallback', 'subView' => 'core.ItemEdit'); return array(null, $results); } }
Traditional views respond by setting templates' variables in GalleryView::loadTemplate. Set template variables in the template's namespace:
$ItemAdmin['navigationLinks'] = $navigationLinks; $ItemAdmin['viewBodyFile'] = $results['body']; $ItemAdmin['item'] = (array)$item; $ItemAdmin['parents'] = $parents; $ItemAdmin['parent'] = empty($parents) ? null : $parents[sizeof($parents) - 1]; $ItemAdmin['itemType'] = $itemType; $ItemAdmin['isRootAlbum'] = $item->getId() == $rootAlbumId; $ItemAdmin['thumbnail'] = $thumbnailData; $ItemAdmin['subViewChoices'] = $subViewChoices; $ItemAdmin['viewL10Domain'] = $subView->getL10Domain(); $ItemAdmin['isSiteAdmin'] = $isSiteAdmin; if (!isset($ItemAdmin['enctype'])) { $ItemAdmin['enctype'] = 'application/x-www-form-urlencoded'; } $template->setVariable('ItemAdmin', $ItemAdmin);
GalleryView::loadTemplate isn't used for block templates. Instead, other templates set the block's variables. Blocks can use callbacks to override variables or set variables not already set by other templates:
class SlideshowCallbacks { function callback($params, &$smarty, $callback, $userId) { switch ($callback) { case 'RotateBlock': $RotateBlock =& $smarty->_tpl_vars['RotateBlock']; $theme = $smarty->_tpl_vars['theme']; if (empty($RotateBlock['item']['id'])) { $RotateBlock['item']['id'] = $theme['item']['id']; } if (empty($RotateBlock['item']['serialNumber'])) { $RotateBlock['item']['id'] = $theme['item']['serialNumber']; } if (empty($RotateBlock['item']['mimeType'])) { $RotateBlock['item']['mimeType'] = $theme['item']['mimeType']; } if (empty($RotateBlock['enctype'])) { $RotateBlock['enctype'] = 'application/x-www-form-urlencoded'; } if (empty($RotateBlock['controller'])) { $RotateBlock['controller'] = 'core.ItemEdit'; } if (empty($RotateBlock['callbackController'])) { $RotateBlock['controller'] = 'core.ItemEditCallback'; } if (empty($RotateBlock['editPlugin'])) { $RotateBlock['editPlugin'] = 'ItemEditRotateAndScalePhoto'; } $ret = RotateBlockHelper::build($RotateBlock); if ($ret) { return $ret->wrap(__FILE__, __LINE__); } return; } return GalleryCoreApi::error(ERROR_BAD_PARAMETER, __FILE__, __LINE__); } }
A callback view can be created simply be extending a traditional view & responding with those template variables used by the client-side update function in JSON format:
class ItemAdminCallbackView extends ItemAdminView { /** * @see GalleryView::isImmediate */ function isImmediate() { return true; } /** * @see GalleryView::renderImmediate */ function renderImmediate($status, $error) { global $gallery; $urlGenerator =& $gallery->getUrlGenerator(); $jsonService = new Services_JSON(); $template = new GalleryTemplate(dirname(__FILE__) . '/../..'); list ($ret, $results, $theme) = $this->doLoadTemplate($template); if ($ret) { header('X-JSON: ' . $jsonService->encode(array('delegate' => true))); return $ret->wrap(__FILE__, __LINE__); } if (!empty($results['redirect'])) { header('X-JSON: ' . $jsonService->encode(array( 'redirect' => $urlGenerator->generateUrl($results['redirect'], array('htmlEntities' => false))))); } $json = array(); $ItemAdmin = $template->getVariable('ItemAdmin'); $json['ItemAdmin']['thumbnail']['src'] = str_replace('&', '&', $ItemAdmin['thumbnail']['src']); $json['ItemAdmin']['thumbnail']['width'] = $ItemAdmin['thumbnail']['width']; $json['ItemAdmin']['thumbnail']['height'] = $ItemAdmin['thumbnail']['height']; header('X-JSON: ' . $jsonService->encode($json)); } }
A better solution is to factor the code which builds both template variables & Ajax variables into a build function
The build function takes one argument: A reference to the namespace in which to build variables
The namespace may already contain some variables, set either by:
The build function is called either by the traditional view or block callback, or by the callback view, to set variables common to both traditional & callback views:
/** * Build parts of RotateBlock data-structure used by RotateBlock.update * JavaScript * * RotateBlock.item.id, RotateBlock.item.serialNumber, & * RotateBlock.item.mimeType must already be set */ function build(&$RotateBlock) { if (!isset($RotateBlock['editPhoto']['can']['rotate'])) { list ($ret, $permissions) = GalleryCoreApi::getPermissions( $RotateBlock['item']['id']); if ($ret) { return $ret->wrap(__FILE__, __LINE__); } if (isset($permissions['core.edit'])) { list ($ret, $preferreds) = GalleryCoreApi::fetchPreferredsByItemIds(array($RotateBlock['item']['id'])); if ($ret) { return $ret->wrap(__FILE__, __LINE__); } foreach (array_unique(array($RotateBlock['item']['mimeType'], $preferreds[$RotateBlock['item']['id']])) as $mimeType) { list ($ret, $toolkit) = GalleryCoreApi::getToolkitByOperation($mimeType, 'rotate'); if ($ret) { return $ret->wrap(__FILE__, __LINE__); } if (isset($toolkit)) { break; } } $RotateBlock['editPhoto']['can']['rotate'] = isset($toolkit); } } }
Eventually, it might be possible to flag variables in templates to be returned in Ajax responses
By extending Smarty_Compiler & overriding _compile_file, _compile_tag, & _compile_custom_tag, templates can be compiled to PHP code which responds with flagged variables in JSON format
This will eliminate the need to create separate traditional & callback views - the same template will get compiled as appropriate for the request
On the other hand, I haven't yet thought of an elegant syntax to flag variables in templates. Also, there could be significant performance savings in manually building only those variables needed for Ajax responses