Ajax - Gallery Codex
Personal tools

Ajax

From Gallery Codex

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

Namespace

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};


Update Function

Write a client-side function to update the template

By collecting code in a function, it can be called to update the template both:

  1. Whenever an Ajax response is received
  2. By other templates which update the page - eg. the slideshow template calls "RotateBlock.update" when the slide changes

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});


Submit Function

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:

  1. Prototype's Form.serialize by default serializes all submit buttons, so Form.Element.serialize must be used to serialize only the target of the click event
  2. The form's controller will by default delegate or redirect to a traditional view. Instead, the Ajax response must contain variables in JSON format, so the form's controller must delegate to a callback view, or the Ajax request must be sent to a controller which does - http://sourceforge.net/tracker/index.php?func=detail&aid=1484914&group_id=7130&atid=357130
Currently the best way is to substitute a controller which delegates to a callback view when serializing the form's controller input
  1. The submit function must return false to prevent submit buttons from triggering traditional requests as well as Ajax requests


{* 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

Server Controller

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);
    }
}


Server View

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:

  1. The traditional view
  2. The callback view
  3. Another template

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);
        }
    }
}


The Future

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