Merge pull request #162 from WoltLab/tutorial5
authorMatthias Schmidt <gravatronics@live.com>
Thu, 22 Apr 2021 08:51:45 +0000 (10:51 +0200)
committerGitHub <noreply@github.com>
Thu, 22 Apr 2021 08:51:45 +0000 (10:51 +0200)
Add part 5 of tutorial series

44 files changed:
docs/tutorial/series/overview.md
docs/tutorial/series/part_5.md [new file with mode: 0644]
mkdocs.yml
snippets/tutorial/tutorial-series/part-5/acpMenu.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/acptemplates/personAdd.tpl [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/acptemplates/personList.tpl [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/eventListener.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/acp/database/install_com.woltlab.wcf.people.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/js/WoltLabSuite/Core/Controller/Person.js [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/acp/form/PersonAddForm.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/acp/form/PersonEditForm.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/acp/page/PersonListPage.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/data/person/Person.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/data/person/PersonAction.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/data/person/PersonEditor.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/data/person/PersonList.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformation.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformationAction.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformationEditor.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformationList.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/page/PersonListPage.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/page/PersonPage.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/system/box/PersonListBoxController.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/system/cache/runtime/PersonRuntimeCache.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/system/comment/manager/PersonCommentManager.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/system/condition/person/PersonFirstNameTextPropertyCondition.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/system/condition/person/PersonLastNameTextPropertyCondition.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonPruneIpAddressesCronjobListener.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserActionRenameListener.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserExportGdprListener.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserMergeListener.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/system/page/handler/PersonPageHandler.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/language/de.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/language/en.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/menuItem.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/objectType.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/objectTypeDefinition.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/package.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/page.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/templates/boxPersonList.tpl [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/templates/person.tpl [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/templates/personList.tpl [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/ts/WoltLabSuite/Core/Controller/Person.ts [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/userGroupOption.xml [new file with mode: 0644]

index 3f22a3942e64e110ab423ad3e8f6cf102674d38d..4f7be8bceba230f6a5ffffaddb48c21b0f048e38 100644 (file)
@@ -10,3 +10,4 @@ Note that in the context of this example, not every added feature might make per
 - [Part 2: Event and Template Listeners](part_2.md)
 - [Part 3: Person Page and Comments](part_3.md)
 - [Part 4: Box and Box Conditions](part_4.md)
+- [Part 5: Person Information](part_5.md)
diff --git a/docs/tutorial/series/part_5.md b/docs/tutorial/series/part_5.md
new file mode 100644 (file)
index 0000000..eb75326
--- /dev/null
@@ -0,0 +1,220 @@
+# Part 5: Person Information
+
+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.
+
+
+## Package Functionality
+
+In addition to the existing functions from [part 4](part_4.md), the package will provide the following functionality after this part of the tutorial:
+
+- Users are able to add information on the people in the frontend.
+- Users are able to edit and delete the pieces of information they added.
+- Moderators are able to edit and delete all pieces of information.
+
+
+## Used Components
+
+In addition to the components used in previous parts, we will use the [form builder API](../../php/api/form_builder/overview.md) to create forms shown in dialogs instead of dedicated pages and we will, for the first time, add [TypeScript code](../../javascript/typescript.md).
+
+
+## Package Structure
+
+The package will have the following file structure _excluding_ unchanged files from previous parts:
+
+```
+├── files
+│   ├── acp
+│   │   └── database
+│   │       └── install_com.woltlab.wcf.people.php
+│   ├── js
+│   │   └── WoltLabSuite
+│   │       └── Core
+│   │           └── Controller
+│   │               └── Person.js
+│   └── lib
+│       └── data
+│           └── person
+│               ├── Person.class.php
+│               └── information
+│                   ├── PersonInformation.class.php
+│                   ├── PersonInformationAction.class.php
+│                   ├── PersonInformationEditor.class.php
+│                   └── PersonInformationList.class.php
+├── language
+│   ├── de.xml
+│   └── en.xml
+├── objectType.xml
+├── templates
+│   ├── person.tpl
+│   └── personList.tpl
+├── ts
+│   └── WoltLabSuite
+│       └── Core
+│           └── Controller
+│               └── Person.ts
+└── userGroupOption.xml
+```
+
+For all changes, please refer to the [source code on GitHub]({jinja{ repo_url }}tree/{jinja{ edit_uri.split("/")[1] }}/snippets/tutorial/tutorial-series/part-5).
+
+
+## Miscellaneous
+
+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.
+
+
+## Person Information Model
+
+The PHP file with the database layout has been updated as follows:
+
+```php
+--8<-- "tutorial/tutorial-series/part-5/files/acp/database/install_com.woltlab.wcf.people.php"
+```
+
+- 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`.
+
+```php
+--8<-- "tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformation.class.php"
+```
+
+`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](#miscellaneous).
+
+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:
+
+```php
+--8<-- "tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformationList.class.php"
+```
+
+
+## Listing and Deleting Person Information
+
+The `person.tpl` template has been updated to include a block for listing the information at the beginning:
+
+```smarty
+--8<-- "tutorial/tutorial-series/part-5/templates/person.tpl"
+```
+
+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](../../migration/wsc53/javascript.md#wcfactiondelete-and-wcfactiontoggle).
+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.
+
+
+## Creating and Editing Person Information
+
+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:
+
+```TypeScript
+--8<-- "tutorial/tutorial-series/part-5/ts/WoltLabSuite/Core/Controller/Person.ts"
+```
+
+We use the [`WoltLabSuite/Core/Form/Builder/Dialog` module](https://github.com/WoltLab/WCF/blob/master/ts/WoltLabSuite/Core/Form/Builder/Dialog.ts), 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:
+
+```php
+--8<-- "tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformationAction.class.php"
+```
+
+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()`.
+
+
+## Username and IP Address Event Listeners
+
+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:
+
+1. 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:
+
+    ```php
+    --8<-- "tutorial//tutorial-series/part-5/files/lib/system/event/listener/PersonUserActionRenameListener.class.php"
+    ```
+2. 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:
+
+    ```php
+    --8<-- "tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserMergeListener.class.php"
+    ```
+3. 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:
+
+    ```php
+    --8<-- "tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonPruneIpAddressesCronjobListener.class.php"
+    ```
+4. 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: 
+
+    ```php
+    --8<-- "tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserExportGdprListener.class.php"
+    ```
+
+Lastly, we present the updated `eventListener.xml` file with new entries for all of these event listeners:
+
+```xml
+--8<-- "tutorial/tutorial-series/part-5/eventListener.xml"
+```
\ No newline at end of file
index b2e0562e2ccfea78d0102cf5dcdb35a7e5a8b927..6d6c88cce43abde5a8104adc003036996f9d218a 100644 (file)
@@ -135,6 +135,7 @@ nav:
         - 'Part 2': 'tutorial/series/part_2.md'
         - 'Part 3': 'tutorial/series/part_3.md'
         - 'Part 4': 'tutorial/series/part_4.md'
+        - 'Part 5': 'tutorial/series/part_5.md'
 
 plugins:
   - git-revision-date
diff --git a/snippets/tutorial/tutorial-series/part-5/acpMenu.xml b/snippets/tutorial/tutorial-series/part-5/acpMenu.xml
new file mode 100644 (file)
index 0000000..6ccf64f
--- /dev/null
@@ -0,0 +1,19 @@
+<?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/5.4/acpMenu.xsd">
+       <import>
+               <acpmenuitem name="wcf.acp.menu.link.person">
+                       <parent>wcf.acp.menu.link.content</parent>
+               </acpmenuitem>
+               <acpmenuitem name="wcf.acp.menu.link.person.list">
+                       <controller>wcf\acp\page\PersonListPage</controller>
+                       <parent>wcf.acp.menu.link.person</parent>
+                       <permissions>admin.content.canManagePeople</permissions>
+               </acpmenuitem>
+               <acpmenuitem name="wcf.acp.menu.link.person.add">
+                       <controller>wcf\acp\form\PersonAddForm</controller>
+                       <parent>wcf.acp.menu.link.person.list</parent>
+                       <permissions>admin.content.canManagePeople</permissions>
+                       <icon>fa-plus</icon>
+               </acpmenuitem>
+       </import>
+</data>
diff --git a/snippets/tutorial/tutorial-series/part-5/acptemplates/personAdd.tpl b/snippets/tutorial/tutorial-series/part-5/acptemplates/personAdd.tpl
new file mode 100644 (file)
index 0000000..4cf2ec4
--- /dev/null
@@ -0,0 +1,19 @@
+{include file='header' pageTitle='wcf.acp.person.'|concat:$action}
+
+<header class="contentHeader">
+       <div class="contentHeaderTitle">
+               <h1 class="contentTitle">{lang}wcf.acp.person.{$action}{/lang}</h1>
+       </div>
+       
+       <nav class="contentHeaderNavigation">
+               <ul>
+                       <li><a href="{link controller='PersonList'}{/link}" class="button"><span class="icon icon16 fa-list"></span> <span>{lang}wcf.acp.menu.link.person.list{/lang}</span></a></li>
+                       
+                       {event name='contentHeaderNavigation'}
+               </ul>
+       </nav>
+</header>
+
+{@$form->getHtml()}
+
+{include file='footer'}
diff --git a/snippets/tutorial/tutorial-series/part-5/acptemplates/personList.tpl b/snippets/tutorial/tutorial-series/part-5/acptemplates/personList.tpl
new file mode 100644 (file)
index 0000000..71766ef
--- /dev/null
@@ -0,0 +1,75 @@
+{include file='header' pageTitle='wcf.acp.person.list'}
+
+<header class="contentHeader">
+       <div class="contentHeaderTitle">
+               <h1 class="contentTitle">{lang}wcf.acp.person.list{/lang}</h1>
+       </div>
+       
+       <nav class="contentHeaderNavigation">
+               <ul>
+                       <li><a href="{link controller='PersonAdd'}{/link}" class="button"><span class="icon icon16 fa-plus"></span> <span>{lang}wcf.acp.menu.link.person.add{/lang}</span></a></li>
+                       
+                       {event name='contentHeaderNavigation'}
+               </ul>
+       </nav>
+</header>
+
+{hascontent}
+       <div class="paginationTop">
+               {content}{pages print=true assign=pagesLinks controller="PersonList" link="pageNo=%d&sortField=$sortField&sortOrder=$sortOrder"}{/content}
+       </div>
+{/hascontent}
+
+{if $objects|count}
+       <div class="section tabularBox">
+               <table class="table jsObjectActionContainer" data-object-action-class-name="wcf\data\person\PersonAction">
+                       <thead>
+                               <tr>
+                                       <th class="columnID columnPersonID{if $sortField == 'personID'} active {@$sortOrder}{/if}" colspan="2"><a href="{link controller='PersonList'}pageNo={@$pageNo}&sortField=personID&sortOrder={if $sortField == 'personID' && $sortOrder == 'ASC'}DESC{else}ASC{/if}{/link}">{lang}wcf.global.objectID{/lang}</a></th>
+                                       <th class="columnTitle columnFirstName{if $sortField == 'firstName'} active {@$sortOrder}{/if}"><a href="{link controller='PersonList'}pageNo={@$pageNo}&sortField=firstName&sortOrder={if $sortField == 'firstName' && $sortOrder == 'ASC'}DESC{else}ASC{/if}{/link}">{lang}wcf.person.firstName{/lang}</a></th>
+                                       <th class="columnTitle columnLastName{if $sortField == 'lastName'} active {@$sortOrder}{/if}"><a href="{link controller='PersonList'}pageNo={@$pageNo}&sortField=lastName&sortOrder={if $sortField == 'lastName' && $sortOrder == 'ASC'}DESC{else}ASC{/if}{/link}">{lang}wcf.person.lastName{/lang}</a></th>
+                                       
+                                       {event name='columnHeads'}
+                               </tr>
+                       </thead>
+                       
+                       <tbody class="jsReloadPageWhenEmpty">
+                               {foreach from=$objects item=person}
+                                       <tr class="jsObjectActionObject" data-object-id="{@$person->getObjectID()}">
+                                               <td class="columnIcon">
+                                                       <a href="{link controller='PersonEdit' object=$person}{/link}" title="{lang}wcf.global.button.edit{/lang}" class="jsTooltip"><span class="icon icon16 fa-pencil"></span></a>
+                                                       {objectAction action="delete" objectTitle=$person->getTitle()}
+                                                       
+                                                       {event name='rowButtons'}
+                                               </td>
+                                               <td class="columnID">{#$person->personID}</td>
+                                               <td class="columnTitle columnFirstName"><a href="{link controller='PersonEdit' object=$person}{/link}">{$person->firstName}</a></td>
+                                               <td class="columnTitle columnLastName"><a href="{link controller='PersonEdit' object=$person}{/link}">{$person->lastName}</a></td>
+                                               
+                                               {event name='columns'}
+                                       </tr>
+                               {/foreach}
+                       </tbody>
+               </table>
+       </div>
+       
+       <footer class="contentFooter">
+               {hascontent}
+                       <div class="paginationBottom">
+                               {content}{@$pagesLinks}{/content}
+                       </div>
+               {/hascontent}
+               
+               <nav class="contentFooterNavigation">
+                       <ul>
+                               <li><a href="{link controller='PersonAdd'}{/link}" class="button"><span class="icon icon16 fa-plus"></span> <span>{lang}wcf.acp.menu.link.person.add{/lang}</span></a></li>
+                               
+                               {event name='contentFooterNavigation'}
+                       </ul>
+               </nav>
+       </footer>
+{else}
+       <p class="info">{lang}wcf.global.noItems{/lang}</p>
+{/if}
+
+{include file='footer'}
diff --git a/snippets/tutorial/tutorial-series/part-5/eventListener.xml b/snippets/tutorial/tutorial-series/part-5/eventListener.xml
new file mode 100644 (file)
index 0000000..fbbc0a5
--- /dev/null
@@ -0,0 +1,29 @@
+<?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/5.4/eventListener.xsd">
+       <import>
+               <eventlistener name="rename@wcf\data\user\UserAction">
+                       <eventclassname>wcf\data\user\UserAction</eventclassname>
+                       <eventname>rename</eventname>
+                       <listenerclassname>wcf\system\event\listener\PersonUserActionRenameListener</listenerclassname>
+                       <environment>all</environment>
+               </eventlistener>
+               <eventlistener name="save@wcf\acp\form\UserMergeForm">
+                       <eventclassname>wcf\acp\form\UserMergeForm</eventclassname>
+                       <eventname>save</eventname>
+                       <listenerclassname>wcf\system\event\listener\PersonUserMergeListener</listenerclassname>
+                       <environment>admin</environment>
+               </eventlistener>
+               <eventlistener name="execute@wcf\system\cronjob\PruneIpAddressesCronjob">
+                       <eventclassname>wcf\system\cronjob\PruneIpAddressesCronjob</eventclassname>
+                       <eventname>execute</eventname>
+                       <listenerclassname>wcf\system\event\listener\PersonPruneIpAddressesCronjobListener</listenerclassname>
+                       <environment>all</environment>
+               </eventlistener>
+               <eventlistener name="export@wcf\acp\action\UserExportGdprAction">
+                       <eventclassname>wcf\acp\action\UserExportGdprAction</eventclassname>
+                       <eventname>export</eventname>
+                       <listenerclassname>wcf\system\event\listener\PersonUserExportGdprListener</listenerclassname>
+                       <environment>admin</environment>
+               </eventlistener>
+       </import>
+</data>
diff --git a/snippets/tutorial/tutorial-series/part-5/files/acp/database/install_com.woltlab.wcf.people.php b/snippets/tutorial/tutorial-series/part-5/files/acp/database/install_com.woltlab.wcf.people.php
new file mode 100644 (file)
index 0000000..5d73715
--- /dev/null
@@ -0,0 +1,55 @@
+<?php
+
+use wcf\system\database\table\column\DefaultTrueBooleanDatabaseTableColumn;
+use wcf\system\database\table\column\IntDatabaseTableColumn;
+use wcf\system\database\table\column\NotNullInt10DatabaseTableColumn;
+use wcf\system\database\table\column\NotNullVarchar255DatabaseTableColumn;
+use wcf\system\database\table\column\ObjectIdDatabaseTableColumn;
+use wcf\system\database\table\column\SmallintDatabaseTableColumn;
+use wcf\system\database\table\column\TextDatabaseTableColumn;
+use wcf\system\database\table\column\VarcharDatabaseTableColumn;
+use wcf\system\database\table\DatabaseTable;
+use wcf\system\database\table\index\DatabaseTableForeignKey;
+
+return [
+    DatabaseTable::create('wcf1_person')
+        ->columns([
+            ObjectIdDatabaseTableColumn::create('personID'),
+            NotNullVarchar255DatabaseTableColumn::create('firstName'),
+            NotNullVarchar255DatabaseTableColumn::create('lastName'),
+            NotNullInt10DatabaseTableColumn::create('informationCount')
+                ->defaultValue(0),
+            SmallintDatabaseTableColumn::create('comments')
+                ->length(5)
+                ->notNull()
+                ->defaultValue(0),
+            DefaultTrueBooleanDatabaseTableColumn::create('enableComments'),
+        ]),
+
+    DatabaseTable::create('wcf1_person_information')
+        ->columns([
+            ObjectIdDatabaseTableColumn::create('informationID'),
+            NotNullInt10DatabaseTableColumn::create('personID'),
+            TextDatabaseTableColumn::create('information'),
+            IntDatabaseTableColumn::create('userID')
+                ->length(10),
+            NotNullVarchar255DatabaseTableColumn::create('username'),
+            VarcharDatabaseTableColumn::create('ipAddress')
+                ->length(39)
+                ->notNull(true)
+                ->defaultValue(''),
+            NotNullInt10DatabaseTableColumn::create('time'),
+        ])
+        ->foreignKeys([
+            DatabaseTableForeignKey::create()
+                ->columns(['personID'])
+                ->referencedTable('wcf1_person')
+                ->referencedColumns(['personID'])
+                ->onDelete('CASCADE'),
+            DatabaseTableForeignKey::create()
+                ->columns(['userID'])
+                ->referencedTable('wcf1_user')
+                ->referencedColumns(['userID'])
+                ->onDelete('SET NULL'),
+        ]),
+];
diff --git a/snippets/tutorial/tutorial-series/part-5/files/js/WoltLabSuite/Core/Controller/Person.js b/snippets/tutorial/tutorial-series/part-5/files/js/WoltLabSuite/Core/Controller/Person.js
new file mode 100644 (file)
index 0000000..6c3b501
--- /dev/null
@@ -0,0 +1,72 @@
+/**
+ * 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
+ */
+define(["require", "exports", "tslib", "WoltLabSuite/Core/Form/Builder/Dialog", "WoltLabSuite/Core/Language", "WoltLabSuite/Core/Ui/Notification"], function (require, exports, tslib_1, Dialog_1, Language, UiNotification) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+    exports.init = void 0;
+    Dialog_1 = tslib_1.__importDefault(Dialog_1);
+    Language = tslib_1.__importStar(Language);
+    UiNotification = tslib_1.__importStar(UiNotification);
+    let addDialog;
+    const editDialogs = new Map();
+    /**
+     * Opens the edit dialog after clicking on the edit button for a piece of information.
+     */
+    function editInformation(event) {
+        event.preventDefault();
+        const currentTarget = event.currentTarget;
+        const information = currentTarget.closest(".jsObjectActionObject");
+        const informationId = information.dataset.objectId;
+        if (!editDialogs.has(informationId)) {
+            editDialogs.set(informationId, new Dialog_1.default(`personInformationEditDialog${informationId}`, "wcf\\data\\person\\information\\PersonInformationAction", "getEditDialog", {
+                actionParameters: {
+                    informationID: informationId,
+                },
+                dialog: {
+                    title: Language.get("wcf.person.information.edit"),
+                },
+                submitActionName: "submitEditDialog",
+                successCallback(returnValues) {
+                    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.
+     */
+    function init(personId, options) {
+        if (options.canAddInformation) {
+            // Initialize the dialog to add new information.
+            addDialog = new Dialog_1.default("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)));
+    }
+    exports.init = init;
+});
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/acp/form/PersonAddForm.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/acp/form/PersonAddForm.class.php
new file mode 100644 (file)
index 0000000..565274d
--- /dev/null
@@ -0,0 +1,75 @@
+<?php
+
+namespace wcf\acp\form;
+
+use wcf\data\person\PersonAction;
+use wcf\form\AbstractFormBuilderForm;
+use wcf\system\form\builder\container\FormContainer;
+use wcf\system\form\builder\field\BooleanFormField;
+use wcf\system\form\builder\field\TextFormField;
+
+/**
+ * Shows the form to create a new 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\Acp\Form
+ */
+class PersonAddForm extends AbstractFormBuilderForm
+{
+    /**
+     * @inheritDoc
+     */
+    public $activeMenuItem = 'wcf.acp.menu.link.person.add';
+
+    /**
+     * @inheritDoc
+     */
+    public $formAction = 'create';
+
+    /**
+     * @inheritDoc
+     */
+    public $neededPermissions = ['admin.content.canManagePeople'];
+
+    /**
+     * @inheritDoc
+     */
+    public $objectActionClass = PersonAction::class;
+
+    /**
+     * @inheritDoc
+     */
+    public $objectEditLinkController = PersonEditForm::class;
+
+    /**
+     * @inheritDoc
+     */
+    public function createForm()
+    {
+        parent::createForm();
+
+        $this->form->appendChild(
+            FormContainer::create('data')
+                ->label('wcf.global.form.data')
+                ->appendChildren([
+                    TextFormField::create('firstName')
+                        ->label('wcf.person.firstName')
+                        ->required()
+                        ->autoFocus()
+                        ->maximumLength(255),
+
+                    TextFormField::create('lastName')
+                        ->label('wcf.person.lastName')
+                        ->required()
+                        ->maximumLength(255),
+
+                    BooleanFormField::create('enableComments')
+                        ->label('wcf.person.enableComments')
+                        ->description('wcf.person.enableComments.description')
+                        ->value(true),
+                ])
+        );
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/acp/form/PersonEditForm.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/acp/form/PersonEditForm.class.php
new file mode 100644 (file)
index 0000000..47c2b76
--- /dev/null
@@ -0,0 +1,43 @@
+<?php
+
+namespace wcf\acp\form;
+
+use wcf\data\person\Person;
+use wcf\system\exception\IllegalLinkException;
+
+/**
+ * Shows the form to edit an existing 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\Acp\Form
+ */
+class PersonEditForm extends PersonAddForm
+{
+    /**
+     * @inheritDoc
+     */
+    public $activeMenuItem = 'wcf.acp.menu.link.person';
+
+    /**
+     * @inheritDoc
+     */
+    public $formAction = 'update';
+
+    /**
+     * @inheritDoc
+     */
+    public function readParameters()
+    {
+        parent::readParameters();
+
+        if (isset($_REQUEST['id'])) {
+            $this->formObject = new Person($_REQUEST['id']);
+
+            if (!$this->formObject->getObjectID()) {
+                throw new IllegalLinkException();
+            }
+        }
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/acp/page/PersonListPage.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/acp/page/PersonListPage.class.php
new file mode 100644 (file)
index 0000000..9d57855
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+
+namespace wcf\acp\page;
+
+use wcf\data\person\PersonList;
+use wcf\page\SortablePage;
+
+/**
+ * Shows the list of people.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\Acp\Page
+ */
+class PersonListPage extends SortablePage
+{
+    /**
+     * @inheritDoc
+     */
+    public $activeMenuItem = 'wcf.acp.menu.link.person.list';
+
+    /**
+     * @inheritDoc
+     */
+    public $neededPermissions = ['admin.content.canManagePeople'];
+
+    /**
+     * @inheritDoc
+     */
+    public $objectListClassName = PersonList::class;
+
+    /**
+     * @inheritDoc
+     */
+    public $validSortFields = ['personID', 'firstName', 'lastName'];
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/Person.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/Person.class.php
new file mode 100644 (file)
index 0000000..aca4223
--- /dev/null
@@ -0,0 +1,78 @@
+<?php
+
+namespace wcf\data\person;
+
+use wcf\data\DatabaseObject;
+use wcf\data\ITitledLinkObject;
+use wcf\data\person\information\PersonInformation;
+use wcf\data\person\information\PersonInformationList;
+use wcf\page\PersonPage;
+use wcf\system\request\LinkHandler;
+
+/**
+ * Represents 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
+ *
+ * @property-read   integer     $personID   unique id of the person
+ * @property-read   string      $firstName  first name of the person
+ * @property-read   string      $lastName   last name of the person
+ * @property-read   int         $informationCount   number of pieces of information added for the person
+ * @property-read   int         $enableComments     is `1` if comments are enabled for the person, otherwise `0`
+ */
+class Person extends DatabaseObject implements ITitledLinkObject
+{
+    /**
+     * Returns the first and last name of the person if a person object is treated as a string.
+     *
+     * @return  string
+     */
+    public function __toString()
+    {
+        return $this->getTitle();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function getLink()
+    {
+        return LinkHandler::getInstance()->getControllerLink(PersonPage::class, [
+            'object' => $this,
+        ]);
+    }
+
+    /**
+     * Returns all pieces of information added for the person.
+     *
+     * @return  PersonInformation[]
+     */
+    public function getInformation(): array
+    {
+        if ($this->information === null) {
+            $this->information = [];
+
+            if ($this->informationCount) {
+                $list = new PersonInformationList();
+                $list->getConditionBuilder()->add('personID = ?', [$this->getObjectID()]);
+                $list->sqlOrderBy = 'time DESC';
+                $list->readObjects();
+
+                $this->information = $list->getObjects();
+            }
+        }
+
+        return $this->information;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function getTitle()
+    {
+        return $this->firstName . ' ' . $this->lastName;
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/PersonAction.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/PersonAction.class.php
new file mode 100644 (file)
index 0000000..3f34655
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+
+namespace wcf\data\person;
+
+use wcf\data\AbstractDatabaseObjectAction;
+
+/**
+ * Executes person-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
+ *
+ * @method  Person      create()
+ * @method  PersonEditor[]  getObjects()
+ * @method  PersonEditor    getSingleObject()
+ */
+class PersonAction extends AbstractDatabaseObjectAction
+{
+    /**
+     * @inheritDoc
+     */
+    protected $permissionsDelete = ['admin.content.canManagePeople'];
+
+    /**
+     * @inheritDoc
+     */
+    protected $requireACP = ['delete'];
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/PersonEditor.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/PersonEditor.class.php
new file mode 100644 (file)
index 0000000..b8d5a3c
--- /dev/null
@@ -0,0 +1,25 @@
+<?php
+
+namespace wcf\data\person;
+
+use wcf\data\DatabaseObjectEditor;
+
+/**
+ * Provides functions to edit people.
+ *
+ * @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
+ *
+ * @method static   Person  create(array $parameters = [])
+ * @method      Person  getDecoratedObject()
+ * @mixin       Person
+ */
+class PersonEditor extends DatabaseObjectEditor
+{
+    /**
+     * @inheritDoc
+     */
+    protected static $baseClass = Person::class;
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/PersonList.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/PersonList.class.php
new file mode 100644 (file)
index 0000000..4e16a0d
--- /dev/null
@@ -0,0 +1,22 @@
+<?php
+
+namespace wcf\data\person;
+
+use wcf\data\DatabaseObjectList;
+
+/**
+ * Represents a list of people.
+ *
+ * @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
+ *
+ * @method  Person      current()
+ * @method  Person[]    getObjects()
+ * @method  Person|null search($objectID)
+ * @property    Person[]    $objects
+ */
+class PersonList extends DatabaseObjectList
+{
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformation.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformation.class.php
new file mode 100644 (file)
index 0000000..43a30b6
--- /dev/null
@@ -0,0 +1,96 @@
+<?php
+
+namespace wcf\data\person\information;
+
+use wcf\data\DatabaseObject;
+use wcf\data\person\Person;
+use wcf\data\user\UserProfile;
+use wcf\system\cache\runtime\PersonRuntimeCache;
+use wcf\system\cache\runtime\UserProfileRuntimeCache;
+use wcf\system\html\output\HtmlOutputProcessor;
+use wcf\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
+ */
+class PersonInformation extends DatabaseObject
+{
+    /**
+     * Returns `true` if the active user can delete this piece of information and `false` otherwise.
+     */
+    public function canDelete(): bool
+    {
+        if (
+            WCF::getUser()->userID
+            && WCF::getUser()->userID == $this->userID
+            && WCF::getSession()->getPermission('user.person.canDeleteInformation')
+        ) {
+            return true;
+        }
+
+        return WCF::getSession()->getPermission('mod.person.canDeleteInformation');
+    }
+
+    /**
+     * Returns `true` if the active user can edit this piece of information and `false` otherwise.
+     */
+    public function canEdit(): bool
+    {
+        if (
+            WCF::getUser()->userID
+            && WCF::getUser()->userID == $this->userID
+            && WCF::getSession()->getPermission('user.person.canEditInformation')
+        ) {
+            return true;
+        }
+
+        return WCF::getSession()->getPermission('mod.person.canEditInformation');
+    }
+
+    /**
+     * Returns the formatted information.
+     */
+    public function getFormattedInformation(): string
+    {
+        $processor = new HtmlOutputProcessor();
+        $processor->process(
+            $this->information,
+            'com.woltlab.wcf.people.information',
+            $this->informationID
+        );
+
+        return $processor->getHtml();
+    }
+
+    /**
+     * Returns the person the information belongs to.
+     */
+    public function getPerson(): Person
+    {
+        return PersonRuntimeCache::getInstance()->getObject($this->personID);
+    }
+
+    /**
+     * Returns the user profile of the user who added the information.
+     */
+    public function getUserProfile(): UserProfile
+    {
+        if ($this->userID) {
+            return UserProfileRuntimeCache::getInstance()->getObject($this->userID);
+        } else {
+            return UserProfile::getGuestUserProfile($this->username);
+        }
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformationAction.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformationAction.class.php
new file mode 100644 (file)
index 0000000..d6f74a9
--- /dev/null
@@ -0,0 +1,303 @@
+<?php
+
+namespace wcf\data\person\information;
+
+use wcf\data\AbstractDatabaseObjectAction;
+use wcf\data\person\PersonAction;
+use wcf\data\person\PersonEditor;
+use wcf\system\cache\runtime\PersonRuntimeCache;
+use wcf\system\event\EventHandler;
+use wcf\system\exception\IllegalLinkException;
+use wcf\system\exception\PermissionDeniedException;
+use wcf\system\exception\UserInputException;
+use wcf\system\form\builder\container\wysiwyg\WysiwygFormContainer;
+use wcf\system\form\builder\DialogFormDocument;
+use wcf\system\html\input\HtmlInputProcessor;
+use wcf\system\WCF;
+
+/**
+ * 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()
+ */
+class PersonInformationAction extends AbstractDatabaseObjectAction
+{
+    /**
+     * @var DialogFormDocument
+     */
+    public $dialog;
+
+    /**
+     * @var PersonInformation
+     */
+    public $information;
+
+    /**
+     * @return  PersonInformation
+     */
+    public function create()
+    {
+        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'] = WCF::getSession()->ipAddress;
+            }
+        } 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();
+
+        (new PersonAction([$information->personID], 'update', [
+            'counters' => [
+                'informationCount' => 1,
+            ],
+        ]))->executeAction();
+
+        return $information;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function update()
+    {
+        if (!empty($this->parameters['information_htmlInputProcessor'])) {
+            /** @var HtmlInputProcessor $htmlInputProcessor */
+            $htmlInputProcessor = $this->parameters['information_htmlInputProcessor'];
+            $this->parameters['data']['information'] = $htmlInputProcessor->getHtml();
+        }
+
+        parent::update();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function validateDelete()
+    {
+        if (empty($this->objects)) {
+            $this->readObjects();
+
+            if (empty($this->objects)) {
+                throw new UserInputException('objectIDs');
+            }
+        }
+
+        foreach ($this->getObjects() as $informationEditor) {
+            if (!$informationEditor->canDelete()) {
+                throw new PermissionDeniedException();
+            }
+        }
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function delete()
+    {
+        $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 ($counterUpdates as $personID => $counterUpdate) {
+            (new PersonEditor(PersonRuntimeCache::getInstance()->getObject($personID)))->updateCounters([
+                'informationCount' => $counterUpdate,
+            ]);
+        }
+        WCF::getDB()->commitTransaction();
+
+        return $deleteCount;
+    }
+
+    /**
+     * Validates the `getAddDialog` action.
+     */
+    public function validateGetAddDialog(): void
+    {
+        WCF::getSession()->checkPermissions(['user.person.canAddInformation']);
+
+        $this->readInteger('personID');
+        if (PersonRuntimeCache::getInstance()->getObject($this->parameters['personID']) === null) {
+            throw new UserInputException('personID');
+        }
+    }
+
+    /**
+     * Returns the data to show the dialog to add a new piece of information on a person.
+     *
+     * @return  string[]
+     */
+    public function getAddDialog(): array
+    {
+        $this->buildDialog();
+
+        return [
+            'dialog' => $this->dialog->getHtml(),
+            'formId' => $this->dialog->getId(),
+        ];
+    }
+
+    /**
+     * Validates the `submitAddDialog` action.
+     */
+    public function validateSubmitAddDialog(): 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[]
+     */
+    public function submitAddDialog(): array
+    {
+        // If there are any validation errors, show the form again.
+        if ($this->dialog->hasValidationErrors()) {
+            return [
+                'dialog' => $this->dialog->getHtml(),
+                'formId' => $this->dialog->getId(),
+            ];
+        }
+
+        (new static([], 'create', \array_merge($this->dialog->getData(), [
+            'data' => [
+                'personID' => $this->parameters['personID'],
+            ],
+        ])))->executeAction();
+
+        return [];
+    }
+
+    /**
+     * Validates the `getEditDialog` action.
+     */
+    public function validateGetEditDialog(): void
+    {
+        WCF::getSession()->checkPermissions(['user.person.canAddInformation']);
+
+        $this->readInteger('informationID');
+        $this->information = new PersonInformation($this->parameters['informationID']);
+        if (!$this->information->getObjectID()) {
+            throw new UserInputException('informationID');
+        }
+        if (!$this->information->canEdit()) {
+            throw new IllegalLinkException();
+        }
+    }
+
+    /**
+     * Returns the data to show the dialog to edit a piece of information on a person.
+     *
+     * @return  string[]
+     */
+    public function getEditDialog(): array
+    {
+        $this->buildDialog();
+        $this->dialog->updatedObject($this->information);
+
+        return [
+            'dialog' => $this->dialog->getHtml(),
+            'formId' => $this->dialog->getId(),
+        ];
+    }
+
+    /**
+     * Validates the `submitEditDialog` action.
+     */
+    public function validateSubmitEditDialog(): 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[]
+     */
+    public function submitEditDialog(): array
+    {
+        // If there are any validation errors, show the form again.
+        if ($this->dialog->hasValidationErrors()) {
+            return [
+                'dialog' => $this->dialog->getHtml(),
+                'formId' => $this->dialog->getId(),
+            ];
+        }
+
+        (new static([$this->information], 'update', $this->dialog->getData()))->executeAction();
+
+        // Reload the information with the updated data.
+        $information = new PersonInformation($this->information->getObjectID());
+
+        return [
+            'formattedInformation' => $information->getFormattedInformation(),
+            'informationID' => $this->information->getObjectID(),
+        ];
+    }
+
+    /**
+     * Builds the dialog to create or edit person information.
+     */
+    protected function buildDialog(): 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();
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformationEditor.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformationEditor.class.php
new file mode 100644 (file)
index 0000000..a5dffa7
--- /dev/null
@@ -0,0 +1,25 @@
+<?php
+
+namespace wcf\data\person\information;
+
+use wcf\data\DatabaseObjectEditor;
+
+/**
+ * Provides functions to edit person information.
+ *
+ * @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\Informtion
+ *
+ * @method static   PersonInformation   create(array $parameters = [])
+ * @method          PersonInformation   getDecoratedObject()
+ * @mixin           PersonInformation
+ */
+class PersonInformationEditor extends DatabaseObjectEditor
+{
+    /**
+     * @inheritDoc
+     */
+    protected static $baseClass = PersonInformation::class;
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformationList.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformationList.class.php
new file mode 100644 (file)
index 0000000..5e4e89e
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+
+namespace wcf\data\person\information;
+
+use wcf\data\DatabaseObjectList;
+use wcf\system\cache\runtime\PersonRuntimeCache;
+use wcf\system\cache\runtime\UserProfileRuntimeCache;
+
+/**
+ * Represents a list of person information.
+ *
+ * @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\PersonInformation
+ *
+ * @method      PersonInformation       current()
+ * @method      PersonInformation[]     getObjects()
+ * @method      PersonInformation|null  search($objectID)
+ * @property    PersonInformation[]     $objects
+ */
+class PersonInformationList extends DatabaseObjectList
+{
+    public function readObjects()
+    {
+        parent::readObjects();
+
+        UserProfileRuntimeCache::getInstance()->cacheObjectIDs(\array_unique(\array_filter(\array_column(
+            $this->objects,
+            'userID'
+        ))));
+        PersonRuntimeCache::getInstance()->cacheObjectIDs(\array_unique(\array_column(
+            $this->objects,
+            'personID'
+        )));
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/page/PersonListPage.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/page/PersonListPage.class.php
new file mode 100644 (file)
index 0000000..5fbacd4
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+
+namespace wcf\page;
+
+use wcf\data\person\PersonList;
+
+/**
+ * Shows the list of people.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\Page
+ */
+class PersonListPage extends SortablePage
+{
+    /**
+     * @inheritDoc
+     */
+    public $defaultSortField = 'lastName';
+
+    /**
+     * @inheritDoc
+     */
+    public $objectListClassName = PersonList::class;
+
+    /**
+     * @inheritDoc
+     */
+    public $validSortFields = ['personID', 'firstName', 'lastName'];
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/page/PersonPage.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/page/PersonPage.class.php
new file mode 100644 (file)
index 0000000..24be70a
--- /dev/null
@@ -0,0 +1,105 @@
+<?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-2021 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();
+        }
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/box/PersonListBoxController.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/box/PersonListBoxController.class.php
new file mode 100644 (file)
index 0000000..95dc305
--- /dev/null
@@ -0,0 +1,69 @@
+<?php
+
+namespace wcf\system\box;
+
+use wcf\data\person\PersonList;
+use wcf\system\WCF;
+
+/**
+ * Dynamic box controller implementation for a list of persons.
+ *
+ * @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\Box
+ */
+class PersonListBoxController extends AbstractDatabaseObjectListBoxController
+{
+    /**
+     * @inheritDoc
+     */
+    protected $conditionDefinition = 'com.woltlab.wcf.box.personList.condition';
+
+    /**
+     * @inheritDoc
+     */
+    public $defaultLimit = 5;
+
+    /**
+     * @inheritDoc
+     */
+    protected $sortFieldLanguageItemPrefix = 'wcf.person';
+
+    /**
+     * @inheritDoc
+     */
+    protected static $supportedPositions = [
+        'sidebarLeft',
+        'sidebarRight',
+    ];
+
+    /**
+     * @inheritDoc
+     */
+    public $validSortFields = [
+        'firstName',
+        'lastName',
+        'comments',
+    ];
+
+    /**
+     * @inheritDoc
+     */
+    public function getObjectList()
+    {
+        return new PersonList();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function getTemplate()
+    {
+        return WCF::getTPL()->fetch('boxPersonList', 'wcf', [
+            'boxPersonList' => $this->objectList,
+            'boxSortField' => $this->sortField,
+            'boxPosition' => $this->box->position,
+        ], true);
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/cache/runtime/PersonRuntimeCache.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/cache/runtime/PersonRuntimeCache.class.php
new file mode 100644 (file)
index 0000000..1f9a67f
--- /dev/null
@@ -0,0 +1,26 @@
+<?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-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\System\Cache\Runtime
+ *
+ * @method  Person[]    getCachedObjects()
+ * @method  Person      getObject($objectID)
+ * @method  Person[]    getObjects(array $objectIDs)
+ */
+class PersonRuntimeCache extends AbstractRuntimeCache
+{
+    /**
+     * @inheritDoc
+     */
+    protected $listClassName = PersonList::class;
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/comment/manager/PersonCommentManager.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/comment/manager/PersonCommentManager.class.php
new file mode 100644 (file)
index 0000000..6cbad9d
--- /dev/null
@@ -0,0 +1,90 @@
+<?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-2021 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]);
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/condition/person/PersonFirstNameTextPropertyCondition.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/condition/person/PersonFirstNameTextPropertyCondition.class.php
new file mode 100644 (file)
index 0000000..38ae076
--- /dev/null
@@ -0,0 +1,47 @@
+<?php
+
+namespace wcf\system\condition\person;
+
+use wcf\data\person\Person;
+use wcf\system\condition\AbstractObjectTextPropertyCondition;
+
+/**
+ * Condition implementation for the first name of a person.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license WoltLab License <http://www.woltlab.com/license-agreement.html>
+ * @package WoltLabSuite\Core\System\Condition
+ */
+class PersonFirstNameTextPropertyCondition extends AbstractObjectTextPropertyCondition
+{
+    /**
+     * @inheritDoc
+     */
+    protected $className = Person::class;
+
+    /**
+     * @inheritDoc
+     */
+    protected $description = 'wcf.person.condition.firstName.description';
+
+    /**
+     * @inheritDoc
+     */
+    protected $fieldName = 'personFirstName';
+
+    /**
+     * @inheritDoc
+     */
+    protected $label = 'wcf.person.firstName';
+
+    /**
+     * @inheritDoc
+     */
+    protected $propertyName = 'firstName';
+
+    /**
+     * @inheritDoc
+     */
+    protected $supportsMultipleValues = true;
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/condition/person/PersonLastNameTextPropertyCondition.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/condition/person/PersonLastNameTextPropertyCondition.class.php
new file mode 100644 (file)
index 0000000..8c06314
--- /dev/null
@@ -0,0 +1,47 @@
+<?php
+
+namespace wcf\system\condition\person;
+
+use wcf\data\person\Person;
+use wcf\system\condition\AbstractObjectTextPropertyCondition;
+
+/**
+ * Condition implementation for the last name of a person.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license WoltLab License <http://www.woltlab.com/license-agreement.html>
+ * @package WoltLabSuite\Core\System\Condition
+ */
+class PersonLastNameTextPropertyCondition extends AbstractObjectTextPropertyCondition
+{
+    /**
+     * @inheritDoc
+     */
+    protected $className = Person::class;
+
+    /**
+     * @inheritDoc
+     */
+    protected $description = 'wcf.person.condition.lastName.description';
+
+    /**
+     * @inheritDoc
+     */
+    protected $fieldName = 'personLastName';
+
+    /**
+     * @inheritDoc
+     */
+    protected $label = 'wcf.person.lastName';
+
+    /**
+     * @inheritDoc
+     */
+    protected $propertyName = 'lastName';
+
+    /**
+     * @inheritDoc
+     */
+    protected $supportsMultipleValues = true;
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonPruneIpAddressesCronjobListener.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonPruneIpAddressesCronjobListener.class.php
new file mode 100644 (file)
index 0000000..a854da0
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+
+namespace wcf\system\event\listener;
+
+use wcf\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
+ */
+class PersonPruneIpAddressesCronjobListener extends AbstractEventListener
+{
+    protected function onExecute(PruneIpAddressesCronjob $cronjob): void
+    {
+        $cronjob->columns['wcf' . WCF_N . '_person_information']['ipAddress'] = 'time';
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserActionRenameListener.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserActionRenameListener.class.php
new file mode 100644 (file)
index 0000000..e18384e
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+
+namespace wcf\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
+ */
+class PersonUserActionRenameListener extends AbstractUserActionRenameListener
+{
+    /**
+     * @inheritDoc
+     */
+    protected $databaseTables = [
+        'wcf{WCF_N}_person_information',
+    ];
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserExportGdprListener.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserExportGdprListener.class.php
new file mode 100644 (file)
index 0000000..6d76add
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+
+namespace wcf\system\event\listener;
+
+use wcf\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
+ */
+class PersonUserExportGdprListener extends AbstractEventListener
+{
+    protected function onExport(UserExportGdprAction $action): void
+    {
+        $action->ipAddresses['com.woltlab.wcf.people'] = ['wcf' . WCF_N . '_person_information'];
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserMergeListener.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserMergeListener.class.php
new file mode 100644 (file)
index 0000000..f66467e
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+
+namespace wcf\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
+ */
+class PersonUserMergeListener extends AbstractUserMergeListener
+{
+    /**
+     * @inheritDoc
+     */
+    protected $databaseTables = [
+        'wcf{WCF_N}_person_information',
+    ];
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/page/handler/PersonPageHandler.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/page/handler/PersonPageHandler.class.php
new file mode 100644 (file)
index 0000000..353900b
--- /dev/null
@@ -0,0 +1,104 @@
+<?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(Page $page, UserOnline $user)
+    {
+        if ($user->pageObjectID !== null) {
+            PersonRuntimeCache::getInstance()->cacheObjectID($user->pageObjectID);
+        }
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/language/de.xml b/snippets/tutorial/tutorial-series/part-5/language/de.xml
new file mode 100644 (file)
index 0000000..60e2523
--- /dev/null
@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<language 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/5.4/language.xsd" languagecode="de">
+       <category name="wcf.acp.box">
+               <item name="wcf.acp.box.boxController.com.woltlab.wcf.personList"><![CDATA[Personen]]></item>
+       </category>
+       
+       <category name="wcf.acp.group">
+               <item name="wcf.acp.group.option.admin.content.canManagePeople"><![CDATA[Kann Personen verwalten]]></item>
+               <item name="wcf.acp.group.option.category.mod.person"><![CDATA[Personen]]></item>
+               <item name="wcf.acp.group.option.category.user.person"><![CDATA[Personen]]></item>
+               <item name="wcf.acp.group.option.mod.person.canDeleteComment"><![CDATA[Kann Kommentare löschen]]></item>
+               <item name="wcf.acp.group.option.mod.person.canDeleteInformation"><![CDATA[Kann Informationen löschen]]></item>
+               <item name="wcf.acp.group.option.mod.person.canEditComment"><![CDATA[Kann Kommentare bearbeiten]]></item>
+               <item name="wcf.acp.group.option.mod.person.canEditInformation"><![CDATA[Kann Informationen bearbeiten]]></item>
+               <item name="wcf.acp.group.option.mod.person.canModerateComment"><![CDATA[Kann Kommentare moderieren]]></item>
+               <item name="wcf.acp.group.option.user.person.canAddComment"><![CDATA[Kann Kommentare erstellen]]></item>
+               <item name="wcf.acp.group.option.user.person.canAddCommentWithoutModeration"><![CDATA[Kann Kommentare ohne Moderation erstellen]]></item>
+               <item name="wcf.acp.group.option.user.person.canAddInformation"><![CDATA[Kann Informationen erstellen]]></item>
+               <item name="wcf.acp.group.option.user.person.canDeleteComment"><![CDATA[Kann eigene Kommentare löschen]]></item>
+               <item name="wcf.acp.group.option.user.person.canDeleteInformation"><![CDATA[Kann eigene Informationen löschen]]></item>
+               <item name="wcf.acp.group.option.user.person.canEditComment"><![CDATA[Kann eigene Kommentare bearbeiten]]></item>
+               <item name="wcf.acp.group.option.user.person.canEditInformation"><![CDATA[Kann eigene Informationen bearbeiten]]></item>
+       </category>
+       
+       <category name="wcf.acp.menu">
+               <item name="wcf.acp.menu.link.person"><![CDATA[Personen]]></item>
+               <item name="wcf.acp.menu.link.person.add"><![CDATA[Person hinzufügen]]></item>
+               <item name="wcf.acp.menu.link.person.list"><![CDATA[Personen]]></item>
+       </category>
+       
+       <category name="wcf.acp.person">
+               <item name="wcf.acp.person.add"><![CDATA[Person hinzufügen]]></item>
+               <item name="wcf.acp.person.edit"><![CDATA[Person bearbeiten]]></item>
+               <item name="wcf.acp.person.list"><![CDATA[Personen]]></item>
+       </category>
+       
+       <category name="wcf.page">
+               <item name="wcf.page.onlineLocation.com.woltlab.wcf.people.Person"><![CDATA[Person {anchor object=$person}]]></item>
+       </category>
+       
+       <category name="wcf.person">
+               <item name="wcf.person.boxList.description.comments"><![CDATA[{plural value=$boxPerson->comments 1='1 Kommentar' other='# Kommentare'}]]></item>
+               <item name="wcf.person.comment"><![CDATA[Person-Kommentar]]></item>
+               <item name="wcf.person.commentResponse"><![CDATA[Antwort auf Person-Kommentar]]></item>
+               <item name="wcf.person.comments"><![CDATA[Kommentare]]></item>
+               <item name="wcf.person.condition.firstName.description"><![CDATA[Mehrere Vornamen müssen durch ein Komma getrennt werden.]]></item>
+               <item name="wcf.person.condition.lastName.description"><![CDATA[Mehrere Nachnamen müssen durch ein Komma getrennt werden.]]></item>
+               <item name="wcf.person.enableComments"><![CDATA[Kommentare aktivieren]]></item>
+               <item name="wcf.person.enableComments.description"><![CDATA[Erlaubt es Benutzern diese Person zu kommentieren.]]></item>
+               <item name="wcf.person.firstName"><![CDATA[Vorname]]></item>
+               <item name="wcf.person.informationCount"><![CDATA[Informationen]]></item>
+               <item name="wcf.person.lastName"><![CDATA[Nachname]]></item>
+               <item name="wcf.person.list"><![CDATA[Personen]]></item>
+       </category>
+       
+       <category name="wcf.person.information">
+               <item name="wcf.person.information.add"><![CDATA[Information hinzufügen]]></item>
+               <item name="wcf.person.information.add.success"><![CDATA[Die Information wurde erfolgreich hinzugefügt.]]></item>
+               <item name="wcf.person.information.delete.confirmMessage"><![CDATA[{if LANGUAGE_USE_INFORMAL_VARIANT}Willst du{else}Wollen Sie{/if} diese Information wirklich löschen?]]></item>
+               <item name="wcf.person.information.edit"><![CDATA[Information bearbeiten]]></item>
+               <item name="wcf.person.information.edit.success"><![CDATA[Die Information wurde erfolgreich bearbeitet.]]></item>
+               <item name="wcf.person.information.list"><![CDATA[Informationen]]></item>
+       </category>
+</language>
diff --git a/snippets/tutorial/tutorial-series/part-5/language/en.xml b/snippets/tutorial/tutorial-series/part-5/language/en.xml
new file mode 100644 (file)
index 0000000..ff19b81
--- /dev/null
@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<language 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/5.4/language.xsd" languagecode="en">
+       <category name="wcf.acp.box">
+               <item name="wcf.acp.box.boxController.com.woltlab.wcf.personList"><![CDATA[People]]></item>
+       </category>
+       
+       <category name="wcf.acp.group">
+               <item name="wcf.acp.group.option.admin.content.canManagePeople"><![CDATA[Can manage people]]></item>
+               <item name="wcf.acp.group.option.category.mod.person"><![CDATA[People]]></item>
+               <item name="wcf.acp.group.option.category.user.person"><![CDATA[People]]></item>
+               <item name="wcf.acp.group.option.mod.person.canDeleteComment"><![CDATA[Can delete comments]]></item>
+               <item name="wcf.acp.group.option.mod.person.canDeleteInformation"><![CDATA[Can delete pieces of information]]></item>
+               <item name="wcf.acp.group.option.mod.person.canEditComment"><![CDATA[Can edit comments]]></item>
+               <item name="wcf.acp.group.option.mod.person.canEditInformation"><![CDATA[Can edit pieces of information]]></item>
+               <item name="wcf.acp.group.option.mod.person.canModerateComment"><![CDATA[Can moderate comments]]></item>
+               <item name="wcf.acp.group.option.user.person.canAddComment"><![CDATA[Can create comments]]></item>
+               <item name="wcf.acp.group.option.user.person.canAddCommentWithoutModeration"><![CDATA[Can create comments without approval]]></item>
+               <item name="wcf.acp.group.option.user.person.canAddInformation"><![CDATA[Can create information]]></item>
+               <item name="wcf.acp.group.option.user.person.canDeleteComment"><![CDATA[Can delete their comments]]></item>
+               <item name="wcf.acp.group.option.user.person.canDeleteInformation"><![CDATA[Can delete their pieces of information]]></item>
+               <item name="wcf.acp.group.option.user.person.canEditComment"><![CDATA[Can edit their comments]]></item>
+               <item name="wcf.acp.group.option.user.person.canEditInformation"><![CDATA[Can edit their pieces of information]]></item>
+       </category>
+       
+       <category name="wcf.acp.menu">
+               <item name="wcf.acp.menu.link.person"><![CDATA[People]]></item>
+               <item name="wcf.acp.menu.link.person.add"><![CDATA[Add Person]]></item>
+               <item name="wcf.acp.menu.link.person.list"><![CDATA[People]]></item>
+       </category>
+       
+       <category name="wcf.acp.person">
+               <item name="wcf.acp.person.add"><![CDATA[Add Person]]></item>
+               <item name="wcf.acp.person.edit"><![CDATA[Edit Person]]></item>
+               <item name="wcf.acp.person.list"><![CDATA[People]]></item>
+       </category>
+       
+       <category name="wcf.page">
+               <item name="wcf.page.onlineLocation.com.woltlab.wcf.people.Person"><![CDATA[Person {anchor object=$person}]]></item>
+       </category>
+       
+       <category name="wcf.person">
+               <item name="wcf.person.boxList.description.comments"><![CDATA[{plural value=$boxPerson->comments 1='1 Comment' other='# Comments'}]]></item>
+               <item name="wcf.person.comment"><![CDATA[Person Comment]]></item>
+               <item name="wcf.person.commentResponse"><![CDATA[Reply to Person Comment]]></item>
+               <item name="wcf.person.comments"><![CDATA[Comments]]></item>
+               <item name="wcf.person.condition.firstName.description"><![CDATA[Multiple first names have to be separated by commas.]]></item>
+               <item name="wcf.person.condition.lastName.description"><![CDATA[Multiple last names have to be separated by commas.]]></item>
+               <item name="wcf.person.enableComments"><![CDATA[Allow Comments]]></item>
+               <item name="wcf.person.enableComments.description"><![CDATA[Allow users to comment on this person.]]></item>
+               <item name="wcf.person.firstName"><![CDATA[First Name]]></item>
+               <item name="wcf.person.informationCount"><![CDATA[Pieces of Information]]></item>
+               <item name="wcf.person.lastName"><![CDATA[Last Name]]></item>
+               <item name="wcf.person.list"><![CDATA[People]]></item>
+       </category>
+       
+       <category name="wcf.person.information">
+               <item name="wcf.person.information.add"><![CDATA[Add Information]]></item>
+               <item name="wcf.person.information.add.success"><![CDATA[The piece of information has been added successfully.]]></item>
+               <item name="wcf.person.information.delete.confirmMessage"><![CDATA[Do you really want to delete this piece of information?]]></item>
+               <item name="wcf.person.information.edit"><![CDATA[Edit Information]]></item>
+               <item name="wcf.person.information.edit.success"><![CDATA[The piece of information has been edited successfully.]]></item>
+               <item name="wcf.person.information.list"><![CDATA[Information]]></item>
+       </category>
+</language>
diff --git a/snippets/tutorial/tutorial-series/part-5/menuItem.xml b/snippets/tutorial/tutorial-series/part-5/menuItem.xml
new file mode 100644 (file)
index 0000000..bcf3e04
--- /dev/null
@@ -0,0 +1,11 @@
+<?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/5.4/menuItem.xsd">
+       <import>
+               <item identifier="com.woltlab.wcf.people.PersonList">
+                       <menu>com.woltlab.wcf.MainMenu</menu>
+                       <title language="de">Personen</title>
+                       <title language="en">People</title>
+                       <page>com.woltlab.wcf.people.PersonList</page>
+               </item>
+       </import>
+</data>
diff --git a/snippets/tutorial/tutorial-series/part-5/objectType.xml b/snippets/tutorial/tutorial-series/part-5/objectType.xml
new file mode 100644 (file)
index 0000000..120c761
--- /dev/null
@@ -0,0 +1,29 @@
+<?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/5.4/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>
+               <type>
+                       <name>com.woltlab.wcf.personList</name>
+                       <definitionname>com.woltlab.wcf.boxController</definitionname>
+                       <classname>wcf\system\box\PersonListBoxController</classname>
+               </type>
+               <type>
+                       <name>com.woltlab.wcf.people.firstName</name>
+                       <definitionname>com.woltlab.wcf.box.personList.condition</definitionname>
+                       <classname>wcf\system\condition\person\PersonFirstNameTextPropertyCondition</classname>
+               </type>
+               <type>
+                       <name>com.woltlab.wcf.people.lastName</name>
+                       <definitionname>com.woltlab.wcf.box.personList.condition</definitionname>
+                       <classname>wcf\system\condition\person\PersonLastNameTextPropertyCondition</classname>
+               </type>
+               <type>
+                       <name>com.woltlab.wcf.people.information</name>
+                       <definitionname>com.woltlab.wcf.message</definitionname>
+               </type>
+       </import>
+</data>
diff --git a/snippets/tutorial/tutorial-series/part-5/objectTypeDefinition.xml b/snippets/tutorial/tutorial-series/part-5/objectTypeDefinition.xml
new file mode 100644 (file)
index 0000000..82414de
--- /dev/null
@@ -0,0 +1,9 @@
+<?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/5.4/objectTypeDefinition.xsd">
+       <import>
+               <definition>
+                       <name>com.woltlab.wcf.box.personList.condition</name>
+                       <interfacename>wcf\system\condition\IObjectListCondition</interfacename>
+               </definition>
+       </import>
+</data>
diff --git a/snippets/tutorial/tutorial-series/part-5/package.xml b/snippets/tutorial/tutorial-series/part-5/package.xml
new file mode 100644 (file)
index 0000000..f7df729
--- /dev/null
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<package name="com.woltlab.wcf.people" 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/5.4/package.xsd">
+       <packageinformation>
+               <packagename>WoltLab Suite Core Tutorial: People</packagename>
+               <packagedescription>Adds a simple management system for people as part of a tutorial to create packages.</packagedescription>
+               <version>5.4.0</version>
+               <date>2021-04-16</date>
+       </packageinformation>
+       
+       <authorinformation>
+               <author>WoltLab GmbH</author>
+               <authorurl>http://www.woltlab.com</authorurl>
+       </authorinformation>
+       
+       <requiredpackages>
+               <requiredpackage minversion="5.4.0 Alpha 1">com.woltlab.wcf</requiredpackage>
+       </requiredpackages>
+       
+       <excludedpackages>
+               <excludedpackage version="6.0.0 Alpha 1">com.woltlab.wcf</excludedpackage>
+       </excludedpackages>
+       
+       <instructions type="install">
+               <instruction type="acpTemplate" />
+               <instruction type="file" />
+               <instruction type="database">acp/database/install_com.woltlab.wcf.people.php</instruction>
+               <instruction type="template" />
+               <instruction type="language" />
+               
+               <instruction type="acpMenu" />
+               <instruction type="eventListener" />
+               <instruction type="page" />
+               <instruction type="menuItem" />
+               <instruction type="userGroupOption" />
+       </instructions>
+</package>
diff --git a/snippets/tutorial/tutorial-series/part-5/page.xml b/snippets/tutorial/tutorial-series/part-5/page.xml
new file mode 100644 (file)
index 0000000..8270c57
--- /dev/null
@@ -0,0 +1,27 @@
+<?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/5.4/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>
diff --git a/snippets/tutorial/tutorial-series/part-5/templates/boxPersonList.tpl b/snippets/tutorial/tutorial-series/part-5/templates/boxPersonList.tpl
new file mode 100644 (file)
index 0000000..146ce16
--- /dev/null
@@ -0,0 +1,15 @@
+<ul class="sidebarItemList">
+    {foreach from=$boxPersonList item=boxPerson}
+        <li class="box24">
+            <span class="icon icon24 fa-user"></span>
+
+            <div class="sidebarItemTitle">
+                <h3>{anchor object=$boxPerson}</h3>
+                {capture assign='__boxPersonDescription'}{lang __optional=true}wcf.person.boxList.description.{$boxSortField}{/lang}{/capture}
+                {if $__boxPersonDescription}
+                    <small>{@$__boxPersonDescription}</small>
+                {/if}
+            </div>
+        </li>
+    {/foreach}
+</ul>
diff --git a/snippets/tutorial/tutorial-series/part-5/templates/person.tpl b/snippets/tutorial/tutorial-series/part-5/templates/person.tpl
new file mode 100644 (file)
index 0000000..863cdf2
--- /dev/null
@@ -0,0 +1,141 @@
+{capture assign='pageTitle'}{$person} - {lang}wcf.person.list{/lang}{/capture}
+
+{capture assign='contentTitle'}{$person}{/capture}
+
+{include file='header'}
+
+{if $person->informationCount || $__wcf->session->getPermission('user.person.canAddInformation')}
+       <section class="section sectionContainerList">
+               <header class="sectionHeader">
+                       <h2 class="sectionTitle">
+                               {lang}wcf.person.information.list{/lang}
+                               {if $person->informationCount}
+                                       <span class="badge">{#$person->informationCount}</span>
+                               {/if}
+                       </h2>
+               </header>
+               
+               <ul class="commentList containerList personInformationList jsObjectActionContainer" {*
+                       *}data-object-action-class-name="wcf\data\person\information\PersonInformationAction"{*
+               *}>
+                       {if $__wcf->session->getPermission('user.person.canAddInformation')}
+                               <li class="containerListButtonGroup">
+                                       <ul class="buttonGroup">
+                                               <li>
+                                                       <a href="#" class="button" id="personInformationAddButton">
+                                                               <span class="icon icon16 fa-plus"></span>
+                                                               <span>{lang}wcf.person.information.add{/lang}</span>
+                                                       </a>
+                                               </li>
+                                       </ul>
+                               </li>
+                       {/if}
+                       
+                       {foreach from=$person->getInformation() item=$information}
+                               <li class="comment personInformation jsObjectActionObject" data-object-id="{@$information->getObjectID()}">
+                                       <div class="box48{if $__wcf->getUserProfileHandler()->isIgnoredUser($information->userID)} ignoredUserContent{/if}">
+                                               {user object=$information->getUserProfile() type='avatar48' ariaHidden='true' tabindex='-1'}
+                                               
+                                               <div class="commentContentContainer">
+                                                       <div class="commentContent">
+                                                               <div class="containerHeadline">
+                                                                       <h3>
+                                                                               {if $information->userID}
+                                                                                       {user object=$information->getUserProfile()}
+                                                                               {else}
+                                                                                       <span>{$information->username}</span>
+                                                                               {/if}
+                                                                               
+                                                                               <small class="separatorLeft">{@$information->time|time}</small>
+                                                                       </h3>
+                                                               </div>
+                                                               
+                                                               <div class="htmlContent userMessage" id="personInformation{@$information->getObjectID()}">
+                                                                       {@$information->getFormattedInformation()}
+                                                               </div>
+                                                               
+                                                               <nav class="jsMobileNavigation buttonGroupNavigation">
+                                                                       <ul class="buttonList iconList">
+                                                                               {if $information->canEdit()}
+                                                                                       <li class="jsOnly">
+                                                                                               <a href="#" title="{lang}wcf.global.button.edit{/lang}" class="jsEditInformation jsTooltip">
+                                                                                                       <span class="icon icon16 fa-pencil"></span>
+                                                                                                       <span class="invisible">{lang}wcf.global.button.edit{/lang}</span>
+                                                                                               </a>
+                                                                                       </li>
+                                                                               {/if}
+                                                                               {if $information->canDelete()}
+                                                                                       <li class="jsOnly">
+                                                                                               <a href="#" title="{lang}wcf.global.button.delete{/lang}" class="jsObjectAction jsTooltip" data-object-action="delete" data-confirm-message="{lang}wcf.person.information.delete.confirmMessage{/lang}">
+                                                                                                       <span class="icon icon16 fa-times"></span>
+                                                                                                       <span class="invisible">{lang}wcf.global.button.edit{/lang}</span>
+                                                                                               </a>
+                                                                                       </li>
+                                                                               {/if}
+                                                                               
+                                                                               {event name='informationOptions'}
+                                                                       </ul>
+                                                               </nav>
+                                                       </div>
+                                               </div>
+                                       </div>
+                               </li>
+                       {/foreach}
+               </ul>
+       </section>
+{/if}
+
+{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>
+
+<script data-relocate="true">
+       require(['Language', 'WoltLabSuite/Core/Controller/Person'], (Language, ControllerPerson) => {
+               Language.addObject({
+                       'wcf.person.information.add': '{jslang}wcf.person.information.add{/jslang}',
+                       'wcf.person.information.add.success': '{jslang}wcf.person.information.add.success{/jslang}',
+                       'wcf.person.information.edit': '{jslang}wcf.person.information.edit{/jslang}',
+                       'wcf.person.information.edit.success': '{jslang}wcf.person.information.edit.success{/jslang}',
+               });
+               
+               ControllerPerson.init({@$person->personID}, {
+                       canAddInformation: {if $__wcf->session->getPermission('user.person.canAddInformation')}true{else}false{/if},
+               });
+       });
+</script>
+
+{include file='footer'}
diff --git a/snippets/tutorial/tutorial-series/part-5/templates/personList.tpl b/snippets/tutorial/tutorial-series/part-5/templates/personList.tpl
new file mode 100644 (file)
index 0000000..1f72ee9
--- /dev/null
@@ -0,0 +1,113 @@
+{capture assign='contentTitle'}{lang}wcf.person.list{/lang} <span class="badge">{#$items}</span>{/capture}
+
+{capture assign='headContent'}
+       {if $pageNo < $pages}
+               <link rel="next" href="{link controller='PersonList'}pageNo={@$pageNo+1}{/link}">
+       {/if}
+       {if $pageNo > 1}
+               <link rel="prev" href="{link controller='PersonList'}{if $pageNo > 2}pageNo={@$pageNo-1}{/if}{/link}">
+       {/if}
+       <link rel="canonical" href="{link controller='PersonList'}{if $pageNo > 1}pageNo={@$pageNo}{/if}{/link}">
+{/capture}
+
+{capture assign='sidebarRight'}
+       <section class="box">
+               <form method="post" action="{link controller='PersonList'}{/link}">
+                       <h2 class="boxTitle">{lang}wcf.global.sorting{/lang}</h2>
+                       
+                       <div class="boxContent">
+                               <dl>
+                                       <dt></dt>
+                                       <dd>
+                                               <select id="sortField" name="sortField">
+                                                       <option value="firstName"{if $sortField == 'firstName'} selected{/if}>{lang}wcf.person.firstName{/lang}</option>
+                                                       <option value="lastName"{if $sortField == 'lastName'} selected{/if}>{lang}wcf.person.lastName{/lang}</option>
+                                                       {event name='sortField'}
+                                               </select>
+                                               <select name="sortOrder">
+                                                       <option value="ASC"{if $sortOrder == 'ASC'} selected{/if}>{lang}wcf.global.sortOrder.ascending{/lang}</option>
+                                                       <option value="DESC"{if $sortOrder == 'DESC'} selected{/if}>{lang}wcf.global.sortOrder.descending{/lang}</option>
+                                               </select>
+                                       </dd>
+                               </dl>
+                               
+                               <div class="formSubmit">
+                                       <input type="submit" value="{lang}wcf.global.button.submit{/lang}" accesskey="s">
+                               </div>
+                       </div>
+               </form>
+       </section>
+{/capture}
+
+{include file='header'}
+
+{hascontent}
+       <div class="paginationTop">
+               {content}
+                       {pages print=true assign=pagesLinks controller='PersonList' link="pageNo=%d&sortField=$sortField&sortOrder=$sortOrder"}
+               {/content}
+       </div>
+{/hascontent}
+
+{if $items}
+       <div class="section sectionContainerList">
+               <ol class="containerList personList">
+                       {foreach from=$objects item=person}
+                               <li>
+                                       <div class="box48">
+                                               <span class="icon icon48 fa-user"></span>
+                                               
+                                               <div class="details personInformation">
+                                                       <div class="containerHeadline">
+                                                               <h3>{anchor object=$person}</h3>
+                                                       </div>
+                                                       
+                                                       {hascontent}
+                                                               <ul class="inlineList commaSeparated">
+                                                                       {content}{event name='personData'}{/content}
+                                                               </ul>
+                                                       {/hascontent}
+                                                       
+                                                       {hascontent}
+                                                               <dl class="plain inlineDataList small">
+                                                                       {content}
+                                                                               {if $person->informationCount}
+                                                                                       <dt>{lang}wcf.person.informationCount{/lang}</dt>
+                                                                                       <dd>{#$person->informationCount}</dd>
+                                                                               {/if}
+                                                                               {if $person->enableComments}
+                                                                                       <dt>{lang}wcf.person.comments{/lang}</dt>
+                                                                                       <dd>{#$person->comments}</dd>
+                                                                               {/if}
+                                                                               
+                                                                               {event name='personStatistics'}
+                                                                       {/content}
+                                                               </dl>
+                                                       {/hascontent}
+                                               </div>
+                                       </div>
+                               </li>
+                       {/foreach}
+               </ol>
+       </div>
+{else}
+       <p class="info">{lang}wcf.global.noItems{/lang}</p>
+{/if}
+
+<footer class="contentFooter">
+       {hascontent}
+               <div class="paginationBottom">
+                       {content}{@$pagesLinks}{/content}
+               </div>
+       {/hascontent}
+       
+       {hascontent}
+               <nav class="contentFooterNavigation">
+                       <ul>
+                               {content}{event name='contentFooterNavigation'}{/content}
+                       </ul>
+               </nav>
+       {/hascontent}
+</footer>
+
+{include file='footer'}
diff --git a/snippets/tutorial/tutorial-series/part-5/ts/WoltLabSuite/Core/Controller/Person.ts b/snippets/tutorial/tutorial-series/part-5/ts/WoltLabSuite/Core/Controller/Person.ts
new file mode 100644 (file)
index 0000000..5a8e150
--- /dev/null
@@ -0,0 +1,99 @@
+/**
+ * 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
+ */
+
+import FormBuilderDialog from "WoltLabSuite/Core/Form/Builder/Dialog";
+import * as Language from "WoltLabSuite/Core/Language";
+import * as UiNotification from "WoltLabSuite/Core/Ui/Notification";
+
+let addDialog: FormBuilderDialog;
+const editDialogs = new Map<string, FormBuilderDialog>();
+
+interface EditReturnValues {
+  formattedInformation: string;
+  informationID: number;
+}
+
+interface Options {
+  canAddInformation: true;
+}
+
+/**
+ * Opens the edit dialog after clicking on the edit button for a piece of information.
+ */
+function editInformation(event: Event): void {
+  event.preventDefault();
+
+  const currentTarget = event.currentTarget as HTMLElement;
+  const information = currentTarget.closest(".jsObjectActionObject") as HTMLElement;
+  const informationId = information.dataset.objectId!;
+
+  if (!editDialogs.has(informationId)) {
+    editDialogs.set(
+      informationId,
+      new FormBuilderDialog(
+        `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.
+ */
+export function init(personId: number, options: Options): void {
+  if (options.canAddInformation) {
+    // Initialize the dialog to add new information.
+    addDialog = new FormBuilderDialog(
+      "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)));
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/userGroupOption.xml b/snippets/tutorial/tutorial-series/part-5/userGroupOption.xml
new file mode 100644 (file)
index 0000000..d5bc636
--- /dev/null
@@ -0,0 +1,102 @@
+<?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/5.4/userGroupOption.xsd">
+       <import>
+               <categories>
+                       <category name="mod.person">
+                               <parent>mod</parent>
+                       </category>
+                       <category name="user.person">
+                               <parent>user</parent>
+                       </category>
+               </categories>
+               
+               <options>
+                       <option name="admin.content.canManagePeople">
+                               <categoryname>admin.content</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>0</defaultvalue>
+                               <admindefaultvalue>1</admindefaultvalue>
+                               <usersonly>1</usersonly>
+                       </option>
+                       <option name="mod.person.canModerateComment">
+                               <categoryname>mod.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>0</defaultvalue>
+                               <moddefaultvalue>1</moddefaultvalue>
+                               <usersonly>1</usersonly>
+                       </option>
+                       <option name="mod.person.canEditComment">
+                               <categoryname>mod.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>0</defaultvalue>
+                               <moddefaultvalue>1</moddefaultvalue>
+                               <usersonly>1</usersonly>
+                       </option>
+                       <option name="mod.person.canDeleteComment">
+                               <categoryname>mod.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>0</defaultvalue>
+                               <moddefaultvalue>1</moddefaultvalue>
+                               <usersonly>1</usersonly>
+                       </option>
+                       <option name="mod.person.canEditInformation">
+                               <categoryname>mod.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>0</defaultvalue>
+                               <moddefaultvalue>1</moddefaultvalue>
+                               <usersonly>1</usersonly>
+                       </option>
+                       <option name="mod.person.canDeleteInformation">
+                               <categoryname>user.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>0</defaultvalue>
+                               <moddefaultvalue>1</moddefaultvalue>
+                               <usersonly>1</usersonly>
+                       </option>
+                       <option name="user.person.canAddInformation">
+                               <categoryname>user.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>1</defaultvalue>
+                               <excludedInTinyBuild>1</excludedInTinyBuild>
+                       </option>
+                       <option name="user.person.canEditInformation">
+                               <categoryname>user.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>1</defaultvalue>
+                               <excludedInTinyBuild>1</excludedInTinyBuild>
+                       </option>
+                       <option name="user.person.canDeleteInformation">
+                               <categoryname>user.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>0</defaultvalue>
+                               <excludedInTinyBuild>1</excludedInTinyBuild>
+                       </option>
+                       <option name="user.person.canAddComment">
+                               <categoryname>user.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>1</defaultvalue>
+                               <enableoptions>user.person.canAddCommentWithoutModeration</enableoptions>
+                               <excludedInTinyBuild>1</excludedInTinyBuild>
+                       </option>
+                       <option name="user.person.canAddCommentWithoutModeration">
+                               <categoryname>user.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>1</defaultvalue>
+                               <excludedInTinyBuild>1</excludedInTinyBuild>
+                       </option>
+                       <option name="user.person.canEditComment">
+                               <categoryname>user.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>1</defaultvalue>
+                               <usersonly>1</usersonly>
+                       </option>
+                       <option name="user.person.canDeleteComment">
+                               <categoryname>user.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>0</defaultvalue>
+                               <admindefaultvalue>1</admindefaultvalue>
+                               <usersonly>1</usersonly>
+                       </option>
+               </options>
+       </import>
+</data>