Skip to content

Tutorial Series Part 3: Person Page and Comments#

In this part of our tutorial series, we will add a new front end page to our package that is dedicated to each person and shows their personal details. To make good use of this new page and introduce a new API of WoltLab Suite, we will add the opportunity for users to comment on the person using WoltLab Suite’s reusable comment functionality.

Package Functionality#

In addition to the existing functions from part 1, the package will provide the following possibilities/functions after this part of the tutorial:

  • Details page for each person linked in the front end person list
  • Comment on people on their respective page (can be disabled per person)
  • User online location for person details page with name and link to person details page
  • Create menu items linking to specific person details pages

Used Components#

In addition to the components used in part 1, we will use the objectType package installation plugin, use the comment API, create a runtime cache, and create a page handler.

Package Structure#

The complete package will have the following file structure (including the files from part 1):

├── acpMenu.xml
├── acptemplates
│   ├── personAdd.tpl
│   └── personList.tpl
├── files
│   └── lib
│       ├── acp
│       │   ├── form
│       │   │   ├── PersonAddForm.class.php
│       │   │   └── PersonEditForm.class.php
│       │   └── page
│       │       └── PersonListPage.class.php
│       ├── data
│       │   └── person
│       │       ├── Person.class.php
│       │       ├── PersonAction.class.php
│       │       ├── PersonEditor.class.php
│       │       └── PersonList.class.php
│       ├── page
│       │   ├── PersonListPage.class.php
│       │   └── PersonPage.class.php
│       └── system
│           ├── cache
│           │   └── runtime
│           │       └── PersonRuntimeCache.class.php
│           ├── comment
│           │   └── manager
│           │       └── PersonCommentManager.class.php
│           └── page
│               └── handler
│                   └── PersonPageHandler.class.php
├── install.sql
├── language
│   ├── de.xml
│   └── en.xml
├── menuItem.xml
├── objectType.xml
├── package.xml
├── page.xml
├── templates
│   ├── person.tpl
│   └── personList.tpl
└── userGroupOption.xml

We will not mention every code change between the first part and this part, as we only want to focus on the important, new parts of the code. For example, there is a new Person::getLink() method and new language items have been added. For all changes, please refer to the source code on GitHub.

Runtime Cache#

To reduce the number of database queries when different APIs require person objects, we implement a runtime cache for people:

<?php
namespace wcf\system\cache\runtime;
use wcf\data\person\Person;
use wcf\data\person\PersonList;

/**
 * Runtime cache implementation for people.
 *
 * @author  Matthias Schmidt
 * @copyright   2001-2019 WoltLab GmbH
 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 * @package WoltLabSuite\Core\System\Cache\Runtime
 * @since   3.0
 *
 * @method  Person[]    getCachedObjects()
 * @method  Person      getObject($objectID)
 * @method  Person[]    getObjects(array $objectIDs)
 */
class PersonRuntimeCache extends AbstractRuntimeCache {
    /**
     * @inheritDoc
     */
    protected $listClassName = PersonList::class;
}

Comments#

To allow users to comment on people, we need to tell the system that people support comments. This is done by registering a com.woltlab.wcf.comment.commentableContent object type whose processor implements ICommentManager:

<?xml version="1.0" encoding="UTF-8"?>
<data xmlns="http://www.woltlab.com" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.woltlab.com http://www.woltlab.com/XSD/tornado/objectType.xsd">
    <import>
        <type>
            <name>com.woltlab.wcf.person.personComment</name>
            <definitionname>com.woltlab.wcf.comment.commentableContent</definitionname>
            <classname>wcf\system\comment\manager\PersonCommentManager</classname>
        </type>
    </import>
</data>

The PersonCommentManager class extended ICommentManager’s default implementation AbstractCommentManager:

<?php
namespace wcf\system\comment\manager;
use wcf\data\person\Person;
use wcf\data\person\PersonEditor;
use wcf\system\cache\runtime\PersonRuntimeCache;
use wcf\system\WCF;

/**
 * Comment manager implementation for people.
 *
 * @author  Matthias Schmidt
 * @copyright   2001-2019 WoltLab GmbH
 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 * @package WoltLabSuite\Core\System\Comment\Manager
 */
class PersonCommentManager extends AbstractCommentManager {
    /**
     * @inheritDoc
     */
    protected $permissionAdd = 'user.person.canAddComment';

    /**
     * @inheritDoc
     */
    protected $permissionAddWithoutModeration = 'user.person.canAddCommentWithoutModeration';

    /**
     * @inheritDoc
     */
    protected $permissionCanModerate = 'mod.person.canModerateComment';

    /**
     * @inheritDoc
     */
    protected $permissionDelete = 'user.person.canDeleteComment';

    /**
     * @inheritDoc
     */
    protected $permissionEdit = 'user.person.canEditComment';

    /**
     * @inheritDoc
     */
    protected $permissionModDelete = 'mod.person.canDeleteComment';

    /**
     * @inheritDoc
     */
    protected $permissionModEdit = 'mod.person.canEditComment';

    /**
     * @inheritDoc
     */
    public function getLink($objectTypeID, $objectID) {
        return PersonRuntimeCache::getInstance()->getObject($objectID)->getLink();
    }

    /**
     * @inheritDoc
     */
    public function isAccessible($objectID, $validateWritePermission = false) {
        return PersonRuntimeCache::getInstance()->getObject($objectID) !== null;
    }

    /**
     * @inheritDoc
     */
    public function getTitle($objectTypeID, $objectID, $isResponse = false) {
        if ($isResponse) {
            return WCF::getLanguage()->get('wcf.person.commentResponse');
        }

        return WCF::getLanguage()->getDynamicVariable('wcf.person.comment');
    }

    /**
     * @inheritDoc
     */
    public function updateCounter($objectID, $value) {
        (new PersonEditor(new Person($objectID)))->updateCounters(['comments' => $value]);
    }
}
  • First, the system is told the names of the permissions via the $permission* properties. More information about comment permissions can be found here.
  • The getLink() method returns the link to the person with the passed comment id. As in isAccessible(), PersonRuntimeCache is used to potentially save database queries.
  • The isAccessible() method checks if the active user can access the relevant person. As we do not have any special restrictions for accessing people, we only need to check if the person exists.
  • The getTitle() method returns the title used for comments and responses, which is just a generic language item in this case.
  • The updateCounter() updates the comments’ counter of the person. We have added a new comments database table column to the wcf1_person database table in order to keep track on the number of comments.

Additionally, we have added a new enableComments database table column to the wcf1_person database table whose value can be set when creating or editing a person in the ACP. With this option, comments on individual people can be disabled.

Liking comments is already built-in and only requires some extra code in the PersonPage class for showing the likes of pre-loaded comments.

Person Page#

PersonPage#

<?php
namespace wcf\page;
use wcf\data\person\Person;
use wcf\system\comment\CommentHandler;
use wcf\system\comment\manager\PersonCommentManager;
use wcf\system\exception\IllegalLinkException;
use wcf\system\WCF;

/**
 * Shows the details of a certain person.
 * 
 * @author  Matthias Schmidt
 * @copyright   2001-2019 WoltLab GmbH
 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 * @package WoltLabSuite\Core\Page
 */
class PersonPage extends AbstractPage {
    /**
     * list of comments
     * @var StructuredCommentList
     */
    public $commentList;

    /**
     * person comment manager object
     * @var PersonCommentManager
     */
    public $commentManager;

    /**
     * id of the person comment object type
     * @var integer
     */
    public $commentObjectTypeID = 0;

    /**
     * shown person
     * @var Person
     */
    public $person;

    /**
     * id of the shown person
     * @var integer
     */
    public $personID = 0;

    /**
     * @inheritDoc
     */
    public function assignVariables() {
        parent::assignVariables();

        WCF::getTPL()->assign([
            'commentCanAdd' => WCF::getSession()->getPermission('user.person.canAddComment'),
            'commentList' => $this->commentList,
            'commentObjectTypeID' => $this->commentObjectTypeID,
            'lastCommentTime' => $this->commentList ? $this->commentList->getMinCommentTime() : 0,
            'likeData' => (MODULE_LIKE && $this->commentList) ? $this->commentList->getLikeData() : [],
            'person' => $this->person
        ]);
    }

    /**
     * @inheritDoc
     */
    public function readData() {
        parent::readData();

        if ($this->person->enableComments) {
            $this->commentObjectTypeID = CommentHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.person.personComment');
            $this->commentManager = CommentHandler::getInstance()->getObjectType($this->commentObjectTypeID)->getProcessor();
            $this->commentList = CommentHandler::getInstance()->getCommentList($this->commentManager, $this->commentObjectTypeID, $this->person->personID);
        }
    }

    /**
     * @inheritDoc
     */
    public function readParameters() {
        parent::readParameters();

        if (isset($_REQUEST['id'])) $this->personID = intval($_REQUEST['id']);
        $this->person = new Person($this->personID);
        if (!$this->person->personID) {
            throw new IllegalLinkException();
        }
    }
}

The PersonPage class is similar to the PersonEditForm in the ACP in that it reads the id of the requested person from the request data and validates the id in readParameters(). The rest of the code only handles fetching the list of comments on the requested person. In readData(), this list is fetched using CommentHandler::getCommentList() if comments are enabled for the person. The assignVariables() method assigns some additional template variables like $commentCanAdd, which is 1 if the active person can add comments and is 0 otherwise, $lastCommentTime, which contains the UNIX timestamp of the last comment, and $likeData, which contains data related to the likes for the disabled comments.

person.tpl#

{capture assign='pageTitle'}{$person} - {lang}wcf.person.list{/lang}{/capture}

{capture assign='contentTitle'}{$person}{/capture}

{include file='header'}

{if $person->enableComments}
    {if $commentList|count || $commentCanAdd}
        <section id="comments" class="section sectionContainerList">
            <header class="sectionHeader">
                <h2 class="sectionTitle">{lang}wcf.person.comments{/lang}{if $person->comments} <span class="badge">{#$person->comments}</span>{/if}</h2>
            </header>

            {include file='__commentJavaScript' commentContainerID='personCommentList'}

            <div class="personComments">
                <ul id="personCommentList" class="commentList containerList"
                    data-can-add="{if $commentCanAdd}true{else}false{/if}" 
                    data-object-id="{@$person->personID}"
                    data-object-type-id="{@$commentObjectTypeID}"
                    data-comments="{if $person->comments}{@$commentList->countObjects()}{else}0{/if}"
                    data-last-comment-time="{@$lastCommentTime}"
                >
                    {include file='commentListAddComment' wysiwygSelector='personCommentListAddComment'}
                    {include file='commentList'}
                </ul>
            </div>
        </section>
    {/if}
{/if}

<footer class="contentFooter">
    {hascontent}
        <nav class="contentFooterNavigation">
            <ul>
                {content}{event name='contentFooterNavigation'}{/content}
            </ul>
        </nav>
    {/hascontent}
</footer>

{include file='footer'}

For now, the person template is still very empty and only shows the comments in the content area. The template code shown for comments is very generic and used in this form in many locations as it only sets the header of the comment list and the container ul#personCommentList element for the comments shown by commentList template. The ul#personCommentList elements has five additional data- attributes required by the JavaScript API for comments for loading more comments or creating new ones. The commentListAddComment template adds the WYSIWYG support. The attribute wysiwygSelector should be the id of the comment list personCommentList with an additional AddComment suffix.

page.xml#

<?xml version="1.0" encoding="UTF-8"?>
<data xmlns="http://www.woltlab.com" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.woltlab.com http://www.woltlab.com/XSD/tornado/page.xsd">
    <import>
        <page identifier="com.woltlab.wcf.people.PersonList">
            <pageType>system</pageType>
            <controller>wcf\page\PersonListPage</controller>
            <name language="de">Personen-Liste</name>
            <name language="en">Person List</name>

            <content language="de">
                <title>Personen</title>
            </content>
            <content language="en">
                <title>People</title>
            </content>
        </page>
        <page identifier="com.woltlab.wcf.people.Person">
            <pageType>system</pageType>
            <controller>wcf\page\PersonPage</controller>
            <handler>wcf\system\page\handler\PersonPageHandler</handler>
            <name language="de">Person</name>
            <name language="en">Person</name>
            <requireObjectID>1</requireObjectID>
            <parent>com.woltlab.wcf.people.PersonList</parent>
        </page>
    </import>
</data>

The page.xml file has been extended for the new person page with identifier com.woltlab.wcf.people.Person. Compared to the pre-existing com.woltlab.wcf.people.PersonList page, there are four differences:

  1. It has a <handler> element with a class name as value. This aspect will be discussed in more detail in the next section.
  2. There are no <content> elements because, both, the title and the content of the page are dynamically generated in the template.
  3. The <requireObjectID> tells the system that this page requires an object id to properly work, in this case a valid person id.
  4. This page has a <parent> page, the person list page. In general, the details page for any type of object that is listed on a different page has the list page as its parent.

PersonPageHandler#

<?php
namespace wcf\system\page\handler;
use wcf\data\page\Page;
use wcf\data\person\PersonList;
use wcf\data\user\online\UserOnline;
use wcf\system\cache\runtime\PersonRuntimeCache;
use wcf\system\database\util\PreparedStatementConditionBuilder;
use wcf\system\WCF;

/**
 * Page handler implementation for person page.
 * 
 * @author  Matthias Schmidt
 * @copyright   2001-2019 WoltLab GmbH
 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 * @package WoltLabSuite\Core\System\Page\Handler
 */
class PersonPageHandler extends AbstractLookupPageHandler implements IOnlineLocationPageHandler {
    use TOnlineLocationPageHandler;

    /**
     * @inheritDoc
     */
    public function getLink($objectID) {
        return PersonRuntimeCache::getInstance()->getObject($objectID)->getLink();
    }

    /**
     * Returns the textual description if a user is currently online viewing this page.
     *
     * @see IOnlineLocationPageHandler::getOnlineLocation()
     *
     * @param   Page        $page       visited page
     * @param   UserOnline  $user       user online object with request data
     * @return  string
     */
    public function getOnlineLocation(Page $page, UserOnline $user) {
        if ($user->pageObjectID === null) {
            return '';
        }

        $person = PersonRuntimeCache::getInstance()->getObject($user->pageObjectID);
        if ($person === null) {
            return '';
        }

        return WCF::getLanguage()->getDynamicVariable('wcf.page.onlineLocation.'.$page->identifier, ['person' => $person]);
    }

    /**
     * @inheritDoc
     */
    public function isValid($objectID = null) {
        return PersonRuntimeCache::getInstance()->getObject($objectID) !== null;
    }

    /**
     * @inheritDoc
     */
    public function lookup($searchString) {
        $conditionBuilder = new PreparedStatementConditionBuilder(false, 'OR');
        $conditionBuilder->add('person.firstName LIKE ?', ['%' . $searchString . '%']);
        $conditionBuilder->add('person.lastName LIKE ?', ['%' . $searchString . '%']);

        $personList = new PersonList();
        $personList->getConditionBuilder()->add($conditionBuilder, $conditionBuilder->getParameters());
        $personList->readObjects();

        $results = [];
        foreach ($personList as $person) {
            $results[] = [
                'image' => 'fa-user',
                'link' => $person->getLink(),
                'objectID' => $person->personID,
                'title' => $person->getTitle()
            ];
        }

        return $results;
    }

    /**
     * Prepares fetching all necessary data for the textual description if a user is currently online
     * viewing this page.
     * 
     * @see IOnlineLocationPageHandler::prepareOnlineLocation()
     *
     * @param   Page        $page       visited page
     * @param   UserOnline  $user       user online object with request data
     */
    public function prepareOnlineLocation(/** @noinspection PhpUnusedParameterInspection */Page $page, UserOnline $user) {
        if ($user->pageObjectID !== null) {
            PersonRuntimeCache::getInstance()->cacheObjectID($user->pageObjectID);
        }
    }
}

Like any page handler, the PersonPageHandler class has to implement the IMenuPageHandler interface, which should be done by extending the AbstractMenuPageHandler class. As we want administrators to link to specific people in menus, for example, we have to also implement the ILookupPageHandler interface by extending the AbstractLookupPageHandler class.

For the ILookupPageHandler interface, we need to implement three methods:

  1. getLink($objectID) returns the link to the person page with the given id. In this case, we simply delegate this method call to the Person object returned by PersonRuntimeCache::getObject().
  2. isValid($objectID) returns true if the person with the given id exists, otherwise false. Here, we use PersonRuntimeCache::getObject() again and check if the return value is null, which is the case for non-existing people.
  3. lookup($searchString) is used when setting up an internal link and when searching for the linked person. This method simply searches the first and last name of the people and returns an array with the person data. While the link, the objectID, and the title element are self-explanatory, the image element can either contain an HTML <img> tag, which is displayed next to the search result (WoltLab Suite uses an image tag for users showing their avatar, for example), or a FontAwesome icon class (starting with fa-).

Additionally, the class also implements IOnlineLocationPageHandler which is used to determine the online location of users. To ensure upwards-compatibility if the IOnlineLocationPageHandler interface changes, the TOnlineLocationPageHandler trait is used. The IOnlineLocationPageHandler interface requires two methods to be implemented:

  1. getOnlineLocation(Page $page, UserOnline $user) returns the textual description of the online location. The language item for the user online locations should use the pattern wcf.page.onlineLocation.{page identifier}.
  2. prepareOnlineLocation(Page $page, UserOnline $user) is called for each user online before the getOnlineLocation() calls. In this case, calling prepareOnlineLocation() first enables us to add all relevant person ids to the person runtime cache so that for all getOnlineLocation() calls combined, only one database query is necessary to fetch all person objects.

This concludes the third part of our tutorial series after which each person has a dedicated page on which people can comment on the person.

The complete source code of this part can be found on GitHub.