From 3f7695bb011f5e394d04e839176aa24b641658e8 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Mon, 18 May 2026 11:33:51 +0200 Subject: [PATCH 01/18] Add node tree view --- .../templates/shared_nodeTreeView.tpl | 5 + .../templates/shared_nodeTreeViewItems.tpl | 19 ++ .../files/acp/templates/menuItemList.tpl | 70 +------ .../lib/acp/page/MenuItemListPage.class.php | 60 +++--- .../MenuItemInteractionCollecting.class.php | 19 ++ .../page/AbstractNodeTreeViewPage.class.php | 83 ++++++++ .../core/menus/items/DeleteMenuItem.class.php | 47 +++++ .../admin/MenuItemInteractions.class.php | 40 ++++ .../AbstractNodeTreeView.class.php | 178 ++++++++++++++++++ .../admin/MenuItemNodeTreeView.class.php | 59 ++++++ .../install/files/style/ui/nodeTreeView.scss | 50 +++++ 11 files changed, 529 insertions(+), 101 deletions(-) create mode 100644 com.woltlab.wcf/templates/shared_nodeTreeView.tpl create mode 100644 com.woltlab.wcf/templates/shared_nodeTreeViewItems.tpl create mode 100644 wcfsetup/install/files/lib/event/interaction/admin/MenuItemInteractionCollecting.class.php create mode 100644 wcfsetup/install/files/lib/page/AbstractNodeTreeViewPage.class.php create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/menus/items/DeleteMenuItem.class.php create mode 100644 wcfsetup/install/files/lib/system/interaction/admin/MenuItemInteractions.class.php create mode 100644 wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php create mode 100644 wcfsetup/install/files/lib/system/nodeTreeView/admin/MenuItemNodeTreeView.class.php create mode 100644 wcfsetup/install/files/style/ui/nodeTreeView.scss diff --git a/com.woltlab.wcf/templates/shared_nodeTreeView.tpl b/com.woltlab.wcf/templates/shared_nodeTreeView.tpl new file mode 100644 index 00000000000..361c9445b5f --- /dev/null +++ b/com.woltlab.wcf/templates/shared_nodeTreeView.tpl @@ -0,0 +1,5 @@ +
+
    + {unsafe:$view->renderItems()} +
+
diff --git a/com.woltlab.wcf/templates/shared_nodeTreeViewItems.tpl b/com.woltlab.wcf/templates/shared_nodeTreeViewItems.tpl new file mode 100644 index 00000000000..faf13b58354 --- /dev/null +++ b/com.woltlab.wcf/templates/shared_nodeTreeViewItems.tpl @@ -0,0 +1,19 @@ +{foreach from=$view->getNodes() item='node'} +
  • +
    + {icon name='grip-vertical'} + {$node->getTitle()} + {if $view->hasInteractions()} +
    + {unsafe:$view->renderQuickInteractions($node)} + {unsafe:$view->renderInteractionContextMenuButton($node)} +
    + {/if} +
    + +
      {if !$node->hasChildren()}
  • {/if} + + {if !$node->hasChildren() && $node->isLastSibling()} + {unsafe:""|str_repeat:$node->getOpenParentNodes()} + {/if} +{/foreach} diff --git a/wcfsetup/install/files/acp/templates/menuItemList.tpl b/wcfsetup/install/files/acp/templates/menuItemList.tpl index 75e7ad0168f..23fa03c613d 100644 --- a/wcfsetup/install/files/acp/templates/menuItemList.tpl +++ b/wcfsetup/install/files/acp/templates/menuItemList.tpl @@ -1,20 +1,5 @@ {include file='header' pageTitle='wcf.acp.menu.item.list'} - -

    {lang}wcf.acp.menu.item.list{/lang}

    @@ -31,57 +16,8 @@
    -{hascontent} - - -
    - -
    -{hascontentelse} - {lang}wcf.global.noItems{/lang} -{/hascontent} +
    + {unsafe:$nodeTreeView->render()} +
    {include file='footer'} diff --git a/wcfsetup/install/files/lib/acp/page/MenuItemListPage.class.php b/wcfsetup/install/files/lib/acp/page/MenuItemListPage.class.php index 4357fafd3a6..587bc0e1783 100644 --- a/wcfsetup/install/files/lib/acp/page/MenuItemListPage.class.php +++ b/wcfsetup/install/files/lib/acp/page/MenuItemListPage.class.php @@ -2,20 +2,22 @@ namespace wcf\acp\page; -use wcf\data\menu\item\MenuItemNodeTree; use wcf\data\menu\Menu; -use wcf\page\AbstractPage; -use wcf\system\exception\IllegalLinkException; +use wcf\http\Helper; +use wcf\page\AbstractNodeTreeViewPage; +use wcf\system\nodeTreeView\admin\MenuItemNodeTreeView; use wcf\system\WCF; /** * Shows a list of menu items. * - * @author Marcel Werk - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * + * @extends AbstractNodeTreeViewPage */ -class MenuItemListPage extends AbstractPage +class MenuItemListPage extends AbstractNodeTreeViewPage { /** * @inheritDoc @@ -27,36 +29,14 @@ class MenuItemListPage extends AbstractPage */ public $neededPermissions = ['admin.content.cms.canManageMenu']; - /** - * menu item node tree - * @var MenuItemNodeTree - */ - public $menuItems; - - /** - * menu id - * @var int - */ - public $menuID = 0; - - /** - * menu object - * @var Menu - */ - public $menu; + public Menu $menu; #[\Override] public function readParameters() { parent::readParameters(); - if (isset($_REQUEST['id'])) { - $this->menuID = \intval($_REQUEST['id']); - } - $this->menu = new Menu($this->menuID); - if (!$this->menu->menuID) { - throw new IllegalLinkException(); - } + $this->menu = Helper::fetchObjectFromQueryParameter(Menu::class); } #[\Override] @@ -64,7 +44,7 @@ public function readData() { parent::readData(); - $this->menuItems = new MenuItemNodeTree($this->menuID, null, false); + //$this->menuItems = new MenuItemNodeTree($this->menuID, null, false); } #[\Override] @@ -73,9 +53,21 @@ public function assignVariables() parent::assignVariables(); WCF::getTPL()->assign([ - 'menuID' => $this->menuID, 'menu' => $this->menu, - 'menuItemNodeList' => $this->menuItems->getNodeList(), + 'menuID' => $this->menu->getObjectID(), + //'menuItemNodeList' => $this->menuItems->getNodeList(), ]); } + + #[\Override] + protected function createNodeTreeView(): MenuItemNodeTreeView + { + return new MenuItemNodeTreeView($this->menu->getObjectID()); + } + + #[\Override] + protected function getBaseUrlParameters(): array + { + return ['id' => $this->menu->getObjectID()]; + } } diff --git a/wcfsetup/install/files/lib/event/interaction/admin/MenuItemInteractionCollecting.class.php b/wcfsetup/install/files/lib/event/interaction/admin/MenuItemInteractionCollecting.class.php new file mode 100644 index 00000000000..e9bcbefc2c0 --- /dev/null +++ b/wcfsetup/install/files/lib/event/interaction/admin/MenuItemInteractionCollecting.class.php @@ -0,0 +1,19 @@ + + * @since 6.3 + */ +final class MenuItemInteractionCollecting implements IPsr14Event +{ + public function __construct(public readonly MenuItemInteractions $provider) {} +} diff --git a/wcfsetup/install/files/lib/page/AbstractNodeTreeViewPage.class.php b/wcfsetup/install/files/lib/page/AbstractNodeTreeViewPage.class.php new file mode 100644 index 00000000000..6d9a7e1453a --- /dev/null +++ b/wcfsetup/install/files/lib/page/AbstractNodeTreeViewPage.class.php @@ -0,0 +1,83 @@ + + * @since 6.3 + * + * @template TNodeTreeView of AbstractNodeTreeView + */ +abstract class AbstractNodeTreeViewPage extends AbstractPage +{ + /** + * @var TNodeTreeView + */ + protected AbstractNodeTreeView $nodeTreeView; + + #[\Override] + public function show() + { + $this->canonicalURL = $this->getCanonicalUrl(); + + return parent::show(); + } + + #[\Override] + public function readData() + { + parent::readData(); + + $this->initNodeTreeView(); + } + + #[\Override] + public function assignVariables() + { + parent::assignVariables(); + + WCF::getTPL()->assign([ + 'nodeTreeView' => $this->nodeTreeView, + ]); + } + + protected function initNodeTreeView(): void + { + $this->nodeTreeView = $this->createNodeTreeView(); + if (!$this->nodeTreeView->isAccessible()) { + throw new PermissionDeniedException(); + } + } + + /** + * @return array + */ + protected function getBaseUrlParameters(): array + { + return []; + } + + protected function getCanonicalUrl(): string + { + return LinkHandler::getInstance()->getControllerLink( + static::class, + $this->getBaseUrlParameters() + ); + } + + /** + * Returns the node tree view instance for the rendering of this page. + * + * @return TNodeTreeView + */ + protected abstract function createNodeTreeView(): AbstractNodeTreeView; +} diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/menus/items/DeleteMenuItem.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/menus/items/DeleteMenuItem.class.php new file mode 100644 index 00000000000..a2e0f0b1b94 --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/menus/items/DeleteMenuItem.class.php @@ -0,0 +1,47 @@ + + * @since 6.3 + */ +#[DeleteRequest("/core/menus/items/{id:\d+}")] +final class DeleteMenuItem implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $menuItem = Helper::fetchObjectFromRequestParameter($variables['id'], MenuItem::class); + + $this->assertMenuItemCanBeDeleted($menuItem); + + (new MenuItemAction([$menuItem], 'delete'))->executeAction(); + + return new JsonResponse([]); + } + + private function assertMenuItemCanBeDeleted(MenuItem $menuItem): void + { + WCF::getSession()->checkPermissions(['admin.content.cms.canManageMenu']); + + if (!$menuItem->canDelete()) { + throw new PermissionDeniedException(); + } + } +} diff --git a/wcfsetup/install/files/lib/system/interaction/admin/MenuItemInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/admin/MenuItemInteractions.class.php new file mode 100644 index 00000000000..d68b65954a0 --- /dev/null +++ b/wcfsetup/install/files/lib/system/interaction/admin/MenuItemInteractions.class.php @@ -0,0 +1,40 @@ + + * @since 6.3 + */ +final class MenuItemInteractions extends AbstractInteractionProvider +{ + public function __construct() + { + $this->addInteractions([ + new DeleteInteraction( + 'core/menus/items/%s', + static fn(MenuItem $object) => $object->canDelete() + ), + ]); + + EventHandler::getInstance()->fire( + new MenuItemInteractionCollecting($this) + ); + } + + #[\Override] + public function getObjectClassName(): string + { + return MenuItem::class; + } +} diff --git a/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php b/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php new file mode 100644 index 00000000000..a194416d623 --- /dev/null +++ b/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php @@ -0,0 +1,178 @@ + + * @since 6.3 + */ +abstract class AbstractNodeTreeView +{ + /** + * @var IInteraction[] + */ + private array $quickInteractions = []; + + private ?IInteractionProvider $interactionProvider = null; + private InteractionContextMenuComponent $interactionContextMenuComponent; + + /** + * Sets the interaction provider that is used to render the interaction context menu. + */ + public function setInteractionProvider(IInteractionProvider $provider): void + { + $this->interactionProvider = $provider; + } + + /** + * Returns the interaction provider of the node tree view. + */ + public function getInteractionProvider(): ?IInteractionProvider + { + return $this->interactionProvider; + } + + /** + * @return \RecursiveIteratorIterator + */ + public abstract function getNodes(): \RecursiveIteratorIterator; + + public abstract function getNodeLink(IObjectTreeNode $node): string; + + /** + * Renders the node tree view and returns the HTML code. + */ + public function render(): string + { + return WCF::getTPL()->render('wcf', 'shared_nodeTreeView', ['view' => $this]); + } + + /** + * Renders the items and returns the HTML code. + */ + public function renderItems(): string + { + return WCF::getTPL()->render('wcf', 'shared_nodeTreeViewItems', ['view' => $this]); + } + + /** + * Returns true, if this node tree view is accessible for the active user. + */ + public function isAccessible(): bool + { + return true; + } + + /** + * Gets the additional parameters of the node tree view. + * + * @return mixed[] + */ + public function getParameters(): array + { + return []; + } + + public function getNodePadding(IObjectTreeNode $node): int + { + return ($node->getDepth() - 1) * 26 + 10; + } + + /** + * Returns true, if this node tree view has interactions. + */ + public function hasInteractions(): bool + { + return $this->interactionProvider !== null || $this->quickInteractions !== []; + } + + /** + * Adds a quick interaction. + */ + public function addQuickInteraction(IInteraction $interaction): void + { + $this->quickInteractions[] = $interaction; + } + + /** + * Returns the quick interactions. + * + * @return IInteraction[] + */ + public function getQuickInteractions(): array + { + return $this->quickInteractions; + } + + /** + * Renders the quick interactions for the given node. + */ + public function renderQuickInteractions(IObjectTreeNode $node): string + { + if ($node instanceof DatabaseObjectDecorator) { + $object = $node->getDecoratedObject(); + } else if ($node instanceof DatabaseObject) { + $object = $node; + } else { + throw new \LogicException('node has to be a `DatabaseObject`'); + } + + $availableInteractions = \array_filter( + $this->getQuickInteractions(), + static fn($interaction) => $interaction->isAvailable($object) + ); + + return \implode("\n", \array_map( + static fn($interaction) => $interaction->render($object), + $availableInteractions + )); + } + + /** + * Renders the interactions for the given node. + */ + public function renderInteractionContextMenuButton(IObjectTreeNode $node): string + { + if ($this->interactionProvider === null) { + return ''; + } + + if ($node instanceof DatabaseObjectDecorator) { + $object = $node->getDecoratedObject(); + } else if ($node instanceof DatabaseObject) { + $object = $node; + } else { + throw new \LogicException('node has to be a `DatabaseObject`'); + } + + return $this->getInteractionContextMenuComponent()->renderButton($object); + } + + /** + * Returns the view of the interaction context menu. + */ + public function getInteractionContextMenuComponent(): InteractionContextMenuComponent + { + if ($this->interactionProvider === null) { + throw new \BadMethodCallException("Missing interaction provider."); + } + + if (!isset($this->interactionContextMenuComponent)) { + $this->interactionContextMenuComponent = new InteractionContextMenuComponent($this->interactionProvider); + } + + return $this->interactionContextMenuComponent; + } +} diff --git a/wcfsetup/install/files/lib/system/nodeTreeView/admin/MenuItemNodeTreeView.class.php b/wcfsetup/install/files/lib/system/nodeTreeView/admin/MenuItemNodeTreeView.class.php new file mode 100644 index 00000000000..7cd3301b70d --- /dev/null +++ b/wcfsetup/install/files/lib/system/nodeTreeView/admin/MenuItemNodeTreeView.class.php @@ -0,0 +1,59 @@ +addInteractions([ + new Divider(), + new EditInteraction(MenuItemEditForm::class) + ]); + $this->setInteractionProvider($provider); + $this->addQuickInteraction( + new ToggleInteraction( + 'enable', + 'core/menus/items/%s/enable', + 'core/menus/items/%s/disable' + ) + ); + } + + #[\Override] + public function getNodes(): \RecursiveIteratorIterator + { + $nodeTree = new MenuItemNodeTree($this->menuID, null, false); + + return $nodeTree->getNodeList(); + } + + #[\Override] + public function getParameters(): array + { + return ['menuID' => $this->menuID]; + } + + #[\Override] + public function getNodeLink(IObjectTreeNode $node): string + { + \assert($node instanceof MenuItemNode); + + return LinkHandler::getInstance()->getControllerLink( + MenuItemEditForm::class, + ['id' => $node->getObjectID()] + ); + } +} diff --git a/wcfsetup/install/files/style/ui/nodeTreeView.scss b/wcfsetup/install/files/style/ui/nodeTreeView.scss new file mode 100644 index 00000000000..4b16ad373ec --- /dev/null +++ b/wcfsetup/install/files/style/ui/nodeTreeView.scss @@ -0,0 +1,50 @@ +.nodeTreeView__list { + counter-reset: count; + display: flex; + flex-direction: column; +} + +.nodeTreeView__item { + counter-increment: count; +} + +.nodeTreeView__item__content { + border: 1px solid transparent; + padding: 8px 10px; + display: flex; + align-items: center; + column-gap: 8px; + border-bottom: 1px solid var(--wcfContentBorderInner); +} + +.nodeTreeView__item__content::before { + content: counter(count, decimal); + background-color: var(--wcfContentText); + border-radius: 2px; + color: var(--wcfContentBackground); + padding: 2px 6px; + font-variant: tabular-nums; + + @include wcfFontSmall; +} + +.nodeTreeView__item__content:hover { + background-color: var(--wcfTabularBoxBackgroundActive); +} + +.nodeTreeView__item__link { + color: inherit; + font-weight: 600; + flex: 1 0 auto; + + &:hover { + color: inherit; + } +} + +.nodeTreeView__item__buttons { + align-items: center; + display: flex; + gap: 10px; + justify-content: end; +} From d4fadd626a32b735cd6bcfbce8cf4b8288e6d798 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Mon, 18 May 2026 12:22:43 +0200 Subject: [PATCH 02/18] Add drag and drop handle --- .../nodeTreeView/AbstractNodeTreeView.class.php | 2 +- wcfsetup/install/files/style/ui/nodeTreeView.scss | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php b/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php index a194416d623..ea05e959e49 100644 --- a/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php +++ b/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php @@ -87,7 +87,7 @@ public function getParameters(): array public function getNodePadding(IObjectTreeNode $node): int { - return ($node->getDepth() - 1) * 26 + 10; + return ($node->getDepth() - 1) * 26 + 36; } /** diff --git a/wcfsetup/install/files/style/ui/nodeTreeView.scss b/wcfsetup/install/files/style/ui/nodeTreeView.scss index 4b16ad373ec..5973fc734e1 100644 --- a/wcfsetup/install/files/style/ui/nodeTreeView.scss +++ b/wcfsetup/install/files/style/ui/nodeTreeView.scss @@ -15,6 +15,7 @@ align-items: center; column-gap: 8px; border-bottom: 1px solid var(--wcfContentBorderInner); + position: relative; } .nodeTreeView__item__content::before { @@ -48,3 +49,13 @@ gap: 10px; justify-content: end; } + +.nodeTreeView__item__handle { + position: absolute; + left: 10px; + color: var(--wcfContentDimmedText); +} + +.nodeTreeView__item__content:hover .nodeTreeView__item__handle { + color: var(--wcfContentText); +} From bdbe21b58a759bce7688612121692fd37127c9a7 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Mon, 18 May 2026 13:47:45 +0200 Subject: [PATCH 03/18] Add RPC endpoints that allow to enable / disable menu items. --- .../menu/item/DisableMenuItem.class.php | 33 +++++++++++++ .../menu/item/EnableMenuItem.class.php | 33 +++++++++++++ .../menu/item/MenuItemDisabled.class.php | 19 ++++++++ .../event/menu/item/MenuItemEnabled.class.php | 19 ++++++++ .../menus/items/DisableMenuItem.class.php | 48 +++++++++++++++++++ .../core/menus/items/EnableMenuItem.class.php | 48 +++++++++++++++++++ 6 files changed, 200 insertions(+) create mode 100644 wcfsetup/install/files/lib/command/menu/item/DisableMenuItem.class.php create mode 100644 wcfsetup/install/files/lib/command/menu/item/EnableMenuItem.class.php create mode 100644 wcfsetup/install/files/lib/event/menu/item/MenuItemDisabled.class.php create mode 100644 wcfsetup/install/files/lib/event/menu/item/MenuItemEnabled.class.php create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/menus/items/DisableMenuItem.class.php create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/menus/items/EnableMenuItem.class.php diff --git a/wcfsetup/install/files/lib/command/menu/item/DisableMenuItem.class.php b/wcfsetup/install/files/lib/command/menu/item/DisableMenuItem.class.php new file mode 100644 index 00000000000..1a80960a20a --- /dev/null +++ b/wcfsetup/install/files/lib/command/menu/item/DisableMenuItem.class.php @@ -0,0 +1,33 @@ + + * @since 6.3 + */ +final class DisableMenuItem +{ + public function __construct(private readonly MenuItem $menuItem) {} + + public function __invoke(): void + { + (new MenuItemEditor($this->menuItem))->update([ + 'isDisabled' => 1, + ]); + + MenuItemEditor::resetCache(); + + $event = new MenuItemDisabled($this->menuItem); + EventHandler::getInstance()->fire($event); + } +} diff --git a/wcfsetup/install/files/lib/command/menu/item/EnableMenuItem.class.php b/wcfsetup/install/files/lib/command/menu/item/EnableMenuItem.class.php new file mode 100644 index 00000000000..70de29990b5 --- /dev/null +++ b/wcfsetup/install/files/lib/command/menu/item/EnableMenuItem.class.php @@ -0,0 +1,33 @@ + + * @since 6.3 + */ +final class EnableMenuItem +{ + public function __construct(private readonly MenuItem $menuItem) {} + + public function __invoke(): void + { + (new MenuItemEditor($this->menuItem))->update([ + 'isDisabled' => 0, + ]); + + MenuItemEditor::resetCache(); + + $event = new MenuItemEnabled($this->menuItem); + EventHandler::getInstance()->fire($event); + } +} diff --git a/wcfsetup/install/files/lib/event/menu/item/MenuItemDisabled.class.php b/wcfsetup/install/files/lib/event/menu/item/MenuItemDisabled.class.php new file mode 100644 index 00000000000..0fb12aeb8bd --- /dev/null +++ b/wcfsetup/install/files/lib/event/menu/item/MenuItemDisabled.class.php @@ -0,0 +1,19 @@ + + * @since 6.3 + */ +final class MenuItemDisabled implements IPsr14Event +{ + public function __construct(public readonly MenuItem $menuItem) {} +} diff --git a/wcfsetup/install/files/lib/event/menu/item/MenuItemEnabled.class.php b/wcfsetup/install/files/lib/event/menu/item/MenuItemEnabled.class.php new file mode 100644 index 00000000000..375d9cd580e --- /dev/null +++ b/wcfsetup/install/files/lib/event/menu/item/MenuItemEnabled.class.php @@ -0,0 +1,19 @@ + + * @since 6.3 + */ +final class MenuItemEnabled implements IPsr14Event +{ + public function __construct(public readonly MenuItem $menuItem) {} +} diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/menus/items/DisableMenuItem.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/menus/items/DisableMenuItem.class.php new file mode 100644 index 00000000000..890077727a1 --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/menus/items/DisableMenuItem.class.php @@ -0,0 +1,48 @@ + + * @since 6.3 + */ +#[PostRequest("/core/menus/items/{id:\d+}/disable")] +final class DisableMenuItem implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $menuItem = Helper::fetchObjectFromRequestParameter($variables['id'], MenuItem::class); + + $this->assertMenuItemCanBeDisabled($menuItem); + + if (!$menuItem->isDisabled) { + (new \wcf\command\menu\item\DisableMenuItem($menuItem))(); + } + + return new JsonResponse([]); + } + + private function assertMenuItemCanBeDisabled(MenuItem $menuItem): void + { + WCF::getSession()->checkPermissions(['admin.content.cms.canManageMenu']); + + if (!$menuItem->canDisable()) { + throw new PermissionDeniedException(); + } + } +} diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/menus/items/EnableMenuItem.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/menus/items/EnableMenuItem.class.php new file mode 100644 index 00000000000..521c1587da8 --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/menus/items/EnableMenuItem.class.php @@ -0,0 +1,48 @@ + + * @since 6.3 + */ +#[PostRequest("/core/menus/items/{id:\d+}/enable")] +final class EnableMenuItem implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $menuItem = Helper::fetchObjectFromRequestParameter($variables['id'], MenuItem::class); + + $this->assertMenuItemCanBeEnabled($menuItem); + + if ($menuItem->isDisabled) { + (new \wcf\command\menu\item\EnableMenuItem($menuItem))(); + } + + return new JsonResponse([]); + } + + private function assertMenuItemCanBeEnabled(MenuItem $menuItem): void + { + WCF::getSession()->checkPermissions(['admin.content.cms.canManageMenu']); + + if (!$menuItem->canDisable()) { + throw new PermissionDeniedException(); + } + } +} From 83630b3c3d77a00652b93057c394feb09f57ee56 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Mon, 18 May 2026 13:48:08 +0200 Subject: [PATCH 04/18] Add interaction handling --- .../templates/shared_nodeTreeView.tpl | 11 ++++- .../Core/Component/NodeTreeView.ts | 48 +++++++++++++++++++ .../Core/Component/NodeTreeView.js | 44 +++++++++++++++++ .../AbstractNodeTreeView.class.php | 38 +++++++++++++++ 4 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 ts/WoltLabSuite/Core/Component/NodeTreeView.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js diff --git a/com.woltlab.wcf/templates/shared_nodeTreeView.tpl b/com.woltlab.wcf/templates/shared_nodeTreeView.tpl index 361c9445b5f..e84e52ea0de 100644 --- a/com.woltlab.wcf/templates/shared_nodeTreeView.tpl +++ b/com.woltlab.wcf/templates/shared_nodeTreeView.tpl @@ -1,5 +1,14 @@ -
    +
      {unsafe:$view->renderItems()}
    + + +{unsafe:$view->renderInteractionInitialization()} diff --git a/ts/WoltLabSuite/Core/Component/NodeTreeView.ts b/ts/WoltLabSuite/Core/Component/NodeTreeView.ts new file mode 100644 index 00000000000..bcfb78c3814 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/NodeTreeView.ts @@ -0,0 +1,48 @@ +/** + * Provides the program logic for node tree views. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ + +import { wheneverFirstSeen } from "../Helper/Selector"; +import UiDropdownSimple from "../Ui/Dropdown/Simple"; + +export class NodeTreeView { + readonly #id: string; + + constructor(id: string) { + this.#id = id; + + this.#initInteractions(); + } + + #initInteractions(): void { + wheneverFirstSeen(`#${this.#id} .nodeTreeView__item`, (node) => { + const content = node.querySelector(":scope > .nodeTreeView__item__content")!; + const containers = [content]; + + content.querySelectorAll(".dropdownToggle").forEach((element) => { + const dropdown = UiDropdownSimple.getDropdownMenu(element.dataset.target!); + if (dropdown) { + containers.push(dropdown); + } + }); + + for (const container of containers) { + container.querySelectorAll("[data-interaction]").forEach((element) => { + element.addEventListener("click", () => { + node.dispatchEvent( + new CustomEvent("interaction:execute", { + detail: element.dataset, + bubbles: true, + }), + ); + }); + }); + } + }); + } +} diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js new file mode 100644 index 00000000000..01ae7e7c168 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js @@ -0,0 +1,44 @@ +/** + * Provides the program logic for node tree views. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +define(["require", "exports", "tslib", "../Helper/Selector", "../Ui/Dropdown/Simple"], function (require, exports, tslib_1, Selector_1, Simple_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.NodeTreeView = void 0; + Simple_1 = tslib_1.__importDefault(Simple_1); + class NodeTreeView { + #id; + constructor(id) { + this.#id = id; + this.#initInteractions(); + } + #initInteractions() { + (0, Selector_1.wheneverFirstSeen)(`#${this.#id} .nodeTreeView__item`, (node) => { + const content = node.querySelector(":scope > .nodeTreeView__item__content"); + const containers = [content]; + content.querySelectorAll(".dropdownToggle").forEach((element) => { + const dropdown = Simple_1.default.getDropdownMenu(element.dataset.target); + if (dropdown) { + containers.push(dropdown); + } + }); + for (const container of containers) { + container.querySelectorAll("[data-interaction]").forEach((element) => { + element.addEventListener("click", () => { + node.dispatchEvent(new CustomEvent("interaction:execute", { + detail: element.dataset, + bubbles: true, + })); + }); + }); + } + }); + } + } + exports.NodeTreeView = NodeTreeView; +}); diff --git a/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php b/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php index ea05e959e49..a8950f6d858 100644 --- a/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php +++ b/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php @@ -175,4 +175,42 @@ public function getInteractionContextMenuComponent(): InteractionContextMenuComp return $this->interactionContextMenuComponent; } + + /** + * Renders the initialization code for the interactions of the node tree view. + */ + public function renderInteractionInitialization(): string + { + $code = ''; + if ($this->interactionProvider !== null) { + $code = $this->getInteractionContextMenuComponent()->renderInitialization($this->getID()); + } + + if ($this->quickInteractions !== []) { + $code .= "\n"; + $code .= \implode("\n", \array_map( + fn($interaction) => $interaction->renderInitialization($this->getID()), + $this->getQuickInteractions() + )); + } + + return $code; + } + + /** + * Returns the id of this node tree view. + */ + public function getID(): string + { + $id = \str_replace('\\', '_', static::class); + + if ($this->getParameters() !== []) { + $parameters = $this->getParameters(); + \array_multisort($parameters); + + $id .= '_' . \sha1(\serialize($parameters)); + } + + return $id; + } } From c2a3b2c2a9734996591de92e977adead988cfbcc Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Mon, 18 May 2026 13:51:21 +0200 Subject: [PATCH 05/18] Remove obsolete code --- .../files/lib/acp/page/MenuItemListPage.class.php | 9 --------- 1 file changed, 9 deletions(-) diff --git a/wcfsetup/install/files/lib/acp/page/MenuItemListPage.class.php b/wcfsetup/install/files/lib/acp/page/MenuItemListPage.class.php index 587bc0e1783..d86206b51e9 100644 --- a/wcfsetup/install/files/lib/acp/page/MenuItemListPage.class.php +++ b/wcfsetup/install/files/lib/acp/page/MenuItemListPage.class.php @@ -39,14 +39,6 @@ public function readParameters() $this->menu = Helper::fetchObjectFromQueryParameter(Menu::class); } - #[\Override] - public function readData() - { - parent::readData(); - - //$this->menuItems = new MenuItemNodeTree($this->menuID, null, false); - } - #[\Override] public function assignVariables() { @@ -55,7 +47,6 @@ public function assignVariables() WCF::getTPL()->assign([ 'menu' => $this->menu, 'menuID' => $this->menu->getObjectID(), - //'menuItemNodeList' => $this->menuItems->getNodeList(), ]); } From 2342f8822ad38fbb33527ce05375b037deb8e95c Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Mon, 18 May 2026 13:51:27 +0200 Subject: [PATCH 06/18] Add phpdoc --- .../system/nodeTreeView/AbstractNodeTreeView.class.php | 2 +- .../nodeTreeView/admin/MenuItemNodeTreeView.class.php | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php b/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php index a8950f6d858..939c0e904b4 100644 --- a/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php +++ b/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php @@ -14,7 +14,7 @@ * Abstract implementation of a node tree view. * * @author Marcel Werk - * @copyright 2001-2025 WoltLab GmbH + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License * @since 6.3 */ 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 7cd3301b70d..a180296e102 100644 --- a/wcfsetup/install/files/lib/system/nodeTreeView/admin/MenuItemNodeTreeView.class.php +++ b/wcfsetup/install/files/lib/system/nodeTreeView/admin/MenuItemNodeTreeView.class.php @@ -13,6 +13,14 @@ use wcf\system\nodeTreeView\AbstractNodeTreeView; use wcf\system\request\LinkHandler; +/** + * Node tree view that shows the items of a menu. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ class MenuItemNodeTreeView extends AbstractNodeTreeView { public function __construct(public readonly int $menuID) From 0b0a0bd4239007ab1f9474bd04afa5e092f081ac Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Mon, 18 May 2026 14:36:19 +0200 Subject: [PATCH 07/18] Add interaction that allows to add child nodes --- .../lib/acp/form/MenuItemAddForm.class.php | 9 ++++++++- .../admin/MenuItemInteractions.class.php | 19 +++++++++++++++++++ wcfsetup/install/lang/de.xml | 1 + wcfsetup/install/lang/en.xml | 1 + 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/wcfsetup/install/files/lib/acp/form/MenuItemAddForm.class.php b/wcfsetup/install/files/lib/acp/form/MenuItemAddForm.class.php index 47643f0d1d6..266ac3f3452 100644 --- a/wcfsetup/install/files/lib/acp/form/MenuItemAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/MenuItemAddForm.class.php @@ -76,6 +76,8 @@ class MenuItemAddForm extends AbstractFormBuilderForm */ public $objectActionClass = MenuItemAction::class; + public ?int $parentItemID = null; + #[\Override] public function readParameters() { @@ -88,6 +90,10 @@ public function readParameters() if (!$this->menu->menuID) { throw new IllegalLinkException(); } + + if (isset($_GET['parentItemID'])) { + $this->parentItemID = \intval($_GET['parentItemID']); + } } #[\Override] @@ -124,7 +130,8 @@ protected function createForm() ]; } return $result; - }, true), + }, true) + ->value($this->parentItemID), TitleFormField::create() ->i18n() ->required() diff --git a/wcfsetup/install/files/lib/system/interaction/admin/MenuItemInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/admin/MenuItemInteractions.class.php index d68b65954a0..016f74e941b 100644 --- a/wcfsetup/install/files/lib/system/interaction/admin/MenuItemInteractions.class.php +++ b/wcfsetup/install/files/lib/system/interaction/admin/MenuItemInteractions.class.php @@ -2,11 +2,15 @@ namespace wcf\system\interaction\admin; +use wcf\acp\form\MenuItemAddForm; +use wcf\data\DatabaseObject; use wcf\data\menu\item\MenuItem; use wcf\event\interaction\admin\MenuItemInteractionCollecting; use wcf\system\event\EventHandler; use wcf\system\interaction\AbstractInteractionProvider; use wcf\system\interaction\DeleteInteraction; +use wcf\system\interaction\LinkInteraction; +use wcf\system\request\LinkHandler; /** * Interaction provider for menu items. @@ -21,6 +25,21 @@ final class MenuItemInteractions extends AbstractInteractionProvider public function __construct() { $this->addInteractions([ + new class('add-child-node', MenuItemAddForm::class, 'wcf.acp.menu.item.addChildNode') extends LinkInteraction { + #[\Override] + protected function getLink(DatabaseObject $object): string + { + \assert($object instanceof MenuItem); + + return LinkHandler::getInstance()->getControllerLink( + $this->controllerClass, + [ + 'menuID' => $object->menuID, + 'parentItemID' => $object->getObjectID(), + ] + ); + } + }, new DeleteInteraction( 'core/menus/items/%s', static fn(MenuItem $object) => $object->canDelete() diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index 5ddf902aace..2e939a5289c 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -1258,6 +1258,7 @@ Sie erreichen das Fehlerprotokoll unter: {link controller='ExceptionLogView' isE + diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index a8a3de1ac8b..dfbab4bd9e2 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -1234,6 +1234,7 @@ You can access the error log at: {link controller='ExceptionLogView' isEmail=tru + From 24d25f857723c253ddfad534bb9f02c13d709624 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Tue, 19 May 2026 16:05:43 +0200 Subject: [PATCH 08/18] Add drag and drop support --- .../templates/shared_nodeTreeViewItems.tpl | 2 +- .../Core/Component/NodeTreeView.ts | 14 ++++++++++ .../Core/Component/NodeTreeView.js | 15 ++++++++++- .../AbstractNodeTreeView.class.php | 5 ---- .../install/files/style/ui/nodeTreeView.scss | 26 +++++++++++++++++++ 5 files changed, 55 insertions(+), 7 deletions(-) diff --git a/com.woltlab.wcf/templates/shared_nodeTreeViewItems.tpl b/com.woltlab.wcf/templates/shared_nodeTreeViewItems.tpl index faf13b58354..1d8812f225b 100644 --- a/com.woltlab.wcf/templates/shared_nodeTreeViewItems.tpl +++ b/com.woltlab.wcf/templates/shared_nodeTreeViewItems.tpl @@ -1,6 +1,6 @@ {foreach from=$view->getNodes() item='node'}
  • -
    +
    {icon name='grip-vertical'} {$node->getTitle()} {if $view->hasInteractions()} diff --git a/ts/WoltLabSuite/Core/Component/NodeTreeView.ts b/ts/WoltLabSuite/Core/Component/NodeTreeView.ts index bcfb78c3814..4c14ef8004b 100644 --- a/ts/WoltLabSuite/Core/Component/NodeTreeView.ts +++ b/ts/WoltLabSuite/Core/Component/NodeTreeView.ts @@ -9,6 +9,7 @@ import { wheneverFirstSeen } from "../Helper/Selector"; import UiDropdownSimple from "../Ui/Dropdown/Simple"; +import Sortable from "sortablejs"; export class NodeTreeView { readonly #id: string; @@ -17,6 +18,7 @@ export class NodeTreeView { this.#id = id; this.#initInteractions(); + this.#initializeSorting(); } #initInteractions(): void { @@ -45,4 +47,16 @@ export class NodeTreeView { } }); } + + #initializeSorting(): void { + wheneverFirstSeen(`#${this.#id} .nodeTreeView__list`, (list) => { + new Sortable(list, { + group: "nested", + animation: 150, + fallbackOnBody: true, + draggable: "li", + handle: ".nodeTreeView__item__handle", + }); + }); + } } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js index 01ae7e7c168..74fda27ae1a 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js @@ -6,16 +6,18 @@ * @license GNU Lesser General Public License * @since 6.3 */ -define(["require", "exports", "tslib", "../Helper/Selector", "../Ui/Dropdown/Simple"], function (require, exports, tslib_1, Selector_1, Simple_1) { +define(["require", "exports", "tslib", "../Helper/Selector", "../Ui/Dropdown/Simple", "sortablejs"], function (require, exports, tslib_1, Selector_1, Simple_1, sortablejs_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.NodeTreeView = void 0; Simple_1 = tslib_1.__importDefault(Simple_1); + sortablejs_1 = tslib_1.__importDefault(sortablejs_1); class NodeTreeView { #id; constructor(id) { this.#id = id; this.#initInteractions(); + this.#initializeSorting(); } #initInteractions() { (0, Selector_1.wheneverFirstSeen)(`#${this.#id} .nodeTreeView__item`, (node) => { @@ -39,6 +41,17 @@ define(["require", "exports", "tslib", "../Helper/Selector", "../Ui/Dropdown/Sim } }); } + #initializeSorting() { + (0, Selector_1.wheneverFirstSeen)(`#${this.#id} .nodeTreeView__list`, (list) => { + new sortablejs_1.default(list, { + group: "nested", + animation: 150, + fallbackOnBody: true, + draggable: "li", + handle: ".nodeTreeView__item__handle", + }); + }); + } } exports.NodeTreeView = NodeTreeView; }); diff --git a/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php b/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php index 939c0e904b4..bed58e94391 100644 --- a/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php +++ b/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php @@ -85,11 +85,6 @@ public function getParameters(): array return []; } - public function getNodePadding(IObjectTreeNode $node): int - { - return ($node->getDepth() - 1) * 26 + 36; - } - /** * Returns true, if this node tree view has interactions. */ diff --git a/wcfsetup/install/files/style/ui/nodeTreeView.scss b/wcfsetup/install/files/style/ui/nodeTreeView.scss index 5973fc734e1..a4eeb3a2f6e 100644 --- a/wcfsetup/install/files/style/ui/nodeTreeView.scss +++ b/wcfsetup/install/files/style/ui/nodeTreeView.scss @@ -59,3 +59,29 @@ .nodeTreeView__item__content:hover .nodeTreeView__item__handle { color: var(--wcfContentText); } + +.nodeTreeView__list { + --baseIndentation: 36px; + --indentation: 26px; + --depth: 0; +} + +.nodeTreeView__list .nodeTreeView__list { + --depth: 1; +} + +.nodeTreeView__list .nodeTreeView__list .nodeTreeView__list { + --depth: 2; +} + +.nodeTreeView__list .nodeTreeView__list .nodeTreeView__list .nodeTreeView__list { + --depth: 3; +} + +.nodeTreeView__list .nodeTreeView__list .nodeTreeView__list .nodeTreeView__list .nodeTreeView__list { + --depth: 4; +} + +.nodeTreeView__item__content { + padding-left: calc(var(--baseIndentation) + calc(var(--depth) * var(--indentation))); +} From 17fa4af768668aad338744a1e53e2af86c28fde6 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Wed, 20 May 2026 13:18:59 +0200 Subject: [PATCH 09/18] Migrate `MenuItemAction::updatePosition()` to an RPC endpoint and a command --- .../templates/shared_nodeTreeView.tpl | 11 +++ .../Core/Component/NodeTreeView.ts | 63 ++++++++++++-- .../Core/Component/NodeTreeView.js | 39 +++++++-- .../menu/item/SetMenuItemPositions.class.php | 45 ++++++++++ .../data/menu/item/MenuItemAction.class.php | 71 +--------------- .../items/SetMenuItemPositions.class.php | 84 +++++++++++++++++++ .../AbstractNodeTreeView.class.php | 11 +++ .../admin/MenuItemNodeTreeView.class.php | 1 + 8 files changed, 241 insertions(+), 84 deletions(-) create mode 100644 wcfsetup/install/files/lib/command/menu/item/SetMenuItemPositions.class.php create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/menus/items/SetMenuItemPositions.class.php diff --git a/com.woltlab.wcf/templates/shared_nodeTreeView.tpl b/com.woltlab.wcf/templates/shared_nodeTreeView.tpl index e84e52ea0de..344e3bcfb31 100644 --- a/com.woltlab.wcf/templates/shared_nodeTreeView.tpl +++ b/com.woltlab.wcf/templates/shared_nodeTreeView.tpl @@ -2,12 +2,23 @@
      {unsafe:$view->renderItems()}
    + +
    diff --git a/ts/WoltLabSuite/Core/Component/NodeTreeView.ts b/ts/WoltLabSuite/Core/Component/NodeTreeView.ts index 4c14ef8004b..28923de5ddd 100644 --- a/ts/WoltLabSuite/Core/Component/NodeTreeView.ts +++ b/ts/WoltLabSuite/Core/Component/NodeTreeView.ts @@ -7,18 +7,26 @@ * @since 6.3 */ +import { postObject } from "../Api/PostObject"; +import { promiseMutex } from "../Helper/PromiseMutex"; import { wheneverFirstSeen } from "../Helper/Selector"; import UiDropdownSimple from "../Ui/Dropdown/Simple"; import Sortable from "sortablejs"; export class NodeTreeView { readonly #id: string; + readonly #setPositionsEndpoint: string; + readonly #sortables = new Map(); - constructor(id: string) { + constructor(id: string, setPositionsEndpoint: string = "") { this.#id = id; + this.#setPositionsEndpoint = setPositionsEndpoint; this.#initInteractions(); - this.#initializeSorting(); + + if (this.#setPositionsEndpoint) { + this.#initializeSorting(); + } } #initInteractions(): void { @@ -48,15 +56,52 @@ export class NodeTreeView { }); } + #showFooter(): void { + document.getElementById(`${this.#id}_footer`)!.hidden = false; + } + + #hideFooter(): void { + document.getElementById(`${this.#id}_footer`)!.hidden = true; + } + + async #setPositions(): Promise { + const positions: Record = {}; + for (const [objectId, sortables] of this.#sortables) { + const objectIds = sortables.toArray(); + if (objectIds.length === 0) { + continue; + } + + positions[objectId] = objectIds.map((objectId) => parseInt(objectId)); + } + + await postObject(`${window.WSC_RPC_API_URL}${this.#setPositionsEndpoint}`, { positions }); + + this.#hideFooter(); + } + #initializeSorting(): void { + const button = document.getElementById(`${this.#id}_submitButton`)!; + button.addEventListener( + "click", + promiseMutex(() => this.#setPositions()), + ); + wheneverFirstSeen(`#${this.#id} .nodeTreeView__list`, (list) => { - new Sortable(list, { - group: "nested", - animation: 150, - fallbackOnBody: true, - draggable: "li", - handle: ".nodeTreeView__item__handle", - }); + this.#sortables.set( + parseInt(list.dataset.parentObjectId!), + new Sortable(list, { + group: "nested", + animation: 150, + fallbackOnBody: true, + draggable: "li", + handle: ".nodeTreeView__item__handle", + dataIdAttr: "data-object-id", + onChange: () => { + this.#showFooter(); + }, + }), + ); }); } } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js index 74fda27ae1a..d637e92d740 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js @@ -6,7 +6,7 @@ * @license GNU Lesser General Public License * @since 6.3 */ -define(["require", "exports", "tslib", "../Helper/Selector", "../Ui/Dropdown/Simple", "sortablejs"], function (require, exports, tslib_1, Selector_1, Simple_1, sortablejs_1) { +define(["require", "exports", "tslib", "../Api/PostObject", "../Helper/PromiseMutex", "../Helper/Selector", "../Ui/Dropdown/Simple", "sortablejs"], function (require, exports, tslib_1, PostObject_1, PromiseMutex_1, Selector_1, Simple_1, sortablejs_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.NodeTreeView = void 0; @@ -14,10 +14,15 @@ define(["require", "exports", "tslib", "../Helper/Selector", "../Ui/Dropdown/Sim sortablejs_1 = tslib_1.__importDefault(sortablejs_1); class NodeTreeView { #id; - constructor(id) { + #setPositionsEndpoint; + #sortables = new Map(); + constructor(id, setPositionsEndpoint = "") { this.#id = id; + this.#setPositionsEndpoint = setPositionsEndpoint; this.#initInteractions(); - this.#initializeSorting(); + if (this.#setPositionsEndpoint) { + this.#initializeSorting(); + } } #initInteractions() { (0, Selector_1.wheneverFirstSeen)(`#${this.#id} .nodeTreeView__item`, (node) => { @@ -41,15 +46,39 @@ define(["require", "exports", "tslib", "../Helper/Selector", "../Ui/Dropdown/Sim } }); } + #showFooter() { + document.getElementById(`${this.#id}_footer`).hidden = false; + } + #hideFooter() { + document.getElementById(`${this.#id}_footer`).hidden = true; + } + async #setPositions() { + const positions = {}; + for (const [objectId, sortables] of this.#sortables) { + const objectIds = sortables.toArray(); + if (objectIds.length === 0) { + continue; + } + positions[objectId] = objectIds.map((objectId) => parseInt(objectId)); + } + await (0, PostObject_1.postObject)(`${window.WSC_RPC_API_URL}${this.#setPositionsEndpoint}`, { positions }); + this.#hideFooter(); + } #initializeSorting() { + const button = document.getElementById(`${this.#id}_submitButton`); + button.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(() => this.#setPositions())); (0, Selector_1.wheneverFirstSeen)(`#${this.#id} .nodeTreeView__list`, (list) => { - new sortablejs_1.default(list, { + this.#sortables.set(parseInt(list.dataset.parentObjectId), new sortablejs_1.default(list, { group: "nested", animation: 150, fallbackOnBody: true, draggable: "li", handle: ".nodeTreeView__item__handle", - }); + dataIdAttr: "data-object-id", + onChange: () => { + this.#showFooter(); + }, + })); }); } } diff --git a/wcfsetup/install/files/lib/command/menu/item/SetMenuItemPositions.class.php b/wcfsetup/install/files/lib/command/menu/item/SetMenuItemPositions.class.php new file mode 100644 index 00000000000..bace20403f2 --- /dev/null +++ b/wcfsetup/install/files/lib/command/menu/item/SetMenuItemPositions.class.php @@ -0,0 +1,45 @@ + + * @since 6.3 + */ +final class SetMenuItemPositions +{ + /** + * @param array> $positions + */ + public function __construct(private readonly array $positions) {} + + public function __invoke(): void + { + $sql = "UPDATE wcf1_menu_item + SET parentItemID = ?, + showOrder = ? + WHERE itemID = ?"; + $statement = WCF::getDB()->prepare($sql); + + WCF::getDB()->beginTransaction(); + foreach ($this->positions as $parentItemID => $children) { + foreach ($children as $showOrder => $menuItemID) { + $statement->execute([ + $parentItemID ?: null, + $showOrder + 1, + $menuItemID, + ]); + } + } + WCF::getDB()->commitTransaction(); + + MenuItemEditor::resetCache(); + } +} diff --git a/wcfsetup/install/files/lib/data/menu/item/MenuItemAction.class.php b/wcfsetup/install/files/lib/data/menu/item/MenuItemAction.class.php index b8d76a36ed0..a4800058de3 100644 --- a/wcfsetup/install/files/lib/data/menu/item/MenuItemAction.class.php +++ b/wcfsetup/install/files/lib/data/menu/item/MenuItemAction.class.php @@ -4,14 +4,10 @@ use wcf\data\AbstractDatabaseObjectAction; use wcf\data\DatabaseObject; -use wcf\data\ISortableAction; use wcf\data\IToggleAction; -use wcf\data\menu\Menu; use wcf\data\TDatabaseObjectToggle; use wcf\data\TI18nDatabaseObjectAction; use wcf\system\exception\PermissionDeniedException; -use wcf\system\exception\UserInputException; -use wcf\system\WCF; /** * Executes menu item related actions. @@ -22,7 +18,7 @@ * * @extends AbstractDatabaseObjectAction */ -class MenuItemAction extends AbstractDatabaseObjectAction implements ISortableAction, IToggleAction +class MenuItemAction extends AbstractDatabaseObjectAction implements IToggleAction { use TDatabaseObjectToggle; use TI18nDatabaseObjectAction; @@ -96,71 +92,6 @@ public function validateToggle() } } - #[\Override] - public function validateUpdatePosition() - { - WCF::getSession()->checkPermissions(['admin.content.cms.canManageMenu']); - - // validate menu id - $this->readInteger('menuID'); - $menu = new Menu($this->parameters['menuID']); - if (!$menu->menuID) { - throw new UserInputException('menuID'); - } - - // validate structure - if ( - !isset($this->parameters['data']) - || !isset($this->parameters['data']['structure']) - || !\is_array($this->parameters['data']['structure']) - ) { - throw new UserInputException('structure'); - } - - $menuItemIDs = []; - foreach ($this->parameters['data']['structure'] as $menuItems) { - $menuItemIDs = \array_merge($menuItemIDs, $menuItems); - } - - $menuItemList = new MenuItemList(); - $menuItemList->getConditionBuilder()->add('menu_item.itemID IN (?)', [$menuItemIDs]); - $menuItemList->getConditionBuilder()->add('menu_item.menuID = ?', [$this->parameters['menuID']]); - $menuItemList->readObjects(); - $menuItems = $menuItemList->getObjects(); - - if (\count($menuItems) != \count($menuItemIDs)) { - throw new UserInputException('structure'); - } - - foreach ($this->parameters['data']['structure'] as $parentItemID => $children) { - if ($parentItemID && !isset($menuItems[$parentItemID])) { - throw new UserInputException('structure'); - } - } - } - - #[\Override] - public function updatePosition() - { - $sql = "UPDATE wcf1_menu_item - SET parentItemID = ?, - showOrder = ? - WHERE itemID = ?"; - $statement = WCF::getDB()->prepare($sql); - - WCF::getDB()->beginTransaction(); - foreach ($this->parameters['data']['structure'] as $parentItemID => $children) { - foreach ($children as $showOrder => $menuItemID) { - $statement->execute([ - $parentItemID ?: null, - $showOrder + 1, - $menuItemID, - ]); - } - } - WCF::getDB()->commitTransaction(); - } - /** * @return array */ diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/menus/items/SetMenuItemPositions.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/menus/items/SetMenuItemPositions.class.php new file mode 100644 index 00000000000..7e6487ff74b --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/menus/items/SetMenuItemPositions.class.php @@ -0,0 +1,84 @@ + + * @since 6.3 + */ +#[PostRequest('/core/menus/{id:\d+}/items/positions')] +final class SetMenuItemPositions implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $menu = Helper::fetchObjectFromRequestParameter($variables['id'], Menu::class); + + WCF::getSession()->checkPermissions(['admin.content.cms.canManageMenu']); + + $parameters = Helper::mapApiParameters($request, SetMenuItemPositionsParameters::class); + $positions = $this->validatePositions($menu, $parameters->positions); + + (new \wcf\command\menu\item\SetMenuItemPositions($positions))(); + + return new JsonResponse([]); + } + + /** + * @param array> $positions + * @return array> + */ + private function validatePositions(Menu $menu, array $positions): array + { + $menuItemIDs = []; + foreach ($positions as $children) { + $menuItemIDs = \array_merge($menuItemIDs, $children); + } + + if ($menuItemIDs === []) { + return $positions; + } + + $menuItemList = new MenuItemList(); + $menuItemList->getConditionBuilder()->add('menu_item.itemID IN (?)', [$menuItemIDs]); + $menuItemList->getConditionBuilder()->add('menu_item.menuID = ?', [$menu->menuID]); + $menuItemList->readObjects(); + $menuItems = $menuItemList->getObjects(); + + if (\count($menuItems) !== \count($menuItemIDs)) { + throw new IllegalLinkException(); + } + + foreach ($positions as $parentItemID => $children) { + if ($parentItemID && !isset($menuItems[$parentItemID])) { + throw new IllegalLinkException(); + } + } + + return $positions; + } +} + +/** @internal */ +final class SetMenuItemPositionsParameters +{ + public function __construct( + /** @var array> */ + public readonly array $positions, + ) {} +} diff --git a/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php b/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php index bed58e94391..9e2b75dd43b 100644 --- a/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php +++ b/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php @@ -27,6 +27,7 @@ abstract class AbstractNodeTreeView private ?IInteractionProvider $interactionProvider = null; private InteractionContextMenuComponent $interactionContextMenuComponent; + private string $setPositionsEndpoint = ''; /** * Sets the interaction provider that is used to render the interaction context menu. @@ -208,4 +209,14 @@ public function getID(): string return $id; } + + public function setSetPositionsEndpoint(string $endpoint): void + { + $this->setPositionsEndpoint = $endpoint; + } + + public function getSetPositionsEndpoint(): string + { + return $this->setPositionsEndpoint; + } } 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 a180296e102..4b203dc322f 100644 --- a/wcfsetup/install/files/lib/system/nodeTreeView/admin/MenuItemNodeTreeView.class.php +++ b/wcfsetup/install/files/lib/system/nodeTreeView/admin/MenuItemNodeTreeView.class.php @@ -38,6 +38,7 @@ public function __construct(public readonly int $menuID) 'core/menus/items/%s/disable' ) ); + $this->setSetPositionsEndpoint("core/menus/{$this->menuID}/items/positions"); } #[\Override] From 04a382a9ae8ae23ff3dc6e9eafd43f92a21e45f3 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Wed, 20 May 2026 13:22:52 +0200 Subject: [PATCH 10/18] Make 'save sorting' button sticky --- .../templates/shared_nodeTreeView.tpl | 16 ++++--- .../install/files/style/ui/nodeTreeView.scss | 45 +++++++++++++++++++ 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/com.woltlab.wcf/templates/shared_nodeTreeView.tpl b/com.woltlab.wcf/templates/shared_nodeTreeView.tpl index 344e3bcfb31..b947f8103d7 100644 --- a/com.woltlab.wcf/templates/shared_nodeTreeView.tpl +++ b/com.woltlab.wcf/templates/shared_nodeTreeView.tpl @@ -4,13 +4,15 @@
    diff --git a/wcfsetup/install/files/style/ui/nodeTreeView.scss b/wcfsetup/install/files/style/ui/nodeTreeView.scss index a4eeb3a2f6e..2b3f37c82ce 100644 --- a/wcfsetup/install/files/style/ui/nodeTreeView.scss +++ b/wcfsetup/install/files/style/ui/nodeTreeView.scss @@ -60,6 +60,51 @@ color: var(--wcfContentText); } +.nodeTreeView__footer { + bottom: -10px; + display: flex; + justify-content: center; + gap: 10px; + min-height: 54px; + padding: 20px 0; + position: sticky; + z-index: 2; +} + +@include screen-sm-down { + .nodeTreeView__footer { + margin-bottom: -10px; + } +} + +@include screen-md-up { + .nodeTreeView__footer { + margin-bottom: -20px; + } +} + +.nodeTreeView__footer__container { + backdrop-filter: blur(24px); + background-color: rgb(var(--wcfContentContainerBackground-rgb) / 78%); + border: 1px solid transparent; + border-radius: var(--wcfBorderRadiusContainer); + box-shadow: var(--wcfBoxShadow); + display: flex; + gap: 21px; + padding: 10px; +} + +@include screen-xs { + .nodeTreeView__footer__container { + align-items: center; + flex-direction: column; + } +} + +html[data-color-scheme="dark"] .nodeTreeView__footer__container { + border-color: var(--wcfContentContainerBorder); +} + .nodeTreeView__list { --baseIndentation: 36px; --indentation: 26px; From aba930527b9068fcaac2b519df2ce5bdeaeefca2 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Wed, 20 May 2026 13:26:40 +0200 Subject: [PATCH 11/18] Make menu item interactions available in edit form --- .../files/acp/templates/menuItemAdd.tpl | 4 +++- .../lib/acp/form/MenuItemEditForm.class.php | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/wcfsetup/install/files/acp/templates/menuItemAdd.tpl b/wcfsetup/install/files/acp/templates/menuItemAdd.tpl index 79a22aad6b6..5c0bb6087bf 100644 --- a/wcfsetup/install/files/acp/templates/menuItemAdd.tpl +++ b/wcfsetup/install/files/acp/templates/menuItemAdd.tpl @@ -24,8 +24,10 @@
  • +
  • + {unsafe:$interactionContextMenu->render()} +
  • {/if} -
  • {icon name='list'} {lang}wcf.acp.menu.item.list{/lang}
  • {event name='contentHeaderNavigation'} diff --git a/wcfsetup/install/files/lib/acp/form/MenuItemEditForm.class.php b/wcfsetup/install/files/lib/acp/form/MenuItemEditForm.class.php index 71609542495..8ef0b34fc43 100644 --- a/wcfsetup/install/files/lib/acp/form/MenuItemEditForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/MenuItemEditForm.class.php @@ -3,11 +3,16 @@ namespace wcf\acp\form; use CuyZ\Valinor\Mapper\MappingError; +use wcf\acp\page\MenuItemListPage; use wcf\data\menu\item\MenuItem; use wcf\data\menu\Menu; use wcf\form\AbstractFormBuilderForm; use wcf\http\Helper; use wcf\system\exception\IllegalLinkException; +use wcf\system\interaction\admin\MenuItemInteractions; +use wcf\system\interaction\StandaloneInteractionContextMenuComponent; +use wcf\system\request\LinkHandler; +use wcf\system\WCF; /** * Shows the menu item edit form. @@ -55,4 +60,21 @@ protected function setFormAction() { AbstractFormBuilderForm::setFormAction(); } + + #[\Override] + public function assignVariables() + { + parent::assignVariables(); + + WCF::getTPL()->assign([ + 'interactionContextMenu' => StandaloneInteractionContextMenuComponent::forContentHeaderButton( + new MenuItemInteractions(), + $this->formObject, + LinkHandler::getInstance()->getControllerLink( + MenuItemListPage::class, + ['id' => $this->menuID] + ) + ), + ]); + } } From 7c2c91ce73bdee5844af3be830b704da054176f6 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Wed, 20 May 2026 16:57:43 +0200 Subject: [PATCH 12/18] Handle removal of nodes --- .../Core/Component/NodeTreeView.ts | 25 +++++++++++++++++++ .../Core/Component/NodeTreeView.js | 21 ++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/ts/WoltLabSuite/Core/Component/NodeTreeView.ts b/ts/WoltLabSuite/Core/Component/NodeTreeView.ts index 28923de5ddd..65a56ed5441 100644 --- a/ts/WoltLabSuite/Core/Component/NodeTreeView.ts +++ b/ts/WoltLabSuite/Core/Component/NodeTreeView.ts @@ -23,6 +23,7 @@ export class NodeTreeView { this.#setPositionsEndpoint = setPositionsEndpoint; this.#initInteractions(); + this.#initEventListeners(); if (this.#setPositionsEndpoint) { this.#initializeSorting(); @@ -104,4 +105,28 @@ export class NodeTreeView { ); }); } + + #initEventListeners(): void { + const nodeTreeView = document.getElementById(this.#id)!; + + nodeTreeView.addEventListener("interaction:invalidate-all", () => { + window.location.reload(); + }); + + nodeTreeView.addEventListener("interaction:invalidate", () => { + window.location.reload(); + }); + + nodeTreeView.addEventListener("interaction:remove", (event) => { + const item = event.target as HTMLElement; + const childList = item.querySelector(":scope > .nodeTreeView__list"); + if (childList) { + const objectId = parseInt(item.dataset.objectId!); + this.#sortables.get(objectId)?.destroy(); + this.#sortables.delete(objectId); + item.before(...childList.children); + } + item.remove(); + }); + } } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js index d637e92d740..8bf226a2045 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js @@ -20,6 +20,7 @@ define(["require", "exports", "tslib", "../Api/PostObject", "../Helper/PromiseMu this.#id = id; this.#setPositionsEndpoint = setPositionsEndpoint; this.#initInteractions(); + this.#initEventListeners(); if (this.#setPositionsEndpoint) { this.#initializeSorting(); } @@ -81,6 +82,26 @@ define(["require", "exports", "tslib", "../Api/PostObject", "../Helper/PromiseMu })); }); } + #initEventListeners() { + const nodeTreeView = document.getElementById(this.#id); + nodeTreeView.addEventListener("interaction:invalidate-all", () => { + window.location.reload(); + }); + nodeTreeView.addEventListener("interaction:invalidate", () => { + window.location.reload(); + }); + nodeTreeView.addEventListener("interaction:remove", (event) => { + const item = event.target; + const childList = item.querySelector(":scope > .nodeTreeView__list"); + if (childList) { + const objectId = parseInt(item.dataset.objectId); + this.#sortables.get(objectId)?.destroy(); + this.#sortables.delete(objectId); + item.before(...childList.children); + } + item.remove(); + }); + } } exports.NodeTreeView = NodeTreeView; }); From 2103c392b82c83cee9a433d259c2fb8cf760a40e Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Wed, 20 May 2026 17:19:39 +0200 Subject: [PATCH 13/18] Handle invalidate events --- .../templates/shared_nodeTreeView.tpl | 6 ++ .../templates/shared_nodeTreeViewItem.tpl | 18 ++++++ .../Core/Api/NodeTreeViews/GetNode.ts | 40 ++++++++++++ .../Core/Api/NodeTreeViews/GetNodes.ts | 38 +++++++++++ .../Core/Component/NodeTreeView.ts | 44 +++++++++++-- .../Core/Api/NodeTreeViews/GetNode.js | 33 ++++++++++ .../Core/Api/NodeTreeViews/GetNodes.js | 32 ++++++++++ .../Core/Component/NodeTreeView.js | 36 +++++++++-- .../core/nodeTreeViews/GetNode.class.php | 64 +++++++++++++++++++ .../core/nodeTreeViews/GetNodes.class.php | 58 +++++++++++++++++ .../AbstractNodeTreeView.class.php | 37 ++++++++++- 11 files changed, 396 insertions(+), 10 deletions(-) create mode 100644 com.woltlab.wcf/templates/shared_nodeTreeViewItem.tpl create mode 100644 ts/WoltLabSuite/Core/Api/NodeTreeViews/GetNode.ts create mode 100644 ts/WoltLabSuite/Core/Api/NodeTreeViews/GetNodes.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/NodeTreeViews/GetNode.js create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/NodeTreeViews/GetNodes.js create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/nodeTreeViews/GetNode.class.php create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/nodeTreeViews/GetNodes.class.php diff --git a/com.woltlab.wcf/templates/shared_nodeTreeView.tpl b/com.woltlab.wcf/templates/shared_nodeTreeView.tpl index b947f8103d7..99f78994db5 100644 --- a/com.woltlab.wcf/templates/shared_nodeTreeView.tpl +++ b/com.woltlab.wcf/templates/shared_nodeTreeView.tpl @@ -20,6 +20,12 @@ require(['WoltLabSuite/Core/Component/NodeTreeView'], ({ NodeTreeView }) => { new NodeTreeView( '{unsafe:$view->getID()|encodeJS}', + '{unsafe:$view->getClassName()|encodeJS}', + new Map([ + {foreach from=$view->getParameters() key='name' item='value'} + ['{unsafe:$name|encodeJS}', {unsafe:$value|json}], + {/foreach} + ]), '{unsafe:$view->getSetPositionsEndpoint()|encodeJS}', ); }); diff --git a/com.woltlab.wcf/templates/shared_nodeTreeViewItem.tpl b/com.woltlab.wcf/templates/shared_nodeTreeViewItem.tpl new file mode 100644 index 00000000000..6e2c0abd00d --- /dev/null +++ b/com.woltlab.wcf/templates/shared_nodeTreeViewItem.tpl @@ -0,0 +1,18 @@ +
  • +
    + {icon name='grip-vertical'} + {$node->getTitle()} + {if $view->hasInteractions()} +
    + {unsafe:$view->renderQuickInteractions($node)} + {unsafe:$view->renderInteractionContextMenuButton($node)} +
    + {/if} +
    + +
      + {foreach from=$node item='child'} + {unsafe:$view->renderItem($child)} + {/foreach} +
    +
  • diff --git a/ts/WoltLabSuite/Core/Api/NodeTreeViews/GetNode.ts b/ts/WoltLabSuite/Core/Api/NodeTreeViews/GetNode.ts new file mode 100644 index 00000000000..4767e624540 --- /dev/null +++ b/ts/WoltLabSuite/Core/Api/NodeTreeViews/GetNode.ts @@ -0,0 +1,40 @@ +/** + * Gets a single node for rendering in a node tree view. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ + +import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; +import { fromInfallibleApiRequest } from "../Result"; + +type Response = { + template: string; +}; + +export async function getNode( + nodeTreeViewClass: string, + objectId: string | number, + nodeTreeViewParameters?: Map, +): Promise { + const url = new URL(`${window.WSC_RPC_API_URL}core/node-tree-views/node`); + url.searchParams.set("nodeTreeView", nodeTreeViewClass); + url.searchParams.set("objectID", objectId.toString()); + if (nodeTreeViewParameters) { + nodeTreeViewParameters.forEach((value, key) => { + if (Array.isArray(value)) { + value.forEach((innerValue, innerKey) => { + url.searchParams.set(`nodeTreeViewParameters[${key}][${innerKey}]`, innerValue); + }); + } else { + url.searchParams.set(`nodeTreeViewParameters[${key}]`, value); + } + }); + } + + return fromInfallibleApiRequest(() => { + return prepareRequest(url).get().allowCaching().disableLoadingIndicator().fetchAsJson(); + }); +} diff --git a/ts/WoltLabSuite/Core/Api/NodeTreeViews/GetNodes.ts b/ts/WoltLabSuite/Core/Api/NodeTreeViews/GetNodes.ts new file mode 100644 index 00000000000..6d1f7ec0c2c --- /dev/null +++ b/ts/WoltLabSuite/Core/Api/NodeTreeViews/GetNodes.ts @@ -0,0 +1,38 @@ +/** + * Gets the items of a node tree view. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ + +import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; +import { fromInfallibleApiRequest } from "../Result"; + +type Response = { + template: string; +}; + +export async function getNodes( + nodeTreeViewClass: string, + nodeTreeViewParameters?: Map, +): Promise { + const url = new URL(`${window.WSC_RPC_API_URL}core/node-tree-views/nodes`); + url.searchParams.set("nodeTreeView", nodeTreeViewClass); + if (nodeTreeViewParameters) { + nodeTreeViewParameters.forEach((value, key) => { + if (Array.isArray(value)) { + value.forEach((innerValue, innerKey) => { + url.searchParams.set(`nodeTreeViewParameters[${key}][${innerKey}]`, innerValue); + }); + } else { + url.searchParams.set(`nodeTreeViewParameters[${key}]`, value); + } + }); + } + + return fromInfallibleApiRequest(() => { + return prepareRequest(url).get().allowCaching().disableLoadingIndicator().fetchAsJson(); + }); +} diff --git a/ts/WoltLabSuite/Core/Component/NodeTreeView.ts b/ts/WoltLabSuite/Core/Component/NodeTreeView.ts index 65a56ed5441..a7b0e14f618 100644 --- a/ts/WoltLabSuite/Core/Component/NodeTreeView.ts +++ b/ts/WoltLabSuite/Core/Component/NodeTreeView.ts @@ -8,18 +8,30 @@ */ import { postObject } from "../Api/PostObject"; +import { getNode } from "../Api/NodeTreeViews/GetNode"; +import { getNodes } from "../Api/NodeTreeViews/GetNodes"; import { promiseMutex } from "../Helper/PromiseMutex"; import { wheneverFirstSeen } from "../Helper/Selector"; +import { createFragmentFromHtml, setInnerHtml } from "../Dom/Util"; import UiDropdownSimple from "../Ui/Dropdown/Simple"; import Sortable from "sortablejs"; export class NodeTreeView { readonly #id: string; + readonly #viewClassName: string; + readonly #viewParameters: Map; readonly #setPositionsEndpoint: string; readonly #sortables = new Map(); - constructor(id: string, setPositionsEndpoint: string = "") { + constructor( + id: string, + viewClassName: string, + viewParameters: Map, + setPositionsEndpoint: string = "", + ) { this.#id = id; + this.#viewClassName = viewClassName; + this.#viewParameters = viewParameters; this.#setPositionsEndpoint = setPositionsEndpoint; this.#initInteractions(); @@ -106,15 +118,39 @@ export class NodeTreeView { }); } + async #reloadTree(): Promise { + const { template } = await getNodes(this.#viewClassName, this.#viewParameters); + const rootList = document.querySelector(`#${this.#id} > .nodeTreeView__list`)!; + for (const [parentObjectId, sortable] of this.#sortables) { + if (parentObjectId === 0) { + continue; + } + sortable.destroy(); + this.#sortables.delete(parentObjectId); + } + setInnerHtml(rootList, template); + } + + async #reloadNode(item: HTMLElement): Promise { + const objectId = parseInt(item.dataset.objectId!); + const { template } = await getNode(this.#viewClassName, objectId, this.#viewParameters); + for (const list of item.querySelectorAll(".nodeTreeView__list")) { + const parentObjectId = parseInt(list.dataset.parentObjectId!); + this.#sortables.get(parentObjectId)?.destroy(); + this.#sortables.delete(parentObjectId); + } + item.replaceWith(createFragmentFromHtml(template)); + } + #initEventListeners(): void { const nodeTreeView = document.getElementById(this.#id)!; nodeTreeView.addEventListener("interaction:invalidate-all", () => { - window.location.reload(); + void this.#reloadTree(); }); - nodeTreeView.addEventListener("interaction:invalidate", () => { - window.location.reload(); + nodeTreeView.addEventListener("interaction:invalidate", (event) => { + void this.#reloadNode(event.target as HTMLElement); }); nodeTreeView.addEventListener("interaction:remove", (event) => { diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/NodeTreeViews/GetNode.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/NodeTreeViews/GetNode.js new file mode 100644 index 00000000000..30b205513a2 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/NodeTreeViews/GetNode.js @@ -0,0 +1,33 @@ +/** + * Gets a single node for rendering in a node tree view. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], function (require, exports, Backend_1, Result_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.getNode = getNode; + async function getNode(nodeTreeViewClass, objectId, nodeTreeViewParameters) { + const url = new URL(`${window.WSC_RPC_API_URL}core/node-tree-views/node`); + url.searchParams.set("nodeTreeView", nodeTreeViewClass); + url.searchParams.set("objectID", objectId.toString()); + if (nodeTreeViewParameters) { + nodeTreeViewParameters.forEach((value, key) => { + if (Array.isArray(value)) { + value.forEach((innerValue, innerKey) => { + url.searchParams.set(`nodeTreeViewParameters[${key}][${innerKey}]`, innerValue); + }); + } + else { + url.searchParams.set(`nodeTreeViewParameters[${key}]`, value); + } + }); + } + return (0, Result_1.fromInfallibleApiRequest)(() => { + return (0, Backend_1.prepareRequest)(url).get().allowCaching().disableLoadingIndicator().fetchAsJson(); + }); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/NodeTreeViews/GetNodes.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/NodeTreeViews/GetNodes.js new file mode 100644 index 00000000000..b63eeebeff8 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/NodeTreeViews/GetNodes.js @@ -0,0 +1,32 @@ +/** + * Gets the items of a node tree view. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], function (require, exports, Backend_1, Result_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.getNodes = getNodes; + async function getNodes(nodeTreeViewClass, nodeTreeViewParameters) { + const url = new URL(`${window.WSC_RPC_API_URL}core/node-tree-views/nodes`); + url.searchParams.set("nodeTreeView", nodeTreeViewClass); + if (nodeTreeViewParameters) { + nodeTreeViewParameters.forEach((value, key) => { + if (Array.isArray(value)) { + value.forEach((innerValue, innerKey) => { + url.searchParams.set(`nodeTreeViewParameters[${key}][${innerKey}]`, innerValue); + }); + } + else { + url.searchParams.set(`nodeTreeViewParameters[${key}]`, value); + } + }); + } + return (0, Result_1.fromInfallibleApiRequest)(() => { + return (0, Backend_1.prepareRequest)(url).get().allowCaching().disableLoadingIndicator().fetchAsJson(); + }); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js index 8bf226a2045..517e72bf62c 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js @@ -6,7 +6,7 @@ * @license GNU Lesser General Public License * @since 6.3 */ -define(["require", "exports", "tslib", "../Api/PostObject", "../Helper/PromiseMutex", "../Helper/Selector", "../Ui/Dropdown/Simple", "sortablejs"], function (require, exports, tslib_1, PostObject_1, PromiseMutex_1, Selector_1, Simple_1, sortablejs_1) { +define(["require", "exports", "tslib", "../Api/PostObject", "../Api/NodeTreeViews/GetNode", "../Api/NodeTreeViews/GetNodes", "../Helper/PromiseMutex", "../Helper/Selector", "../Dom/Util", "../Ui/Dropdown/Simple", "sortablejs"], function (require, exports, tslib_1, PostObject_1, GetNode_1, GetNodes_1, PromiseMutex_1, Selector_1, Util_1, Simple_1, sortablejs_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.NodeTreeView = void 0; @@ -14,10 +14,14 @@ define(["require", "exports", "tslib", "../Api/PostObject", "../Helper/PromiseMu sortablejs_1 = tslib_1.__importDefault(sortablejs_1); class NodeTreeView { #id; + #viewClassName; + #viewParameters; #setPositionsEndpoint; #sortables = new Map(); - constructor(id, setPositionsEndpoint = "") { + constructor(id, viewClassName, viewParameters, setPositionsEndpoint = "") { this.#id = id; + this.#viewClassName = viewClassName; + this.#viewParameters = viewParameters; this.#setPositionsEndpoint = setPositionsEndpoint; this.#initInteractions(); this.#initEventListeners(); @@ -82,13 +86,35 @@ define(["require", "exports", "tslib", "../Api/PostObject", "../Helper/PromiseMu })); }); } + async #reloadTree() { + const { template } = await (0, GetNodes_1.getNodes)(this.#viewClassName, this.#viewParameters); + const rootList = document.querySelector(`#${this.#id} > .nodeTreeView__list`); + for (const [parentObjectId, sortable] of this.#sortables) { + if (parentObjectId === 0) { + continue; + } + sortable.destroy(); + this.#sortables.delete(parentObjectId); + } + (0, Util_1.setInnerHtml)(rootList, template); + } + async #reloadNode(item) { + const objectId = parseInt(item.dataset.objectId); + const { template } = await (0, GetNode_1.getNode)(this.#viewClassName, objectId, this.#viewParameters); + for (const list of item.querySelectorAll(".nodeTreeView__list")) { + const parentObjectId = parseInt(list.dataset.parentObjectId); + this.#sortables.get(parentObjectId)?.destroy(); + this.#sortables.delete(parentObjectId); + } + item.replaceWith((0, Util_1.createFragmentFromHtml)(template)); + } #initEventListeners() { const nodeTreeView = document.getElementById(this.#id); nodeTreeView.addEventListener("interaction:invalidate-all", () => { - window.location.reload(); + void this.#reloadTree(); }); - nodeTreeView.addEventListener("interaction:invalidate", () => { - window.location.reload(); + nodeTreeView.addEventListener("interaction:invalidate", (event) => { + void this.#reloadNode(event.target); }); nodeTreeView.addEventListener("interaction:remove", (event) => { const item = event.target; diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/nodeTreeViews/GetNode.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/nodeTreeViews/GetNode.class.php new file mode 100644 index 00000000000..68d2579f54a --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/nodeTreeViews/GetNode.class.php @@ -0,0 +1,64 @@ + + * @since 6.3 + */ +#[GetRequest('/core/node-tree-views/node')] +final class GetNode implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $parameters = Helper::mapApiParameters($request, GetNodeParameters::class); + + if (!\is_subclass_of($parameters->nodeTreeView, AbstractNodeTreeView::class)) { + throw new UserInputException('nodeTreeView', 'invalid'); + } + + $view = new $parameters->nodeTreeView(...$parameters->nodeTreeViewParameters); + // @phpstan-ignore function.alreadyNarrowedType, instanceof.alwaysTrue + \assert($view instanceof AbstractNodeTreeView); + + if (!$view->isAccessible()) { + throw new PermissionDeniedException(); + } + + $node = $view->getNode($parameters->objectID); + if ($node === null) { + throw new UserInputException('objectID', 'invalid'); + } + + return new JsonResponse([ + 'template' => $view->renderItem($node), + ]); + } +} + +/** @internal */ +final class GetNodeParameters +{ + public function __construct( + /** @var non-empty-string */ + public readonly string $nodeTreeView, + public readonly int $objectID, + /** @var array */ + public readonly array $nodeTreeViewParameters, + ) {} +} diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/nodeTreeViews/GetNodes.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/nodeTreeViews/GetNodes.class.php new file mode 100644 index 00000000000..5944a0a5442 --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/nodeTreeViews/GetNodes.class.php @@ -0,0 +1,58 @@ + + * @since 6.3 + */ +#[GetRequest('/core/node-tree-views/nodes')] +final class GetNodes implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $parameters = Helper::mapApiParameters($request, GetNodesParameters::class); + + if (!\is_subclass_of($parameters->nodeTreeView, AbstractNodeTreeView::class)) { + throw new UserInputException('nodeTreeView', 'invalid'); + } + + $view = new $parameters->nodeTreeView(...$parameters->nodeTreeViewParameters); + // @phpstan-ignore function.alreadyNarrowedType, instanceof.alwaysTrue + \assert($view instanceof AbstractNodeTreeView); + + if (!$view->isAccessible()) { + throw new PermissionDeniedException(); + } + + return new JsonResponse([ + 'template' => $view->renderItems(), + ]); + } +} + +/** @internal */ +final class GetNodesParameters +{ + public function __construct( + /** @var non-empty-string */ + public readonly string $nodeTreeView, + /** @var array */ + public readonly array $nodeTreeViewParameters, + ) {} +} diff --git a/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php b/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php index 9e2b75dd43b..45248aec71c 100644 --- a/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php +++ b/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php @@ -65,7 +65,34 @@ public function render(): string */ public function renderItems(): string { - return WCF::getTPL()->render('wcf', 'shared_nodeTreeViewItems', ['view' => $this]); + return WCF::getTPL()->render('wcf', 'shared_nodeTreeViewItems', [ + 'view' => $this, + ]); + } + + /** + * Renders a single node and its descendants and returns the HTML code. + */ + public function renderItem(IObjectTreeNode $node): string + { + return WCF::getTPL()->render('wcf', 'shared_nodeTreeViewItems', [ + 'view' => $this, + 'node' => $node, + ]); + } + + /** + * Returns the node with the given object id or `null` if no such node exists. + */ + public function getNode(int $objectID): ?IObjectTreeNode + { + foreach ($this->getNodes() as $node) { + if ($node->getObjectID() == $objectID) { + return $node; + } + } + + return null; } /** @@ -193,6 +220,14 @@ public function renderInteractionInitialization(): string return $code; } + /** + * Returns the name of the node tree view class. + */ + public function getClassName(): string + { + return static::class; + } + /** * Returns the id of this node tree view. */ From bebfe032fe8439a20324e118644b97c2474fb241 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Wed, 20 May 2026 17:24:11 +0200 Subject: [PATCH 14/18] Deprecate `WoltLabSuite/Core/Ui/Sortable/List` --- ts/WoltLabSuite/Core/Ui/Sortable/List.ts | 1 + wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Sortable/List.js | 1 + 2 files changed, 2 insertions(+) diff --git a/ts/WoltLabSuite/Core/Ui/Sortable/List.ts b/ts/WoltLabSuite/Core/Ui/Sortable/List.ts index a7c5a452b09..1f911291537 100644 --- a/ts/WoltLabSuite/Core/Ui/Sortable/List.ts +++ b/ts/WoltLabSuite/Core/Ui/Sortable/List.ts @@ -5,6 +5,7 @@ * @copyright 2001-2024 WoltLab GmbH * @license GNU Lesser General Public License * @woltlabExcludeBundle tiny + * @deprecated 6.3 Use `AbstractNodeTreeView` instead. */ import * as Core from "../../Core"; diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Sortable/List.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Sortable/List.js index 15b3e4a1a1e..c15a67e35a1 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Sortable/List.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Sortable/List.js @@ -5,6 +5,7 @@ * @copyright 2001-2024 WoltLab GmbH * @license GNU Lesser General Public License * @woltlabExcludeBundle tiny + * @deprecated 6.3 Use `AbstractNodeTreeView` instead. */ define(["require", "exports", "tslib", "../../Core", "sortablejs", "WoltLabSuite/Core/Ajax", "WoltLabSuite/Core/Language", "WoltLabSuite/Core/Component/Snackbar"], function (require, exports, tslib_1, Core, sortablejs_1, Ajax_1, Language_1, Snackbar_1) { "use strict"; From a6c62a0e189f21d6f8fc6c65e16be79b4539ad16 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Wed, 20 May 2026 17:24:28 +0200 Subject: [PATCH 15/18] Remove obsolete implementation of `IToggleAction` --- .../data/menu/item/MenuItemAction.class.php | 20 ++----------------- 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/wcfsetup/install/files/lib/data/menu/item/MenuItemAction.class.php b/wcfsetup/install/files/lib/data/menu/item/MenuItemAction.class.php index a4800058de3..2de39445c51 100644 --- a/wcfsetup/install/files/lib/data/menu/item/MenuItemAction.class.php +++ b/wcfsetup/install/files/lib/data/menu/item/MenuItemAction.class.php @@ -4,10 +4,7 @@ use wcf\data\AbstractDatabaseObjectAction; use wcf\data\DatabaseObject; -use wcf\data\IToggleAction; -use wcf\data\TDatabaseObjectToggle; use wcf\data\TI18nDatabaseObjectAction; -use wcf\system\exception\PermissionDeniedException; /** * Executes menu item related actions. @@ -18,9 +15,8 @@ * * @extends AbstractDatabaseObjectAction */ -class MenuItemAction extends AbstractDatabaseObjectAction implements IToggleAction +class MenuItemAction extends AbstractDatabaseObjectAction { - use TDatabaseObjectToggle; use TI18nDatabaseObjectAction; /** @@ -46,7 +42,7 @@ class MenuItemAction extends AbstractDatabaseObjectAction implements IToggleActi /** * @inheritDoc */ - protected $requireACP = ['create', 'delete', 'toggle', 'update']; + protected $requireACP = ['create', 'delete', 'update']; #[\Override] public function create() @@ -80,18 +76,6 @@ public function update() } } - #[\Override] - public function validateToggle() - { - parent::validateUpdate(); - - foreach ($this->getObjects() as $object) { - if (!$object->canDisable()) { - throw new PermissionDeniedException(); - } - } - } - /** * @return array */ From 8e025602d3611fc1e474fab2019685fde1841a4c Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Wed, 20 May 2026 17:57:12 +0200 Subject: [PATCH 16/18] Fix wrong template name --- .../lib/system/nodeTreeView/AbstractNodeTreeView.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php b/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php index 45248aec71c..cd2d358fc4f 100644 --- a/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php +++ b/wcfsetup/install/files/lib/system/nodeTreeView/AbstractNodeTreeView.class.php @@ -75,7 +75,7 @@ public function renderItems(): string */ public function renderItem(IObjectTreeNode $node): string { - return WCF::getTPL()->render('wcf', 'shared_nodeTreeViewItems', [ + return WCF::getTPL()->render('wcf', 'shared_nodeTreeViewItem', [ 'view' => $this, 'node' => $node, ]); From be4c0d331cb218da01eb210e2a11b233a6939b71 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Wed, 20 May 2026 17:57:25 +0200 Subject: [PATCH 17/18] Improve validation of submitted positions --- .../items/SetMenuItemPositions.class.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/menus/items/SetMenuItemPositions.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/menus/items/SetMenuItemPositions.class.php index 7e6487ff74b..c26c0503d74 100644 --- a/wcfsetup/install/files/lib/system/endpoint/controller/core/menus/items/SetMenuItemPositions.class.php +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/menus/items/SetMenuItemPositions.class.php @@ -70,6 +70,28 @@ private function validatePositions(Menu $menu, array $positions): array } } + $parentOf = []; + foreach ($positions as $parentItemID => $children) { + foreach ($children as $childID) { + if (isset($parentOf[$childID])) { + throw new IllegalLinkException(); + } + $parentOf[$childID] = $parentItemID; + } + } + + 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; } } From db96132ff044f60313fe964e667635728d41d2ca Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Wed, 20 May 2026 17:57:34 +0200 Subject: [PATCH 18/18] Add missing permission check --- .../admin/MenuItemNodeTreeView.class.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 4b203dc322f..7a40870c340 100644 --- a/wcfsetup/install/files/lib/system/nodeTreeView/admin/MenuItemNodeTreeView.class.php +++ b/wcfsetup/install/files/lib/system/nodeTreeView/admin/MenuItemNodeTreeView.class.php @@ -6,12 +6,14 @@ use wcf\data\IObjectTreeNode; use wcf\data\menu\item\MenuItemNode; use wcf\data\menu\item\MenuItemNodeTree; +use wcf\data\menu\Menu; use wcf\system\interaction\admin\MenuItemInteractions; use wcf\system\interaction\Divider; use wcf\system\interaction\EditInteraction; use wcf\system\interaction\ToggleInteraction; use wcf\system\nodeTreeView\AbstractNodeTreeView; use wcf\system\request\LinkHandler; +use wcf\system\WCF; /** * Node tree view that shows the items of a menu. @@ -65,4 +67,19 @@ public function getNodeLink(IObjectTreeNode $node): string ['id' => $node->getObjectID()] ); } + + #[\Override] + public function isAccessible(): bool + { + if (!WCF::getSession()->hasPermission('admin.content.cms.canManageMenu')) { + return false; + } + + $menu = new Menu($this->menuID); + if ($menu->isNil()) { + return false; + } + + return true; + } }