From da4904831046cda04504c74d2765d728fd1030b5 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 22 May 2026 16:01:09 +0200 Subject: [PATCH] Migrate category list to node tree view see #6574 --- .../acp/templates/categoryNodeTreeView.tpl | 27 +++++ .../form/CategoryAddFormBuilderForm.class.php | 15 +++ .../page/AbstractCategoryListPage.class.php | 1 + ...AbstractCategoryNodeTreeViewPage.class.php | 76 ++++++++++++ .../page/ArticleCategoryListPage.class.php | 4 +- .../acp/page/MediaCategoryListPage.class.php | 4 +- .../acp/page/SmileyCategoryListPage.class.php | 4 +- .../acp/page/TrophyCategoryListPage.class.php | 4 +- .../category/DisableCategory.class.php | 34 ++++++ .../command/category/EnableCategory.class.php | 34 ++++++ .../category/SetCategoryPositions.class.php | 69 +++++++++++ .../event/category/CategoryDisabled.class.php | 19 +++ .../event/category/CategoryEnabled.class.php | 19 +++ .../CategoryInteractionCollecting.class.php | 19 +++ .../category/AbstractCategoryType.class.php | 14 +++ .../category/ArticleCategoryType.class.php | 14 +++ .../system/category/ICategoryType.class.php | 17 +++ .../category/MediaCategoryType.class.php | 14 +++ .../category/SmileyCategoryType.class.php | 14 +++ .../category/TrophyCategoryType.class.php | 14 +++ .../core/categories/DeleteCategory.class.php | 44 +++++++ .../core/categories/DisableCategory.class.php | 45 +++++++ .../core/categories/EnableCategory.class.php | 45 +++++++ .../categories/SetCategoryPositions.class.php | 112 +++++++++++++++++ .../admin/CategoryInteractions.class.php | 83 +++++++++++++ .../admin/CategoryNodeTreeView.class.php | 114 ++++++++++++++++++ .../admin/MenuItemNodeTreeView.class.php | 1 + wcfsetup/install/lang/de.xml | 1 + wcfsetup/install/lang/en.xml | 1 + 29 files changed, 854 insertions(+), 8 deletions(-) create mode 100644 wcfsetup/install/files/acp/templates/categoryNodeTreeView.tpl create mode 100644 wcfsetup/install/files/lib/acp/page/AbstractCategoryNodeTreeViewPage.class.php create mode 100644 wcfsetup/install/files/lib/command/category/DisableCategory.class.php create mode 100644 wcfsetup/install/files/lib/command/category/EnableCategory.class.php create mode 100644 wcfsetup/install/files/lib/command/category/SetCategoryPositions.class.php create mode 100644 wcfsetup/install/files/lib/event/category/CategoryDisabled.class.php create mode 100644 wcfsetup/install/files/lib/event/category/CategoryEnabled.class.php create mode 100644 wcfsetup/install/files/lib/event/interaction/admin/CategoryInteractionCollecting.class.php create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/categories/DeleteCategory.class.php create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/categories/DisableCategory.class.php create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/categories/EnableCategory.class.php create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/categories/SetCategoryPositions.class.php create mode 100644 wcfsetup/install/files/lib/system/interaction/admin/CategoryInteractions.class.php create mode 100644 wcfsetup/install/files/lib/system/nodeTreeView/admin/CategoryNodeTreeView.class.php diff --git a/wcfsetup/install/files/acp/templates/categoryNodeTreeView.tpl b/wcfsetup/install/files/acp/templates/categoryNodeTreeView.tpl new file mode 100644 index 00000000000..0bf0cb63ec2 --- /dev/null +++ b/wcfsetup/install/files/acp/templates/categoryNodeTreeView.tpl @@ -0,0 +1,27 @@ +{include file='header'} + +
+
+

{unsafe:$objectType->getProcessor()->getLanguageVariable('list')}

+
+ + {hascontent} + + {/hascontent} +
+ +
+ {unsafe:$nodeTreeView->render()} +
+ +{include file='footer'} diff --git a/wcfsetup/install/files/lib/acp/form/CategoryAddFormBuilderForm.class.php b/wcfsetup/install/files/lib/acp/form/CategoryAddFormBuilderForm.class.php index 3e235b41a98..9181ed465a9 100644 --- a/wcfsetup/install/files/lib/acp/form/CategoryAddFormBuilderForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/CategoryAddFormBuilderForm.class.php @@ -82,6 +82,12 @@ abstract class CategoryAddFormBuilderForm extends AbstractFormBuilderForm */ public int $packageID = 0; + /** + * id of the pre-selected parent category, read from the request + * @since 6.3 + */ + public ?int $parentCategoryID = null; + /** * language item with the page title * @deprecated 6.3 No longer in use. @@ -157,6 +163,14 @@ public function readParameters() if ($this->formAction !== 'create') { $this->readFormObject(); } + + if (isset($_GET['parentCategoryID'])) { + $parentCategoryID = \intval($_GET['parentCategoryID']); + $category = CategoryHandler::getInstance()->getCategory($parentCategoryID); + if ($category !== null && $category->objectTypeID === $this->objectType->getObjectID()) { + $this->parentCategoryID = $parentCategoryID; + } + } } #[\Override] @@ -265,6 +279,7 @@ protected function getPositionFormFields(): array ) ->options($categoryNodeTree, true) ->available((bool)$this->getObjectTypeProcessor()->getMaximumNestingLevel()) + ->value($this->parentCategoryID) ->addValidator( new FormFieldValidator( 'recursion', diff --git a/wcfsetup/install/files/lib/acp/page/AbstractCategoryListPage.class.php b/wcfsetup/install/files/lib/acp/page/AbstractCategoryListPage.class.php index 405ccff0e1a..bad93335b80 100644 --- a/wcfsetup/install/files/lib/acp/page/AbstractCategoryListPage.class.php +++ b/wcfsetup/install/files/lib/acp/page/AbstractCategoryListPage.class.php @@ -17,6 +17,7 @@ * @author Matthias Schmidt * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * @deprecated 6.3 Use `AbstractCategoryNodeTreeViewPage` instead. */ abstract class AbstractCategoryListPage extends AbstractPage { diff --git a/wcfsetup/install/files/lib/acp/page/AbstractCategoryNodeTreeViewPage.class.php b/wcfsetup/install/files/lib/acp/page/AbstractCategoryNodeTreeViewPage.class.php new file mode 100644 index 00000000000..4ad571c7b46 --- /dev/null +++ b/wcfsetup/install/files/lib/acp/page/AbstractCategoryNodeTreeViewPage.class.php @@ -0,0 +1,76 @@ + + * @since 6.3 + * + * @extends AbstractNodeTreeViewPage + */ +abstract class AbstractCategoryNodeTreeViewPage extends AbstractNodeTreeViewPage +{ + /** + * language item with the page title + */ + public string $pageTitle = 'wcf.category.list'; + + /** + * category object type object + */ + public ?ObjectType $objectType = null; + + /** + * name of the category object type + */ + public string $objectTypeName = ''; + + /** + * @inheritDoc + */ + public $templateName = 'categoryNodeTreeView'; + + #[\Override] + public function readData() + { + $this->objectType = CategoryHandler::getInstance()->getObjectTypeByName($this->objectTypeName); + if ($this->objectType === null) { + throw new InvalidObjectTypeException($this->objectTypeName, 'com.woltlab.wcf.category'); + } + + parent::readData(); + } + + #[\Override] + public function assignVariables() + { + parent::assignVariables(); + + WCF::getTPL()->assign([ + 'objectType' => $this->objectType, + 'addFormLink' => LinkHandler::getInstance()->getControllerLink($this->objectType->getProcessor()->getAddControllerClass()), + ]); + + if ($this->pageTitle) { + WCF::getTPL()->assign('pageTitle', $this->pageTitle); + } + } + + #[\Override] + protected function createNodeTreeView(): CategoryNodeTreeView + { + return new CategoryNodeTreeView($this->objectTypeName); + } +} diff --git a/wcfsetup/install/files/lib/acp/page/ArticleCategoryListPage.class.php b/wcfsetup/install/files/lib/acp/page/ArticleCategoryListPage.class.php index 6f0c57825fa..c95aa6ea3e1 100644 --- a/wcfsetup/install/files/lib/acp/page/ArticleCategoryListPage.class.php +++ b/wcfsetup/install/files/lib/acp/page/ArticleCategoryListPage.class.php @@ -9,7 +9,7 @@ * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License */ -class ArticleCategoryListPage extends AbstractCategoryListPage +class ArticleCategoryListPage extends AbstractCategoryNodeTreeViewPage { /** * @inheritDoc @@ -19,7 +19,7 @@ class ArticleCategoryListPage extends AbstractCategoryListPage /** * @inheritDoc */ - public $objectTypeName = 'com.woltlab.wcf.article.category'; + public string $objectTypeName = 'com.woltlab.wcf.article.category'; /** * @inheritDoc diff --git a/wcfsetup/install/files/lib/acp/page/MediaCategoryListPage.class.php b/wcfsetup/install/files/lib/acp/page/MediaCategoryListPage.class.php index b9c64b164d9..6a5f8f01388 100644 --- a/wcfsetup/install/files/lib/acp/page/MediaCategoryListPage.class.php +++ b/wcfsetup/install/files/lib/acp/page/MediaCategoryListPage.class.php @@ -9,7 +9,7 @@ * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License */ -class MediaCategoryListPage extends AbstractCategoryListPage +class MediaCategoryListPage extends AbstractCategoryNodeTreeViewPage { /** * @inheritDoc @@ -19,5 +19,5 @@ class MediaCategoryListPage extends AbstractCategoryListPage /** * @inheritDoc */ - public $objectTypeName = 'com.woltlab.wcf.media.category'; + public string $objectTypeName = 'com.woltlab.wcf.media.category'; } diff --git a/wcfsetup/install/files/lib/acp/page/SmileyCategoryListPage.class.php b/wcfsetup/install/files/lib/acp/page/SmileyCategoryListPage.class.php index 0db3a76303c..13cd061e4d0 100644 --- a/wcfsetup/install/files/lib/acp/page/SmileyCategoryListPage.class.php +++ b/wcfsetup/install/files/lib/acp/page/SmileyCategoryListPage.class.php @@ -9,7 +9,7 @@ * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License */ -class SmileyCategoryListPage extends AbstractCategoryListPage +class SmileyCategoryListPage extends AbstractCategoryNodeTreeViewPage { /** * @inheritDoc @@ -19,7 +19,7 @@ class SmileyCategoryListPage extends AbstractCategoryListPage /** * @inheritDoc */ - public $objectTypeName = 'com.woltlab.wcf.bbcode.smiley'; + public string $objectTypeName = 'com.woltlab.wcf.bbcode.smiley'; /** * @inheritDoc diff --git a/wcfsetup/install/files/lib/acp/page/TrophyCategoryListPage.class.php b/wcfsetup/install/files/lib/acp/page/TrophyCategoryListPage.class.php index 4dd86321215..9a021bf834b 100644 --- a/wcfsetup/install/files/lib/acp/page/TrophyCategoryListPage.class.php +++ b/wcfsetup/install/files/lib/acp/page/TrophyCategoryListPage.class.php @@ -9,7 +9,7 @@ * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License */ -class TrophyCategoryListPage extends AbstractCategoryListPage +class TrophyCategoryListPage extends AbstractCategoryNodeTreeViewPage { /** * @inheritDoc @@ -19,7 +19,7 @@ class TrophyCategoryListPage extends AbstractCategoryListPage /** * @inheritDoc */ - public $objectTypeName = 'com.woltlab.wcf.trophy.category'; + public string $objectTypeName = 'com.woltlab.wcf.trophy.category'; /** * @inheritDoc diff --git a/wcfsetup/install/files/lib/command/category/DisableCategory.class.php b/wcfsetup/install/files/lib/command/category/DisableCategory.class.php new file mode 100644 index 00000000000..1fe54c8aa2f --- /dev/null +++ b/wcfsetup/install/files/lib/command/category/DisableCategory.class.php @@ -0,0 +1,34 @@ + + * @since 6.3 + */ +final class DisableCategory +{ + public function __construct(private readonly Category $category) {} + + public function __invoke(): void + { + (new CategoryEditor($this->category))->update([ + 'isDisabled' => 1, + ]); + + CategoryEditor::resetCache(); + + EventHandler::getInstance()->fire( + new CategoryDisabled($this->category) + ); + } +} diff --git a/wcfsetup/install/files/lib/command/category/EnableCategory.class.php b/wcfsetup/install/files/lib/command/category/EnableCategory.class.php new file mode 100644 index 00000000000..991fc4a3892 --- /dev/null +++ b/wcfsetup/install/files/lib/command/category/EnableCategory.class.php @@ -0,0 +1,34 @@ + + * @since 6.3 + */ +final class EnableCategory +{ + public function __construct(private readonly Category $category) {} + + public function __invoke(): void + { + (new CategoryEditor($this->category))->update([ + 'isDisabled' => 0, + ]); + + CategoryEditor::resetCache(); + + EventHandler::getInstance()->fire( + new CategoryEnabled($this->category) + ); + } +} diff --git a/wcfsetup/install/files/lib/command/category/SetCategoryPositions.class.php b/wcfsetup/install/files/lib/command/category/SetCategoryPositions.class.php new file mode 100644 index 00000000000..b931ef05c33 --- /dev/null +++ b/wcfsetup/install/files/lib/command/category/SetCategoryPositions.class.php @@ -0,0 +1,69 @@ + + * @since 6.3 + */ +final class SetCategoryPositions +{ + /** + * @param array> $positions + */ + public function __construct(private readonly array $positions) {} + + public function __invoke(): void + { + $parentUpdates = []; + $objectType = null; + + $sql = "UPDATE wcf1_category + SET parentCategoryID = ?, + showOrder = ? + WHERE categoryID = ?"; + $statement = WCF::getDB()->prepare($sql); + + WCF::getDB()->beginTransaction(); + foreach ($this->positions as $parentCategoryID => $children) { + foreach ($children as $showOrder => $categoryID) { + $category = CategoryHandler::getInstance()->getCategory($categoryID); + if ($category === null) { + continue; + } + + if ($objectType === null) { + $objectType = $category->getObjectType(); + } + + if ($category->parentCategoryID != $parentCategoryID) { + $parentUpdates[$categoryID] = [ + 'oldParentCategoryID' => $category->parentCategoryID, + 'newParentCategoryID' => $parentCategoryID, + ]; + } + + $statement->execute([ + $parentCategoryID, + $showOrder + 1, + $categoryID, + ]); + } + } + WCF::getDB()->commitTransaction(); + + CategoryEditor::resetCache(); + + if ($parentUpdates !== [] && $objectType !== null) { + $objectType->getProcessor()->changedParentCategories($parentUpdates); + } + } +} diff --git a/wcfsetup/install/files/lib/event/category/CategoryDisabled.class.php b/wcfsetup/install/files/lib/event/category/CategoryDisabled.class.php new file mode 100644 index 00000000000..6cf47706740 --- /dev/null +++ b/wcfsetup/install/files/lib/event/category/CategoryDisabled.class.php @@ -0,0 +1,19 @@ + + * @since 6.3 + */ +final class CategoryDisabled implements IPsr14Event +{ + public function __construct(public readonly Category $category) {} +} diff --git a/wcfsetup/install/files/lib/event/category/CategoryEnabled.class.php b/wcfsetup/install/files/lib/event/category/CategoryEnabled.class.php new file mode 100644 index 00000000000..10d9db4d46b --- /dev/null +++ b/wcfsetup/install/files/lib/event/category/CategoryEnabled.class.php @@ -0,0 +1,19 @@ + + * @since 6.3 + */ +final class CategoryEnabled implements IPsr14Event +{ + public function __construct(public readonly Category $category) {} +} diff --git a/wcfsetup/install/files/lib/event/interaction/admin/CategoryInteractionCollecting.class.php b/wcfsetup/install/files/lib/event/interaction/admin/CategoryInteractionCollecting.class.php new file mode 100644 index 00000000000..67aeeeb47c4 --- /dev/null +++ b/wcfsetup/install/files/lib/event/interaction/admin/CategoryInteractionCollecting.class.php @@ -0,0 +1,19 @@ + + * @since 6.3 + */ +final class CategoryInteractionCollecting implements IPsr14Event +{ + public function __construct(public readonly CategoryInteractions $provider) {} +} diff --git a/wcfsetup/install/files/lib/system/category/AbstractCategoryType.class.php b/wcfsetup/install/files/lib/system/category/AbstractCategoryType.class.php index 30338aadb8d..b549de28b0b 100644 --- a/wcfsetup/install/files/lib/system/category/AbstractCategoryType.class.php +++ b/wcfsetup/install/files/lib/system/category/AbstractCategoryType.class.php @@ -183,4 +183,18 @@ public function supportsHtmlDescription() { return false; } + + #[\Override] + public function getEditControllerClass(): string + { + // @phpstan-ignore return.type + return ''; + } + + #[\Override] + public function getAddControllerClass(): string + { + // @phpstan-ignore return.type + return ''; + } } diff --git a/wcfsetup/install/files/lib/system/category/ArticleCategoryType.class.php b/wcfsetup/install/files/lib/system/category/ArticleCategoryType.class.php index e2737a054eb..e9fdcf45f80 100644 --- a/wcfsetup/install/files/lib/system/category/ArticleCategoryType.class.php +++ b/wcfsetup/install/files/lib/system/category/ArticleCategoryType.class.php @@ -2,6 +2,8 @@ namespace wcf\system\category; +use wcf\acp\form\ArticleCategoryAddForm; +use wcf\acp\form\ArticleCategoryEditForm; use wcf\data\article\ArticleAction; use wcf\data\category\CategoryEditor; use wcf\system\WCF; @@ -85,4 +87,16 @@ public function supportsHtmlDescription() { return true; } + + #[\Override] + public function getEditControllerClass(): string + { + return ArticleCategoryEditForm::class; + } + + #[\Override] + public function getAddControllerClass(): string + { + return ArticleCategoryAddForm::class; + } } diff --git a/wcfsetup/install/files/lib/system/category/ICategoryType.class.php b/wcfsetup/install/files/lib/system/category/ICategoryType.class.php index 244b984d3b0..62358134ba8 100644 --- a/wcfsetup/install/files/lib/system/category/ICategoryType.class.php +++ b/wcfsetup/install/files/lib/system/category/ICategoryType.class.php @@ -2,6 +2,7 @@ namespace wcf\system\category; +use wcf\acp\form\CategoryAddFormBuilderForm; use wcf\data\category\CategoryEditor; /** @@ -139,4 +140,20 @@ public function hasDescription(); * @since 5.2 */ public function supportsHtmlDescription(); + + /** + * Returns the name of the controller class used to edit categories of this type. + * + * @return class-string + * @since 6.3 + */ + public function getEditControllerClass(): string; + + /** + * Returns the name of the controller class used to add categories of this type. + * + * @return class-string + * @since 6.3 + */ + public function getAddControllerClass(): string; } diff --git a/wcfsetup/install/files/lib/system/category/MediaCategoryType.class.php b/wcfsetup/install/files/lib/system/category/MediaCategoryType.class.php index e743aae1a77..91d3e824ef9 100644 --- a/wcfsetup/install/files/lib/system/category/MediaCategoryType.class.php +++ b/wcfsetup/install/files/lib/system/category/MediaCategoryType.class.php @@ -2,6 +2,8 @@ namespace wcf\system\category; +use wcf\acp\form\MediaCategoryAddForm; +use wcf\acp\form\MediaCategoryEditForm; use wcf\system\WCF; /** @@ -45,4 +47,16 @@ public function canEditCategory() { return WCF::getSession()->hasPermission('admin.content.cms.canManageMedia'); } + + #[\Override] + public function getEditControllerClass(): string + { + return MediaCategoryEditForm::class; + } + + #[\Override] + public function getAddControllerClass(): string + { + return MediaCategoryAddForm::class; + } } diff --git a/wcfsetup/install/files/lib/system/category/SmileyCategoryType.class.php b/wcfsetup/install/files/lib/system/category/SmileyCategoryType.class.php index 570bba29c6d..2626d7f7e93 100644 --- a/wcfsetup/install/files/lib/system/category/SmileyCategoryType.class.php +++ b/wcfsetup/install/files/lib/system/category/SmileyCategoryType.class.php @@ -2,6 +2,8 @@ namespace wcf\system\category; +use wcf\acp\form\SmileyCategoryAddForm; +use wcf\acp\form\SmileyCategoryEditForm; use wcf\data\category\CategoryEditor; use wcf\system\cache\builder\SmileyCacheBuilder; use wcf\system\WCF; @@ -53,4 +55,16 @@ public function canEditCategory() { return WCF::getSession()->hasPermission('admin.content.smiley.canManageSmiley'); } + + #[\Override] + public function getEditControllerClass(): string + { + return SmileyCategoryEditForm::class; + } + + #[\Override] + public function getAddControllerClass(): string + { + return SmileyCategoryAddForm::class; + } } diff --git a/wcfsetup/install/files/lib/system/category/TrophyCategoryType.class.php b/wcfsetup/install/files/lib/system/category/TrophyCategoryType.class.php index 0434ca23f60..ff197fc0896 100644 --- a/wcfsetup/install/files/lib/system/category/TrophyCategoryType.class.php +++ b/wcfsetup/install/files/lib/system/category/TrophyCategoryType.class.php @@ -2,6 +2,8 @@ namespace wcf\system\category; +use wcf\acp\form\TrophyCategoryAddForm; +use wcf\acp\form\TrophyCategoryEditForm; use wcf\data\category\CategoryEditor; use wcf\data\user\trophy\UserTrophyAction; use wcf\data\user\trophy\UserTrophyList; @@ -80,4 +82,16 @@ public function supportsHtmlDescription() { return true; } + + #[\Override] + public function getEditControllerClass(): string + { + return TrophyCategoryEditForm::class; + } + + #[\Override] + public function getAddControllerClass(): string + { + return TrophyCategoryAddForm::class; + } } diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/DeleteCategory.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/DeleteCategory.class.php new file mode 100644 index 00000000000..e558d9a783c --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/DeleteCategory.class.php @@ -0,0 +1,44 @@ + + * @since 6.3 + */ +#[DeleteRequest("/core/categories/{id:\d+}")] +final class DeleteCategory implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $category = Helper::fetchObjectFromRequestParameter($variables['id'], Category::class); + + $this->assertCategoryCanBeDeleted($category); + + (new CategoryAction([$category->categoryID], 'delete'))->executeAction(); + + return new JsonResponse([]); + } + + private function assertCategoryCanBeDeleted(Category $category): void + { + if (!$category->getObjectType()->getProcessor()->canDeleteCategory()) { + throw new PermissionDeniedException(); + } + } +} diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/DisableCategory.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/DisableCategory.class.php new file mode 100644 index 00000000000..a011de43f1d --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/DisableCategory.class.php @@ -0,0 +1,45 @@ + + * @since 6.3 + */ +#[PostRequest("/core/categories/{id:\d+}/disable")] +final class DisableCategory implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $category = Helper::fetchObjectFromRequestParameter($variables['id'], Category::class); + + $this->assertCategoryCanBeDisabled($category); + + if (!$category->isDisabled) { + (new \wcf\command\category\DisableCategory($category))(); + } + + return new JsonResponse([]); + } + + private function assertCategoryCanBeDisabled(Category $category): void + { + if (!$category->getObjectType()->getProcessor()->canEditCategory()) { + throw new PermissionDeniedException(); + } + } +} diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/EnableCategory.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/EnableCategory.class.php new file mode 100644 index 00000000000..6c142387a46 --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/EnableCategory.class.php @@ -0,0 +1,45 @@ + + * @since 6.3 + */ +#[PostRequest("/core/categories/{id:\d+}/enable")] +final class EnableCategory implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $category = Helper::fetchObjectFromRequestParameter($variables['id'], Category::class); + + $this->assertCategoryCanBeEnabled($category); + + if ($category->isDisabled) { + (new \wcf\command\category\EnableCategory($category))(); + } + + return new JsonResponse([]); + } + + private function assertCategoryCanBeEnabled(Category $category): void + { + if (!$category->getObjectType()->getProcessor()->canEditCategory()) { + throw new PermissionDeniedException(); + } + } +} diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/SetCategoryPositions.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/SetCategoryPositions.class.php new file mode 100644 index 00000000000..5a09673b7e1 --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/SetCategoryPositions.class.php @@ -0,0 +1,112 @@ + + * @since 6.3 + */ +#[PostRequest('/core/categories/object-types/{id:\d+}/positions')] +final class SetCategoryPositions implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $objectType = CategoryHandler::getInstance()->getObjectType(\intval($variables['id'])); + if ($objectType === null) { + throw new UserInputException('objectTypeID'); + } + + if (!$objectType->getProcessor()->canEditCategory()) { + throw new PermissionDeniedException(); + } + + $parameters = Helper::mapApiParameters($request, SetCategoryPositionsParameters::class); + $positions = $this->validatePositions($objectType->objectTypeID, $parameters->positions); + + (new \wcf\command\category\SetCategoryPositions($positions))(); + + return new JsonResponse([]); + } + + /** + * @param array> $positions + * @return array> + */ + private function validatePositions(int $objectTypeID, array $positions): array + { + $categoryIDs = []; + foreach ($positions as $children) { + $categoryIDs = \array_merge($categoryIDs, $children); + } + + if ($categoryIDs === []) { + return $positions; + } + + $categoryList = new CategoryList(); + $categoryList->getConditionBuilder()->add('category.categoryID IN (?)', [$categoryIDs]); + $categoryList->getConditionBuilder()->add('category.objectTypeID = ?', [$objectTypeID]); + $categoryList->readObjects(); + $categories = $categoryList->getObjects(); + + if (\count($categories) !== \count($categoryIDs)) { + throw new IllegalLinkException(); + } + + foreach ($positions as $parentCategoryID => $children) { + if ($parentCategoryID && !isset($categories[$parentCategoryID])) { + throw new IllegalLinkException(); + } + } + + $parentOf = []; + foreach ($positions as $parentCategoryID => $children) { + foreach ($children as $childID) { + if (isset($parentOf[$childID])) { + throw new IllegalLinkException(); + } + $parentOf[$childID] = $parentCategoryID; + } + } + + foreach (\array_keys($parentOf) as $startID) { + $current = $startID; + $seen = [$startID => true]; + while (!empty($parentOf[$current])) { + $current = $parentOf[$current]; + if (isset($seen[$current])) { + throw new IllegalLinkException(); + } + $seen[$current] = true; + } + } + + return $positions; + } +} + +/** @internal */ +final class SetCategoryPositionsParameters +{ + public function __construct( + /** @var array> */ + public readonly array $positions, + ) {} +} diff --git a/wcfsetup/install/files/lib/system/interaction/admin/CategoryInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/admin/CategoryInteractions.class.php new file mode 100644 index 00000000000..f8b7e13ab88 --- /dev/null +++ b/wcfsetup/install/files/lib/system/interaction/admin/CategoryInteractions.class.php @@ -0,0 +1,83 @@ + + * @since 6.3 + */ +final class CategoryInteractions extends AbstractInteractionProvider +{ + public function __construct() + { + $this->addInteractions([ + new class( + 'add-child-node', + '', + 'wcf.category.addChildNode', + static function (Category $category): bool { + $processor = $category->getObjectType()->getProcessor(); + \assert($processor instanceof ICategoryType); + if (!$processor->canAddCategory()) { + return false; + } + + // @phpstan-ignore identical.alwaysFalse + if ($processor->getAddControllerClass() === '') { + return false; + } + + $maximumNestingLevel = $processor->getMaximumNestingLevel(); + if ($maximumNestingLevel === 0) { + return false; + } + + if ($maximumNestingLevel === -1) { + return true; + } + + return \count($category->getParentCategories()) + 1 < $maximumNestingLevel; + } + ) extends LinkInteraction { + #[\Override] + protected function getLink(DatabaseObject $object): string + { + \assert($object instanceof Category); + + return LinkHandler::getInstance()->getControllerLink( + $object->getObjectType()->getProcessor()->getAddControllerClass(), + ['parentCategoryID' => $object->getObjectID()] + ); + } + }, + new DeleteInteraction( + 'core/categories/%s', + static fn(Category $category) => $category->getObjectType()->getProcessor()->canDeleteCategory() + ), + ]); + + EventHandler::getInstance()->fire( + new CategoryInteractionCollecting($this) + ); + } + + #[\Override] + public function getObjectClassName(): string + { + return Category::class; + } +} diff --git a/wcfsetup/install/files/lib/system/nodeTreeView/admin/CategoryNodeTreeView.class.php b/wcfsetup/install/files/lib/system/nodeTreeView/admin/CategoryNodeTreeView.class.php new file mode 100644 index 00000000000..b9fde53b315 --- /dev/null +++ b/wcfsetup/install/files/lib/system/nodeTreeView/admin/CategoryNodeTreeView.class.php @@ -0,0 +1,114 @@ + + * @since 6.3 + */ +class CategoryNodeTreeView extends AbstractNodeTreeView +{ + private ?ObjectType $objectType = null; + + public function __construct(public readonly string $objectTypeName) + { + $provider = new CategoryInteractions(); + $provider->addInteractions([ + new Divider(), + new EditInteraction( + $this->getProcessor()->getEditControllerClass(), + static fn(Category $category) => $category->getObjectType()->getProcessor()->canEditCategory() + ), + ]); + $this->setInteractionProvider($provider); + + $this->addQuickInteraction( + new ToggleInteraction( + 'enable', + 'core/categories/%s/enable', + 'core/categories/%s/disable', + isAvailableCallback: static fn(Category $category) => $category->getObjectType() + ->getProcessor() + ->canEditCategory() + ) + ); + + $this->setSetPositionsEndpoint( + "core/categories/object-types/{$this->getObjectType()->objectTypeID}/positions" + ); + } + + public function getObjectType(): ObjectType + { + if ($this->objectType === null) { + $objectType = CategoryHandler::getInstance()->getObjectTypeByName($this->objectTypeName); + if ($objectType === null) { + throw new InvalidObjectTypeException($this->objectTypeName, 'com.woltlab.wcf.category'); + } + $this->objectType = $objectType; + } + + return $this->objectType; + } + + public function getProcessor(): ICategoryType + { + return $this->getObjectType()->getProcessor(); + } + + #[\Override] + public function getNodes(): \RecursiveIteratorIterator + { + $nodeTree = new CategoryNodeTree($this->objectTypeName, 0, true); + + // @phpstan-ignore return.type + return $nodeTree->getIterator(); + } + + #[\Override] + public function getNodeLink(IObjectTreeNode $node): string + { + \assert($node instanceof CategoryNode); + + return LinkHandler::getInstance()->getControllerLink( + $this->getProcessor()->getEditControllerClass(), + [ + 'id' => $node->getObjectID(), + 'title' => $node->getTitle(), + ] + ); + } + + #[\Override] + public function isAccessible(): bool + { + return $this->getProcessor()->canEditCategory() || $this->getProcessor()->canDeleteCategory(); + } + + #[\Override] + public function getParameters(): array + { + return ['objectTypeName' => $this->objectTypeName]; + } +} diff --git a/wcfsetup/install/files/lib/system/nodeTreeView/admin/MenuItemNodeTreeView.class.php b/wcfsetup/install/files/lib/system/nodeTreeView/admin/MenuItemNodeTreeView.class.php index 7a40870c340..3a14b4c811f 100644 --- a/wcfsetup/install/files/lib/system/nodeTreeView/admin/MenuItemNodeTreeView.class.php +++ b/wcfsetup/install/files/lib/system/nodeTreeView/admin/MenuItemNodeTreeView.class.php @@ -48,6 +48,7 @@ public function getNodes(): \RecursiveIteratorIterator { $nodeTree = new MenuItemNodeTree($this->menuID, null, false); + // @phpstan-ignore return.type return $nodeTree->getNodeList(); } diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index 2e939a5289c..d30880b1f15 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -3431,6 +3431,7 @@ Erlaubte Dateiendungen: {', '|implode:$attachmentHandler->getFormattedAllowedExt + diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index dfbab4bd9e2..7a87a8213ff 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -3354,6 +3354,7 @@ Allowed extensions: {', '|implode:$attachmentHandler->getFormattedAllowedExtensi +