Introduction to MVC programming with Agavi, Part 5: Add paging, file uploads, and custom input validators to your Agavi application

Learn to build scalable Web applications with the Agavi framework

This is the final article in a five-part series written for the PHP developer interested in learning about an open-source, flexible, and scalable framework called Agavi. You'll learn to support file uploads, store user data in sessions, integrate third-party libraries and create custom input validators for your Agavi application.

Share:

Vikram Vaswani, Founder, Melonfire

Photo of Vikram VaswaniVikram Vaswani is the founder and CEO of Melonfire, a consulting services firm with special expertise in open-source tools and technologies. He is also the author of the books PHP Programming Solutions and PHP: A Beginners Guide.



27 October 2009 (First published 15 September 2009)

Also available in Russian Japanese Vietnamese Portuguese

Introduction

At the end of Part 4 in this series, you had a fully functional Web application, complete with administration module, search engine, and XML output capabilities. Now you're wondering why you're back here, especially since the basic requirements for the Web Automobiles Sales Platform (WASP) application are fulfilled.

In this final article, I'll cover some additional techniques and concepts that you're sure to need when you build Web applications. These range from the pedestrian, such as paging and sorting database records, to the more complex, such as supporting file uploads through Web forms and writing custom input validators. In all of these cases, the Agavi framework offers built-in tools to make your work simpler, quicker, and more secure. Come on in, and find out how!


Sorting database records

First up is paging and sorting result sets. Figure 1 illustrates how result sets are currently displayed in the administration module's summary page at http://wasp.localhost/admin/listing/index.

Figure 1. The WASP listing summary page
Screen capture of the WASP listing summary page with details for eight vehicles

Now, add some functionality so users can sort these objects to display according to different criteria. First, edit the AdminIndexSuccess template, and attach sorting links to each table heading, as in Listing 1.

Listing 1. The Listing/AdminIndexSuccess template
<h3>View All Listings</h3>
<?php if (count($t['records']) == 0): ?>
No records available
<?php else: ?>

<div id="list">
  <form action="<?php echo $ro->gen('admin.listing.delete'); ?>" method="post" >
  <table cellspacing="5">
    <tr>
      <td class="key"></td>
      <td class="key"></td>
      <td class="key">Submission Date 
        <a href="<?php echo $ro->gen('admin.listing.index', 
         array('s' => 'RecordDate', 'd' => 'asc')); ?>">&uArr;</a> 
        <a href="<?php echo $ro->gen('admin.listing.index', 
         array('s' => 'RecordDate', 'd' => 'desc')); ?>">&dArr;</a>
      </td>
      <td class="key">Manufacturer
        <a href="<?php echo $ro->gen('admin.listing.index',
         array('s' => 'VehicleManufacturerID', 'd' => 'asc')); 
         ?>">&uArr;</a> 
        <a href="<?php echo $ro->gen('admin.listing.index',
         array('s' => 'VehicleManufacturerID', 'd' => 'desc')); 
         ?>">&dArr;</a>
      </td>
      <td class="key">Model
        <a href="<?php echo $ro->gen('admin.listing.index',
         array('s' => 'VehicleModel', 'd' => 'asc')); 
         ?>">&uArr;</a> 
        <a href="<?php echo $ro->gen('admin.listing.index',
         array('s' => 'VehicleModel', 'd' => 'desc')); 
         ?>">&dArr;</a>
      </td>
      <td class="key">Year
        <a href="<?php echo $ro->gen('admin.listing.index',
         array('s' => 'VehicleYear', 'd' => 'asc')); ?>">&uArr;</a> 
        <a href="<?php echo $ro->gen('admin.listing.index',
         array('s' => 'VehicleYear', 'd' => 'desc')); 
         ?>">&dArr;</a>
      </td>
      <td class="key">Mileage
        <a href="<?php echo $ro->gen('admin.listing.index',
         array('s' => 'VehicleMileage', 'd' => 'asc')); 
         ?>">&uArr;</a> 
        <a href="<?php echo $ro->gen('admin.listing.index',
         array('s' => 'VehicleMileage', 'd' => 'desc')); 
         ?>">&dArr;</a>
      </td>
      <td class="key">Color
        <a href="<?php echo $ro->gen('admin.listing.index',
         array('s' => 'VehicleColor', 'd' => 'asc')); 
         ?>">&uArr;</a> 
        <a href="<?php echo $ro->gen('admin.listing.index',
         array('s' => 'VehicleColor', 'd' => 'desc')); 
         ?>">&dArr;</a>
      </td>
      <td class="key"></td>
    </tr>  
    <?php foreach ($t['records'] as $record): ?>
    <tr>
      <td><input type="checkbox" name="id[]"
       value="<?php echo $record['RecordID']; ?>" style="width:2px" />
      </td>
      <td><?php echo $record['RecordID']; ?></td>
      <td><?php echo date('d M Y', strtotime($record['RecordDate'])); ?>
      </td>
      <td><?php echo $record['Manufacturer']['ManufacturerName']; ?>
      </td>
      <td><?php echo $record['VehicleModel']; ?></td>
      <td><?php echo $record['VehicleYear']; ?></td>
      <td><?php echo $record['VehicleMileage']; ?></td>
      <td><?php echo $record['VehicleColor']; ?></td>
      <td><a href="<?php echo $ro->gen('admin.listing.edit',
       array('id' => $record['RecordID'])); ?>">Edit</a></td>
    </tr>  
    <?php endforeach; ?>
    <tr>
      <td colspan="9"><input type="submit" name="submit" 
       style="width:150px" value="Delete Selected" /></td>
    </tr>    
  </table>  
  </form>
</div>
<?php endif; ?>

Frequently used acronyms

  • API: Application program interface
  • CSS: Cascading stylesheet
  • HTML: Hypertext Markup Language
  • MVC: Model-View-Controller
  • OOP: Object-oriented programming
  • ORM: Object-Relational Mapping
  • SQL: Structured Query Language
  • URL: Uniform Resource Locator
  • XML: Extensible Markup Language

As Listing 1 illustrates, each link has two parameters:

  • s indicates the field to sort against
  • d indicates the direction of the sort (ascending or descending)

Next, edit the AdminIndexAction validator to support these two parameters (Listing 2). Notice that Listing 2 uses the AgaviInArrayValidator to restrict the list of allowed values for each parameter.

Listing 2. The Listing/AdminIndexAction validator
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations
  xmlns="http://agavi.org/agavi/config/parts/validators/1.0"
  xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
  parent="%core.module_dir%/Listing/config/validators.xml"
>
  <ae:configuration>
    
    <validators method="read">
      <validator class="inarray">
        <arguments>
          <argument>s</argument>
        </arguments>
        <errors>
          <error>ERROR: Invalid sort field</error>
        </errors>
        <ae:parameters>
          <ae:parameter name="required">false</ae:parameter>
          <ae:parameter name="values">RecordID,RecordDate,
           VehicleManufacturerID,VehicleModel,
           VehicleColor,VehicleYear,VehicleMileage</ae:parameter>
          <ae:parameter name="sep">,</ae:parameter>
        </ae:parameters>
      </validator>    
      
      <validator class="inarray">
        <arguments>
          <argument>d</argument>
        </arguments>
        <errors>
          <error>ERROR: Invalid sort direction</error>
        </errors>
        <ae:parameters>
          <ae:parameter name="required">false</ae:parameter>
          <ae:parameter name="values">asc,desc</ae:parameter>
          <ae:parameter name="sep">,</ae:parameter>
        </ae:parameters>
      </validator>                      
    </validators>

  </ae:configuration>
</ae:configurations>

Then, edit the AdminIndexAction's executeRead() method to read these two parameters and modify the Doctrine query with an additional ORDER BY clause, as in Listing 3.

Listing 3. The Listing/AdminIndexAction definition
<?php
class Listing_AdminIndexAction extends WASPListingBaseAction
{
  public function getDefaultViewName()
  {
    return 'Success';
  }
  
  public function executeRead(AgaviRequestDataHolder $rd) 
  { 
    try {
      // get input variables
      $id = $rd->getParameter('id');
      $sort = $rd->isParameterValueEmpty('s') ? 
       'RecordID' : $rd->getParameter('s');
      $dir = $rd->isParameterValueEmpty('d') ? 
       'asc' : $rd->getParameter('d');

      // create query
      $q = Doctrine_Query::create()
            ->from('Listing l')
            ->leftJoin('l.Manufacturer m')
            ->leftJoin('l.Country c')
            ->orderBy(sprintf('%s %s', $sort, $dir));
      $result = $q->fetchArray();
      
      // set view variables
      $this->setAttribute('records', $result);
      return 'Success';
    } catch (Exception $e) {
      $this->setAttribute('error', $e->getMessage());  
      return 'Error';
    }
  }
  
  final public function isSecure()
  {
    return true;
  }       
}
?>

And now, when you re-visit the summary page at http://wasp.localhost/admin/listing/index, you'll see sort links displayed next to each table heading. Selecting one of these links re-sorts the result set by the specified field and direction. Figure 2 shows an example.

Figure 2. The WASP listing summary page with sorting functions added
Screen capture of the WASP listing summary page with sorting functions added to table heading

Paging database records

Another common requirement when you work with large result sets is to display the data in pages, both to reduce the load on the database server (it generates a smaller set of results) and to make the data more manageable for the user (who views information in smaller chunks). Unlike some other frameworks, Agavi doesn't come with built-in objects for paging. Nevertheless you can easily implement this functionality with Doctrine.

Doctrine comes with a Doctrine_Pager object, which serves as command central for any operation that involves paging database records. It supports the two most common types of database result set paging (sliding and jumping), and also allows extensive customization of the format and display of page numbers and links. A full examination of these topics is not possible here; see Resources for a link to the relevant Doctrine manual pages.

The first step is to update the AdminIndexAction validator to accept an additional page parameter, which I call p. Listing 4 has the additional code:

Listing 4. The Listing/AdminIndexAction validator
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations
  xmlns="http://agavi.org/agavi/config/parts/validators/1.0"
  xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
  parent="%core.module_dir%/Listing/config/validators.xml"
>
  <ae:configuration>
    
    <validators method="read">
      ...

      <validator class="number">
        <arguments>
          <argument>p</argument>
        </arguments>
        <errors>
          <error>ERROR: Invalid page number</error>
        </errors>
        <ae:parameters>
          <ae:parameter name="type">int</ae:parameter>
          <ae:parameter name="required">false</ae:parameter>
          <ae:parameter name="min">1</ae:parameter>
        </ae:parameters>
      </validator>                        
    </validators>
    
  </ae:configuration>
</ae:configurations>

You also need to update the AdminIndexAction to take note of this additional parameter. Listing 5 has the revised code.

Listing 5. The Listing/AdminIndexAction definition
<?php
class Listing_AdminIndexAction extends WASPListingBaseAction
{
  public function getDefaultViewName()
  {
    return 'Success';
  }
  
  public function executeRead(AgaviRequestDataHolder $rd) 
  { 
    try {
      // get input variables
      $id = $rd->getParameter('id');
      $sort = $rd->isParameterValueEmpty('s') 
       ? 'RecordID' : $rd->getParameter('s');
      $dir = $rd->isParameterValueEmpty('d') 
       ? 'asc' : $rd->getParameter('d');
      $page = $rd->getParameter('p');      

      // create query
      $q = Doctrine_Query::create()
            ->from('Listing l')
            ->leftJoin('l.Manufacturer m')
            ->leftJoin('l.Country c')
            ->orderBy(sprintf('%s %s', $sort, $dir));
            
      // set pager parameters
      $perPage = 5;
      $numPageLinks = 5;      
      
      // initialize pager
      $pager = new Doctrine_Pager($q, $page, $perPage);
      // execute paged query
      $result = $pager->execute(array(), Doctrine::HYDRATE_ARRAY);            
       
      // initialize pager layout
      $pagerRange = new Doctrine_Pager_Range_Sliding(
       array('chunk' => $numPageLinks), $pager
      );
      $pagerUrlBase = $this->getContext()->getRouting()->gen(
       'admin.listing.index', array('s' => $sort, 'd' => $dir)
      );
      $pagerLayout = new Doctrine_Pager_Layout($pager, $pagerRange, $pagerUrlBase);
      
      // set page link display template
      $pagerLayout->setTemplate(
       '<a href="{%url}&p={%page}">{%page}</a>'
      );
      $pagerLayout->setSelectedTemplate('{%page}');      
      $pagerLayout->setSeparatorTemplate('&nbsp;');
      
      // set view variables
      $this->setAttribute('records', $result);
      $this->setAttribute('pages', $pagerLayout->display(null, true));
      return 'Success';
    } catch (Exception $e) {
      $this->setAttribute('error', $e->getMessage());  
      return 'Error';
    }
  }
  
  final public function isSecure()
  {
    return true;
  }       
}
?>

Listing 5 contains a number of new elements, so an explanation is in order. Listing 5 initializes a Doctrine_Pager object and passes the object constructor three key parameters: the SQL query to execute, the current page number (as retrieved from the input variable p), and the number of results to display per page (assumed as 5). The Doctrine_Pager object then executes the query with the appropriate LIMIT clause and retrieves only the required subset of records.

That's not all though—you'll also display a list of page numbers that allow users to jump back and forth in the result set. This is accomplished through the Doctrine_Pager_Layout object, which you can use to define the paging mode, the base URL for each page link, and the number of page links to display. It's also possible to precisely control how page numbers are formatted, separated from each other and displayed in the final layout, by using the Doctrine_Pager_Layout object's setTemplate() methods. Once all this configuration is complete, a call to the Doctrine_Pager_Layout object's display() method generates the HTML code for the necessary page links, which is then assigned to the template variable $t['pages'].

All that's left is to update the AdminIndexSuccess template and interpolate this HTML code into it (Listing 6).

Listing 6. The Listing/AdminIndexSuccess template
<h3>View All Listings</h3>
<?php if (count($t['records']) == 0): ?>
No records available
<?php else: ?>

<div class="pager">
  Pages: <?php echo $t['pages']; ?>
</div>

<div id="list">
...
</div>
<?php endif; ?>

<div class="pager">
  Pages: <?php echo $t['pages']; ?>
</div>

Figure 3 displays an example of the result.

Figure 3. The WASP listing summary, divided into pages
Screen capture of the WASP listing summary, divided into two pages

Storing data in sessions

Another (relatively minor) improvement you can make to the administration module is to selectively display certain links (such as a "Log out" link) only if the user has already logged in. This is fairly simple to do; you just write a conditional test around the AgaviUser::isAuthenticated() method. Listing 7 illustrates the modification needed to the AdminMaster template.

Listing 7. The AdminMaster template
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" 
 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
  <head>
  ...
  </head>
  <body>

    <!-- begin header -->
    <div id="header">
      <div id="logo">
        <img src="/images/logo-admin.jpg" />
      </div>
      <div id="menu">
      <?php if($this->getContext()->getUser()->isAuthenticated()): ?>
      <ul>
        <li><a href="<?php echo $ro->gen('admin.listing.index'); ?>"
         >Listings</a></li>
        <li><a href="<?php echo $ro->gen('admin.logout'); ?>"
         >Log Out</a></li>
      </ul>
      <?php endif; ?>
      </div>
    </div>
    <!-- end header -->
    
    <!-- begin body -->
    ...
    <!-- end body -->
    
    <!-- begin footer -->
    ...
    <!-- end footer -->
  </body>
</html>

If you want to store user data in the session, you can do this with the AgaviUser::setAttribute() method. Listing 8 illustrates how to modify the LoginAction to store the username of the currently logged-in user.

Listing 8. The Default/LoginAction definition
<?php
class Default_LoginAction extends WASPDefaultBaseAction
{

  public function getDefaultViewName()
  {
    return 'Input';
  }
  
  public function executeWrite(AgaviRequestDataHolder $rd)
  {
    try {
      // get input parameters
      $u = $rd->getParameter('username');
      $p = $rd->getParameter('password');
      
      // check user credentials
      $q = Doctrine_Query::create()
            ->from('User u')
            ->where('u.Username = ? AND u.Password = PASSWORD(?)', array($u,$p));
      $result = $q->fetchArray();
        
      // set authentication flag if valid
      if (count($result) == 1) {
        $this->getContext()->getUser()->setAuthenticated(true);
        $this->getContext()->getUser()->setAttribute('username', 
         $u, 'wasp.user.namespace'); 
        return 'Success';
      } else {
        return 'Error';
      }
    } catch (Exception $e) {
        return 'Error';        
    }
  }

}
?>

Listing 9 modifies the AdminMaster template to retrieve this data from the session and display it in the administration module.

Listing 9. The AdminMaster template
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" 
 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
  <head>
  ...
  </head>
  <body>
    <!-- begin header -->
    ...
    <!-- end header -->
    
    <!-- begin body -->
    <div id="body"> 
      <?php if($this->getContext()->getUser()->isAuthenticated()): ?>
      Logged in as: 
      <?php echo $this->getContext()->getUser()->getAttribute('username', 
       'wasp.user.namespace'); ?>
      <?php endif; ?>
      <?php echo $inner; ?>
    </div>
    <!-- end body -->
    
    <!-- begin footer -->
    ...
    <!-- end footer -->
  </body>
</html>

Figure 4 displays the result of these changes.

Figure 4. The WASP administration main menu, with some new links
Screen capture of the WASP administration main menu, with new Listings and Log Out links and username

Handling file uploads

So far, all vehicle data uploaded by sellers is saved to the database. But a picture is worth a thousand words, and allowing sellers to upload images of their vehicles is a nice addition to the WASP application. It's also not very difficult to do.

Begin by creating a directory to store uploaded images at $WASP_ROOT/pub/usr/. Ensure that this directory is writable by the Web server user.

shell> cd /usr/local/apache/htdocs/wasp/pub
shell> mkdir usr

Then, update the CreateInput template with additional file upload fields, as in Listing 10.

Listing 10. The Listing/CreateInput template
<script src="/js/form.js"></script>
<h3>Add Listing</h3>
<form action="<?php echo $ro->gen(null); ?>" method="post" 
 enctype="multipart/form-data">
  ...	
<fieldset>  
    <legend>Vehicle Images</legend>
    <label for="Images[1]">Image #1:</label>
    <input id="Images[1]" type="file" name="Images[1]" />
    <p/>
    <label for="Images[2]">Image #2:</label>
    <input id="Images[2]" type="file" name="Images[2]" />
    <p/>
    <label for="Images[3]">Image #3:</label>
    <input id="Images[3]" type="file" name="Images[3]" />
    <p/>
    <p class="note">
      <em>Images should be 200x125 px, <br/> JPEG or GIF format only.
      </em>
    </p>
  </fieldset>
	
  <input type="submit" name="submit" class="submit" value="Submit Listing" />
</form>

Notice that Listing 10 also adds an enctype attribute to the <form> element to support multi-part form submission.

Uploaded files are an important attack vector for Web applications, so it's important to validate all such uploads before you accept them for storage. The good news here is that Agavi comes with a full-featured AgaviImageFileValidator, which can verify that a particular input parameter is a valid image file. This validator can also confirm that the image corresponds to a particular format, width, and height.

Listing 11 illustrates updates the CreateAction validator with a call to the AgaviImageFileValidator.

Listing 11. The Listing/CreateAction validator
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations
  xmlns="http://agavi.org/agavi/config/parts/validators/1.0"
  xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
  parent="%core.module_dir%/Listing/config/validators.xml"
>
  <ae:configuration>
    
    <validators method="write">
      ...
      <validator class="imagefile">
        <arguments base="Images[]">
          <argument/>
        </arguments>
        <errors>
          <error for="no_image">ERROR: Uploaded file is not an image</error>
          <error for="format">ERROR: Image file format is invalid</error>
          <error>ERROR: Image size is incorrect</error>
        </errors>
        <ae:parameters>
          <ae:parameter name="required">false</ae:parameter>
          <ae:parameter name="format">gif jpeg</ae:parameter>
          <ae:parameter name="min_width">200</ae:parameter>
          <ae:parameter name="max_width">200</ae:parameter>
          <ae:parameter name="min_height">125</ae:parameter>
          <ae:parameter name="max_height">125</ae:parameter>
        </ae:parameters>
      </validator>
    </validators>
    
  </ae:configuration>
</ae:configurations>

It should be clear from Listing 11 that uploaded files will be accepted only if they are in either GIF or JPEG format, with a width of 200 pixels and a height of 125 pixels.

Once an uploaded file is accepted, your application renames and saves it to the designated directory $WASP_ROOT/pub/usr/. Listing 12 revises CreateAction to perform these tasks.

Listing 12. The Listing/CreateAction definition
<?php
class Listing_CreateAction extends WASPListingBaseAction
{
  public function getDefaultViewName()
  {
    return 'Input';
  }
  
  public function executeWrite(AgaviRequestDataHolder $rd)
  {    
    try {
      // initialize object
      $listing = new Listing();
      
      // populate with validated input
      $listing->fromArray($rd->getParameters());      
      $listing->VehicleAccessoryBit = 
       array_sum($rd->getParameter('VehicleAccessoryBit'));

      // set some values manually
      $listing->RecordDate = date('Y-m-d', mktime());
      $listing->DisplayStatus = 0;
      
      // save record and get record ID
      $listing->save();
      $id = $listing->RecordID;   
      
      // rename and move uploaded images
      $target = AgaviConfig::get('core.app_dir') . '/../pub/usr/';
      $x = 1;
      foreach ($rd->getFile('Images') as $file) {
        switch ($file->getType()) {
          case 'image/jpeg':
          case 'image/pjpeg':
            $name = sprintf('%d_%d', $id, $x) . '.jpg';
            $file->move("$target/$name");
            break;
          case 'image/gif':
            $name = sprintf('%d_%d', $id, $x) . '.gif';
            $file->move("$target/$name");
            break;
        }
        $x++;
      }  
               
      return 'Success';
    } catch (Exception $e) {    
      $this->setAttribute('error', $e->getMessage());  
      return 'Error';
    }
  }
}
?>

The AgaviRequestDataHolder offers a getFile() method that makes it easy to retrieve uploaded files as AgaviUploadedFile objects. These objects then expose convenience methods such as getType(), getSize() and getName(), as well as a move() method that moves the file to a new location.

Handling file uploads is only part of the picture, though. You also need to display the uploaded images in the corresponding listing's detail page. To do this, update the DisplaySuccess template with the code in Listing 13.

Listing 13. The Listing/DisplaySuccess template
<h3>
 FOR SALE: <?php printf('%d %s %s (%s)', $t['listing']['VehicleYear'], 
 $t['listing']['Manufacturer']['ManufacturerName'], 
 ucwords(strtolower($t['listing']['VehicleModel'])), 
 ucwords(strtolower($t['listing']['VehicleColor']))); ?>
</h3>

  <div id="container">    
    <div id="gallery">
      <?php $id = $t['listing']['RecordID']; ?>
      <?php $target = AgaviConfig::get('core.app_dir') . '/../pub/usr/'; ?>
      <?php foreach (glob($target.$id.'_*.{gif,jpg}', GLOB_BRACE) as $file): ?>
      <img width="200" height="125" src="/usr/<?php echo basename($file); ?>" 
       style="float:left"/>
      <p/>&nbsp;<p/>
      <?php endforeach; ?>
    </div>
    <div id="specs">
      <table cellspacing="5">
      ...
      </table>
  </div>
</div>

Now, try it out by adding a new listing. The form should now contain additional file upload fields, as shown in Figure 5.

Figure 5. The WASP listing form with support for image upload
Screen capture of the WASP listing form that supports upload of three images

Attempt to upload a non-image file, or an image that does not conform to the specified dimensions, and the AgaviImageFileValidator will throw an error and messages as in Figure 6.

Figure 6. Errors on invalid image uploads
Screen capture of errors caused by invalid image uploads

Following a successful upload, the listing detail page should now show the uploaded images in addition to other vehicle information. Figure 7 has an example.

Figure 7. The WASP listing detail page, with an image gallery
Screen capture of the WASP listing detail page, with an image gallery

To keep things clean, you should also update the AdminDeleteAction to remove associated images when a listing is deleted. Listing 14 has the code.

Listing 14. The Listing/AdminDeleteAction definition
<?php
class Listing_AdminDeleteAction extends WASPListingBaseAction
{
  public function getDefaultViewName()
  {
    return 'Success';
  }
  
  public function executeWrite(AgaviRequestDataHolder $rd)
  {
    try {
      // get record ids
      $ids = $rd->getParameter('id');
      
      foreach ($ids as $id) {
        // delete record from database
        $q = Doctrine_Query::create()
              ->delete('Listing')
              ->addWhere('RecordID = ?', $id);
        $result = $q->execute();       
         
        // delete associated image files
        $target = AgaviConfig::get('core.app_dir') . '/../pub/usr/';
        foreach (glob("$target/$id_*") as $file) {
          unlink($file);
        }        
      }
      
      return 'Success';     
    } catch (Exception $e) {
      $this->setAttribute('error', $e->getMessage());  
      return 'Error';
    }
  } 
  
  final public function isSecure()
  {
    return true;
  }   
}
?>

For completeness, you might also want to update the DisplaySuccessView's executeXml() method to include image information in its XML output. Listing 15 has the code:

Listing 15. The Listing/DisplaySuccessView definition
<?php
class Listing_DisplaySuccessView extends WASPListingBaseView
{
  public function executeHtml(AgaviRequestDataHolder $rd)
  {
    $this->setupHtml($rd);
  }
  
  public function executeXml(AgaviRequestDataHolder $rd)
  {
    // get record
    $record = $this->getAttribute('listing');
    
    // create document
    $dom = new DOMDocument('1.0', 'utf-8');

    // create root element
    $root = $dom->createElementNS('http://www.melonfire.com/agavi-wasp', 
     'wasp:result');    
    $dom->appendChild($root);
    
    // import to SimpleXML for easier manipulation
    $xml = simplexml_import_dom($dom);
    
    // add nodes to XML output
    $xml->addChild('id', $record['RecordID']);
    $xml->addChild('submissionDate', $record['RecordDate']);
    $xml->addChild('manufacturer', $record['Manufacturer']['ManufacturerName']);
    $xml->addChild('model', ucwords(strtolower($record['VehicleModel'])));
    $xml->addChild('year', $record['VehicleYear']);
    $xml->addChild('color', strtolower($record['VehicleColor']));
    $xml->addChild('mileage', $record['VehicleMileage']);
    $xml->addChild('singleOwner', $record['VehicleIsFirstOwned']);
    $xml->addChild('certified', $record['VehicleIsCertified']);
    $xml->addChild('certifiedDate', $record['VehicleCertificationDate']);
    $xml->addChild('note', $record['Note']);
    
    $accessoryArr = array(
      '1'  => 'Power steering', 
      '2'  => 'Power windows', 
      '4'  => 'Audio system', 
      '8'  => 'Video system', 
      '16' => 'Keyless entry system',
      '32' => 'GPS',
      '64' => 'Alloy wheels'
    );
    $accessories = $xml->addChild('accessories');
    foreach ($accessoryArr as $k => $v) {
      if ($record['VehicleAccessoryBit'] & $k) {
        $accessories->addChild('item', $v);
      }
    }
    
    $price = $xml->addChild('price');
    $price->addChild('min', $record['VehicleSalePriceMin']);
    $price->addChild('max', $record['VehicleSalePriceMax']);
    $price->addChild('negotiable', $record['VehicleSalePriceIsNegotiable']);
    
    $location = $xml->addChild('location');
    $location->addChild('city', $record['OwnerCity']);
    $location->addChild('country', $record['Country']['CountryName']);        
            
    // add image information if available
    $images = $xml->addChild('images');
    $id = $record['RecordID'];
    $target = AgaviConfig::get('core.app_dir') . '/../pub/usr/';
    foreach (glob($target.$id.'_*.{gif,jpg}', GLOB_BRACE) as $file) {
      $images->addChild('image', 
       $this->getContext()->getRouting()->getBaseHref() . 
       'usr/' . basename($file));  
    }
        
    // return output
    return $xml->asXML();       
  }  
}
?>

And Figure 8 has an example of the revised XML output.

Figure 8. An example of the revised XML output
An example of the revised XML output

Creating custom input validators

As previous articles have illustrated, Agavi comes with a wide range of input validators that make it very easy for developers to ensure that only clean input makes it into their Actions. These built-in validators are more than sufficient for run-of-the-mill validation tasks, such as checking e-mail addresses or dates. But what happens if you need to perform a validation check that isn't included by default? Write a custom validator, of course!

Recognizing that the validation requirements of each application are unique, Agavi makes it extremely simple to extend the base AgaviValidator class to create your own validators. These custom validators can then be invoked in the usual manner, through the XML validation file for each Action.

To illustrate the process of creating a custom validator, consider that in the CreateInput form, each seller is asked to enter a minimum and maximum expected price for his or her vehicle. Obviously, the minimum price should be lower than the maximum price. However, none of Agavi's built-in validators can perform this type of comparison and so, as things stand right now, it's possible for sellers to enter a minimum price that is higher than the maximum price (try it for yourself and see). To fix this loophole, you'll create a custom validator.

To begin, update the CreateAction validator with the following additional rule (Listing 16):

Listing 16. The Listing/CreateAction validator
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations
  xmlns="http://agavi.org/agavi/config/parts/validators/1.0"
  xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
  parent="%core.module_dir%/Listing/config/validators.xml"
>
  <ae:configuration>
    
    <validators method="write">
      ...
 
      <validator class="PriceRangeCustomValidator">
        <arguments>        
          <argument name="max">VehicleSalePriceMax</argument>
          <argument name="min">VehicleSalePriceMin</argument>
        </arguments>
        <errors>
          <error for="max_min_mismatch">ERROR: Vehicle maximum price 
           is lower than vehicle minimum price</error>
        </errors>
        <ae:parameters>
          <ae:parameter name="required">true</ae:parameter>
        </ae:parameters>
      </validator>               
    </validators>    
  </ae:configuration>
</ae:configurations>

This rule references a custom validator named, aptly, the PriceRangeCustomValidator. This validator is passed two arguments—the minimum price and the maximum price—and it also has a custom error message that it will generate in the event it catches the max_min_mismatch error.

Of course, the PriceRangeCustomValidator doesn't exist yet. To create it, open a new file at $WASP_ROOT/app/lib/validator/PriceRangeCustomValidator.class.php, and fill it with the code in Listing 17.

Listing 17. The Listing/PriceRangeCustomValidator definition
<?php
class PriceRangeCustomValidator extends AgaviValidator {

  protected function validate()
  {
    $args = $this->getArguments();
    $max = $this->getData($args['max']); 
    $min = $this->getData($args['min']);
    if ($min > $max)
    {
        $this->throwError('max_min_mismatch');
        return false;
    }        
    return true;
  }

}
?>

The core of any AgaviValidator is the validate() method, which performs validation and returns true (if the input is valid) or false (if the input is invalid). In Listing 17, the validate() method uses the getData() method to read the arguments passed to the validator, performs a simple comparison test, and returns the result to the caller. In the event the input fails the test, the validate() method throws the max_min_mismatch error, which triggers the AgaviFormPopulationFilter to display the corresponding error message to the requesting client.

You also need to auto-load this validator, by updating $WASP_ROOT/app/config/autoload.xml with an additional entry as in Listing 18.

Listing 18. The list of classes to auto-load
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns="http://agavi.org/agavi/config/parts/autoload/1.0" 
 xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0" 
 parent="%core.system_config_dir%/autoload.xml">
  <ae:configuration>
    
    <autoload name="WASPBaseAction">
    %core.lib_dir%/action/WASPBaseAction.class.php
    </autoload>
    <autoload name="WASPBaseModel">
    %core.lib_dir%/model/WASPBaseModel.class.php
    </autoload>
    <autoload name="WASPBaseView">
    %core.lib_dir%/view/WASPBaseView.class.php
    </autoload>
    <autoload name="Doctrine">
    %core.app_dir%/../libs/doctrine/Doctrine.php
    </autoload>
    <autoload name="PriceRangeCustomValidator">
    %core.lib_dir%/validator/PriceRangeCustomValidator.class.php
    </autoload>
    
  </ae:configuration>
</ae:configurations>

To see this in action, try creating a new listing and entering a minimum price that is higher than the maximum price. Figure 9 displays the result you will see.

Figure 9. The WASP price range validator in action
Screen capture of the WASP price range validator and error message for a minimum price that exceeds the maximum price

Adding an auto-complete function

In recent months, it's become common for Web applications to include auto-complete functionality, wherein the application automatically suggests matches for words based on fragments entered by the user.

Within the Add Listing form, a good candidate for this auto-complete functionality is the Model field. Why? Only a finite set of automobile model names exists. As the database of vehicle listings grows, the probability that a user might enter a model name that already exists in the database also increases. You can use this fact to save the user some keystrokes, and offer a list of matching model names as the user types and allow the use to choose from the list as needed.

Currently, a number of existing third-party libraries can quickly add this functionality to a Web application, including PEAR HTML_QuickForm, Dojo, and the Yahoo! User Interface (YUI) libraries. The YUI library is one of the most complete toolkits for this kind of client-side feature enhancement, so I'll go with that and use it to add auto-complete functionality to the Add Listing form.

To begin, download the YUI AutoComplete widget, consisting of both JavaScript and CSS source files (see the link in Resources). Create the directories $WASP_ROOT/pub/css/yui and $WASP_ROOT/pub/js/yui and copy these CSS and JavaScript files to these locations respectively. Then, edit the WASPListingBaseView:: setInputViewAttributes() base method that you created in Part 3 of this series and update it with an additional SQL query that generates a list of unique model names from the database (Listing 19).

Listing 19. The Listing/WASPListingBaseView definition
<?php
/**
 * The base view from which all Listing module views inherit.
 */
class WASPListingBaseView extends WASPBaseView
{
  // set values from selection lists
  function setInputViewAttributes() {
    $q = Doctrine_Query::create()
          ->from('Country c');    
    $this->setAttribute('countries', $q->fetchArray());
    
    $q = Doctrine_Query::create()
          ->from('Manufacturer m');
    $this->setAttribute('manufacturers', $q->fetchArray());
    
    $q = Doctrine_Query::create()
          ->select('DISTINCT l.VehicleModel AS VehicleModel')
          ->from('Listing l');
    $this->setAttribute('models', $q->fetchArray());           
  }  
}
?>

The results of the query are stored in the template variable $t['models'].

Listing 20 updates the CreateInput template and adds the necessary client-side code to enable YUI AutoComplete functionality.

Listing 20. The Listing/CreateInput template
<script src="/js/form.js"></script>
<h3>Add Listing</h3>
<form action="<?php echo $ro->gen(null); ?>" method="post">
    ...    
    <label for="VehicleModel" class="required">Model:</label>
    <input id="VehicleModel" type="text" name="VehicleModel">
      <div id="ac1" class="yui-skin-sam yui-ac-container" 
       style="position:relative; width:300px; margin-left:210px">
      </div> 
    </input>
    <p/>
    ...
</form>

<!-- YUI autocomplete widget -->
<!-- based on example at 
http://developer.yahoo.com/yui/examples/autocomplete/ac_basic_array.html 
-->
<script src="js/yui/yahoo-min.js"></script> 
<script src="js/yui/dom-min.js"></script> 
<script src="js/yui/event-min.js"></script> 
<script src="js/yui/datasource-min.js"></script> 
<script src="js/yui/autocomplete-min.js"></script> 
<script>        
arrayModels = [ 
<?php if (isset($t['models']) && count($t['models']) > 0): ?>
<?php foreach ($t['models'] as $m): ?>
<?php echo "\"" . $m['VehicleModel'] . "\",\r\n"; ?>
<?php endforeach; ?>
<?php endif; ?>
];       
YAHOO.example.BasicLocal = function() {
  // Use a LocalDataSource
  var oDS = new YAHOO.util.LocalDataSource(arrayModels);

  // Instantiate the AutoCompletes
  var oAC = new YAHOO.widget.AutoComplete("VehicleModel", "ac1", oDS);
  oAC.prehighlightClassName = "yui-ac-prehighlight";
  
  return {
      oDS: oDS,
      oAC: oAC,
  };
}();
</script>

The JavaScript code at the end of Listing 20 first initializes a YUI LocalDataSource object, and fills it with a JavaScript array containing the model names from the PHP template variable $t['models']. This LocalDataSource is then attached to a YUI AutoComplete object, which in turn links to a form element with ID VehicleModel.

The result of all these machinations is that when a user starts entering data into the VehicleModel field, the AutoComplete widget will match the input with the array of values in the LocalDataSource object and throw up a list of matching terms for user selection. Figure 10 has an example of what this looks like.

Figure 10. The WASP listing form with the auto-complete function for the Model field
Screen capture of the WASP listing form with the auto-complete function for the Model field

Conclusion

And that's about it for this series. Over these five articles, I took you on a whirlwind tour of the Agavi framework and taught you the basic techniques for building scalable Web applications with it. As you will have seen, Agavi is one of the best MVC implementations currently available. It offers: a clear separation between Models, Actions and Views; strict conformance to OOP principles; and a range of powerful tools for input validation, security, authentication, database integration, output variants, and application-level configuration. All of this adds up to one result: a more secure, robust, flexible, and extensible application!

I hope you found this article series useful and that it encourages you to seriously look at Agavi the next time you sit down to write a Web application. Until then...happy programming!


Download

DescriptionNameSize
Archive of the WASP app with functions to datewasp-05.zip3,881KB

Resources

Learn

Get products and technologies

Discuss

Comments

developerWorks: Sign in

Required fields are indicated with an asterisk (*).


Need an IBM ID?
Forgot your IBM ID?


Forgot your password?
Change your password

By clicking Submit, you agree to the developerWorks terms of use.

 


The first time you sign into developerWorks, a profile is created for you. Information in your profile (your name, country/region, and company name) is displayed to the public and will accompany any content you post, unless you opt to hide your company name. You may update your IBM account at any time.

All information submitted is secure.

Choose your display name



The first time you sign in to developerWorks, a profile is created for you, so you need to choose a display name. Your display name accompanies the content you post on developerWorks.

Please choose a display name between 3-31 characters. Your display name must be unique in the developerWorks community and should not be your email address for privacy reasons.

Required fields are indicated with an asterisk (*).

(Must be between 3 – 31 characters.)

By clicking Submit, you agree to the developerWorks terms of use.

 


All information submitted is secure.

Dig deeper into XML on developerWorks


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=1
Zone=XML, Open source, Web development
ArticleID=427940
ArticleTitle=Introduction to MVC programming with Agavi, Part 5: Add paging, file uploads, and custom input validators to your Agavi application
publish-date=10272009