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!
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
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')); ?>">⇑</a>
<a href="<?php echo $ro->gen('admin.listing.index',
array('s' => 'RecordDate', 'd' => 'desc')); ?>">⇓</a>
</td>
<td class="key">Manufacturer
<a href="<?php echo $ro->gen('admin.listing.index',
array('s' => 'VehicleManufacturerID', 'd' => 'asc'));
?>">⇑</a>
<a href="<?php echo $ro->gen('admin.listing.index',
array('s' => 'VehicleManufacturerID', 'd' => 'desc'));
?>">⇓</a>
</td>
<td class="key">Model
<a href="<?php echo $ro->gen('admin.listing.index',
array('s' => 'VehicleModel', 'd' => 'asc'));
?>">⇑</a>
<a href="<?php echo $ro->gen('admin.listing.index',
array('s' => 'VehicleModel', 'd' => 'desc'));
?>">⇓</a>
</td>
<td class="key">Year
<a href="<?php echo $ro->gen('admin.listing.index',
array('s' => 'VehicleYear', 'd' => 'asc')); ?>">⇑</a>
<a href="<?php echo $ro->gen('admin.listing.index',
array('s' => 'VehicleYear', 'd' => 'desc'));
?>">⇓</a>
</td>
<td class="key">Mileage
<a href="<?php echo $ro->gen('admin.listing.index',
array('s' => 'VehicleMileage', 'd' => 'asc'));
?>">⇑</a>
<a href="<?php echo $ro->gen('admin.listing.index',
array('s' => 'VehicleMileage', 'd' => 'desc'));
?>">⇓</a>
</td>
<td class="key">Color
<a href="<?php echo $ro->gen('admin.listing.index',
array('s' => 'VehicleColor', 'd' => 'asc'));
?>">⇑</a>
<a href="<?php echo $ro->gen('admin.listing.index',
array('s' => 'VehicleColor', 'd' => 'desc'));
?>">⇓</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; ?>
|
As Listing 1 illustrates, each link has two parameters:
sindicates the field to sort againstdindicates 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
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(' ');
// 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
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
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/> <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
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
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
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
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
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
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!
| Description | Name | Size | Download method |
|---|---|---|---|
| Archive of the WASP app with functions to date | wasp-05.zip | 3,881KB | HTTP |
Information about download methods
Learn
- Introduction to MVC Programming with Agavi, Part 1: Open a whole new world with Agavi: Learn to build scalable Web applications with the Agavi framework (Vikram Vaswani, developerWorks, July 2009): In the first of a five-part series, explore the basic concepts of the Agavi framework and compare it to other frameworks. Read all about views, actions, templates, and routes, and start to build your own scalable Agavi application.
- Introduction to MVC Programming with Agavi, Part 2: Add forms and database support with Agavi and Doctrine (Vikram Vaswani, developerWorks, July 2009): Continue to explore the flexible, open-source Agavi framework as you create an input form, auto-generate data models with Doctrine, and integrate these models into the Agavi project (Part 2 of a five-part series).
- Introduction to MVC Programming with Agavi, Part 3: Add authentication and administrative functions with Agavi (Vikram Vaswani, developerWorks, August 2009): Build more function into the sample Web Automobile Sales Platform by adding the ability to add, delete, and update the automobile records. You will also see how to separate user functions from administrative functions with authentication. (Part 3 of a five-part series).
- Introduction to MVC programming with Agavi, Part 4: Create an Agavi search engine with multiple output types including XML, RSS, or SOAP (Vikram Vaswani, developerWorks, August 2009): Implement a simple search engine and add support for multiple output types such as XML, RSS, or SOAP for your sample Agavi program (Part 4 of a five-part series).
- Implement access control with Agavi (Vikram Vaswani, developerWorks, October 2009): Add sophisticated role-based access control to your Agavi app with the full-featured API for user authentication and role-based access control in Agavi.
- The official Agavi Web site and the Agavi Guide: Learn more about this scalable PHP5 application framework that follows the MVC paradigm.
- The Agavi API documentation:Take a closer look at the Agavi base classes.
- The Agavi cookbook: Find out how to perform common tasks.
- Custom validator: Create an Agavi validator that is a class based on AgaviValidator returns true or false and see more examples of custom validators.
- Documentation on the Doctrine Pager object: Read about the flexible pager package in Doctrine. Create pager objects, control pager styles, and overview the pager layout object—a powerful page links displayer.
- The YUI AutoComplete widget: Learn more about the front-end logic for text-entry suggestion and completion functionality.
- The Agavi blog: Read Agavi news.
- The Agavi mailing lists and IRC channels: Participate in the Agavi community, ask questions and get answers.
- Open Source area on
developerWorks: Learn about application development with CakePHP (Duane O'Brien, June 2009), other PHP
frameworks (Duane O'Brien, October 2007), and building a PHP wiki (Duane O'Brien, February 2007).
- IBM XML certification: Find out how you can become an IBM-Certified Developer in XML and related technologies.
- XML technical library: See the developerWorks XML Zone for a wide range of technical articles and tips, tutorials, standards, and IBM Redbooks.
- developerWorks technical events and webcasts: Stay current with technology in these sessions.
- The technology bookstore: Browse for books on these and other technical topics.
- developerWorks podcasts: Listen to interesting interviews and discussions for software developers.
Get products and technologies
- The Agavi framework: Download this PHP5-MVC pattern application framework for cleaner extensible code.
- The MySQL database server: Download a popular open source database.
- The Doctrine ORM package: Download this object relational mapper for PHP.
- The YUI library: Download this set of utilities and controls, written in JavaScript, for building interactive Web apps with DOM scripting, DHTML, and Ajax.
- IBM product evaluation versions: Download or explore the online trials in the IBM SOA Sandbox and get your hands on application development tools and middleware products from DB2®, Lotus®, Rational®, Tivoli®, and WebSphere®.
Discuss
- XML zone discussion forums: Participate in any of several XML-related discussions.
- developerWorks blogs: Check out these blogs and get involved in the developerWorks community.

Vikram 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.
Comments (Undergoing maintenance)





