This part of our tutorial series lays the foundation for future parts in which we will be using additional APIs, which we have not used in this series yet.
To make use of those APIs, we need content generated by users in the frontend.
In addition to the components used in previous parts, we will use the form builder API to create forms shown in dialogs instead of dedicated pages and we will, for the first time, add TypeScript code.
Before we focus on the main aspects of this part, we mention some minor aspects that will be used later on:
Several new user group options and the relevant language items have been added related to creating, editing, and deleting information:
mod.person.canEditInformation and mod.person.canDeleteInformation are moderative permissions to edit and delete any piece of information, regardless of who created it.
user.person.canAddInformation is the permission for users to add new pieces of information.
user.person.canEditInformation and user.person.canDeleteInformation are the user permissions to edit and the piece of information they created.
The actual information text will be entered via a WYSIWYG editor, which requires an object type of the definition com.woltlab.wcf.message: com.woltlab.wcf.people.information.
personList.tpl has been adjusted to show the number of pieces of information in the person statistics section.
We have not updated the person list box to also support sorting by the number of pieces of information added for each person.
The number of pieces of information per person is tracked via the new informationCount column.
The wcf1_person_information table has been added for the PersonInformation model.
The meaning of the different columns is explained in the property documentation part of PersonInformation's documentation (see below).
The two foreign keys ensure that if a person is deleted, all of their information is also deleted, and that if a user is deleted, the userID column is set to NULL.
<?phpnamespacewcf\data\person\information;usewcf\data\DatabaseObject;usewcf\data\person\Person;usewcf\data\user\UserProfile;usewcf\system\cache\runtime\PersonRuntimeCache;usewcf\system\cache\runtime\UserProfileRuntimeCache;usewcf\system\html\output\HtmlOutputProcessor;usewcf\system\WCF;/** * Represents a piece of information for a person. * * @author Matthias Schmidt * @copyright 2001-2021 WoltLab GmbH * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php> * @package WoltLabSuite\Core\Data\Person\Information * * @property-read int $informationID unique id of the information * @property-read int $personID id of the person the information belongs to * @property-read string $information information text * @property-read int|null $userID id of the user who added the information or `null` if the user no longer exists * @property-read string $username name of the user who added the information * @property-read int $time timestamp at which the information was created */classPersonInformationextendsDatabaseObject{/** * Returns `true` if the active user can delete this piece of information and `false` otherwise. */publicfunctioncanDelete():bool{if(WCF::getUser()->userID&&WCF::getUser()->userID==$this->userID&&WCF::getSession()->getPermission('user.person.canDeleteInformation')){returntrue;}returnWCF::getSession()->getPermission('mod.person.canDeleteInformation');}/** * Returns `true` if the active user can edit this piece of information and `false` otherwise. */publicfunctioncanEdit():bool{if(WCF::getUser()->userID&&WCF::getUser()->userID==$this->userID&&WCF::getSession()->getPermission('user.person.canEditInformation')){returntrue;}returnWCF::getSession()->getPermission('mod.person.canEditInformation');}/** * Returns the formatted information. */publicfunctiongetFormattedInformation():string{$processor=newHtmlOutputProcessor();$processor->process($this->information,'com.woltlab.wcf.people.information',$this->informationID);return$processor->getHtml();}/** * Returns the person the information belongs to. */publicfunctiongetPerson():Person{returnPersonRuntimeCache::getInstance()->getObject($this->personID);}/** * Returns the user profile of the user who added the information. */publicfunctiongetUserProfile():UserProfile{if($this->userID){returnUserProfileRuntimeCache::getInstance()->getObject($this->userID);}else{returnUserProfile::getGuestUserProfile($this->username);}}}
PersonInformation provides two methods, canDelete() and canEdit(), to check whether the active user can delete or edit a specific piece of information.
In both cases, it is checked if the current user has created the relevant piece of information to check the user-specific permissions or to fall back to the moderator-specific permissions.
There also two getter methods for the person, the piece of information belongs to (getPerson()), and for the user profile of the user who created the information (getUserProfile()).
In both cases, we use runtime caches, though in getUserProfile(), we also have to consider the case of the user who created the information being deleted, i.e. userID being null.
For such a case, we also save the name of the user who created the information in username, so that we can return a guest user profile object in this case.
The most interesting method is getFormattedInformation(), which returns the HTML code of the information text meant for output.
To generate such an output, HtmlOutputProcessor::process() is used and here is where we first use the associated message object type com.woltlab.wcf.people.information mentioned before.
While PersonInformationEditor is simply the default implementation and thus not explicitly shown here, PersonInformationList::readObjects() caches the relevant ids of the associated people and users who created the pieces of information using runtime caches:
To keep things simple here, we reuse the structure and CSS classes used for comments.
Additionally, we always list all pieces of information.
If there are many pieces of information, a nicer solution would be a pagination or loading more pieces of information with JavaScript.
First, we note the jsObjectActionContainer class in combination with the data-object-action-class-name attribute, which are needed for the delete button for each piece of information, as explained here.
In PersonInformationAction, we have overridden the default implementations of validateDelete() and delete() which are called after clicking on a delete button.
In validateDelete(), we call PersonInformation::canDelete() on all pieces of information to be deleted for proper permission validation, and in delete(), we update the informationCount values of the people the deleted pieces of information belong to (see below).
The button to add a new piece of information, #personInformationAddButton, and the buttons to edit existing pieces of information, .jsEditInformation, are controlled with JavaScript code initialized at the very end of the template.
Lastly, in create() we provide default values for the time, userID, username, and ipAddress for cases like here when creating a new piece of information, where do not explicitly provide this data.
Additionally, we extract the information text from the information_htmlInputProcessor parameter provided by the associated WYSIWYG form field and update the number of pieces of information created for the relevant person.
To create new pieces of information or editing existing ones, we do not add new form controllers but instead use dialogs generated by the form builder API so that the user does not have to leave the person page.
When clicking on the add button or on any of the edit buttons, a dialog opens with the relevant form:
/** * Provides the JavaScript code for the person page. * * @author Matthias Schmidt * @copyright 2001-2021 WoltLab GmbH * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php> * @module WoltLabSuite/Core/Controller/Person */importFormBuilderDialogfrom"WoltLabSuite/Core/Form/Builder/Dialog";import*asLanguagefrom"WoltLabSuite/Core/Language";import*asUiNotificationfrom"WoltLabSuite/Core/Ui/Notification";letaddDialog:FormBuilderDialog;consteditDialogs=newMap<string,FormBuilderDialog>();interfaceEditReturnValues{formattedInformation:string;informationID:number;}interfaceOptions{canAddInformation:true;}/** * Opens the edit dialog after clicking on the edit button for a piece of information. */functioneditInformation(event:Event):void{event.preventDefault();constcurrentTarget=event.currentTargetasHTMLElement;constinformation=currentTarget.closest(".jsObjectActionObject")asHTMLElement;constinformationId=information.dataset.objectId!;if(!editDialogs.has(informationId)){editDialogs.set(informationId,newFormBuilderDialog(`personInformationEditDialog${informationId}`,"wcf\\data\\person\\information\\PersonInformationAction","getEditDialog",{actionParameters:{informationID:informationId,},dialog:{title:Language.get("wcf.person.information.edit"),},submitActionName:"submitEditDialog",successCallback(returnValues:EditReturnValues){document.getElementById(`personInformation${returnValues.informationID}`)!.innerHTML=returnValues.formattedInformation;UiNotification.show(Language.get("wcf.person.information.edit.success"));},},),);}editDialogs.get(informationId)!.open();}/** * Initializes the JavaScript code for the person page. */exportfunctioninit(personId:number,options:Options):void{if(options.canAddInformation){// Initialize the dialog to add new information.addDialog=newFormBuilderDialog("personInformationAddDialog","wcf\\data\\person\\information\\PersonInformationAction","getAddDialog",{actionParameters:{personID:personId,},dialog:{title:Language.get("wcf.person.information.add"),},submitActionName:"submitAddDialog",successCallback(){UiNotification.show(Language.get("wcf.person.information.add.success"),()=>window.location.reload());},},);document.getElementById("personInformationAddButton")!.addEventListener("click",(event)=>{event.preventDefault();addDialog.open();});}document.querySelectorAll(".jsEditInformation").forEach((el)=>el.addEventListener("click",(ev)=>editInformation(ev)));}
We use the WoltLabSuite/Core/Form/Builder/Dialog module, which takes care of the internal handling with regard to these dialogs.
We only have to provide some data during for initializing these objects and call the open() function after a button has been clicked.
Explanation of the initialization arguments for WoltLabSuite/Core/Form/Builder/Dialog used here:
The first argument is the id of the dialog used to identify it.
The second argument is the PHP class name which provides the contents of the dialog's form and handles the data after the form is submitted.
The third argument is the name of the method in the referenced PHP class in the previous argument that returns the dialog form.
The fourth argument contains additional options:
actionParameters are additional parameters send during each AJAX request.
Here, we either pass the id of the person for who a new piece of information is added or the id of the edited piece of information.
dialog contains the options for the dialog, see the DialogOptions interface.
Here, we only provide the title of the dialog.
submitActionName is the name of the method in the referenced PHP class that is called with the form data after submitting the form.
successCallback is called after the submit AJAX request was successful.
After adding a new piece of information, we reload the page, and after editing an existing piece of information, we update the existing information text with the updated text.
(Dynamically inserting a newly added piece of information instead of reloading the page would also be possible, of course, but for this tutorial series, we kept things simple.)
Next, we focus on PersonInformationAction, which actually provides the contents of these dialogs and creates and edits the information:
<?phpnamespacewcf\data\person\information;usewcf\data\AbstractDatabaseObjectAction;usewcf\data\person\PersonAction;usewcf\data\person\PersonEditor;usewcf\system\cache\runtime\PersonRuntimeCache;usewcf\system\event\EventHandler;usewcf\system\exception\IllegalLinkException;usewcf\system\exception\PermissionDeniedException;usewcf\system\exception\UserInputException;usewcf\system\form\builder\container\wysiwyg\WysiwygFormContainer;usewcf\system\form\builder\DialogFormDocument;usewcf\system\html\input\HtmlInputProcessor;usewcf\system\WCF;usewcf\util\UserUtil;/** * Executes person information-related actions. * * @author Matthias Schmidt * @copyright 2001-2021 WoltLab GmbH * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php> * @package WoltLabSuite\Core\Data\Person\Information * * @method PersonInformationEditor[] getObjects() * @method PersonInformationEditor getSingleObject() */classPersonInformationActionextendsAbstractDatabaseObjectAction{/** * @var DialogFormDocument */public$dialog;/** * @var PersonInformation */public$information;/** * @return PersonInformation */publicfunctioncreate(){if(!isset($this->parameters['data']['time'])){$this->parameters['data']['time']=TIME_NOW;}if(!isset($this->parameters['data']['userID'])){$this->parameters['data']['userID']=WCF::getUser()->userID;$this->parameters['data']['username']=WCF::getUser()->username;}if(LOG_IP_ADDRESS){if(!isset($this->parameters['data']['ipAddress'])){$this->parameters['data']['ipAddress']=UserUtil::getIpAddress();}}else{unset($this->parameters['data']['ipAddress']);}if(!empty($this->parameters['information_htmlInputProcessor'])){/** @var HtmlInputProcessor $htmlInputProcessor */$htmlInputProcessor=$this->parameters['information_htmlInputProcessor'];$this->parameters['data']['information']=$htmlInputProcessor->getHtml();}/** @var PersonInformation $information */$information=parent::create();(newPersonAction([$information->personID],'update',['counters'=>['informationCount'=>1,],]))->executeAction();return$information;}/** * @inheritDoc */publicfunctionupdate(){if(!empty($this->parameters['information_htmlInputProcessor'])){/** @var HtmlInputProcessor $htmlInputProcessor */$htmlInputProcessor=$this->parameters['information_htmlInputProcessor'];$this->parameters['data']['information']=$htmlInputProcessor->getHtml();}parent::update();}/** * @inheritDoc */publicfunctionvalidateDelete(){if(empty($this->objects)){$this->readObjects();if(empty($this->objects)){thrownewUserInputException('objectIDs');}}foreach($this->getObjects()as$informationEditor){if(!$informationEditor->canDelete()){thrownewPermissionDeniedException();}}}/** * @inheritDoc */publicfunctiondelete(){$deleteCount=parent::delete();if(!$deleteCount){return$deleteCount;}$counterUpdates=[];foreach($this->getObjects()as$informationEditor){if(!isset($counterUpdates[$informationEditor->personID])){$counterUpdates[$informationEditor->personID]=0;}$counterUpdates[$informationEditor->personID]--;}WCF::getDB()->beginTransaction();foreach($counterUpdatesas$personID=>$counterUpdate){(newPersonEditor(PersonRuntimeCache::getInstance()->getObject($personID)))->updateCounters(['informationCount'=>$counterUpdate,]);}WCF::getDB()->commitTransaction();return$deleteCount;}/** * Validates the `getAddDialog` action. */publicfunctionvalidateGetAddDialog():void{WCF::getSession()->checkPermissions(['user.person.canAddInformation']);$this->readInteger('personID');if(PersonRuntimeCache::getInstance()->getObject($this->parameters['personID'])===null){thrownewUserInputException('personID');}}/** * Returns the data to show the dialog to add a new piece of information on a person. * * @return string[] */publicfunctiongetAddDialog():array{$this->buildDialog();return['dialog'=>$this->dialog->getHtml(),'formId'=>$this->dialog->getId(),];}/** * Validates the `submitAddDialog` action. */publicfunctionvalidateSubmitAddDialog():void{$this->validateGetAddDialog();$this->buildDialog();$this->dialog->requestData($_POST['parameters']['data']??[]);$this->dialog->readValues();$this->dialog->validate();}/** * Creates a new piece of information on a person after submitting the dialog. * * @return string[] */publicfunctionsubmitAddDialog():array{// If there are any validation errors, show the form again.if($this->dialog->hasValidationErrors()){return['dialog'=>$this->dialog->getHtml(),'formId'=>$this->dialog->getId(),];}(newstatic([],'create',\array_merge($this->dialog->getData(),['data'=>['personID'=>$this->parameters['personID'],],])))->executeAction();return[];}/** * Validates the `getEditDialog` action. */publicfunctionvalidateGetEditDialog():void{WCF::getSession()->checkPermissions(['user.person.canAddInformation']);$this->readInteger('informationID');$this->information=newPersonInformation($this->parameters['informationID']);if(!$this->information->getObjectID()){thrownewUserInputException('informationID');}if(!$this->information->canEdit()){thrownewIllegalLinkException();}}/** * Returns the data to show the dialog to edit a piece of information on a person. * * @return string[] */publicfunctiongetEditDialog():array{$this->buildDialog();$this->dialog->updatedObject($this->information);return['dialog'=>$this->dialog->getHtml(),'formId'=>$this->dialog->getId(),];}/** * Validates the `submitEditDialog` action. */publicfunctionvalidateSubmitEditDialog():void{$this->validateGetEditDialog();$this->buildDialog();$this->dialog->updatedObject($this->information,false);$this->dialog->requestData($_POST['parameters']['data']??[]);$this->dialog->readValues();$this->dialog->validate();}/** * Updates a piece of information on a person after submitting the edit dialog. * * @return string[] */publicfunctionsubmitEditDialog():array{// If there are any validation errors, show the form again.if($this->dialog->hasValidationErrors()){return['dialog'=>$this->dialog->getHtml(),'formId'=>$this->dialog->getId(),];}(newstatic([$this->information],'update',$this->dialog->getData()))->executeAction();// Reload the information with the updated data.$information=newPersonInformation($this->information->getObjectID());return['formattedInformation'=>$information->getFormattedInformation(),'informationID'=>$this->information->getObjectID(),];}/** * Builds the dialog to create or edit person information. */protectedfunctionbuildDialog():void{if($this->dialog!==null){return;}$this->dialog=DialogFormDocument::create('personInformationAddDialog')->appendChild(WysiwygFormContainer::create('information')->messageObjectType('com.woltlab.wcf.people.information')->required());EventHandler::getInstance()->fireAction($this,'buildDialog');$this->dialog->build();}}
When setting up the WoltLabSuite/Core/Form/Builder/Dialog object for adding new pieces of information, we specified getAddDialog and submitAddDialog as the names of the dialog getter and submit handler.
In addition to these two methods, the matching validation methods validateGetAddDialog() and validateGetAddDialog() are also added.
As the forms for adding and editing pieces of information have the same structure, this form is created in buildDialog() using a DialogFormDocument object, which is intended for forms in dialogs.
We fire an event in buildDialog() so that plugins are able to easily extend the dialog with additional data.
validateGetAddDialog() checks if the user has the permission to create new pieces of information and if a valid id for the person, the information will belong to, is given.
The method configured in the WoltLabSuite/Core/Form/Builder/Dialog object returning the dialog is expected to return two values:
the id of the form (formId) and the contents of form shown in the dialog (dialog).
This data is returned by getAddDialog using the dialog build previously by buildDialog().
After the form is submitted, validateSubmitAddDialog() has to do the same basic validation as validateGetAddDialog() so that validateGetAddDialog() is simply called.
Additionally, the form data is read and validated.
In submitAddDialog(), we first check if there have been any validation errors:
If any error occured during validation, we return the same data as in getAddDialog() so that the dialog is shown again with the erroneous fields marked as such.
Otherwise, if the validation succeeded, the form data is used to create the new piece of information.
In addition to the form data, we manually add the id of the person to whom the information belongs to.
Lastly, we could return some data that we could access in the JavaScript callback function after successfully submitting the dialog.
As we will simply be reloading the page, no such data is returned.
An alternative to reloading to the page would be dynamically inserting the new piece of information in the list so that we would have to return the rendered list item for the new piece of information.
The process for getting and submitting the dialog to edit existing pieces of information is similar to the process for adding new pieces of information.
Instead of the id of the person, however, we now pass the id of the edited piece of information and in submitEditDialog(), we update the edited information instead of creating a new one like in submitAddDialog().
After editing a piece of information, we do not reload the page but dynamically update the text of the information in the TypeScript code so that we return the updated rendered information text and id of the edited pieced of information in submitAddDialog().
As we store the name of the user who create a new piece of information and store their IP address, we have to add event listeners to properly handle the following scenarios:
If the user is renamed, the value of username stored with the person information has to be updated, which can be achieved by a simple event listener that only has to specify the name of relevant database table if AbstractUserActionRenameListener is extended:
<?phpnamespacewcf\system\event\listener;/** * Updates person information during user renaming. * * @author Matthias Schmidt * @copyright 2001-2021 WoltLab GmbH * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php> * @package WoltLabSuite\Core\System\Event\Listener */classPersonUserActionRenameListenerextendsAbstractUserActionRenameListener{/** * @inheritDoc */protected$databaseTables=['wcf{WCF_N}_person_information',];}
If users are merged, all pieces of information need to be assigned to the target user of the merging.
Again, we only have to specify the name of relevant database table if AbstractUserMergeListener is extended:
<?phpnamespacewcf\system\event\listener;/** * Updates person information during user merging. * * @author Matthias Schmidt * @copyright 2001-2021 WoltLab GmbH * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php> * @package WoltLabSuite\Core\System\Event\Listener */classPersonUserMergeListenerextendsAbstractUserMergeListener{/** * @inheritDoc */protected$databaseTables=['wcf{WCF_N}_person_information',];}
If the option to prune stored ip addresses after a certain period of time is enabled, we also have to prune them in the person information database table.
Here we also only have to specify the name of the relevant database table and provide the mapping from the ipAddress column to the time column:
<?phpnamespacewcf\system\event\listener;usewcf\system\cronjob\PruneIpAddressesCronjob;/** * Prunes old ip addresses. * * @author Matthias Schmidt * @copyright 2001-2021 WoltLab GmbH * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php> * @package WoltLabSuite\Core\System\Event\Listener */classPersonPruneIpAddressesCronjobListenerextendsAbstractEventListener{protectedfunctiononExecute(PruneIpAddressesCronjob$cronjob):void{$cronjob->columns['wcf'.WCF_N.'_person_information']['ipAddress']='time';}}
The ip addresses in the person information database table also have to be considered for the user data export which can also be done with minimal effort by providing the name of the relevant database table:
<?phpnamespacewcf\system\event\listener;usewcf\acp\action\UserExportGdprAction;/** * Adds the ip addresses stored with the person information during user data export. * * @author Matthias Schmidt * @copyright 2001-2021 WoltLab GmbH * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php> * @package WoltLabSuite\Core\System\Event\Listener */classPersonUserExportGdprListenerextendsAbstractEventListener{protectedfunctiononExport(UserExportGdprAction$action):void{$action->ipAddresses['com.woltlab.wcf.people']=['wcf'.WCF_N.'_person_information'];}}
Lastly, we present the updated eventListener.xml file with new entries for all of these event listeners: