Merge pull request #152 from WoltLab/tutorial4
authorMatthias Schmidt <gravatronics@live.com>
Tue, 20 Apr 2021 08:06:08 +0000 (10:06 +0200)
committerGitHub <noreply@github.com>
Tue, 20 Apr 2021 08:06:08 +0000 (10:06 +0200)
 Add fourth part of tutorial series on boxes and box conditions

33 files changed:
docs/tutorial/series/overview.md
docs/tutorial/series/part_4.md [new file with mode: 0644]
mkdocs.yml
snippets/tutorial/tutorial-series/part-4/acpMenu.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/acptemplates/personAdd.tpl [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/acptemplates/personList.tpl [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/files/acp/database/install_com.woltlab.wcf.people.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/files/lib/acp/form/PersonAddForm.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/files/lib/acp/form/PersonEditForm.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/files/lib/acp/page/PersonListPage.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/files/lib/data/person/Person.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/files/lib/data/person/PersonAction.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/files/lib/data/person/PersonEditor.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/files/lib/data/person/PersonList.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/files/lib/page/PersonListPage.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/files/lib/page/PersonPage.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/files/lib/system/box/PersonListBoxController.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/files/lib/system/cache/runtime/PersonRuntimeCache.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/files/lib/system/comment/manager/PersonCommentManager.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/files/lib/system/condition/person/PersonFirstNameTextPropertyCondition.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/files/lib/system/condition/person/PersonLastNameTextPropertyCondition.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/files/lib/system/page/handler/PersonPageHandler.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/language/de.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/language/en.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/menuItem.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/objectType.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/objectTypeDefinition.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/package.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/page.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/templates/boxPersonList.tpl [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/templates/person.tpl [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/templates/personList.tpl [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-4/userGroupOption.xml [new file with mode: 0644]

index 46f9877042d7d87fa0b0b681b4bbd3ef3974654c..ce702be8e4d68cf50bfc944dd602ae5a07eb3683 100644 (file)
@@ -9,3 +9,4 @@ Note that in the context of this example, not every added feature might make per
 - [Part 1: Base Structure](part_1.md)
 - [Part 2: Event Listeners and Template Listeners](part_2.md)
 - [Part 3: Person Page and Comments](part_3.md)
+- [Part 4: Box and Box Conditions](part_4.md)
diff --git a/docs/tutorial/series/part_4.md b/docs/tutorial/series/part_4.md
new file mode 100644 (file)
index 0000000..31fb74a
--- /dev/null
@@ -0,0 +1,130 @@
+# Part 4: Box and Box Conditions
+
+In this part of our tutorial series, we add support for creating boxes listing people.
+
+## Package Functionality
+
+In addition to the existing functions from [part 3](part_3.md), the package will provide the following functionality after this part of the tutorial:
+
+- Creating boxes dynamically listing people
+- Filtering the people listed in boxes using conditions
+
+## Used Components
+
+In addition to the components used in previous parts, we will use the [`objectTypeDefinition` package installation plugin](../../package/pip/object-type-definition.md) and use the box and condition APIs.
+
+To pre-install a specific person list box, we refer to the documentation of the [`box` package installation plugin](../../package/pip/box.md).
+
+
+## Package Structure
+
+The complete package will have the following file structure (_excluding_ unchanged files from [part 3](part_3.md)):
+
+```
+├── files
+│   └── lib
+│       └── system
+│           ├── box
+│           │   └── PersonListBoxController.class.php
+│           └── condition
+│               └── person
+│                   ├── PersonFirstNameTextPropertyCondition.class.php
+│                   └── PersonLastNameTextPropertyCondition.class.php
+├── language
+│   ├── de.xml
+│   └── en.xml
+├── objectType.xml
+├── objectTypeDefinition.xml
+└── templates
+    └── boxPersonList.tpl
+```
+
+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-4).
+
+
+## Box Controller
+
+In addition to static boxes with fixed contents, administrators are able to create dynamic boxes with contents from the database.
+In our case here, we want administrators to be able to create boxes listing people.
+To do so, we first have to register a new object type for this person list box controller for the object type definition `com.woltlab.wcf.boxController`:
+
+```xml
+<type>
+       <name>com.woltlab.wcf.personList</name>
+       <definitionname>com.woltlab.wcf.boxController</definitionname>
+       <classname>wcf\system\box\PersonListBoxController</classname>
+</type>
+```
+
+The `com.woltlab.wcf.boxController` object type definition requires the provided class to implement `wcf\system\box\IBoxController`:
+
+```php
+--8<-- "tutorial/tutorial-series/part-4/files/lib/system/box/PersonListBoxController.class.php"
+```
+
+By extending `AbstractDatabaseObjectListBoxController`, we only have to provide minimal data ourself and rely mostly on the default implementation provided by `AbstractDatabaseObjectListBoxController`:
+
+1. As we will support [conditions](#conditions) for the listed people, we have to set the relevant condition definition via `$conditionDefinition`.
+2. `AbstractDatabaseObjectListBoxController` already supports restricting the number of listed objects.
+   To do so, you only have to specify the default number of listed objects via `$defaultLimit`.
+3. `AbstractDatabaseObjectListBoxController` also supports setting the sort order of the listed objects.
+   You have to provide the supported sort fields via `$validSortFields` and specify the prefix used for the language items of the sort fields via `$sortFieldLanguageItemPrefix` so that for every `$validSortField` in `$validSortFields`, the language item `{$sortFieldLanguageItemPrefix}.{$validSortField}` must exist.
+4. The box system supports [different positions](../../package/pip/box.md#position).
+   Each box controller specifies the positions it supports via `$supportedPositions`.
+   To keep the implementation simple here as different positions might require different output in the template, we restrict ourselves to sidebars.
+5. `getObjectList()` returns an instance of `DatabaseObjectList` that is used to read the listed objects.
+   `getObjectList()` itself must not call `readObjects()`, as `AbstractDatabaseObjectListBoxController` takes care of calling the method after adding the conditions and setting the sort order.
+6. `getTemplate()` returns the contents of the box relying on the `boxPersonList` template here:
+   ```smarty
+   --8<-- "tutorial/tutorial-series/part-4/templates/boxPersonList.tpl"
+   ```
+   The template relies on a `.sidebarItemList` element, which is generally used for sidebar listings.
+   (If different box positions were supported, we either have to generate different output by considering the value of `$boxPosition` in the template or by using different templates in `getTemplate()`.)
+   One specific piece of code is the `$__boxPersonDescription` variable, which supports an optional description below the person's name relying on the optional language item `wcf.person.boxList.description.{$boxSortField}`.
+   We only add one such language item when sorting the people by comments:
+   In such a case, the number of comments will be shown.
+   (When sorting by first and last name, there are no additional useful information that could be shown here, though the plugin from [part 2](part_2.md) adding support for birthdays might also show the birthday when sorting by first or last name.)
+   
+Lastly, we also provide the language item `wcf.acp.box.boxController.com.woltlab.wcf.personList`, which is used in the list of available box controllers.
+
+
+## Conditions
+
+The condition system can be used to generally filter a list of objects.
+In our case, the box system supports conditions to filter the objects shown in a specific box.
+Admittedly, our current person implementation only contains minimal data so that filtering might not make the most sense here but it will still show how to use the condition system for boxes.
+We will support filtering the people by their first and last name so that, for example, a box can be created listing all people with a specific first name.
+
+The first step for condition support is to register a object type definition for the relevant conditions requiring the `IObjectListCondition` interface:
+
+```xml
+--8<-- "tutorial/tutorial-series/part-4/objectTypeDefinition.xml"
+```
+
+Next, we register the specific conditions for filtering by the first and last name using this object type condition:
+
+```xml
+<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>
+```
+
+`PersonFirstNameTextPropertyCondition` and `PersonLastNameTextPropertyCondition` only differ minimally so that we only focus on `PersonFirstNameTextPropertyCondition` here, which relies on the default implementation `AbstractObjectTextPropertyCondition` and only requires specifying different object properties:
+
+```php
+--8<-- "tutorial/tutorial-series/part-4/files/lib/system/condition/person/PersonFirstNameTextPropertyCondition.class.php"
+```
+
+1. `$className` contains the class name of the relevant database object from which the class name of the database object list is derived and `$propertyName` is the name of the database object's property that contains the value used for filtering.
+1. By setting `$supportsMultipleValues` to `true`, multiple comma-separated values can be specified so that, for example, a box can also only list people with either of two specific first names.
+1. `$description` (optional), `$fieldName`, and `$label` are used in the output of the form field.
+
+(The implementation here is specific for `AbstractObjectTextPropertyCondition`.
+The `wcf\system\condition` namespace also contains several other default condition implementations.)
index 910af4f0a584c81dd122b7261608d7b70f50b8b7..b2e0562e2ccfea78d0102cf5dcdb35a7e5a8b927 100644 (file)
@@ -134,6 +134,7 @@ nav:
         - 'Part 1': 'tutorial/series/part_1.md'
         - 'Part 2': 'tutorial/series/part_2.md'
         - 'Part 3': 'tutorial/series/part_3.md'
+        - 'Part 4': 'tutorial/series/part_4.md'
 
 plugins:
   - git-revision-date
diff --git a/snippets/tutorial/tutorial-series/part-4/acpMenu.xml b/snippets/tutorial/tutorial-series/part-4/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-4/acptemplates/personAdd.tpl b/snippets/tutorial/tutorial-series/part-4/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-4/acptemplates/personList.tpl b/snippets/tutorial/tutorial-series/part-4/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-4/files/acp/database/install_com.woltlab.wcf.people.php b/snippets/tutorial/tutorial-series/part-4/files/acp/database/install_com.woltlab.wcf.people.php
new file mode 100644 (file)
index 0000000..895cbe5
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+
+use wcf\system\database\table\column\DefaultTrueBooleanDatabaseTableColumn;
+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\DatabaseTable;
+
+return [
+    DatabaseTable::create('wcf1_person')
+        ->columns([
+            ObjectIdDatabaseTableColumn::create('personID'),
+            NotNullVarchar255DatabaseTableColumn::create('firstName'),
+            NotNullVarchar255DatabaseTableColumn::create('lastName'),
+            SmallintDatabaseTableColumn::create('comments')
+                ->length(5)
+                ->notNull()
+                ->defaultValue(0),
+            DefaultTrueBooleanDatabaseTableColumn::create('enableComments'),
+        ]),
+];
diff --git a/snippets/tutorial/tutorial-series/part-4/files/lib/acp/form/PersonAddForm.class.php b/snippets/tutorial/tutorial-series/part-4/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-4/files/lib/acp/form/PersonEditForm.class.php b/snippets/tutorial/tutorial-series/part-4/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-4/files/lib/acp/page/PersonListPage.class.php b/snippets/tutorial/tutorial-series/part-4/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-4/files/lib/data/person/Person.class.php b/snippets/tutorial/tutorial-series/part-4/files/lib/data/person/Person.class.php
new file mode 100644 (file)
index 0000000..d5c6d16
--- /dev/null
@@ -0,0 +1,52 @@
+<?php
+
+namespace wcf\data\person;
+
+use wcf\data\DatabaseObject;
+use wcf\data\ITitledLinkObject;
+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         $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,
+        ]);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function getTitle()
+    {
+        return $this->firstName . ' ' . $this->lastName;
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-4/files/lib/data/person/PersonAction.class.php b/snippets/tutorial/tutorial-series/part-4/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-4/files/lib/data/person/PersonEditor.class.php b/snippets/tutorial/tutorial-series/part-4/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-4/files/lib/data/person/PersonList.class.php b/snippets/tutorial/tutorial-series/part-4/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-4/files/lib/page/PersonListPage.class.php b/snippets/tutorial/tutorial-series/part-4/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-4/files/lib/page/PersonPage.class.php b/snippets/tutorial/tutorial-series/part-4/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-4/files/lib/system/box/PersonListBoxController.class.php b/snippets/tutorial/tutorial-series/part-4/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-4/files/lib/system/cache/runtime/PersonRuntimeCache.class.php b/snippets/tutorial/tutorial-series/part-4/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-4/files/lib/system/comment/manager/PersonCommentManager.class.php b/snippets/tutorial/tutorial-series/part-4/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-4/files/lib/system/condition/person/PersonFirstNameTextPropertyCondition.class.php b/snippets/tutorial/tutorial-series/part-4/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-4/files/lib/system/condition/person/PersonLastNameTextPropertyCondition.class.php b/snippets/tutorial/tutorial-series/part-4/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-4/files/lib/system/page/handler/PersonPageHandler.class.php b/snippets/tutorial/tutorial-series/part-4/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-4/language/de.xml b/snippets/tutorial/tutorial-series/part-4/language/de.xml
new file mode 100644 (file)
index 0000000..8921b4c
--- /dev/null
@@ -0,0 +1,48 @@
+<?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.canEditComment"><![CDATA[Kann Kommentare 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.canDeleteComment"><![CDATA[Kann eigene Kommentare löschen]]></item>
+               <item name="wcf.acp.group.option.user.person.canEditComment"><![CDATA[Kann eigene Kommentare 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.lastName"><![CDATA[Nachname]]></item>
+               <item name="wcf.person.list"><![CDATA[Personen]]></item>
+       </category>
+</language>
diff --git a/snippets/tutorial/tutorial-series/part-4/language/en.xml b/snippets/tutorial/tutorial-series/part-4/language/en.xml
new file mode 100644 (file)
index 0000000..3f968b3
--- /dev/null
@@ -0,0 +1,48 @@
+<?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.canEditComment"><![CDATA[Can edit comments]]></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.canDeleteComment"><![CDATA[Can delete their comments]]></item>
+               <item name="wcf.acp.group.option.user.person.canEditComment"><![CDATA[Can edit their comments]]></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.lastName"><![CDATA[Last Name]]></item>
+               <item name="wcf.person.list"><![CDATA[People]]></item>
+       </category>
+</language>
diff --git a/snippets/tutorial/tutorial-series/part-4/menuItem.xml b/snippets/tutorial/tutorial-series/part-4/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-4/objectType.xml b/snippets/tutorial/tutorial-series/part-4/objectType.xml
new file mode 100644 (file)
index 0000000..7947a90
--- /dev/null
@@ -0,0 +1,25 @@
+<?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>
+       </import>
+</data>
diff --git a/snippets/tutorial/tutorial-series/part-4/objectTypeDefinition.xml b/snippets/tutorial/tutorial-series/part-4/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-4/package.xml b/snippets/tutorial/tutorial-series/part-4/package.xml
new file mode 100644 (file)
index 0000000..ecc46fd
--- /dev/null
@@ -0,0 +1,35 @@
+<?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="page" />
+               <instruction type="menuItem" />
+               <instruction type="userGroupOption" />
+       </instructions>
+</package>
diff --git a/snippets/tutorial/tutorial-series/part-4/page.xml b/snippets/tutorial/tutorial-series/part-4/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-4/templates/boxPersonList.tpl b/snippets/tutorial/tutorial-series/part-4/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-4/templates/person.tpl b/snippets/tutorial/tutorial-series/part-4/templates/person.tpl
new file mode 100644 (file)
index 0000000..4f6bebd
--- /dev/null
@@ -0,0 +1,45 @@
+{capture assign='pageTitle'}{$person} - {lang}wcf.person.list{/lang}{/capture}
+
+{capture assign='contentTitle'}{$person}{/capture}
+
+{include file='header'}
+
+{if $person->enableComments}
+       {if $commentList|count || $commentCanAdd}
+               <section id="comments" class="section sectionContainerList">
+                       <header class="sectionHeader">
+                               <h2 class="sectionTitle">
+                                       {lang}wcf.person.comments{/lang}
+                                       {if $person->comments}<span class="badge">{#$person->comments}</span>{/if}
+                               </h2>
+                       </header>
+                       
+                       {include file='__commentJavaScript' commentContainerID='personCommentList'}
+                       
+                       <div class="personComments">
+                               <ul id="personCommentList" class="commentList containerList" {*
+                                       *}data-can-add="{if $commentCanAdd}true{else}false{/if}" {*
+                                       *}data-object-id="{@$person->personID}" {*
+                                       *}data-object-type-id="{@$commentObjectTypeID}" {*
+                                       *}data-comments="{if $person->comments}{@$commentList->countObjects()}{else}0{/if}" {*
+                                       *}data-last-comment-time="{@$lastCommentTime}" {*
+                               *}>
+                                       {include file='commentListAddComment' wysiwygSelector='personCommentListAddComment'}
+                                       {include file='commentList'}
+                               </ul>
+                       </div>
+               </section>
+       {/if}
+{/if}
+
+<footer class="contentFooter">
+       {hascontent}
+               <nav class="contentFooterNavigation">
+                       <ul>
+                               {content}{event name='contentFooterNavigation'}{/content}
+                       </ul>
+               </nav>
+       {/hascontent}
+</footer>
+
+{include file='footer'}
diff --git a/snippets/tutorial/tutorial-series/part-4/templates/personList.tpl b/snippets/tutorial/tutorial-series/part-4/templates/personList.tpl
new file mode 100644 (file)
index 0000000..eb193f1
--- /dev/null
@@ -0,0 +1,109 @@
+{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><a href="{$person->getLink()}">{$person}</a></h3>
+                                                       </div>
+                                                       
+                                                       {hascontent}
+                                                               <ul class="inlineList commaSeparated">
+                                                                       {content}{event name='personData'}{/content}
+                                                               </ul>
+                                                       {/hascontent}
+                                                       
+                                                       {hascontent}
+                                                               <dl class="plain inlineDataList small">
+                                                                       {content}
+                                                                               {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-4/userGroupOption.xml b/snippets/tutorial/tutorial-series/part-4/userGroupOption.xml
new file mode 100644 (file)
index 0000000..bb2e311
--- /dev/null
@@ -0,0 +1,70 @@
+<?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="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>