Module Development 4: Adding Frontend Interactivity

From PapayaCMS

Jump to: navigation, search

Tutorial

Abstract This tutorial describes how to add web forms for frontend modules and how to use community features.
Intended audience PHP developers
Level intermediate
Software requirements SVN checkout of papaya CMS; instructions here
PHPUnit >= 3.5 for unit tests
Date 2011-04-12
Previous tutorial Module Development 3: Creating an Admin Interface
Next tutorial Module Development 5: Refactoring and Miscellania

In the fourth part of the basic papaya CMS module development tutorial, we will implement a web form for a frontend module. The form is only available to logged-in frontend users, or surfers as they are referred to in papaya CMS terminology. To add some diversity, the module we build in this tutorial will be a box module to be included into a page. Eventually, we are going to build a custom XSLT template to display the module's contents.

Please note that you should adhere to the Papaya CMS Coding Standards, especially if you plan to contribute your modules for the papaya Community.

This tutorial makes use of unit tests which is a highly recommended way to build software. In order to have a papaya CMS version that includes the PHPUnit framework and the PapayaTestCase class, you need to check out papaya CMS from the SVN repository. Instructions on how to obtain it can be found here.

Contents

Overview of files and directories

After finishing this tutorial, you will have created the following files and subdirectories in papaya-lib/modules/special/myproject/tutorial (new files from the current part are bold):

+ [DATA]
|  |
|  + table_tutorial_planets.xml
|  |
|  + table_tutorial_planet_ratings.xml
|
+ edmodule_tutorial_hello.php
|
+ [Hello]
|  |
|  + Connector.php
|  |
|  + [Page]
|  |  |
|  |  + Base.php
|  |
|  + Page.php
|  |
|  + [Planet]
|  |  |
|  |  + Admin.php
|  |  |
|  |  + [Database]
|  |  |  |
|  |  |  + Access.php
|  |  |
|  |  + [Rating]
|  |  |  |
|  |  |  + [Box]
|  |  |  |  |
|  |  |  |  + Base.php
|  |  |  |
|  |  |  + Box.php
|  |  |  |
|  |  |  + [Database]
|  |  |     |
|  |  |     + Access.php
|  |  |
|  |  + Rating.php
|  |
|  + Planet.php
|
+ modules.xml

---------------------------------------

papaya-data/templates/default-xhtml/html/box_planet_rating.xsl

 Note: Names in square brackets indicate directories

In the testing/tests-unittests/papaya-lib/modules/special/myproject/tutorial directory, you will have created the following structure (new files from the current part are bold):

+ [Hello]
   |
   + ConnectorTest.php
   |
   + [Page]
   |  |
   |  + BaseTest.php
   |
   + [Planet]
   |  |
   |  + AdminTest.php
   |  |
   |  + [Database]
   |  |  |
   |  |  + AccessTest.php
   |  |
   |  + [Rating]
   |  |  |
   |  |  + [Box]
   |  |  |  |
   |  |  |  + BaseTest.php
   |  |  |
   |  |  + BoxTest.php
   |  |  |
   |  |  + [Database]
   |  |     |
   |  |     + AccessTest.php
   |  |
   |  + RatingTest.php
   |
   + PlanetTest.php
   |
   + BaseTest.php

 Note: Names in square brackets indicate directories

Setting up the Module

The idea of this extension is to allow logged-in surfers to rate planets. Users who are not logged in can only see the results. For the ratings, we need another database table. Add the following line to the modules.xml file, below the existing <table> line:

  <table name="tutorial_planet_ratings" />

Now use your database administration tool to create a database table called papaya_tutorial_planet_ratings with the following columns:

  • surfer_id -- VARCHAR(32)
  • planet_id -- INT
  • rating_points -- INT

Set a combined primary key for the surfer_id and planet_id fields.

You can also use the following query to create the table:

CREATE TABLE papaya_tutorial_planet_ratings (
  surfer_id VARCHAR(32),
  planet_id INT,
  rating_points INT,
  PRIMARY KEY(surfer_id, planet_id)
)

To make the table definition available for users of the module, follow these steps:

  1. Select Modules in the papaya backend's Administration area.
  2. In the Packages list on the left, click on Hello World tutorial module.
  3. In the Package content section, open the Tables list by clicking on the plus sign.
  4. Click on the papaya_tutorial_planet_ratings table. The error message Could not load table structure file! will be displayed.
  5. In the main toolbar, click on the Export table button. Your browser will offer you to save the file; follow the instructions.
  6. Copy the new file to the DATA directory in your module package's main directory, removing the timestamp from the file name. The correct name is table_tutorial_planet_ratings.xml.
  7. In the Tables section, click on papaya_tutorial_planet_ratings again to make sure the table definition exists and corresponds to the actual table.

Now add the following XML to the <modules> section of the modules.xml file to introduce the new box module (create and add a GUID as usual):

    <module type="box"
            guid=""
            name="Planet Rating Box"
            class="HelloPlanetRatingBox"
            file="Hello/Planet/Rating/Box.php">
      This module allows logged-in surfers to rate planets; all others can see the results.
    </module>

Creating a static Box Module

Like with the page module created in the first and second parts of this tutorial series, we are starting out by creating a static box module. Dynamic contents and interactivity will be added later using an additional class. Create two new directories, each of them called Rating, in the Hello/Planet subdirectories of the module and unit test directories, respectively. In the test space's Rating directory, create a PHP file called BoxTest.php. Then create the corresponding class file in the module package, Hello/Planet/Rating/Box.php.

Box modules work much like page modules: They, too, have a $this->editFields property for their content settings, and a getParsedData() method to return the XML output. One of the most important differences is that box modules extend base_actionbox instead of base_content.

Here's the test file for the first, static implementation:

<?php
require_once(substr(dirname(__FILE__), 0, -66).'/Framework/PapayaTestCase.php');
PapayaTestCase::registerPapayaAutoloader();
require_once(PAPAYA_INCLUDE_PATH.'modules/special/myproject/tutorial/Hello/Planet/Rating/Box.php');
 
class HelloPlanetRatingBoxTest extends PapayaTestCase {
  /**
  * @covers HelloPlanetRatingBox::getParsedData
  */
  public function testGetParsedData() {
    $box = new PlanetRatingBox_TestProxy();
    $this->assertEquals('<ratings />', $box->getParsedData());
  }
}
 
class HelloPlanetRatingBox_TestProxy extends PlanetRatingBox {
  public function __construct() {
    // Nothing to do here, just override the constructor
  }
}

As for page modules, we are using a proxy class to override the actual box module's constructor. The first, static implementation of the box module is just as straightforward:

<?php
/**
* Hello World tutorial, Planet rating box
*
* The Planet rating box allows for logged-in users to rate planets
* while all others can view the results.
*
* @package Papaya-Modules
* @subpackage tutorial
*/
 
/**
* Basic class base_object
*/
require_once(PAPAYA_INCLUDE_PATH.'system/sys_base_object.php');
 
/**
* Hello World tutorial, Planet rating box
*
* @package Papaya-Modules
* @subpackage tutorial
*/
class HelloPlanetRatingBox extends base_actionbox {
  /**
  * Parameter namespace
  * @var string
  */
  public $paramName = 'tut';
 
  /**
  * Edit fields for box configuration
  * @var array
  */
  public $editFields = array();
 
  /**
  * Get the box module's XML output
  *
  * @return string XML
  */
  public function getParsedData() {
    return '<ratings />';
  }
}

Creating the Rating Box base class

The real rating interactivity and output will not be performed by the box class but by a separate class called HelloPlanetRatingBoxBase. The first version of the class is static, strictly following the agile top-down approach. Here's the unit test class, Hello/Planet/Rating/Box/BaseTest.php, to be written one test method at a time:

<?php
require_once(substr(dirname(__FILE__), 0, -70).'/Framework/PapayaTestCase.php');
PapayaTestCase::registerPapayaAutoloader();
require_once(PAPAYA_INCLUDE_PATH.'modules/special/myproject/tutorial/Hello/Planet/Rating/Box/Base.php');
 
class HelloPlanetRatingBoxBaseTest extends PapayaTestCase {
  /**
  * @covers HelloPlanetRating::setData
  */
  public function testSetData() {
    $baseObject = new HelloPlanetRatingBoxBase();
    $expectedData = array('title' => 'Planet ratings');
    $baseObject->setData($expectedData);
    $this->assertAttributeEquals($expectedData, '_data', $baseObject);
  }
 
  /**
  * @covers HelloPlanetRating::setParams
  */
  public function testSetParams() {
    $baseObject = new HelloPlanetRatingBoxBase();
    $expectedParams = array('planet_id' => 1, 'rating_points' => 5);
    $baseObject->setParams($expectedParams);
    $this->assertAttributeEquals($expectedParams, '_params', $baseObject);
  }
 
  /**
  * @covers HelloPlanetRating::getRatingsXml
  */
  public function testGetRatingsXml() {
    $ratingsObject = new HelloPlanetRatingBoxBase();
    $expectedXml = '<ratings>
<planet name="Mars" rating="5" />
<planet name="Jupiter" rating="4.5" />
</ratings>
';
    $this->assertEquals($expectedXml, $ratingsObject->getRatingsXml());
  }
 
  /**
  * @covers HelloPlanetRating::getBoxXml
  */
  public function testGetBoxXml() {
    $ratingsObject = new HelloPlanetRatingBoxBase();
    $ratingsObject->setData(array('title' => 'Planet ratings'));
    $expectedXml = '<ratingbox>
<title>Planet ratings</title>
<ratings>
<planet name="Mars" rating="5" />
<planet name="Jupiter" rating="4.5" />
</ratings>
</ratingbox>
';
    $this->assertEquals($expectedXml, $ratingsObject->getBoxXml());
  }
}

And this is the corresponding implementation, Hello/Planet/Rating/Box/Base.php:

<?php
/**
* Hello World tutorial, Planet rating box base class
*
* Provides basic functionality for the rating box.
*
* @package Papaya-Modules
* @subpackage tutorial
*/
 
/**
* Hello World tutorial, Planet rating box base class
*
* @package Papaya-Modules
* @subpackage tutorial
*/
class HelloPlanetRatingBoxBase {
  /**
  * Box configuration data
  * @var array
  */
  private $_data = array();
 
  /**
  * Box request parameters
  * @var array
  */
  private $_params = array();
 
  /**
  * Set configuration data
  *
  * @param array $data
  */
  public function setData($data) {
    $this->_data = $data;
  }
 
  /**
  * Set box request parameters
  * @param array $params
  */
  public function setParams($params) {
    $this->_params = $params;
  }
 
  /**
  * Get XML for the average ratings
  */
  public function getRatingsXml() {
    $result = '<ratings>'.LF;
    $result .= '<planet name="Mars" rating="5" />'.LF;
    $result .= '<planet name="Jupiter" rating="4.5" />'.LF;
    $result .= '</ratings>'.LF;
    return $result;
  }
 
  /**
  * Get box XML output
  *
  * @return string XML
  */
  public function getBoxXml() {
    $result = '<ratingbox>'.LF;
    $result .= sprintf(
      '<title>%s</title>'.LF,
      papaya_strings::escapeHTMLChars($this->_data['title'])
    );
    $result .= $this->getRatingsXml();
    $result .= '</ratingbox>'.LF;
    return $result;
  }
}

There's nothing to be explained about this class yet. Just add one method at a time and perform the unit tests. The class will be extended and refactored later when the rating functionality is available.

Refactoring the Box class

We are now ready to refactor the box class to use the base class. Modify the HelloPlanetRatingBoxTest unit test class as follows, providing tests for the new setBaseObject() and getBaseObject() methods as well as expanding the testGetParsedData() method:

<?php
require_once(substr(dirname(__FILE__), 0, -66).'/Framework/PapayaTestCase.php');
PapayaTestCase::registerPapayaAutoloader();
require_once(PAPAYA_INCLUDE_PATH.'modules/special/myproject/tutorial/Hello/Planet/Rating/Box.php');
require_once(PAPAYA_INCLUDE_PATH.'modules/special/myproject/tutorial/Hello/Planet/Rating/Box/Base.php');
 
class HelloPlanetRatingBoxTest extends PapayaTestCase {
  /**
  * @covers HelloPlanetRatingBox::setBaseObject
  */
  public function testSetBaseObject() {
    $box = new HelloPlanetRatingBox_TestProxy();
    $baseObject = $this->getMock('HelloPlanetRatingBoxBase');
    $box->setBaseObject($baseObject);
    $this->assertAttributeSame($baseObject, '_baseObject', $box);
  }
 
  /**
  * @covers HelloPlanetRatingBox::getBaseObject
  */
  public function testGetBaseObject() {
    $box = new HelloPlanetRatingBox_TestProxy();
    $baseObject = $box->getBaseObject();
    $this->assertInstanceOf('HelloPlanetRatingBoxBase', $baseObject);
  }
 
  /**
  * @covers HelloPlanetRatingBox::getParsedData
  */
  public function testGetParsedData() {
    $box = new HelloPlanetRatingBox_TestProxy();
    $baseObject = $this->getMock('HelloPlanetRatingBoxBase');
    $expectedXml = '<ratingbox>
<title>Planet ratings</title>
<ratings>
<planet name="Mars" rating="5" />
<planet name="Jupiter" rating="4.5" />
</ratings>
</ratingbox>';
    $baseObject
       ->expects($this->once())
       ->method('getBoxXml')
       ->will($this->returnValue($expectedXml));
    $box->setBaseObject($baseObject);
    $box->data = array('title' => 'Planet ratings');
    $this->assertEquals($expectedXml, $box->getParsedData());
  }
}
 
class HelloPlanetRatingBox_TestProxy extends HelloPlanetRatingBox {
  public function __construct() {
    // Nothing to do here, just override the constructor
  }
 
  public function initializeParams() {
    // Nothing here either, just override the original method
  }
}

The need to override initializeParams() in the proxy class has already been addressed in an earlier part of this series.

The refactored implementation of the HelloPlanetRatingBox class looks like this:

<?php
/**
* Hello World tutorial, Planet rating box
*
* The Planet rating box allows for logged-in users to rate planets
* while all others can view the results.
*
* @package Papaya-Modules
* @subpackage tutorial
*/
 
/**
* Basic class base_actionbox
*/
require_once(PAPAYA_INCLUDE_PATH.'system/base_actionbox.php');
 
/**
* Hello World tutorial, Planet rating box
*
* @package Papaya-Modules
* @subpackage tutorial
*/
class HelloPlanetRatingBox extends base_actionbox {
  /**
  * The base object to be used
  * @var PlanetRatingBoxBase
  */
  protected $_baseObject = NULL;
 
  /**
  * Parameter namespace
  * @var string
  */
  public $paramName = 'tut';
 
  /**
  * Edit fields for box configuration
  * @var array
  */
  public $editFields = array(
    'title' => array('Title', 'isNoHTML', TRUE, 'input', 100, '', 'Planet Ratings')
  );
 
  /**
  * Set the base object to be used
  *
  * @param HelloPlanetRatingBoxBase $baseObject
  */
  public function setBaseObject($baseObject) {
    $this->_baseObject = $baseObject;
  }
 
  /**
  * Get (and, if necessary, initialize) the base object
  *
  * @return HelloPlanetRatingBoxBase
  */
  public function getBaseObject() {
    if (!is_object($this->_baseObject)) {
      include_once(dirname(__FILE__).'/Box/Base.php');
      $this->_baseObject = new HelloPlanetRatingBoxBase();
    }
    return $this->_baseObject;
  }
 
  /**
  * Get the box module's XML output
  *
  * @return string XML
  */
  public function getParsedData() {
    $this->setDefaultData();
    $this->initializeParams();
    $baseObject = $this->getBaseObject();
    $baseObject->setBoxData($this->data);
    return $baseObject->getBoxXml();
  }
}

Implementing the rating functionality

To keep up a complete top-down approach, you should take the following steps:

  • Add static implementations of the rating methods to the connector and refactor the HelloPlanetRatingBoxBase class
  • Add static implementations to the public HelloPlanetRating class and refactor the connector to use these
  • Add the real implementations to the HelloPlanetRatingDatabaseAccess class and refactor the HelloPlanetRating class

As you know this principle by now, we are going to skip a few steps to speed things up. The following subsections present the state where all three steps have already happened. You should still follow these steps as an exercise, though.

Implementing the rating database access class

The rating is using its own database access class. Implement the unit test class first, as usual. It's called HelloPlanetRatingDatabaseAccessTest and resides in the file Hello/Planet/Rating/Database/AccessTest.php within your testing directory. Here's the complete source code of the test class:

<?php
require_once(substr(dirname(__FILE__), 0, -75).'/Framework/PapayaTestCase.php');
PapayaTestCase::registerPapayaAutoloader();
require_once(
  PAPAYA_INCLUDE_PATH.'modules/special/myproject/tutorial/Hello/Planet/Rating/Database/Access.php'
);
 
class HelloPlanetRatingDatabaseAccessTest extends PapayaTestCase {
  /**
  * Get a PapayaDatabaseAccess mock object
  *
  * @param array $methods the methods to be modeled
  * @return PapayaDatabaseAccess mock object
  */
  private function getPapayaDatabaseAccessObjectFixture($methods) {
    if (!in_array('getTableName', $methods)) {
      $methods[] = 'getTableName';
    }
    $databaseAccessObject = $this->getMock(
      'PapayaDatabaseAccess',
      $methods,
      array(),
      'Mock_'.md5(__CLASS__.microtime()),
      FALSE
    );
    return $databaseAccessObject;
  }
 
  /**
  * Get a database result mock object
  *
  * @param array $methods List of methods to be mocked
  * @return dbresult_mysql mock object
  */
  private function getDatabaseResultObjectFixture($methods) {
    if (!defined('DB_FETCHMODE_ASSOC')) {
      define('DB_FETCHMODE_ASSOC', 0);
    }
    return $this->getMock(
      'dbresult_mysql',
      $methods,
      array(),
      'Mock_'.md5(__CLASS__.microtime()),
      FALSE
    );
  }
 
  /**
  * @covers HelloPlanetRatingDatabaseAccess::getAverageRatings
  */
  public function testGetAverageRatings() {
    $ratingDatabaseAccess = new HelloPlanetRatingDatabaseAccess();
    $papayaDatabaseAccess = $this->getPapayaDatabaseAccessObjectFixture(
      array('queryFmt')
    );
    $resultObject = $this->getDatabaseResultObjectFixture(array('fetchRow'));
    $data = array(
      array('planet_id' => 1, 'planet_name' => 'Mars', 'points' => 5),
      array('planet_id' => 2, 'planet_name' => 'Jupiter', 'points' => 4.5)
    );
    $resultObject
      ->expects($this->atLeastOnce())
      ->method('fetchRow')
      ->will(
          $this->onConsecutiveCalls($data[0], $data[1], NULL)
        );
    $papayaDatabaseAccess
      ->expects($this->exactly(2))
      ->method('getTableName')
      ->will(
          $this->onConsecutiveCalls(
            'papaya_table_tutorial_planets',
            'papaya_table_tutorial_planet_ratings'
          )
        );
    $papayaDatabaseAccess
      ->expects($this->once())
      ->method('queryFmt')
      ->will($this->returnValue($resultObject));
    $ratingDatabaseAccess->setDatabaseAccess($papayaDatabaseAccess);
    $this->assertEquals(
      array(1 => $data[0], 2 => $data[1]),
      $ratingDatabaseAccess->getAverageRatings()
    );
  }
 
  /**
  * @covers HelloPlanetRatingDatabaseAccess::getUserRatings
  */
  public function testGetUserRatings() {
    $ratingDatabaseAccess = new HelloPlanetRatingDatabaseAccess();
    $papayaDatabaseAccess = $this->getPapayaDatabaseAccessObjectFixture(
      array('queryFmt')
    );
    $resultObject = $this->getDatabaseResultObjectFixture(array('fetchRow'));
    $ratings = array(
      array('surfer_id' => 'abc123', 'planet_id' => 1, 'rating_points' => 4),
      array('surfer_id' => 'abc123', 'planet_id' => 2, 'rating_points' => 5)
    );
    $resultObject
      ->expects($this->atLeastOnce())
      ->method('fetchRow')
      ->will($this->onConsecutiveCalls($ratings[0], $ratings[1], NULL));
    $papayaDatabaseAccess
      ->expects($this->once())
      ->method('getTableName')
      ->will($this->returnValue('papaya_tutorial_ratings'));
    $papayaDatabaseAccess
      ->expects($this->once())
      ->method('queryFmt')
      ->will($this->returnValue($resultObject));
    $ratingDatabaseAccess->setDatabaseAccess($papayaDatabaseAccess);
    $expected = array(1 => 4, 2 => 5);
    $this->assertEquals($expected, $ratingDatabaseAccess->getUserRatings('abc123'));
  }
 
  /**
  * @covers HelloPlanetRatingDatabaseAccess::deleteRatings
  */
  public function testDeleteRatings() {
    $ratingDatabaseAccess = new HelloPlanetRatingDatabaseAccess();
    $papayaDatabaseAccess = $this->getPapayaDatabaseAccessObjectFixture(
      array('deleteRecord')
    );
    $papayaDatabaseAccess
      ->expects($this->once())
      ->method('getTableName')
      ->will($this->returnValue('papaya_tutorial_planet_ratings'));
    $papayaDatabaseAccess
      ->expects($this->once())
      ->method('deleteRecord')
      ->will($this->returnValue(2));
    $ratingDatabaseAccess->setDatabaseAccess($papayaDatabaseAccess);
    $this->assertTrue(
      $ratingDatabaseAccess->deleteRatings('1234567890abcdef1234567890abcdef', array(1, 2))
    );
  }
 
  /**
  * @covers HelloPlanetRatingDatabaseAccess::modifyRatings
  */
  public function testModifyRatings() {
    $ratingDatabaseAccess = new HelloPlanetRatingDatabaseAccess();
    $papayaDatabaseAccess = $this->getPapayaDatabaseAccessObjectFixture(
      array('deleteRecord', 'insertRecords')
    );
    $papayaDatabaseAccess
      ->expects($this->exactly(2))
      ->method('getTableName')
      ->will($this->returnValue('papaya_tutorial_planet_ratings'));
    $papayaDatabaseAccess
      ->expects($this->once())
      ->method('deleteRecord')
      ->will($this->returnValue(2));
    $papayaDatabaseAccess
      ->expects($this->once())
      ->method('insertRecords')
      ->will($this->returnValue(2));
    $ratingDatabaseAccess->setDatabaseAccess($papayaDatabaseAccess);
    $this->assertTrue(
      $ratingDatabaseAccess->modifyRatings(
        '1234567890abcdef1234567890abcdef',
        array(1 => 4, 2 => 5)
      )
    );
  }
}

After writing the tests, we can implement the class itself, HelloPlanetRatingDatabaseAccess in the file Hello/Planet/Rating/Database/Access.php:

<?php
/**
* Hello World tutorial, Planet rating database access
*
* Provides the database access functionality for planet rating.
*
* @package Papaya-Modules
* @subpackage tutorial
*/
 
/**
* Hello World tutorial, Planet rating database access class
*
* @package Papaya-Modules
* @subpackage tutorial
*/
class HelloPlanetRatingDatabaseAccess extends PapayaDatabaseObject {
  /**
  * Base name of database table planets
  * @var string
  */
  private $_tablePlanets = 'tutorial_planets';
 
  /**
  * Base name of database table planet ratings
  * @var string
  */
  private $_tableRatings = 'tutorial_planet_ratings';
 
  /**
  * Get average ratings
  *
  * @return array
  */
  public function getAverageRatings() {
    $result = array();
    $sql = "SELECT r.planet_id, AVG(r.rating_points) points, p.planet_name
              FROM %s r
             RIGHT JOIN %s p
                ON r.planet_id = p.planet_id
             GROUP BY r.planet_id";
    $sqlParams = array(
      $this->databaseGetTableName($this->_tableRatings),
      $this->databaseGetTableName($this->_tablePlanets)
    );
    if ($res = $this->databaseQueryFmt($sql, $sqlParams)) {
      while ($row = $res->fetchRow(DB_FETCHMODE_ASSOC)) {
        $result[$row['planet_id']] = $row;
      }
    }
    return $result;
  }
 
  /**
  * Get ratings for a specific surfer
  *
  * @param string $surferId
  * @return array
  */
  public function getUserRatings($surferId) {
    $sql = "SELECT surfer_id, planet_id, rating_points
              FROM %s
             WHERE surfer_id = '%s'";
    $sqlParams = array($this->databaseGetTableName($this->_tableRatings), $surferId);
    $result = array();
    if ($res = $this->databaseQueryFmt($sql, $sqlParams)) {
      while ($row = $res->fetchRow()) {
        $result[$row['planet_id']] = $row['rating_points'];
      }
    }
    return $result;
  }
 
  /**
  * Delete ratings
  *
  * @param string $surferId optional, default empty string
  * @param array $planetIds optional, default empty array
  * @return boolean TRUE on success, FALSE otherwise
  */
  public function deleteRatings($surferId = '', $planetIds = array()) {
    $result = FALSE;
    $conditions = array();
    if (!empty($surferId)) {
      $conditions['surfer_id'] = $surferId;
    }
    if (!empty($planetIds)) {
      $conditions['planet_id'] = $planetIds;
    }
    $success = $this->databaseDeleteRecord(
      $this->databaseGetTableName($this->_tableRatings),
      $conditions
    );
    if (FALSE !== $success) {
      $result = TRUE;
    }
    return $result;
  }
 
  /**
  * Add/replace ratings
  *
  * @param string $surferId
  * @param array $ratings
  * @return boolean TRUE on success, FALSE otherwise
  */
  public function modifyRatings($surferId, $ratings) {
    $result = FALSE;
    $this->deleteRatings($surferId, array_keys($ratings));
    $data = array();
    foreach ($ratings as $planetId => $points) {
      $data[] = array(
        'surfer_id' => $surferId,
        'planet_id' => $planetId,
        'rating_points' => $points
      );
    }
    $success = $this->databaseInsertRecords(
      $this->databaseGetTableName($this->_tableRatings),
      $data
    );
    if (FALSE !== $success) {
      $result = TRUE;
    }
    return $result;
  }
}

There are four methods in this database access class with the following purposes:

  • getAverageRatings( ) returns an array of average ratings for each planet. It will be used to display the results in the box.
  • getUserRatings( ) returns an array containing the ratings for a single user, identified by his or her unique surfer id. This method is used to pre-select the options for a logged-in user who loads the box again.
  • deleteRatings( ) is only used to delete a user's ratings before saving their new ratings. Its implementation, however, allows to delete all ratings by a specific user, all ratings for a specific planet, or all ratings altogether. If you want an additional excercise, you can add an invocation of this method to the admin class to delete ratings for a planet that is deleted. You will call the corresponding method in the connector, though.
  • modifyRatings( ) stores all the planet ratings for a specific user. Before inserting the new ratings, deleteRatings( ) is called to get rid of potentially existing ratings by the same user. This is often easier than figuring out which data exists and then doing update/insert.

The rating database access methods work similar to the HelloPlanetDatabaseAccess class covered in part 2 of this tutorial. The only new method used here is databaseInsertRecords( ). It takes in two arguments, the table name and a nested array of data in which each record is an array of field => value pairs.

Implementing the public access class for the rating methods

Much like we did with the planets, we are going to implement a public access class for the rating methods provided by the database access class. There is nothing about this class that needs explaining. The unit test, Hello/Planet/RatingTest.php, looks like this:

<?php
require_once(substr(dirname(__FILE__), 0, -59).'/Framework/PapayaTestCase.php');
PapayaTestCase::registerPapayaAutoloader();
require_once(PAPAYA_INCLUDE_PATH.'modules/special/myproject/tutorial/Hello/Planet/Rating.php');
require_once(
  PAPAYA_INCLUDE_PATH.'modules/special/myproject/tutorial/Hello/Planet/Rating/Database/Access.php'
);
 
class HelloPlanetRatingTest extends PapayaTestCase {
  /**
  * @covers HelloPlanetRating::setDatabaseAccessObject
  */
  public function testSetDatabaseAccessObject() {
    $rating = new HelloPlanetRating();
    $databaseAccessObject = $this->getMock('HelloPlanetRatingDatabaseAccess');
    $rating->setDatabaseAccessObject($databaseAccessObject);
    $this->assertAttributeSame($databaseAccessObject, '_databaseAccessObject', $rating);
  }
 
  /**
  * @covers HelloPlanetRating::getDatabaseAccessObject
  */
  public function testGetDatabaseAccessObject() {
    $rating = new HelloPlanetRating();
    $databaseAccessObject = $rating->getDatabaseAccessObject();
    $this->assertInstanceOf('HelloPlanetRatingDatabaseAccess', $databaseAccessObject);
  }
 
  /**
  * @covers HelloPlanetRating::getRatings
  */
  public function testGetRatings() {
    $rating = new HelloPlanetRating();
    $expected = array(1 => array('planet_id' => 1, 'planet_name' => 'Mars', 'points' => 5));
    $databaseAccessObject = $this->getMock('HelloPlanetRatingDatabaseAccess');
    $databaseAccessObject
      ->expects($this->once())
      ->method('getAverageRatings')
      ->will($this->returnValue($expected));
    $rating->setDatabaseAccessObject($databaseAccessObject);
    $this->assertEquals($expected, $rating->getRatings());
  }
 
  /**
  * @covers HelloPlanetRating::getUserRatings
  */
  public function testGetUserRatings() {
    $rating = new HelloPlanetRating();
    $expected = array(1 => 4, 2 => 5);
    $databaseAccessObject = $this->getMock('HelloPlanetRatingDatabaseAccess');
    $databaseAccessObject
      ->expects($this->once())
      ->method('getUserRatings')
      ->will($this->returnValue($expected));
    $rating->setDatabaseAccessObject($databaseAccessObject);
    $this->assertEquals($expected, $rating->getUserRatings('abc123'));
  }
 
  /**
  * @covers HelloPlanetRating::deleteRatings
  */
  public function testDeleteRatings() {
    $rating = new HelloPlanetRating();
    $databaseAccessObject = $this->getMock('HelloPlanetRatingDatabaseAccess');
    $databaseAccessObject
      ->expects($this->once())
      ->method('deleteRatings')
      ->will($this->returnValue(TRUE));
    $rating->setDatabaseAccessObject($databaseAccessObject);
    $this->assertTrue(
      $rating->deleteRatings('1234567890abcdef1234567890abcdef', array(1, 2))
    );
  }
 
  /**
  * @covers HelloPlanetRating::modifyRatings
  */
  public function testModifyRatings() {
    $rating = new HelloPlanetRating();
    $databaseAccessObject = $this->getMock('HelloPlanetRatingDatabaseAccess');
    $databaseAccessObject
      ->expects($this->once())
      ->method('modifyRatings')
      ->will($this->returnValue(TRUE));
    $rating->setDatabaseAccessObject($databaseAccessObject);
    $this->assertTrue(
      $rating->modifyRatings('1234567890abcdef1234567890abcdef', array(1 => 4, 2 => 5))
    );
  }
}

Next, you can implement the rating class itself, saving it in Hello/Planet/Rating.php:

<?php
/**
* Hello World tutorial, Planet rating base functionality
*
* Public interface methods for planet rating
*
* @package Papaya-Modules
* @subpackage tutorial
*/
 
/**
* Hello World tutorial, Planet rating base functionality class
*
* @package Papaya-Modules
* @subpackage tutorial
*/
class HelloPlanetRating {
  /**
  * The planet ratings database access object to be used
  * @var HelloPlanetRatingDatabaseAccess
  */
  private $_databaseAccessObject = NULL;
 
  /**
  * Set the rating database access object to be used
  *
  * @param HelloPlanetRatingDatabaseAccess $databaseAccessObject
  */
  public function setDatabaseAccessObject($databaseAccessObject) {
    $this->_databaseAccessObject = $databaseAccessObject;
  }
 
  /**
  * Get (and, if necessary, initialize) the rating database access object
  *
  * @return HelloPlanetRatingDatabaseAccess
  */
  public function getDatabaseAccessObject() {
    if (!is_object($this->_databaseAccessObject)) {
      include_once(dirname(__FILE__).'/Rating/Database/Access.php');
      $this->_databaseAccessObject = new HelloPlanetRatingDatabaseAccess();
    }
    return $this->_databaseAccessObject;
  }
 
  /**
  * Get all average planet ratings
  *
  * @return array
  */
  public function getRatings() {
    $databaseAccessObject = $this->getDatabaseAccessObject();
    return $databaseAccessObject->getAverageRatings();
  }
 
  /**
  * Get ratings for a specific user
  *
  * @param string $surferId
  * @return array
  */
  public function getUserRatings($surferId) {
    $databaseAccessObject = $this->getDatabaseAccessObject();
    return $databaseAccessObject->getUserRatings($surferId);
  }
 
  /**
  * Delete ratings
  *
  * @param string $surferId optional, default empty string
  * @param array $planetIds optional, default empty array
  * @return boolean TRUE on success, FALSE otherwise
  */
  public function deleteRatings($surferId = '', $planetIds = array()) {
    $databaseAccessObject = $this->getDatabaseAccessObject();
    return $databaseAccessObject->deleteRatings($surferId, $planetIds);
  }
 
  /**
  * Modify ratings
  *
  * @param string $surferId
  * @param array $ratings
  * @return boolean TRUE on success, FALSE otherwise
  */
  public function modifyRatings($surferId, $ratings) {
    $databaseAccessObject = $this->getDatabaseAccessObject();
    return $databaseAccessObject->modifyRatings($surferId, $ratings);
  }
}

Adding the rating methods to the connector class

As the connector centralizes all basic functionality that the content modules use, we are going to make the rating methods available in it. First, add the following test methods to Hello/ConnectorTest.php:

  // Add the following two methods below testGetPlanetObject():
 
  /**
  * @covers HelloConnector::setRatingObject
  */
  public function testSetRatingObject() {
    $helloConnectorObject = new HelloConnector_TestProxy();
    $ratingObject = $this->getMock('HelloPlanetRating');
    $helloConnectorObject->setRatingObject($ratingObject);
    $this->assertAttributeSame($ratingObject, '_ratingObject', $helloConnectorObject);
  }
 
  /**
  * @covers HelloConnector::getRatingObject
  */
  public function testGetRatingObject() {
    $helloConnectorObject = new HelloConnector_TestProxy();
    $ratingObject = $helloConnectorObject->getRatingObject();
    $this->assertInstanceOf('HelloPlanetRating', $ratingObject);
  }
 
  // ... other test methods ...
 
  // Add these four to the end of the HelloConnectorTest class:
  /**
  * @covers HelloConnector::getPlanetRatings
  */
  public function testGetPlanetRatings() {
    $helloConnectorObject = new HelloConnector_TestProxy();
    $expected = array(
      1 => array('planet_id' => 1, 'planet_name' => 'Mars', 'points' => 5),
      2 => array('planet_id' => 2, 'planet_name' => 'Jupiter', 'points' => 4.5)
    );
    $ratingObject = $this->getMock('HelloPlanetRating');
    $ratingObject
      ->expects($this->once())
      ->method('getRatings')
      ->will($this->returnValue($expected));
    $helloConnectorObject->setRatingObject($ratingObject);
    $this->assertEquals($expected, $helloConnectorObject->getPlanetRatings());
  }
 
  /**
  * @covers HelloConnector::getUserPlanetRatings
  */
  public function testGetUserPlanetRatings() {
    $helloConnectorObject = new HelloConnector_TestProxy();
    $expected = array(1 => 5, 2 => 4);
    $ratingObject = $this->getMock('HelloPlanetRating');
    $ratingObject
      ->expects($this->once())
      ->method('getUserRatings')
      ->will($this->returnValue($expected));
    $helloConnectorObject->setRatingObject($ratingObject);
    $this->assertEquals($expected, $helloConnectorObject->getUserPlanetRatings('abc123'));
  }
 
  /**
  * @covers HelloConnector::deletePlanetRatings
  */
  public function testDeletePlanetRatings() {
    $helloConnectorObject = new HelloConnector_TestProxy();
    $ratingObject = $this->getMock('HelloPlanetRating');
    $ratingObject
      ->expects($this->once())
      ->method('deleteRatings')
      ->will($this->returnValue(TRUE));
    $helloConnectorObject->setRatingObject($ratingObject);
    $this->assertTrue(
      $helloConnectorObject->deletePlanetRatings(
        '1234567890abcdef1234567890abcdef',
        array(1, 2)
      )
    );
  }
 
  /**
  * @covers HelloConnector::modifyPlanetRatings
  */
  public function testModifyPlanetRatings() {
    $helloConnectorObject = new HelloConnector_TestProxy();
    $ratingObject = $this->getMock('HelloPlanetRating');
    $ratingObject
      ->expects($this->once())
      ->method('modifyRatings')
      ->will($this->returnValue(TRUE));
    $helloConnectorObject->setRatingObject($ratingObject);
    $this->assertTrue(
      $helloConnectorObject->modifyPlanetRatings(
        '1234567890abcdef1234567890abcdef',
        array(1 => 4, 2 => 5, 3 => 4)
      )
    );
  }

After adding the test, add the implementation of the methods to Hello/Connector.php:

  // Declaration of the $_ratingObject attribute goes below the $_planetObject declaration:
 
  /**
  * The HelloPlanetRating object to be used
  * @var HelloPlanetRating
  */
  protected $_ratingObject = NULL;
 
  // Add these two methods after getPlanetObject():
 
  /**
  * Set the HelloPlanetRating object to be used
  *
  * @param HelloPlanetRating $ratingObject
  */
  public function setRatingObject($ratingObject) {
    $this->_ratingObject = $ratingObject;
  }
 
  /**
  * Get (and, if necessary, initialize) the HelloPlanetRating object
  *
  * @return HelloPlanetRating
  */
  public function getRatingObject() {
    if (!is_object($this->_ratingObject)) {
      include_once(dirname(__FILE__).'/Planet/Rating.php');
      $this->_ratingObject = new HelloPlanetRating();
    }
    return $this->_ratingObject;
  }
 
  // ... other methods ...
 
  // Add these four at the end of the HelloConnector class:
 
  /**
  * Get planet ratings
  *
  * @return array
  */
  public function getPlanetRatings() {
    $ratingObject = $this->getRatingObject();
    return $ratingObject->getRatings();
  }
 
  /**
  * Get planet ratings for a specific surfer
  *
  * @param string $surferId
  * @return array
  */
  public function getUserPlanetRatings($surferId) {
    $ratingObject = $this->getRatingObject();
    return $ratingObject->getUserRatings($surferId);
  }
 
  /**
  * Delete planet ratings
  *
  * @param string $surferId optional, default ''
  * @param array $ratings optional, default array()
  * @return boolean TRUE on success, FALSE otherwise
  */
  public function deletePlanetRatings($surferId = '', $planetIds = array()) {
    $ratingObject = $this->getRatingObject();
    return $ratingObject->deleteRatings($surferId, $planetIds);
  }
 
  /**
  * Modify planet ratings
  *
  * @param string $surferId
  * @param array $ratings
  * @return boolean TRUE on success, FALSE otherwise
  */
  public function modifyPlanetRatings($surferId, $ratings) {
    $ratingObject = $this->getRatingObject();
    return $ratingObject->modifyRatings($surferId, $ratings);
  }

Implementing the rating functionality in the box module

Our box module's output class needs to be modified and extended to provide the actual functionality for voting and showing results. There are going to be some explanations, but before, here's the completely rewritten unit test class for the HelloPlanetRatingBoxBase class, i.e. HelloPlanetRatingBoxBaseTest:

<?php
require_once(substr(dirname(__FILE__), 0, -70).'/Framework/PapayaTestCase.php');
PapayaTestCase::registerPapayaAutoloader();
PapayaTestCase::defineConstantDefaults(
  array(
    'PAPAYA_DB_TBL_SURFER',
    'PAPAYA_DB_TBL_SURFERGROUPS',
    'PAPAYA_DB_TBL_SURFERPERM',
    'PAPAYA_DB_TBL_SURFERACTIVITY',
    'PAPAYA_DB_TBL_SURFERPERMLINK',
    'PAPAYA_DB_TBL_SURFERCHANGEREQUESTS',
    'PAPAYA_DB_TBL_TOPICS'
  )
);
require_once(
  PAPAYA_INCLUDE_PATH.'modules/special/myproject/tutorial/Hello/Planet/Rating/Box/Base.php'
);
require_once(
  PAPAYA_INCLUDE_PATH.'modules/special/myproject/tutorial/Hello/Planet/Rating/Box.php'
);
require_once(PAPAYA_INCLUDE_PATH.'modules/special/myproject/tutorial/Hello/Connector.php');
 
class HelloPlanetRatingBoxBaseTest extends PapayaTestCase {
  /**
  * @covers HelloPlanetRatingBoxBase::setPluginloaderObject
  */
  public function testSetPluginloaderObject() {
    $baseObject = new HelloPlanetRatingBoxBase();
    $pluginloaderObject = $this->getMock('base_pluginloader');
    $baseObject->setPluginloaderObject($pluginloaderObject);
    $this->assertAttributeSame($pluginloaderObject, '_pluginloaderObject', $baseObject);
  }
 
  /**
  * @covers HelloPlanetRatingBoxBase::getPluginloaderObject
  */
  public function testGetPluginloaderObject() {
    $baseObject = new HelloPlanetRatingBoxBase();
    $pluginloaderObject = $baseObject->getPluginloaderObject();
    $this->assertInstanceOf('base_pluginloader', $pluginloaderObject);
  }
 
  /**
  * @covers HelloPlanetRatingBoxBase::setConnectorObject
  */
  public function testSetConnectorObject() {
    $baseObject = new HelloPlanetRatingBoxBase();
    $connectorObject = $this->getMock('HelloConnector');
    $baseObject->setConnectorObject($connectorObject);
    $this->assertAttributeSame($connectorObject, '_connectorObject', $baseObject);
  }
 
  /**
  * @covers HelloPlanetRatingBoxBase::getConnectorObject
  */
  public function testGetConnectorObject() {
    $baseObject = new HelloPlanetRatingBoxBase();
    $pluginloaderObject = $this->getMock('base_pluginloader');
    $connectorObject = $this->getMock('HelloConnector');
    $pluginloaderObject
      ->expects($this->once())
      ->method('getPluginInstance')
      ->will($this->returnValue($connectorObject));
    $baseObject->setPluginloaderObject($pluginloaderObject);
    $this->assertSame($connectorObject, $baseObject->getConnectorObject());
  }
 
  /**
  * @covers HelloPlanetRatingBoxBase::setOwner
  */
  public function testSetOwner() {
    $baseObject = new HelloPlanetRatingBoxBase();
    $owner = $this->getMock(
      'HelloPlanetRatingBox',
      array(),
      array(),
      'Mock_'.md5(__CLASS__.  microtime()),
      FALSE
    );
    $baseObject->setOwner($owner);
    $this->assertAttributeSame($owner, '_owner', $baseObject);
  }
 
  /**
  * @covers HelloPlanetRatingBoxBase::setDialogObject
  */
  public function testSetDialogObject() {
    $baseObject = new HelloPlanetRatingBoxBase();
    $dialogObject = $this->getMock(
      'base_dialog',
      array(),
      array(),
      'Mock_'.md5(__CLASS__.  microtime()),
      FALSE
    );
    $baseObject->setDialogObject($dialogObject);
    $this->assertAttributeSame($dialogObject, '_dialogObject', $baseObject);
  }
 
  /**
  * @covers HelloPlanetRatingBoxBase::getDialogObject
  */
  public function testGetDialogObject() {
    $this->markTestSkipped();
  }
 
  /**
  * @covers HelloPlanetRatingBoxBase::setData
  */
  public function testSetData() {
    $baseObject = new HelloPlanetRatingBoxBase();
    $expectedData = array('title' => 'Planet ratings');
    $baseObject->setData($expectedData);
    $this->assertAttributeEquals($expectedData, '_data', $baseObject);
  }
 
  /**
  * @covers HelloPlanetRatingBoxBase::setParams
  */
  public function testSetParams() {
    $baseObject = new HelloPlanetRatingBoxBase();
    $expectedParams = array('planet_id' => 1, 'rating_points' => 5);
    $baseObject->setParams($expectedParams);
    $this->assertAttributeEquals($expectedParams, '_params', $baseObject);
  }
 
  /**
  * @covers HelloPlanetRatingBoxBase::saveRatings
  */
  public function testSaveRatingsSuccess() {
    $baseObject = new HelloPlanetRatingBoxBase();
    $helloConnectorObject = $this->getMock('HelloConnector');
    $helloConnectorObject
      ->expects($this->once())
      ->method('modifyPlanetRatings')
      ->will($this->returnValue(TRUE));
    $baseObject->setConnectorObject($helloConnectorObject);
    $baseObject->setData(array('message_success' => 'Success'));
    $baseObject->setParams(array('planet_1' => 4, 'planet_2' => 5));
    $expectedXml = '<message type="success">Success</message>
';
    $this->assertEquals($expectedXml, $baseObject->saveRatings('abc123'));
  }
 
  /**
  * @covers HelloPlanetRatingBoxBase::saveRatings
  */
  public function testSaveRatingsFailure() {
    $baseObject = new HelloPlanetRatingBoxBase();
    $helloConnectorObject = $this->getMock('HelloConnector');
    $helloConnectorObject
      ->expects($this->once())
      ->method('modifyPlanetRatings')
      ->will($this->returnValue(FALSE));
    $baseObject->setConnectorObject($helloConnectorObject);
    $baseObject->setData(array('message_error' => 'Error'));
    $baseObject->setParams(array('planet_1' => 4, 'planet_2' => 5));
    $expectedXml = '<message type="error">Error</message>
';
    $this->assertEquals($expectedXml, $baseObject->saveRatings('abc123'));
  }
 
  /**
  * @covers HelloPlanetRatingBoxBase::getRatingsForm
  */
  public function testGetRatingsForm() {
    $baseObject = new HelloPlanetRatingBoxBase();
    $helloConnectorObject = $this->getMock('HelloConnector');
    $helloConnectorObject
      ->expects($this->once())
      ->method('getAllPlanets')
      ->will($this->returnValue(array(1 => 'Mars', 2 => 'Jupiter')));
    $helloConnectorObject
      ->expects($this->once())
      ->method('getUserPlanetRatings')
      ->will($this->returnValue(array(1 => 4, 2 => 5)));
    $baseObject->setConnectorObject($helloConnectorObject);
    $dialogObject = $this->getMock(
      'base_dialog',
      array(),
      array(),
      'Mock_'.md5(__CLASS__. microtime()),
      FALSE
    );
    $dialogObject
      ->expects($this->once())
      ->method('getDialogXML')
      ->will($this->returnValue('<dialog />'));
    $baseObject->setDialogObject($dialogObject);
    $baseObject->setData(array('caption_dialog' => 'Rate planets', 'caption_submit' => 'Rate'));
    $this->assertEquals('<dialog />', $baseObject->getRatingsForm('abc123'));
  }
 
  /**
  * @covers HelloPlanetRatingBoxBase::getRatingsXml
  */
  public function testGetRatingsXml() {
    $baseObject = new HelloPlanetRatingBoxBase();
    $expectedXml = '<ratings>
<planet id="1" name="Mars" rating="5.0" />
<planet id="2" name="Jupiter" rating="4.5" />
</ratings>
';
    $connectorObject = $this->getMock('HelloConnector');
    $connectorObject
      ->expects($this->once())
      ->method('getPlanetRatings')
      ->will(
          $this->returnValue(
            array(
              1 => array('planet_id' => 1, 'planet_name' => 'Mars', 'points' => 5),
              2 => array('planet_id' => 2, 'planet_name' => 'Jupiter', 'points' => 4.5)
            )
          )
        );
    $baseObject->setConnectorObject($connectorObject);
    $this->assertEquals($expectedXml, $baseObject->getRatingsXml());
  }
 
  /**
  * @covers HelloPlanetRatingBoxBase::getBoxXml
  */
  public function testGetBoxXmlLoggedInSave() {
    $baseObject = new HelloPlanetRatingBoxBase();
    $connectorObject = $this->getMock('HelloConnector');
    $connectorObject
      ->expects($this->once())
      ->method('modifyPlanetRatings')
      ->will($this->returnValue(TRUE));
   $connectorObject
      ->expects($this->once())
      ->method('getPlanetRatings')
      ->will($this->returnValue(array()));
    $baseObject->setConnectorObject($connectorObject);
    $surfer = $this->getMock('base_surfer');
    $surfer->isValid = TRUE;
    $surfer->surferId = 'abc123';
    $application = $this->getMockApplicationObject(array('surfer' => $surfer));
    $baseObject->setApplication($application);
    $baseObject->setData(array('title' => 'Planet ratings', 'message_success' => 'Success'));
    $baseObject->setParams(array('save' => 1, 'planet_1' => 4, 'planet_2' => 5));
    $expectedXml = '<ratingbox>
<title>Planet ratings</title>
<message type="success">Success</message>
</ratingbox>
';
    $this->assertEquals($expectedXml, $baseObject->getBoxXml());
  }
 
  /**
  * @covers HelloPlanetRatingBoxBase::getBoxXml
  */
  public function testGetBoxXmlLoggedInDialog() {
    $baseObject = new HelloPlanetRatingBoxBase();
    $connectorObject = $this->getMock('HelloConnector');
    $connectorObject
      ->expects($this->once())
      ->method('getAllPlanets')
      ->will($this->returnValue(array()));
    $baseObject->setConnectorObject($connectorObject);
    $surfer = $this->getMock('base_surfer');
    $surfer->isValid = TRUE;
    $surfer->surferId = 'abc123';
    $application = $this->getMockApplicationObject(array('surfer' => $surfer));
    $baseObject->setApplication($application);
    $baseObject->setData(array('title' => 'Planet ratings'));
    $expectedXml = '<ratingbox>
<title>Planet ratings</title>
</ratingbox>
';
    $this->assertEquals($expectedXml, $baseObject->getBoxXml());
  }
 
  /**
  * @covers HelloPlanetRatingBoxBase::getBoxXml
  */
  public function testGetBoxXmlNotLoggedIn() {
    $baseObject = new HelloPlanetRatingBoxBase();
    $connectorObject = $this->getMock('HelloConnector');
    $connectorObject
      ->expects($this->once())
      ->method('getPlanetRatings')
      ->will($this->returnValue(array()));
    $baseObject->setConnectorObject($connectorObject);
    $surfer = $this->getMock('base_surfer');
    $surfer->isValid = FALSE;
    $application = $this->getMockApplicationObject(array('surfer' => $surfer));
    $baseObject->setApplication($application);
    $baseObject->setData(array('title' => 'Planet ratings'));
    $expectedXml = '<ratingbox>
<title>Planet ratings</title>
</ratingbox>
';
    $this->assertEquals($expectedXml, $baseObject->getBoxXml());
  }
}

As you can see, we define quite a bunch of constants using the static defineConstantDefaults() method from PapayaTestCase. This is necessary to mock the base_surfer class, a legacy papaya base class that represents the current frontend user.

And here's the class that should match the revised unit tests, HelloPlanetRatingBoxBase:

<?php
/**
* Hello World tutorial, Planet rating box base class
*
* Provides basic functionality for the rating box.
*
* @package Papaya-Modules
* @subpackage tutorial
*/
 
/**
* Hello World tutorial, Planet rating box base class
*
* @package Papaya-Modules
* @subpackage tutorial
*/
class HelloPlanetRatingBoxBase extends PapayaObject {
 
  /**
  * The plugin loader object to be used
  * @var base_pluginloader
  */
  private $_pluginloaderObject = NULL;
 
  /**
  * The HelloConnector object to be used
  * @var HelloConnector
  */
  private $_connectorObject = NULL;
 
  /**
  * The dialog object to be used
  * @var base_dialog
  */
  private $_dialogObject = NULL;
 
  /**
  * Owner object
  * @var HelloPlanetRatingBox
  */
  private $_owner = NULL;
 
  /**
  * Box configuration data
  * @var array
  */
  private $_data = array();
 
  /**
  * Box request parameters
  * @var array
  */
  private $_params = array();
 
  /**
  * Set the plugin loader object to be used
  *
  * @param base_pluginloader $pluginloaderObject
  */
  public function setPluginloaderObject($pluginloaderObject) {
    $this->_pluginloaderObject = $pluginloaderObject;
  }
 
  /**
  * Get (and, if necessary, initialize) the plugin loader object
  *
  * @return base_pluginloader
  */
  public function getPluginloaderObject() {
    if (!is_object($this->_pluginloaderObject)) {
      include_once(PAPAYA_INCLUDE_PATH.'system/base_pluginloader.php');
      $this->_pluginloaderObject = new base_pluginloader();
    }
    return $this->_pluginloaderObject;
  }
 
  /**
  * Set the HelloConnector object to be used
  *
  * @param HelloConnector $connectorObject
  */
  public function setConnectorObject($connectorObject) {
    $this->_connectorObject = $connectorObject;
  }
 
  /**
  * Get (and, if necessary, initialize) the HelloConnector object
  *
  * @return HelloConnector
  */
  public function getConnectorObject() {
    if (!is_object($this->_connectorObject)) {
      $pluginloaderObject = $this->getPluginloaderObject();
      $this->_connectorObject =
        $pluginloaderObject->getPluginInstance('eeb42aad2491cd607c7c64bc57eae455', $this);
    }
    return $this->_connectorObject;
  }
 
  /**
  * Set the owner object
  *
  * @param HelloPlanetRatingBox $owner
  */
  public function setOwner($owner) {
    $this->_owner = $owner;
  }
 
  /**
  * Set the dialog object to be used
  *
  * @param base_dialog $dialogObject
  */
  public function setDialogObject($dialogObject) {
    $this->_dialogObject = $dialogObject;
  }
 
  /**
  * Get (and, if necessary, initialize) the dialog object
  *
  * @param array $fields
  * @param array $data
  * @param array $hidden
  * @return base_dialog
  */
  public function getDialogObject($fields, $data, $hidden) {
    if (!is_object($this->_dialogObject)) {
      $this->_dialogObject = new base_dialog(
        $this,
        $this->_owner->paramName,
        $fields,
        $data,
        $hidden
      );
    }
    return $this->_dialogObject;
  }
 
  /**
  * Set configuration data
  *
  * @param array $data
  */
  public function setData($data) {
    $this->_data = $data;
  }
 
  /**
  * Set box request parameters
  * @param array $params
  */
  public function setParams($params) {
    $this->_params = $params;
  }
 
  /**
  * Save the ratings
  *
  * @param string $surferId
  * @return string XML
  */
  public function saveRatings($surferId) {
    $ratings = array();
    $result = '';
    foreach ($this->_params as $field => $value) {
      if (strpos($field, 'planet_') === 0) {
        $id = substr($field, 7);
        $ratings[$id] = $value;
      }
    }
    if (!empty($ratings)) {
      $connectorObject = $this->getConnectorObject();
      $success = $connectorObject->modifyPlanetRatings($surferId, $ratings);
      if ($success) {
        $result = sprintf(
          '<message type="success">%s</message>'.LF,
          papaya_strings::escapeHTMLChars($this->_data['message_success'])
        );
      } else {
        $result = sprintf(
          '<message type="error">%s</message>'.LF,
          papaya_strings::escapeHTMLChars($this->_data['message_error'])
        );
      }
    }
    return $result;
  }
 
  /**
  * Get the ratings form
  *
  * @param string $surferId
  * @return string XML
  */
  public function getRatingsForm($surferId) {
    $result = '';
    $connectorObject = $this->getConnectorObject();
    $planets = $connectorObject->getAllPlanets();
    if (!empty($planets)) {
      $ratingCombo = array(1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5);
      $fields = array();
      foreach ($planets as $id => $name) {
        $fields['planet_'.$id] = array($name, 'isNum', FALSE, 'combo', $ratingCombo);
      }
      $ratings = $connectorObject->getUserPlanetRatings($surferId);
      $data = array();
      if (!empty($ratings)) {
        foreach ($ratings as $id => $points) {
          $data['planet_'.$id] = $points;
        }
      }
      $hidden = array('save' => 1);
      $dialog = $this->getDialogObject($fields, $data, $hidden);
      if (is_object($dialog)) {
        $dialog->dialogTitle = $this->_data['caption_dialog'];
        $dialog->buttonTitle = $this->_data['caption_submit'];
        $result = $dialog->getDialogXML();
      }
    }
    return $result;
  }
  /**
  * Get XML for the average ratings
  */
  public function getRatingsXml() {
    $result = '';
    $connectorObject = $this->getConnectorObject();
    $ratings = $connectorObject->getPlanetRatings();
    if (!empty($ratings)) {
      $result = '<ratings>'.LF;
      foreach ($ratings as $id => $data) {
        $result .= sprintf(
          '<planet id="%d" name="%s" rating="%.1f" />'.LF,
          $id,
          papaya_strings::escapeHTMLChars($data['planet_name']),
          $data['points']
        );
      }
      $result .= '</ratings>'.LF;
    }
    return $result;
  }
 
  /**
  * Get box XML output
  *
  * @return string XML
  */
  public function getBoxXml() {
    $result = '<ratingbox>'.LF;
    $result .= sprintf(
      '<title>%s</title>'.LF,
      papaya_strings::escapeHTMLChars($this->_data['title'])
    );
    $surfer = $this->getApplication()->surfer;
    if ($surfer->isValid) {
      if (isset($this->_params['save']) && $this->_params['save'] == 1) {
        $result .= $this->saveRatings($surfer->surferId);
        $result .= $this->getRatingsXml();
      } else {
        $result .= $this->getRatingsForm($surfer->surferId);
      }
    } else {
      $result .= $this->getRatingsXml();
    }
    $result .= '</ratingbox>'.LF;
    return $result;
  }
}

The HelloPlanetRatingBoxBase class is now a child class of PapayaObject. This is necessary to use the papaya application, an implementation of the registry design pattern that allows to store and objects and make them available in each class of your project that is derived from PapayaObject or one of its many descendants. This approach is a test-friendly alternative to using Singletons.

The surfer property in the application object is the instance of the base_surfer class. base_surfer represents the current frontend user. If a user is logged in, the isValid property is TRUE. The surferId property contains the unique id of the logged-in surfer. We use it to store a surfer's planet ratings.

For the rating form, we use the base_dialog class that we already used in the admin class in part 3 of the tutorial. When using it in a frontend class, the only difference is that you need to write your own XSLT template code for its output as you need to do for all of the XML your modules generate. Note the skipped unit test for getDialogObject( ), we've already explained the reasons in part 3. New and improved test-friendly implementations of the papaya user interface classes are under development right now, and once they're ready we will update this tutorial.

The main action method in the rating box base class is getBoxXml( ), just like before. After adding the XML root element <ratingbox> and the title node to the output, the method checks whether a surfer is logged in ($surfer->isValid). If this is the case, we can check for the save parameter provided as a hidden form field. If this parameter is set and has the expected value 1, we call the saveRatings( ) method that reads the form data and stores it in the database as the current user's ratings. After that, we call the getRatingsXml() method that shows the average planet ratings including the user's own opinion. If the save parameter is not set, we invoke getRatingsForm() instead to add the rating dialog to the output. If no surfer is logged in, we simply call getRatingsXml() to show the results. There are three paths of execution in this method, each of them represented by its own unit test:

  • Logged in - save parameter set to 1 => testGetBoxXmlLoggedInSave( )
  • Logged in - no save parameter => testGetBoxXmlLoggedInDialog( )
  • Not logged in => testGetBoxXmlNotLoggedIn( )

Writing an XSLT template for the rating box

As the box outputs its own XML, we need a special template to transform it to proper HTML. Here's the complete source code of the template file, box_planet_rating.xsl:

<?xml version="1.0"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
 
<!--
  @papaya:modules HelloPlanetRatingBox
-->
 
  <xsl:import href="./base/boxes.xsl" />
 
  <xsl:template match="/ratingbox">
    <h2><xsl:value-of select="title/text()" /></h2>
    <xsl:if test="dialog">
      <xsl:call-template name="showDialog">
        <xsl:with-param name="dialog" select="dialog" />
      </xsl:call-template>
    </xsl:if>
    <xsl:if test="message">
      <div>
        <xsl:attribute name="class">
          <xsl:choose>
            <xsl:when test="message/@type = 'error'">message error</xsl:when>
            <xsl:otherwise>message info</xsl:otherwise>
          </xsl:choose>
        </xsl:attribute>
        <xsl:value-of select="message/text()" />
      </div>
    </xsl:if>
    <xsl:if test="ratings">
      <xsl:call-template name="showRatings">
        <xsl:with-param name="ratings" select="ratings" />
      </xsl:call-template>
    </xsl:if>
  </xsl:template>
 
  <xsl:template name="showDialog">
    <xsl:param name="dialog" />
    <h3><xsl:value-of select="$dialog/@title" /></h3>
    <form action="{$dialog/@action}" method="{$dialog/@method}">
      <xsl:copy-of select="$dialog/input[@type = 'hidden']" />
      <xsl:for-each select="$dialog/lines/line">
        <div class="dialogSelect">
          <caption for="{@fid}"><xsl:value-of select="@caption" /></caption>
          <select id="{@fid}" name="{select/@name}">
            <xsl:copy-of select="select/option" />
          </select>
        </div>
      </xsl:for-each>
      <input type="submit" value="{$dialog/dlgbutton/@value}" />
    </form>
  </xsl:template>
 
  <xsl:template name="showRatings">
    <xsl:param name="ratings" />
    <table class="planetRatings">
      <xsl:for-each select="$ratings/planet">
        <tr>
          <td><xsl:value-of select="@name" /></td>
          <td><xsl:value-of select="@rating" /></td>
        </tr>
      </xsl:for-each>
    </table>
  </xsl:template>
</xsl:stylesheet>

The template is written for the default template set that comes with papaya CMS. Save it in papaya-data/templates/default-xhtml/html. Then go to the Views section of the papaya backend. If you haven't created a view for the Planet Rating Box from the Hello World tutorial module package yet, do so now, otherwise select this view. In the Output filter sidebar on the right, check the checkbox next to the html extension. Select box_planet_rating.xsl from the XSL stylesheet pull-down menu and click on Save. After that, you can go to the Boxes section of the backend and create a rating box. Then, it can be linked to a page of your choice in the Boxes subsection of a page in the Pages section.

The @papaya:modules comment in the XSL stylesheet indicates the module or modules for which the template can be used. If the XSLT file is suitable for more than one module, you can provide a comma-separated list of modules. As a box module, the stylesheet imports the base/boxes.xsl template file that contains helpful resources for box templates. Box templates typically contain a template element with a match attribute for the root element of the box module's XML, /ratingbox in this case. Page templates, however, usually start with a named template for the page's content area that is called by the main page template.

The XSLT file also contains two named templates to display the rating form and rating results, respectively. This approach makes your templates more readable.

To build your own templates, you can view the XML preview of the page or box you are working on and then write XSLT that matches this XML. The rating box, however, leaves you with a problem when you want to do this: The form is only displayed when a surfer is logged in. Therefore you first need to add a login page or login box to your content. Both can be found in the Community package. The go to the Community application in the backend and create a surfer. Please note that you need to select Valid from the Status drop-down menu so that the surfer can actually log in.

Note: The mode that displays published pages and the preview mode use different sessions -- as preview only works when you're logged in to the backend, the admin session will be used for them. This means that in order to get the XML preview of the rating form, you need to use the index.<id-of-your-login-page>.html.preview URL path rather than index.<id-of-your-login-page>.html.

Personal tools
In other languages