《Agile Web Application Development with Yii1.1 and PHP5》

Chapter 6: Iteration 3: Adding tasks

In the previous iteration, we delivered the basic functionality around the project entity. The project is the foundation of the TrackStar application. However, projects by themselves are not very useful. Projects are the basic containers of the issues we want this application to manage. As managing project issues is the main purpose of this application, we want to spend the next iteration adding some basic issue management functionality.

Iteration planning

We already have the ability to create and list projects, but these projects are not yet able to contain anything. At the end of this iteration, we want the application to expose all CRUD operations on the project issues or tasks (we tend to use the terms issue and task interchangeably, but in our data model, a task will actually be just one type of issue). We also want to restrict all CRUD operations on issues to be within the context of a specific Project. That is, issues belong to projects. The user must have selected an existing project to work within prior to being able to perform any CRUD operations on the project issues.

In order to achieve the preceding outlined goals, we need to identify all the granular items that we will work on within this iteration. The following list outlines these items:

    Design the database schema, and build the objects to support project issues • Create the Yii model classes that allow the application to easily interact with the database table(s) that we created • Create the controller class that will house the functionality to allow us to: ° Create new issues ° Fetch a list of existing issues within a project from the database ° Update/edit existing issues ° Delete existing issues • Create views to render user interfaces for these (preceding) actions

This is enough information to allow us to get started. After we run our tests, we'll get started on making the necessary database changes.

Running the test suite

It is always a good idea to run our existing test suite prior to diving into development. Our test suite grew a little with the previous iteration's work. We now have tests for our db connection as well as all CRUD operations for projects. Once again, we'll run them all at once. Navigate to the test folder, /protected/tests/ unit, and run all unit tests:

%phpunit unit/
PHPUnit 3.3.17 by Sebastian Bergmann. 
..... 
Time: 0 seconds OK (5 tests, 11 assertions)

Everything passes. Let's start making some changes.

Designing the schema

Back in Chapter 3,The TrackStar Application we proposed some initial ideas about the issue entity. We proposed it have a type, an owner, a requester, a status, and a description. We also mentioned when we created the tbl_project table that we would be adding basic audit history information to each table we create to track the dates, times and users who update tables. Nothing has changed in the requirements that would alter this approach, so we can move ahead with that initial proposal. However, types, owners, requesters, and statuses are themselves, their own entities. To keep our model flexible and extensible, we'll model some of these separately. Owners and requesters are both users of the system, and will be referenced to the rows in a table called tbl_user. We have already introduced the idea of a user in the tbl_project table, as we added the columns create_user_id and update_user_id to track the identification of the user who initially created the project, as well as, the user who was responsible for last updating the project details. Even though we have not formally introduced that table yet, these fields were modeled to be foreign keys to another table in the database for storing the user data table. The owner_id and requestor_id in the our tbl_issue table will also be foreign keys that relate back to the tbl_user table.

We could similarly model the type and status attributes in the same manner. However, until our requirements demand this extra complexity in the model, we can keep things simple. The type and status columns in the tbl_issue table will remain integer values that map to named types and statuses. Instead of complicating our model by using separate tables, we will model these as basic class constant (const) valueswithintheAR modelclasswecreatefortheissueentity.Don'tworryifallof this is a little fuzzy, it will make more sense in the coming sections.

Defining some relationships

As we are going to be introduced to the tbl_user table, we need to go back and define the relationship between users and projects. Back when we introduced the TrackStar application in Chapter 3, we specified that users (we called them project members) would be associated with one or more projects. We also mentioned that projects can also have many (zero or more) users. As projects can have many users, and users can be associated with many projects, we call this a many-to-many relationship between projects and users. The easiest way to model a many-to-many relationship in a relational database is to use an association or assignment table. So, we need to add this table to our model as well.

The following figure outlines a basic entity relationship we need to model among users, projects, and issues. Projects can have zero to many users. A user needs to be associated with at least one project, but can also be associated with many. Issues belong to one and only one project, while projects can have zero to many issues. Finally, an issue is assigned to (or requested by) a single user.

Building the database and the relationships

So, we need to create three new tables: tbl_issue, tbl_user, and our association table, tbl_project_user_assignment. For your convenience we have provided the basic Data Definition Language (DDL) statements for the tables as well as their relationships. We also provided a little seed data for the users table, so we have a couple of rows populated for immediate use because basic user management is not a part of this iteration. Please proceed as you have done in previous iterations to create the following tables and relationships. The exact syntax of the following statements assumes a MySQL database:

DROP TABLE IF EXISTS `tbl_project`;
CREATE TABLE `tbl_project`
(
    `id` INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
    `name` VARCHAR(128),
    `description` TEXT,
    `create_time` DATETIME,
    `create_user_id` INTEGER,
    `update_time` DATETIME,
    `update_user_id` INTEGER
) ENGINE = InnoDB;
 
 
CREATE TABLE IF NOT EXISTS `tbl_issue`
(
    `id` INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
    `name` VARCHAR(256) NOT NULL,
    `description` VARCHAR(2000),
    `project_id` INTEGER,
    `type_id` INTEGER,
    `status_id` INTEGER,
    `owner_id` INTEGER,
    `requester_id` INTEGER,
    `create_time` DATETIME,
    `create_user_id` INTEGER,
    `update_time` DATETIME,
    `update_user_id` INTEGER,
     INDEX (`project_id`)
) ENGINE = InnoDB;
 
 
CREATE TABLE IF NOT EXISTS `tbl_user`
(
    `id` INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
    `email` VARCHAR(256) NOT NULL,
    `username` VARCHAR(256),
    `password` VARCHAR(256),
    `last_login_time` DATETIME,
    `create_time` DATETIME,
    `create_user_id` INTEGER,
    `update_time` DATETIME,
    `update_user_id` INTEGER
) ENGINE = InnoDB;
 
 
CREATE TABLE IF NOT EXISTS `tbl_project_user_assignment`
(
    `project_id` INT(11) NOT NULL,
    `user_id` INT(11) NOT NULL,
    `create_time` DATETIME,
    `create_user_id` INTEGER,
    `update_time` DATETIME,
    `update_user_id` INTEGER,
    PRIMARY KEY (`project_id`,`user_id`)
) ENGINE = InnoDB;
 
 
-- The Relationships
ALTER TABLE `tbl_issue` ADD CONSTRAINT `FK_issue_project`
FOREIGN KEY (`project_id`) REFERENCES `tbl_project` (`id`)
ON DELETE CASCADE ON UPDATE RESTRICT;
 
 
ALTER TABLE `tbl_issue` ADD CONSTRAINT `FK_issue_owner`
FOREIGN KEY (`owner_id`) REFERENCES `tbl_user` (`id`)
ON DELETE CASCADE ON UPDATE RESTRICT;
 
 
ALTER TABLE `tbl_issue` ADD CONSTRAINT `FK_issue_requester`
FOREIGN KEY (`requester_id`) REFERENCES `tbl_user` (`id`)
ON DELETE CASCADE ON UPDATE RESTRICT;
 
 
ALTER TABLE `tbl_project_user_assignment`
ADD CONSTRAINT `FK_project_user` FOREIGN KEY (`project_id`)
REFERENCES `tbl_project` (`id`)
ON DELETE CASCADE ON UPDATE RESTRICT;
 
 
ALTER TABLE `tbl_project_user_assignment`
ADD CONSTRAINT `FK_user_project` FOREIGN KEY (`user_id`)
REFERENCES `tbl_user` (`id`)
ON DELETE CASCADE ON UPDATE RESTRICT;
 
-- Insert some seed data so we can just begin using the database 
 
INSERT INTO `tbl_user`
(`email`, `username`, `password`)
VALUES
('test1@notanaddress.com','Test_User_One', MD5('test1')),
('test2@notanaddress.com','Test_User_Two', MD5('test2'));

Creating the Active Record model classes

Now that we have these tables created, we need to create the Yii AR model classes to allow us to easily interact with these tables within the application. We did this when creating the Project.php model class in Chapter 5, Iteration 2: Project CRUD using the Gii code generation tool. We'll remind you of the steps again here, but spare you of all the screenshots. Please refer back to Chapter 5 for a more detailed walkthrough of using the Gii tool.

Creating the Issue model class

Navigate to the Gii tool via http://localhost/trackstar/index.php?r=gii, and choose the Model Generator link. Leave the table prefix as tbl_. Fill in the Table Name field as tbl_issue, which will auto-populate the Model Class field as Issue.

Once the form is filled out, click the Preview button to get a link to a popup that will show you all of the code about to be generated. Then click the Generate button to actually create the new Issue.php model class in the /protected/models/ folder. The full listing of the generated code is as follows:

/**
 * This is the model class for table "tbl_issue".
 *
 * The followings are the available columns in table 'tbl_issue':
 * @property integer $id
 * @property string $name
 * @property string $description
 * @property integer $project_id
 * @property integer $type_id
 * @property integer $status_id
 * @property integer $owner_id
 * @property integer $requester_id
 * @property string $create_time
 * @property integer $create_user_id
 * @property string $update_time
 * @property integer $update_user_id
 *
 * The followings are the available model relations:
 * @property User $requester
 * @property User $owner
 * @property Project $project
 */
class Issue extends CActiveRecord {
 
    /**
     * Returns the static model of the specified AR class.
     * @return Issue the static model class
     */
    public static function model($className=__CLASS__) {
        return parent::model($className);
    }
 
    /**
     * @return string the associated database table name
     */
    public function tableName() {
        return 'tbl_issue';
    }
 
    /**
     * @return array validation rules for model attributes.
     */
    public function rules() {
        // NOTE: you should only define rules for those attributes that
        // will receive user inputs.
        return array(
            array('name', 'required'),
            array('project_id, type_id, status_id, owner_id, requester_id, create_user_id, update_user_id', 'numerical', 'integerOnly' => true),
            array('name', 'length', 'max' => 256),
            array('description', 'length', 'max' => 2000),
            array('create_time, update_time', 'safe'),
            // The following rule is used by search().
            // Please remove those attributes that should not be searched.
            array('id, name, description, project_id, type_id, status_id, owner_id, requester_id, create_time, create_user_id, update_time, update_user_id', 'safe', 'on' => 'search'),
        );
    }
 
    /**
     * @return array relational rules.
     */
    public function relations() {
        // NOTE: you may need to adjust the relation name and the related
        // class name for the relations automatically generated below.
        return array(
            'requester' => array(self::BELONGS_TO, 'User', 'requester_id'),
            'owner' => array(self::BELONGS_TO, 'User', 'owner_id'),
            'project' => array(self::BELONGS_TO, 'Project', 'project_id'),
        );
    }
 
    /**
     * @return array customized attribute labels (name=>label)
     */
    public function attributeLabels() {
        return array(
            'id' => 'ID',
            'name' => 'Name',
            'description' => 'Description',
            'project_id' => 'Project',
            'type_id' => 'Type',
            'status_id' => 'Status',
            'owner_id' => 'Owner',
            'requester_id' => 'Requester',
            'create_time' => 'Create Time',
            'create_user_id' => 'Create User',
            'update_time' => 'Update Time',
            'update_user_id' => 'Update User',
        );
    }
 
    /**
     * Retrieves a list of models based on the current search/filter conditions.
     * @return CActiveDataProvider the data provider that can return the models based on the search/filter conditions.
     */
    public function search() {
        // Warning: Please modify the following code to remove attributes that
        // should not be searched.
 
        $criteria = new CDbCriteria;
 
        $criteria->compare('id', $this->id);
        $criteria->compare('name', $this->name, true);
        $criteria->compare('description', $this->description, true);
        $criteria->compare('project_id', $this->project_id);
        $criteria->compare('type_id', $this->type_id);
        $criteria->compare('status_id', $this->status_id);
        $criteria->compare('owner_id', $this->owner_id);
        $criteria->compare('requester_id', $this->requester_id);
        $criteria->compare('create_time', $this->create_time, true);
        $criteria->compare('create_user_id', $this->create_user_id);
        $criteria->compare('update_time', $this->update_time, true);
        $criteria->compare('update_user_id', $this->update_user_id);
 
        return new CActiveDataProvider(get_class($this), array(
            'criteria' => $criteria,
        ));
    }
 
}

Creating the User model class

This is probably getting to be old-hat for you at this point, so we are going to leave the creation of the User AR class as an exercise for you. This particular class becomes much more important in the next chapter, when we dive into user authentication and authorization.

What about the AR class for the tbl_project_user_assignment table? Although one could create an AR class for this table, it is not necessary. The AR model provides an Object Relational Mapping (ORM) layer to our application to help us work easily with our domain objects. However, ProjectUserAssignment is not a domain object of our application. It is simply a construct in a relational database to help us model and manage the many-to-many relationship between projects and users. Maintaining a separate AR class to handle the management of this table is extra complexity, and we can avoid this for the time being. We will avoid the additional maintenance and slight performance overhead by managing the inserts, updates, and deletes on this table using Yii's DAO directly.

Creating the Issue CRUD operations

Now that we have our AR classes in place, we can turn to building the functionality required to manage our project issues. As the CRUD operations on project issues are the main goal of this iteration, we'll again lean on the Gii code generation tool to help create the basics of this functionality. We did this in detail for the projects in Chapter 5. We'll remind you of the basic steps for issues again here.

Navigate to the Gii generator menu at http://localhost/trackstar/index. php?r=gii, and choose the Crud Generator link. Fill out the form using Issue as the value for the Model Class field. This will auto-populate the Controller ID to also be Issue. The Base Controller Class and Code Template fields can remain their predefined default values. Click the Preview button to get a list of all of the files that the Gii tool is proposing to create. The following screenshot shows this list of files:

You can click each individual link to preview the code to be generated. Once satisfied, click the Generate button to have all of these files created. You should receive the following success message:

Using the Issue CRUD operations

Let's try this out. Either click the try it now link shown in the previous screenshot or simply navigate to http://localhost/trackstar/index.php?r=issue. You should be presented with something similar to what is shown in the following screenshot:

Creating a new Issue

As we have not added any new issues as yet, there are none to list. So, let's create a new one. Click on the Create Issue link (if this takes you to the login page, then log in using either demo/demo or admin/admin), you should now see a new issue input form similar to what is shown in the following screenshot:

When looking at this input form, we notice that it has an input field for every column in the database table, just as it is defined in the database table. However, as we know from when we designed our schema and built our tables, some of these fields are not direct input fields, but rather represent relationships to other entities. For example, rather than having a Type free-form input text field on this form, we should use a drop-down input form field that is populated with choices of allowed issue types. A similar argument could be made for the Status field. The Owner and Requester fields should also be drop-downs exposing choices of the names of users who have been assigned to work on the project under which the issue resides. Also all issue management should be taking place within the context of a specific project. Therefore, the Project field should not even be a part of this form at all. Lastly, the Create Time, Create User, Update Time, and Update User fields are all values that should be calculated and determined once the form is submitted, and should not be available to the user to directly manipulate.

Okay, so we have identified a number of corrections we would like to make on this initial input form. As we mentioned in Chapter 5, the auto-created CRUD scaffolding code that is generated by the Gii tool is just the starting point. Rarely is it enough on its own to meet all the specific functionality needs of an application. We have certainly identified many changes we need to make to this issue creation process. We'll take them on, one at a time.

Adding the types drop-down menu

We'll start with adding a dropdown menu for the issue types.

Issues have just the following three types:

    Bugs • Features • Tasks

What we would like to see when creating a new issue is a drop-down menu input type form field with these three choices. We will achieve this by having the Issue model class itself provide a list of its available types. As you might have guessed, we'll add this new functionality to the Issue model AR class by first writing a test.

As you remember, back in Chapter 5, we added a new database to run our tests against called trackstar_test. We did this to ensure our testing environment would not have an adverse impact on our development environment. So please make sure that you have updated your test database with the new tables, tbl_issue and tbl_user, which we created earlier.

Getting the test in the "Red"

As we know, the first step in our TDD process is to quickly write a test that fails. Create a new unit test file protected/tests/unit/IssueTest.php and add to it the following:

class IssueTest extends CDbTestCase {
 
    public function testGetTypes() {
        $options = Issue::model()->typeOptions;
        $this->assertTrue(is_array($options));
    }
 
}

Now toggle to the command line and run the test from with the /protected/tests folder

phpunit unit/IssueTest.php
PHPUnit 3.3.17 by Sebastian Bergmann.
.E
Time: 0 seconds
There was 1 error:
testGetTypes(IssueTest)
CException: Property "Issue.typeOptions" is not defined.
/YiiRoot/framework/base/CComponent.php:131
/YiiRoot/yii-read-only/framework/db/ar/CActiveRecord.php:107
/Webroot/tasctrak/protected/tests/unit/IssueTest.php:6
 
FAILURES!
Tests: 1, Assertions: 0, Errors: 1.

Okay, so we have accomplished the first step in TDD ( that is, quickly writing a test that fails). The test fails for obvious reasons. There is no method Issue::typeOptions() in the model class. We need to add one.

Moving From "Red" To "Green"

Now open the AR model class, in the protected/models/Issue.php folder, and add the following method to the class:

/**
 * @return array issue type names indexed by type IDs
 */
public function getTypeOptions() {
    return array();
}

We have added a simple method, named appropriately, that returns an array type (albeit still empty at the moment).

Now if we run our test again:

phpunit unit/IssueTest.php
PHPUnit 3.3.17 by Sebastian Bergmann.
..
Time: 0 seconds
OK (1 tests, 1 assertion)

It should be noted that Yii Framework base classes make use of the PHP __get magic function. This allows us in our child classes to write methods such as getTypeOptions(), and yet reference those methods like class properties using >typeOptions syntax.

So now our test will pass, and we are in the "green". This is great, but we don't actually have any values returned yet. We certainly can't add our drop-down menu based on this empty array. For our basic three issue types, we are going to use class constants to map these to integer values, and then we will use our getTypeOptions() method to return user friendly descriptions to be used in the drop-down menu.

Moving Back To "Red"

Before adding this to the Issue class, let's get our test to fail again. Let's add one more assertion that interrogates the returned array and verifies that its contents are as expected. We'll test to ensure that the returned array has three elements, and that these values correspond to our issue types: Bug, Feature, and Task. Alter the test to be:

public function testGetTypes() {
      $options = Issue::model()->typeOptions; 
      $this->assertTrue(is_array($options)); 
      $this->assertTrue(3 == count($options));
      $this->assertTrue(in_array('Bug', $options)); 
      $this->assertTrue(in_array('Feature', $options)); 
      $this->assertTrue(in_array('Task', $options));
}

As the getTypeOptions() method still returns a blank array, our assertions are sure to fail. So, we are back in the red. Let's add the code to the Issue.php class to get these new assertions to pass.

Getting back to "Green" once again

At the top of the Issue class, add the following three constant definitions:

const TYPE_BUG=0; 
const TYPE_FEATURE=1; 
const TYPE_TASK=2;

Then, alter the Issue::getTypeOptions() method to return an array based on these defined constants:

public function getTypeOptions()
{
    return array( 
        self::TYPE_BUG=>'Bug', 
        self::TYPE_FEATURE=>'Feature', 
        self::TYPE_TASK=>'Task',
    );
}

Now if we run our tests again, all five of our assertions pass, and we are back in the green.

phpunit unit/IssueTest.php
PHPUnit 3.3.17 by Sebastian Bergmann.
..
Time: 0 seconds
OK (1 tests, 5 assertions)

We now have our model class returning our issue types as needed, but we don't yet have a drop-down field in the input form that takes advantage of these values. Let's add that now.

Adding the issue type dropdown

Open up the file containing the new issue creation form, protected/views/issue/_ form.php, and find the lines that correspond to the Type field on the form:

<div class="row"> 
    <?php echo $form->labelEx($model,'type_id'); ?> 
    <?php echo $form->textField($model,'type_id'); ?> 
    <?php echo $form->error($model,'type_id'); ?>
</div>

These lines need a little clarification. In order to understand this, we need to refer to some code at the top of the _form.php file which is as follows:

<?php $form=$this->beginWidget('CActiveForm', array( 
    'id'=>'issue-form', 
    'enableAjaxValidation'=>false,
)); ?>

This is defining the $form variable using the CActiveForm widget in Yii. Widgets are going to be covered in much more detail in Chapter 9. For now, we can comprehend this code by better understanding CActiveForm. It can be thought of as a helper class that provides a set of methods to help us to create a data entry form that is associated with a data model class. In this case, it is represented by the Issue model class.

To fully understand the variables in our view file, let's also review our controller code that is rendering the view file(s). As you recall, one way to pass data from the controller to the view is by explicitly declaring an array, the keys of which will be the names of available variables in the view files. As this is the create action for a new issue, the controller method rendering the form is IssueController::actionCre ate(). This method is listed as follows:

public function actionCreate() {
    $model=new Issue;
    // Uncomment the following line if AJAX validation is needed
    // $this->performAjaxValidation($model);
    if(isset($_POST['Issue'])) {
        $model->attributes=$_POST['Issue'];
        if($model->save())
        $this->redirect(array('view','id'=>$model->id));
    }
    $this->render('create',array(
        'model'=>$model,
    ));
}

Here, we see that when the view is being rendered, it is being passed an instance of the Issue model class, that will be available in a variable called $model.

Okay, so now let's go back to the code that is responsible for rendering the Type field on the create new issue entry form. The first line is:

$form->labelEx($model,'type_id');

This line is using the CActiveForm::labelEx() method to render an HTML label for a the Issue model attribute, type_id. It takes in an instance of the model class, and the corresponding model attribute for which we want a label generated. The model class' Issue::attributeLabels() method will be used to determine the label. If we take a look at this method, we see that the attribute type_id is mapped to a label of Type, which is exactly what we see rendered as the label to this form field

public function attributeLabels() {
    return array(
        'id' => 'ID',
        'name' => 'Name',
        'description' => 'Description',
        'project_id' => 'Project',
        'type_id' => 'Type',
        'status_id' => 'Status',
        'owner_id' => 'Owner',
        'requester_id' => 'Requester',
        'create_time' => 'Create Time',
        'create_user_id' => 'Create User',
        'update_time' => 'Update Time',
        'update_user_id' => 'Update User',
    );
}

The next line of code is as follows:

<?php echo $form->textField($model,'type_id'); ?>

It uses the CActiveForm::textField() method to render a text input field for our Issue model attribute, type_id. Any of the validation rules defined for type_id in the model class Issue::rules() method will be applied as form validation rules to this input form.

The final line of code is as follows:

<?php echo $form->error($model,'type_id'); ?>

It uses the CActiveForm::error() method to render any validation errors associated with the specific type_id attribute of the Issue model class on submission. Used in this way, the error message will display directly below the field.

You can try out this validation with the Type field. As the type_id column is defined as an integer type in our MySQL schema definition, the Gii generated Issue model class has a validation rule in the Issue::rules() method to enforce this constraint:

public function rules() {
    // NOTE: you should only define rules for those attributes that
    // will receive user inputs.
    return array(
        array('name', 'required'),
        array('project_id, type_id, status_id, owner_id, requester_id, create_user_id, 
                    update_user_id', 'numerical', 'integerOnly' => true),
        array('name', 'length', 'max' => 256),
        array('description', 'length', 'max' => 2000),
        array('create_time, update_time', 'safe'),
        // The following rule is used by search().
        // Please remove those attributes that should not be searched.
        array('id, name, description, project_id, type_id, status_id, owner_id, requester_id, 
                   create_time, create_user_id, update_time, update_user_id', 
                   'safe', 'on' => 'search'),
    );
}

So, if we attempt to submit a string value in our Type form field, we will receive an inline error, right under the field, as depicted in the following screenshot:

Now that we understand exactly what we have , we are in a better position to change it. What we need to do is change this field from a free-form text input field to a drop-down entry type. It probably comes as little surprise that the CActiveForm class has a dropDownList() method that will generate a drop-down list for a model attribute. So, let's replace the line that calls $form->textField, with the following:

<?php echo $form->dropDownList($model,'type_id', $model->getTypeOptions()); ?>

This still takes in the same model as the first argument and the model attribute as the second. The third argument specifies the list of drop-down choices. This should be an array of value=>display pairs. We already created our getTypeOptions() method in the Issue model class to return an array of this format, so we can use it directly. Save your work and look again at our issue input form. You should see a nice drop- down menu of issue type choices in place of the free-form text field, as displayed in the following screenshot:

Adding the status drop-down menu: Do it yourself

We are going to take the same approach for the issue status. As mentioned back in Chapter 3 when we introduced the application, issues can be in one of three statuses:

    Not yet started • Started • Finished

We are going to leave the implementation of the status dropdown to the reader. After following the same approach we took for the types (and we hope you take a test-first approach), and both the Type and Status form field should be dropdown lists. The form should look similar to what is shown in the following screenshot:

Fixing the owner and requester fields

Another problem that we previously noticed with the issue creation form is that the Owner and Requester fields were also free-form input text fields. However, we know these are integer values in the issue table that hold foreign key identifiers to the tbl_user table. So, we also need to add drop-down fields for these fields. We won't take the exact same approach we took for the Type and Status attributes, as issue owners and requesters need to be taken from the tbl_user table. To complicate things a bit further, because not every user in the system will be associated with the project under which the issue resides, these cannot be dropdowns populated with data taken from the entire tbl_user table. We need to restrict the list to just those users associated with this project.

This brings up another thing we need to address. As mentioned in the Iteration planning section, we need to manage our issues within the context of a specific project. That is, a specific project should be chosen before you are even able to view the form for creating a new issue. Currently, our application functionality does not enforce this workflow.

Let's address these issues in turn. First we will alter the application to enforce a valid project that should be identified first, prior to using any functionality to manage the issues associated with that project. Once a project is chosen, we'll make sure both our Owner and Requester dropdown choices are restricted to only users that are associated with that project.

Enforcing a project context

We want to ensure a valid project context is present before we allow any issue-related functionality. To do this, we are going to implement what is called a filter. A filter in Yii is bit of code that is configured to be executed either before or after a controller action is executed. One common example is if we want to ensure a user is logged in prior to executing a controller action method, then we could write a simple access filter that would check this requirement before the action is executed. Another example is if we want to perform some extra logging or other auditing logic after an action has executed. We could write a simple audit filter to provide this post-action processing.

In this case, we want to ensure a valid project has been chosen prior to creating a new issue. So, we'll add a project filter to our IssueController class to accomplish this.

Implementing a filter

A filter can be defined as a controller class method or it can be a separate class. When using the simple method approach, the method name must begin with word filter and have a specific signature. For example, if we were going to create a filter method called SomeMethodName, our full filter method would look like:

public function filterSomeMethodName($filterChain) 
{
     ...
}

The other approach is to write a separate class to perform the filter logic. When using the separate class approach, the class must extend CFilter and then override at least one of the preFilter() or postFilter() methods depending on whether the logic should be executed before the action is invoked, or after.

Adding a filter

So, let's add a filter to our IssueController class to handle the valid project. We'll take the simplest approach for now, and add a method that begins with the word filter directly to the class. As the invocation of this method is done by the Yii Framework itself, it is hard for us to take a test-first approach with this implementation. We'll break from our preferred approach a little bit in this case, and add this method to the IssueCcontroller without first writing a test.

Open up protected/controllers/IssueController.php and add the following method to the bottom of the class:

public function filterProjectContext($filterChain) 
{
    $filterChain->run();
}

Okay, we now have a filter defined, but it does not do much yet. It simply executes $filterChain->run(), which continues the filtering process and allows execution of the action methods that are being filtered by this method. This brings up another point. How do we define for which action methods we should use this filter?

Specifying the filtered actions

CController, the Yii Framework base class for our controller classes has a filters() method that needs to be overridden in order to specify the actions on which to apply filters. In fact, this method has already been overridden in our IssueController class. This was done for us when we used the Gii tool to autogenerate this class. It already added a simple accessControl filter, which is defined in the CController base class, to handle some basic authorization to ensure that the user has sufficient permission to perform certain actions. We'll be covering user authentication and authorization in the next chapter. For now, we just need to add to this filter configuration array. To specify that our new filter should apply to the create action, alter the IssueController::filters() method by adding the following highlighted code :

/**
 * @return array action filters
 */
public function filters()
{
    return array(
        'accessControl', //perform access control for CRUD operations
        'projectContext + create', //check to ensure valid project context
    );
}

The filters() method should return an array of filter configurations. The previous method returns a configuration that specifies that the projectContext filter, which is defined as a method within the class, should apply to the actionCreate() method. The configuration syntax allows for '+' and '-' symbols to be used to specify whether or not a filter should or apply. For example, if we decided that we wanted this filter to apply to all the actions except the actionUpdate() and actionView() action methods, we could specify:

return array( 
    'projectContext - update, view' ,
);

You should not specify both the plus and the minus operator at the same time. Only one should be used for any given filter configuration. The plus operator means 'Only apply the filter to the following actions'. The minus operators means 'Apply the filter to all actions except the following'. If neither the '+' nor the '-' is in the configuration, the filter will be applied to all actions.

At the moment, we'll keep this restricted to just the create action. So, as defined previously with the + create configuration, our filter method will be called when any user attempts to create a new issue.

Adding some filter logic

Okay, so now we have a filter defined and we have configured it to be called upon every attempted actionCreate() method call within the Issuecontroller class. However, it still does not perform the needed logic. As we want to ensure the project context before the action is attempted, we need to put the logic in the filter method before the call to $filterChain->run().

We'll add a project property to the controller class itself. We'll then use a querystring parameter in our URLs to indicate the project identifier. Our pre-action filter will check to see if the existing project attribute is null. If so, it will use the querystring parameter to attempt to select the project based on the Primary Key identifier. If successful, the action will execute, and if it fails an exception will be thrown. Here is the code that is required in the IssueController class to perform all of this:

class IssueController extends CController {
....
 
    /**
     * @var private property containing the associated Project model instance.
     */
    private $_project = null;
 
    /**
     * Protected method to load the associated Project model class
     * @project_id the primary identifier of the associated Project
     * @return object the Project data model based on the primary key
     */
    protected function loadProject($project_id) {
    //if the project property is null, create it based on input id
        if ($this->_project === null) {
            $this->_project = Project::model()->findbyPk($project_id);
            if ($this->_project === null) {
                throw new CHttpException(404, 'The requested project does not exist.');
            }
        }
        return $this->_project;
    }
 
    /**
     * In-class defined filter method, configured for use in the above filters() method
     * It is called before the actionCreate() action method is run inorder to ensure a proper project context
     */
    public function filterProjectContext($filterChain) {
        //set the project identifier based on either the GET or POST input
        //request variables, since we allow both types for our actions
        $projectId = null;
        if (isset($_GET['pid']))
            $projectId = $_GET['pid'];
        else
            if (isset($_POST['pid']))
                $projectId = $_POST['pid'];
 
        $this->loadProject($projectId);
 
        //complete the running of other filters and execute the requested action
        $filterChain->run();
    }
...
}

With this in place, now attempt to create a new issue by clicking the Create Issue link from the issue listing page at this URL, http://hostname/tasctrak/index. php?r=issue/list

You should be met with an Error 404 error message which also displays the error text we specified previously, The requested project does not exist.

This is good. It shows we have properly implemented the code to prevent a new issue from being created when no project has been identified. The quickest way to get past this error is to simply add a pid querystring parameter to the URL used for creating new issues. Let's do that so we can supply the filter with a valid project identifier, and proceed to the form to create a new issue.

Adding the project ID

Back in Chapter 5, we added several new projects to the application as we were testing and implementing the CRUD operations on Projects. So, it is likely that you still have a valid project in your development database. If not, simply use the application to create a new project again. Once complete, take note of the project ID created, as we need to add this ID to the new issue URL.

The link we need to alter is in the view file for the issue listing page: /protected/ views/issue/index.php. At the top of that file you will see the create new link specified in the menu as shown in the following highlighted code:

$this->menu=array(
    array('label'=>'Create Issue', 'url'=>array('create')),
    array('label'=>'Manage Issue', 'url'=>array('admin')),
);

To add a querystring parameter to this link, we simply append a name=>value pair in the array defined for the url. The code we added for the filter is expecting the querystring parameter to be pid (for project ID). Also, as we are using the first (project ID = 1) project for this example, we alter the Create Issue link as follows:

array('label'=>'Create Issue', 'url'=>array('create', 'pid'=>1)),

Now when you view the issue listing page, you will see that the Create Issue hyperlink opens a URL with a querystring parameter appended to the end:http://localhost/trackstar/index.php?r=issue/create&pid=1

This querystring parameter allows the filter to properly set the project context. So, this time when you click the link, rather than getting the 404 error, the create new issue form will be displayed.

Altering the project details page

Adding the project ID to the URL for the create new issue link was a good first step to ensure our filter was working as expected. However, now we have hard-coded the link to always associate a new issue with the project ID '=' 1. Of course, this is not what we want. What we want to do is to have the menu option for creating a new issue be a part of the project details page. This way, once you have chosen a project from the project listing page, the specific project context will be known, and we can dynamically append that project ID to the create new issue link. Let's make that change.

Open up the project details view, /protected/views/project/view.php. At the top of this file, you will notice the menu items contained within the $this->menu array. We need to add another create a new issue link to the end of this list of defined menu links:

$this->menu = array(
    array('label' => 'List Project', 'url' => array('index')),
    array('label' => 'Create Project', 'url' => array('create')),
    array('label' => 'Update Project', 'url' => array('update', 'id' => $model->id)),
    array('label' => 'Delete Project', 'url' => '#', 'linkOptions' => array('submit' => array('delete', 'id' => $model->id), 'confirm' => 'Are you sure you want to delete this item?')),
    array('label' => 'Manage Project', 'url' => array('admin')),
    array('label' => 'Create Issue', 'url' => array('issue/create', 'pid' => $model->id)), //&lt;---注意,这一行
);

What we have done is moved the menu option to create a new issue to the page that lists the details for a specific project. We used a link similar to the one before, but this time we had to specify the full controllerId/actionId pair (issue/create). Also, rather than hardcode the project ID to be 1, we have used the $model variable within the view file, which is the AR class for the specific project. This way, regardless of the project we choose, this variable will always reflect the correct project id attribute for that project.

Removing the project input form field

Now that we have the project context properly set when creating a new issue, we can remove the Project field as a user input form field. However, we do still need the project ID to be submitted with the form. As we know the project ID before we render this input form, we can set the project model attribute in the create action. This way, the $model instance that is passed to the view file will already have the proper project ID set.

First, let's alter the IssueController::actionCreate() method to set the project_id property of the Issue model instance just after it is created:

public function actionCreate() {
    $model=new Issue;
    $model->project_id = $this->_project->id;
    ...
}

Now the project_id property is set and will be available in the form file.

Open up the view file for the new issue form, /protected/views/issue/_form. php. Remove the following lines that are associated with the Project input field:

<div class="row"> 
    <?php echo $form->labelEx($model,'project_id'); ?> 
    <?php echo $form->textField($model,'project_id'); ?> 
    <?php echo $form->error($model,'project_id'); ?>
</div>

Replace them with a hidden field:

<div class="row"> 
    <?php echo $form->hiddenField($model,'project_id'); ?>
</div>

Now when we submit the form, the project_id attribute will be correctly set. Even though we don't have our Owner and Requester drop-down menu set yet, we can submit the form and a new issue will be created with the proper project ID set.

Returning back to the owner and requester dropdowns

Finally, we can turn back to what we set out to do, which is to change the Owner and Requester fields to be dropdown choices of valid members of that project. In order to do this properly, we need to associate some users with a project. As user management is the focus of Chapter 7 and Chapter 8, we will do this quickly by adding the association directly to the database via SQL. We already added two new test users as part of our seed data in our earlier DDL statements. As a reminder, that insert statement was as follows:

INSERT INTO `tbl_user`
(`email`, `username`, `password`)
VALUES
('test1@notanaddress.com','Test_User_One', MD5('test1')),
('test2@notanaddress.com','Test_User_Two', MD5('test2'));

This created two new users in our system with ID's 1 and 2. Let's manually assign these two users to Project #1.

To do so, run the following insert statement against your trackstar_dev and trackstar_test databases:

INSERT INTO `tbl_project_user_assignment` (`project_id`, `user_id`) VALUES (1,1), (1,2);

After running the preceding SQL, we have two valid members assigned to Project #1.

One of the wonderful features of relational Active Record within Yii, is the ability to access valid members of a project to which an issue belongs directly from the issue $model instance itself. When we used the Gii tool to initially create our issue model class, it was smart enough to look at the underlying database and build in the relevant relationships. This can be seen in the relations() method within /protected/models/Issue.php. As we created this class after adding the appropriate relationships to the database, the method should look similar to this:

/**
 * @return array relational rules.
 */
public function relations() {
    // NOTE: you may need to adjust the relation name and the related
    // class name for the relations automatically generated below.
    return array(
        'requester' => array(self::BELONGS_TO, 'User', 'requester_id'),
        'owner' => array(self::BELONGS_TO, 'User', 'owner_id'),
        'project' => array(self::BELONGS_TO, 'Project', 'project_id'),
    );
}

As the NOTE suggests, you may have slightly different attributed names and may want to adjust them as needed. This array configuration defines properties on the model instance that are themselves other AR instances. With these relations in place, we can access the related AR instances incredibly easily. For example, say we want to access the Project model class to which an issue is associated. We can do so by using the following syntax:

//create the model instance by primary key:
$model = Issue::model()->findbyPk(1);
//access the associated Project AR instance
$project = $model->project;

Now, because we created our Project model class prior to having other tables and relationships defined in our database, there are no relations defined yet. However, now that we have some relationships defined, we need to add these to the Project::relations() method. Open the Project AR class in /protected/models/ Project.php, and replace the entire relations() method with the following:

/**
 * @return array relational rules.
 */
public function relations() {
    // NOTE: you may need to adjust the relation name and the related
    // class name for the relations automatically generated below.
    return array(
        'issues' => array(self::HAS_MANY, 'Issue', 'project_id'),
        'users' => array(self::MANY_MANY, 'User', 
                                     'tbl_project_user_assignment(project_id, user_id)'),
    );
}

With these in place, we can easily access all of the issues and/or users associated with a project with incredibly easy syntax as follows:

//create the Project model instance by primary key:
$model = Project::model()->findbyPk(1);
//get an array of all associated Issue AR instances
$allIssues = $model->issues;
//get an array of all associated User AR instance
$allUsers = $model->users;
//get the User AR instance representing the owner of
//the first issue associated with this project
$ownerOfFirstIssue = $model->issues[0]->owner;

Normally we would have to write complicated SQL join statements to access such related data. Using relational AR in Yii saves us from this complexity and tedium. We can now access these relationships in a very elegant and concise object oriented manner.

Generating the data to populate the drop-down menu

Now, there are a couple of ways by which we could use this data to populate our needed dropdowns for the Requester and Owner fields. We'll follow a similar approach as we did for the Status and Type drop-down data, and place the logic inside a model class. In this case, the Project AR class makes the most sense, as valid users are associated with a project, and not with an issue.

As we are going to add a new public method to the Project AR class, we can once again use our TDD approach. So, let's quickly write a test that fails.

Once again, remember that we have now setup a trackstar_test database against which to test. If you are following along, please ensure this database schema is in sync with the trackstar_dev database.

Open the /protected/tests/unit/ProjectTest.php file and add the following test:

public function testGetUserOptions() 
{
    $project = $this->projects('project1'); 
    $options = $project->userOptions;
    $this->assertTrue(is_array($options));
}

Now run the test.

>>phpunit unit/ProjectTest.php 
PHPUnit 3.3.17 by Sebastian Bergmann. 
....E 
Time: 0 seconds There was 1 error: 
1) ProjectTest::testGetUserOptions 
CException: Property "Project.userOptions" is not defined.... 
FAILURES! 
Tests: 5, Assertions: 10, Errors: 1.

Okay, we have a test that fails. It is failing for obvious reasons, as we are testing a method in the Project AR class that does not yet exist. So let's add it. Open up the file /protected/models/Project.php, and add the following method to the bottom of the class:

/**
 * @return array of valid users for this project, indexed by user IDs
 */
public function getUserOptions() 
{
    $usersArray = array(); 
    return $usersArray;
}

If we run our tests again, we see we are back in the "green". However, we only have a method that returns an empty array. What we need is a valid user array that can be used to populate the form dropdowns. Let's get our test back in the "red" by testing to ensure the count of the returned array is > 0.

Alter the test method to be:

public function testGetUserOptions() 
{
    $project = $this->projects('project1'); 
    $options = $project->userOptions; 
    $this->assertTrue(is_array($options)); 
    $this->assertTrue(count($options) > 0);
}

Running the test again should now result in the following error:

There was 1 failure:
1) ProjectTest::testGetUserOptions 
Failed asserting that <boolean:false> is true.

So, let's toggle back to the Project::getUserOptions() method and return some actual users. Alter that method to be:

public function getUserOptions() 
{
    $usersArray = CHtml::listData($this->users, 'id', 'username'); 
    return $usersArray;
}

Here we are using Yii's CHtml helper class to help us create an array of id=>username pairs from each user associated with the project. Remember that the users property in the Project class maps to an array of User AR instances. The CHtml::listData() method can take in this list and product a valid array suitable for CActiveForm::dropDownList(). Now, as long as we remember to populate our test database with our two users and associate them with Project #1, our tests will pass.

Adding User and ProjectUserAssignment fixtures

Our tests are now passing, but only because we explicitly added users, and we also explicitly added the related entries to the project association table. What happens if someone comes along and removes these entries? We need to fix this fragile relationship. We already know that test fixtures are exactly what we need to ensure that our tests involving database data can be repeatedly run in a consistent manner. We did this before for our project data. We need to do it again for data related to both the tbl_user and tbl_project_user_assignment tables.

Create a new file, /protected/tests/fixtures/tbl_user.php, and add to it the following:

return array(
    'user1' => array(
        'email' => 'test1@notanaddress.com',
        'username' => 'Test_User_One',
        'password' => MD5('test1'),
        'last_login_time' => '',
        'create_time' => '',
        'create_user_id' => '',
        'update_time' => '',
        'update_user_id' => '',
    ),
    'user2' => array(
        'email' => 'test2@notanaddress.com',
        'username' => 'Test_User_Two',
        'password' => MD5('test2'),
        'last_login_time' => '',
        'create_time' => '',
        'create_user_id' => '',
        'update_time' => '',
        'update_user_id' => '',
    ),
);

This is the same data we added manually via explicit SQL earlier, but here it is represented as fixture data.

We need to do the same for our association table. Create another new file, /protected/tests/fixtures/tbl_project_user_assignment.php and add the following content:

return array(
    'user1ToProject1' => array(
        'project_id' => 1,
        'user_id' => 1,
        'create_time' => '',
        'create_user_id' => '',
        'update_time' => '',
        'update_user_id' => '',
    ),
    'user2ToProject1' => array(
        'project_id' => 1,
        'user_id' => 2,
        'create_time' => '',
        'create_user_id' => '',
        'update_time' => '',
        'update_user_id' => '',
    ),
);

This is also the same data as we added to the tbl_project_user_assignment table manually, but represented as fixture data.

Now we need to add the fixture to the unit test. Open up the ProjectTest file, /protected/tests/unit/ProjectTest.php, and add it to the fixtures definition at the top of that file with the following highlighted code:

public $fixtures = array(
    'projects' => 'Project',
    'users' => 'User',
    'projUsrAssign' => ':tbl_project_user_assignment',
);

Notice that we had to add the : when mapping to the tbl_project_user_ assignment table. This is needed to indicate that this is a database table, and not an AR model class.

Now that this has been added, each time we run the ProjectTest.php unit test, our tbl_user and tbl_project_user_assignment tables will be reset to a consistent state using the data defined in the corresponding fixture data files.

Now let us run our project-related tests again:

>> unit/ProjectTest.php 
PHPUnit 3.4.12 by Sebastian Bergmann.
..... Time: 0 seconds 
OK (5 tests, 12 assertions)

We still have passing tests, but now they are using this new fixture data.

Now that we have our getUserOptions() method working as expected, we need to implement the dropdown to display that returned data. We already added a private $_project attribute to our IssueController class. This attribute contains the valid project context. We need to access this same project attribute in our view file that displays the input form. So, we need to add a simple getter method to expose this private attribute. Add the following method to the bottom of the IssueController class:

/**
   * Returns the project model instance to which this issue belongs
   */
public function getProject() {
    return $this->_project;
}

Now, open up the view file containing the input form elements, /protected/views/ issue/_form.php, and find where the two text field input forms element definitions for owner_id and requester_id.

Replace

<?php echo $form->textField($model,'owner_id'); ?>

with this:

<?php echo $form->dropDownList($model,'owner_id', $this->getProject()->getUserOptions()); ?>

Also replace this line:

<?php echo $form->textField($model,'requester_id'); ?>

with this:

<?php echo $form->dropDownList($model,'requester_id', $this->getProject()->getUserOptions()); ?>

Now if we view our issue creation form again, we see two nicely populated dropdown fields for the Owner and Requester.

Making one last change

As we already have the Create Issue form view file open, let's quickly make one last change. The creation time and user as well as the last updated time and user fields that we have on every table for basic history and auditing purposes should not be exposed to the user. Later, we will alter the application logic to automatically populate these fields upon inserts and updates. For now, let's just remove them as inputs on the form.

Just completely remove the following lines from /protected/views/issue /_form.php:

<div class="row"> 
    <?php echo $form->labelEx($model,'create_time'); ?> 
    <?php echo $form->textField($model,'create_time'); ?> 
    <?php echo $form->error($model,'create_time'); ?>
</div>
 
<div class="row"> 
    <?php echo $form->labelEx($model,'create_user_id'); ?> 
    <?php echo $form->textField($model,'create_user_id'); ?> 
    <?php echo $form->error($model,'create_user_id'); ?>
</div>
 
<div class="row"> 
    <?php echo $form->labelEx($model,'update_time'); ?> 
    <?php echo $form->textField($model,'update_time'); ?> 
    <?php echo $form->error($model,'update_time'); ?>
</div>
 
<div class="row"> 
    <?php echo $form->labelEx($model,'update_user_id'); ?> 
    <?php echo $form->textField($model,'update_user_id'); ?> 
    <?php echo $form->error($model,'update_user_id'); ?>
</div>

The following screenshot shows what our new issue creation form now looks like with all of these changes:

Finishing the rest of the CRUD

The goal of this iteration is to implement all the CRUD operations for issues. We have finalized the create functionality, but we still need to complete the read, update and delete of issues. Luckily, most of the foundation has already been laid by using the Gii CRUD generation functionality. However, as we want to manage issues all within the context of a project, we need to make some adjustments to how you access this functionality.

Listing the issues

Even though there is the actionIndex() method in the IssueController class that displays a list of all issues in the database, we don't have a need for this functionality as it is currently coded. Rather than a separate standalone page that lists all the issues in the database, we want to only list the issues that are associated with a specific project. So, we'll alter the application to display the listing of issues as part of the project details page. As we are taking advantage of the relational AR model in Yii, it will be a snap to make this change.

Altering the ProjectController

First, let's alter the actionView() method in the ProjectController class. As we want to display a list of the issues associated with a specific project, we can do this on the same page as the project details page. The method actionView() is the method that displays the project details.

Alter that method to be:

/**
 * Displays a particular model.
 */
public function actionView() {
    $id = $_GET['id'];
    $issueDataProvider = new CActiveDataProvider('Issue', array(
                'criteria' => array(
                    'condition' => 'project_id=:projectId',
                    'params' => array(
                        ':projectId' => $this->loadModel($id)->id),
                ),
                'pagination' => array('pageSize' => 1),
    ));
    $this->render('view', array(
        'model' => $this->loadModel($id),
        'issueDataProvider' => $issueDataProvider,
    ));
}

Here we are using the CActiveDataProvider framework class to provide data in terms of ActiveRecord objects. It will use the associated AR model class to retrieve data from the database in a manner that can be used very easily with the Zii widget CListView to display items in a list rendered in a manner specified in a view file. We have used the criteria property to specify the condition that it should only retrieve issues associated with the project being displayed. We also used the pagination property to limit the issue list to just one issue per page. We set this very low so we can quickly demonstrate the paging features by just adding two issues. We'll demonstrate this soon.

The last thing we did was add this data provider to the array defined in the render() to make it available to the view file in a $issueDataProvider variable.

Altering the project view file

We'll use the Zii widget CListView to display our list of issues on the project details page. Open up /protected/views/project/view.php, and add this to the bottom of that file:

<br> 
<h1>Project Issues</h1> 
 
<?php $this->widget('zii.widgets.CListView', array(
            'dataProvider'=>$issueDataProvider,
            'itemView'=>'/issue/_view'
        ));
?>

Here we are setting the dataProvider property of CListView to be our issue data provider we created above. And then we are configuring it to use the protected/ views/issue/_view.php file as a template for rendering each item in the data provider. This file was already created for us by the Gii tool when we generated our CRUD for issues. We are just using it here to display issues on the project details page.

We need to also make a couple of changes to the /protected/views/issue/_view. php file that we specified as a layout template for each issue. Alter the entire contents of that file to be the following:

<div class="view"> 
    <b><?php echo CHtml::encode($data->getAttributeLabel('name')); ?>:</b> 
    <?php echo CHtml::link(CHtml::encode($data->name), array('issue/view', 'id' => $data->id)); ?>
    <br />
    <b><?php echo CHtml::encode($data->getAttributeLabel('description')); ?>:</b> 
    <?php echo CHtml::encode($data->description); ?>
    <br />
    <b><?php echo CHtml::encode($data->getAttributeLabel('type_ id')); ?>:</b> 
    <?php echo CHtml::encode($data->type_id); ?>
    <br />
    <b><?php echo CHtml::encode($data->getAttributeLabel('status_ id')); ?>:</b> 
    <?php echo CHtml::encode($data->status_id); ?>
</div>

Now if we save and view our results by looking at the project details page for Project # 1 (http://localhost/tasctrak/index.php?r=project/view&id=1), and assuming you have created a couple of test issues under that project, you should see a page like the one in the following screen:

As we set the pagination property of our data provider very low (remember we set it to just 1), we can add one more issue to demonstrate the built-in paging functionality. Adding one more issue changes the display of issues to have links that allow us to go from page to page within our Project Issues listing, as depicted in the following screenshot:

Making some final tweaks

We now have a list of our issues associated with a project that are displayed from within the project details page. We also have the ability to view the details of an issue "R"ead, as well as links to "U"pdate and "D"elete issues. So, for the most part our CRUD operations are in place.

However, there are still a few items that need to be addressed before we can close out this iteration. One thing we notice is that the issues display list is showing numeric ID numbers for the Type, Status, Owner and Requester fields. We should change this so that the text values for those are displayed instead. Also, as issues are under a specific project already, it is a bit redundant to have the project ID displayed as part of the issue list data. So, we can remove that. Finally, we need to address some of the navigational links that are displayed on the various other issue related forms to ensure we are always returning to this project details page as the starting place for all of our issue management.

We'll tackle these one at a time.

Getting the status and type text to display

Previously we added public methods to the Issue AR class to retrieve the Status and Type options to populate our dropdowns on the issue creation form. We need to add similar methods on this AR class to return the text for the specific identifier for display on our issues listing.

As these will be public methods on the issue AR class, we should implement it using our TDD approach. To speed things up a bit, we'll do both of these at the same time. Also, as we get a hang of TDD a little bit, we'll start to take bigger steps. We can always return to a more granular approach.

First we need to add some fixture data to ensure we have a couple of issues associated with a project. We also need to make sure our issue tests are using the project fixture data as well as issues belong to projects.

First, add a new fixtures data file for issues, /protected/tests/fixtures/tbl_ issue.php and add to it the following content:

return array('issueBug' => array(
        'name' => 'Test Bug 1',
        'description' => 'This is test bug for project 1',
        'project_id' => 1,
        'type_id' => 0,
        'status_id' => 1,
        'owner_id' => 1,
        'requester_id' => 2,
        'create_time' => '',
        'create_user_id' => '',
        'update_time' => '',
        'update_user_id' => '',
    ),
    'issueFeature' => array(
        'name' => 'Test Bug 2',
        'description' => 'This is test bug for project 2',
        'project_id' => 2,
        'type_id' => 1,
        'status_id' => 0,
        'owner_id' => 2,
        'requester_id' => 1,
        'create_time' => '',
        'create_user_id' => '',
        'update_time' => '',
        'update_user_id' => '',
    ),
);

Now we need to configure our IssueTest class to use some fixture data. Add the following fixtures array at the top of the issue test class:

public $fixtures=array(
            'projects'=>'Project', 
            'issues'=>'Issue',
);

With our fixture data in place, we can add two new tests to the IssueTest unit test class for testing the status and type text:

public function testGetStatusText() 
{
         $this->assertTrue('Started' == $this->issues('issueBug')->getStatusText());
}

And also this test:

public function testGetTypeText()
{
    $this->assertTrue('Bug' == $this->issues('issueBug')->getTypeText());
}

Now if we run the test, we should get a failure due to the fact that we have not yet added these public methods to our AR class:

>>phpunit unit/IssueTest.php 
PHPUnit 3.4.12 by Sebastian Bergmann. 
..EE 
Time: 2 seconds, Memory: 12.25Mb 
There were 2 errors:
 
1) IssueTest::testGetStatusText 
Exception: Unknown method 'issues' for class 'IssueTest'.
 
...
 
2) IssueTest::testGetTypeText 
Exception: Unknown method 'issues' for class 'IssueTest'.
 
...
 
FAILURES! 
Tests: 4, Assertions: 10, Errors: 2.

So, we've got our failing test, let's add the necessary code to our /protected/ models/Issue.php file to get them to pass. Add the following two new public methods to the Issue class to retrieve the status and type text for the current issue:

/**
 * @return string the status text display for the current issue
 */
public function getStatusText() {
    $statusOptions = $this->statusOptions;
    return isset($statusOptions[$this->status_id]) ?
            $statusOptions[$this->status_id] :
            "unknown status ({$this->status_id})";
}
 
/**
 * @return string the type text display for the current issue
 */
public function getTypeText() {
    $typeOptions = $this->typeOptions;
    return isset($typeOptions[$this->type_id]) ?
            $typeOptions[$this->type_id] :
            "unknown type ({$this->type_id})";
}

Now let's run our tests again:

>>phpunit unit/IssueTest.php 
.... 
Time: 1 second, Memory: 12.25Mb 
OK (4 tests, 12 assertions)

We have both tests passing and back in the 'green'.

Adding the text display to the form

Now we have our two new public methods that will return the valid status and type text for our listing to display, we need to make use of them. Alter the following lines of code in /protected/views/issue/_view.php:

Change the following command:

<?php echo CHtml::encode($data->type_id); ?>

to:

<?php echo CHtml::encode($data->getTypeText()); ?>

and change this command:

<?php echo CHtml::encode($data->status_id); ?>

to this:

<?php echo CHtml::encode($data->getStatusText()); ?>

After these changes, our Issues listing page, http://localhost/trackstar/index. php?r=issue no longer displays integer values for our issue Type and Status fields. It now looks like what is displayed in the following screenshot:

As we are using the same view file to display our Issues listing on our project detail pages, these changes are reflected there as well.

Changing the issue detail view

We also need to make these and a few other changes to the detailed view of the Issue. Currently, if we view the Issue details, it should look like the following screenshot:

This is using a view file we have not altered at all as of yet. It is still displaying the project ID, which we don't need to display, as well as the type and status as integer values, rather than their associated text values. Opening the view file used to render this display, /protected/views/issue/view.php, we notice that it is using the Zii extension widget, CDetailView, which we have not seen before. This is similar to the CListView widget used to display the listing, but is used to display the details of a single data model instance (or associative array), rather than for displaying a list view of many. The relevant code from this file showing the use of this widget is as follows:

$this->widget('zii.widgets.CDetailView', array(
    'data'=>$model,
    'attributes'=>array(
        'id',
        'name',
        'description',
        'project_id',
        'type_id',
        'status_id',
        'owner_id',
        'requester_id',
        'create_time',
        'create_user_id',
        'update_time',
        'update_user_id',
    ),
));

Here we are setting the data model of the CDetailView widget to be the Issue model class and then setting a list of attributes of the model to be displayed in the rendered detail view. An attribute can be specified as a string in the format of Name:Type:Label, of which both Type and Label are optional, or as an array itself. Here, just the name of the attributes are specified.

If we specify an attribute as an array, we can customize the display further by declaring a value element. We will take this approach in order to specify the model class methods getTypeText() and getStatusText() be used as the values for the Type and Status fields respectively.

Let's change this use of CDetailView to use the following configuration:

$this->widget('zii.widgets.CDetailView', array(
    'data' => $model,
    'attributes' => array(
        'id',
        'name',
        'description',
        array(
            'name' => 'type_id',
            'value' => CHtml::encode($model->getTypeText())
        ), array(
            'name' => 'status_id',
            'value' => CHtml::encode($model->getStatusText())
        ),
        'owner_id',
        'requester_id',),
));

Here we have removed a few attributes from displaying at all. The project_id, create_time, update_time, create_user_id, and update_user_id. We will handle the population and display of some of these later, but for now we can just remove them from the detail display.

We also changed the declaration of the type_id and status_id attributes to use an array specification so that we could use the value element. We have specified that the corresponding Issue::getTypeText() and Issue::getStatusText() methods be used for getting the values of these attributes. With these changes in place, the Issue details page looks like the following:

Okay, we are getting much closer to what we want, but there are still a couple of changes we need to make.

Getting the owner and requester names to display

Things are looking better, but we still see integer identifiers displaying for the owner and requester, rather than the actual user names. We'll take a similar approach to what we did for the type and status text displays. We'll add two new public methods on the Issue model class to return the names of these two properties.

Using relational AR

As the issues and users are represented as separate database tables and related through a foreign key relationship, we an actually access the owner and requester username directly from $model in the view file. Utilizing the power of Yii's relational AR model features, displaying the username attribute of the related User model class instance is a snap.

As we have mentioned, the model class Issue::relations() method is where the relationships are defined. If we take a peek at this method, we see the following:

/**
 * @return array relational rules.
 */
public function relations() {
    // NOTE: you may need to adjust the relation name and the related
    // class name for the relations automatically generated below.
    return array(
        'requester' => array(self::BELONGS_TO, 'User', 'requester_id'),
        'owner' => array(self::BELONGS_TO, 'User', 'owner_id'),
        'project' => array(self::BELONGS_TO, 'Project', 'project_id'),
    );
}

The highlighted code is what is most relevant for our needs. There are both owner and requester attributes defined as relations to the User model class. These definitions specify that the values of these attributes are User model class instances. The owner_id and the requester_id specify the unique Primary key of their respective User class instances. So, we can access these just as we do for other attributes of the Issue model class.

So, to display the username of the owner and requester User class instances, we once again change our CDetailView configuration to be:

$this->widget('zii.widgets.CDetailView', array(
    'data' => $model,
    'attributes' => array(
        'id',
        'name',
        'description',
        array(
            'name' => 'type_id',
            'value' => CHtml::encode($model->getTypeText())
        ), array(
            'name' => 'status_id',
            'value' => CHtml::encode($model->getStatusText())
        ),
        array(
            'name' => 'owner_id',
            'value' => CHtml::encode($model->owner->username)
        ),
        array(
            'name' => 'requester_id',
            'value' => CHtml::encode($model->requester->username)
        ),
    ),
));

After making these changes, our Issues detail listing is starting to look pretty good. The following figure shows the progress thus far:

Making some final navigation tweaks

We are very close to completing the functionality we set out to implement within this iteration. The only thing left is to clean up our navigation just a little. You may have noticed that there are still some options available that allow the user to navigate to an entire listing of issues, or to create a new issue, outside of a project context. For the purposes of the TrackStar application, everything we do with issues should be within the context of a specific project. Earlier, we enforced this project context for creating a new issue (which is a good start), but we still need to make a few changes.

One thing that we notice is that the application still allows the user to navigate to a listing of all issues, across all projects. For example, on an Issue detail page, like http://localhost/trackstar/index.php?r=issue/view&id=1, we see in the right column menu navigation there are the links List Issue and Manage Issue, corresponding to http://localhost/trackstar/index.php?r=issue/ index and http://localhost/trackstar/index.php?r=issue/admin respectively (remember that to access the admin page, you have to be logged in as admin/ admin). These still display all issues, across all projects. So, we need to limit this list to a specific project.

As these links originate from the Issue details page, and that specific issue has an associated project, we can first alter the links to pass in a specific project ID, and thehe uof that project ID as both the IssueController::actionIndex, and IssueController::actionAdmin() methods.

First let's alter the links. Open up /protected/views/issue/view.php file and locate the array of menu items at the top of the file. Change the menu configuration to be:

$this->menu = array(
    array('label' => 'List Issue', 'url' => array('index', 'pid' => $model->project->id)),
    array('label' => 'Create Issue', 'url' => array('create', 'pid' => $model->project->id)),
    array('label' => 'Update Issue', 'url' => array('update', 'id' => $model->id)),
    array('label' => 'Delete Issue', 'url' => '#', 'linkOptions' => array('submit' => array('delete', 'id' => $model->id), 'confirm' => 'Are you sure you want to delete this item?')),
    array('label' => 'Manage Issue', 'url' => array('admin', 'pid' => $model->project->id))
);

The changes made are highlighted. We have added a new querystring parameter to the new Create Issue link, as well as to the Issue listing page and the issue admin listing page. We already knew we had to make this change for the Create link, as we have previously implemented a filter to enforce a valid project conssue. We won't have to make any further changes relative to this link. But for the index and admin links, we will need to alter their corresponding action methods to make use of this new querystring variable.

As we have already configured a filter to load the associated project using the querysting variable, let's take advantage of this. We'll need to change the filter configuration so that our filter method is called prior to execution of both the IssueController::actionIndex() and IssueController::actionAdmin() methods. Change the filters method as shown:

/**
 * @return array action filters
 */
public function filters() {
    return array(
        'accessControl',
        // perform access control for CRUD operations
        'projectContext + create index admin',
        //perform a check to ensure valid project context
    );
}

With this in place, the associated project will be loaded and available for use. Let's use it in our IssueController::actionIndex() method. Alter that method to be:

public function actionIndex() {
    $dataProvider = new CActiveDataProvider('Issue',array(
        'criteria' => array(
        'condition' => 'project_id=:projectId',
        'params' => array(
            ':projectId' => $this->_project->id),
        ),
    ));
    
    $this->render('index', array(
        'dataProvider' => $dataProvider,
    ));
}

Here, as we have done before, we are simply adding a condition to the creation of the model data provider to only retrieve issues associated with the project. This will limit the list of issues to just the ones under the project.

We need to make the same change to the admin listing page. However, this view file, /protected/views/issue/admin.php is using the results of the model class Issue::search() method to provide the listing of issues. So, we actually need to make two changes to enforce the project context with this listing.

First, we need to alter the IssueController::actionAdmin() method to set the correct project_id attribute on the model instance it is sending to the view. The following highlighted code shows this change:

public function actionAdmin() {
     $model = new Issue('search');
     if (isset($_GET['Issue']))
         $model->attributes = $_GET['Issue'];
 
     $model->project_id = $this->_project->id; //&lt;--这个
 
     $this->render('admin', array(
         'model' => $model,
     ));
}

Then we need to add to our criteria in the Issue::search() model class method. The following highlighted code identifies the change we need to make to this method:

public function search() {
 // Warning: Please modify the following code to remove attributes that
 // should not be searched.
     $criteria = new CDbCriteria;
     $criteria->compare('id', $this->id);
     $criteria->compare('name', $this->name, true);
     $criteria->compare('description', $this->description, true);
     $criteria->compare('type_id', $this->type_id);
     $criteria->compare('status_id', $this->status_id);
     $criteria->compare('owner_id', $this->owner_id);
     $criteria->compare('requester_id', $this->requester_id);
     $criteria->compare('create_time', $this->create_time, true);
     $criteria->compare('create_user_id', $this->create_user_id);
     $criteria->compare('update_time', $this->update_time, true);
     $criteria->compare('update_user_id', $this->update_user_id);
     $criteria->condition = 'project_id=:projectID';    //-->this row
     $criteria->params = array(    //-->this row
         ':projectID' => $this->project_id    //-->this row
     );    //-->this row
     return new CActiveDataProvider(get_class($this), array(
         'criteria' => $criteria,
     ));
 }

With these changes in place, the issues listed on the admin page are now restricted to be only those associated with the specific project.

There are several places throughout the view files under /protected/ views/issues/ that contain links that require a pid querystring to be added in order to work properly. We leave it as an exercise to the reader to make the appropriate changes following the same approach as provided in these examples. As we proceed with our application's development, we'll assume all links to create a new issue or to display a list of issues are properly formatted to contain the appropriate pid querystring parameter.

Summary

We were able to cover a lot of different topics in this iteration. Based on the relationship between issues, projects, and users within our application, the implementation of our issue management functionality was significantly more complicated than our project entity management we worked on in the previous iteration. Fortunately, Yii was able to come to our rescue many times to alleviate the pain of having to write all of the code needed to address this complexity.

Specifically, we covered:

    Using the Gii code generator tool for Active Record model creation as well as for the initial implementation of all basic CRUD operations against the Issue entity • Designing and building database tables with explicit relationships • Using relational Active Record • Adding drop-down menu input type form elements • Controller filters

We have made a lot of progress on our basic application thus far, and have done so without having to write a lot of code. The Yii Framework itself has done most of the heavy lifting. We now have a working application that allows us to manage projects and also manage issues within those projects. This is the heart of what our application is trying to achieve. We should feel proud of the accomplishments thus far.

However, we still have a long way to go before this application is truly ready for production use. A major missing piece is all of the needed functionality around user management. This is going to be the focus of the next two iterations.

评论 X

      友荐云推荐
      Copyright 2011-2014. YiiBook.com