Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions com.woltlab.wcf/templates/shared_nodeTreeView.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<div class="nodeTreeView" id="{$view->getID()}">
<ol class="nodeTreeView__list" data-parent-object-id="0">
{unsafe:$view->renderItems()}
</ol>

<div class="nodeTreeView__footer" id="{$view->getID()}_footer" hidden>
<div class="nodeTreeView__footer__container">
<button
type="button"
class="button buttonPrimary small nodeTreeView__submitButton"
id="{$view->getID()}_submitButton"
>
{lang}wcf.global.button.saveSorting{/lang}
</button>
</div>
</div>
</div>

<script data-relocate="true">
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}',
);
});
</script>
{unsafe:$view->renderInteractionInitialization()}
18 changes: 18 additions & 0 deletions com.woltlab.wcf/templates/shared_nodeTreeViewItem.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<li class="nodeTreeView__item" data-object-id="{$node->getObjectID()}">
<div class="nodeTreeView__item__content">
<span class="nodeTreeView__item__handle">{icon name='grip-vertical'}</span>
<a class="nodeTreeView__item__link" href="{$view->getNodeLink($node)}">{$node->getTitle()}</a>
{if $view->hasInteractions()}
<div class="nodeTreeView__item__buttons">
{unsafe:$view->renderQuickInteractions($node)}
{unsafe:$view->renderInteractionContextMenuButton($node)}
</div>
{/if}
</div>

<ol class="nodeTreeView__list" data-parent-object-id="{$node->getObjectID()}">
{foreach from=$node item='child'}
{unsafe:$view->renderItem($child)}
{/foreach}
</ol>
</li>
19 changes: 19 additions & 0 deletions com.woltlab.wcf/templates/shared_nodeTreeViewItems.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{foreach from=$view->getNodes() item='node'}
<li class="nodeTreeView__item" data-object-id="{$node->getObjectID()}">
<div class="nodeTreeView__item__content">
<span class="nodeTreeView__item__handle">{icon name='grip-vertical'}</span>
<a class="nodeTreeView__item__link" href="{$view->getNodeLink($node)}">{$node->getTitle()}</a>
{if $view->hasInteractions()}
<div class="nodeTreeView__item__buttons">
{unsafe:$view->renderQuickInteractions($node)}
{unsafe:$view->renderInteractionContextMenuButton($node)}
</div>
{/if}
</div>

<ol class="nodeTreeView__list" data-parent-object-id="{$node->getObjectID()}">{if !$node->hasChildren()}</ol></li>{/if}

{if !$node->hasChildren() && $node->isLastSibling()}
{unsafe:"</ol></li>"|str_repeat:$node->getOpenParentNodes()}
{/if}
{/foreach}
40 changes: 40 additions & 0 deletions ts/WoltLabSuite/Core/Api/NodeTreeViews/GetNode.ts
Original file line number Diff line number Diff line change
@@ -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 <http://opensource.org/licenses/lgpl-license.php>
* @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<string, string>,
): Promise<Response> {
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();
});
}
38 changes: 38 additions & 0 deletions ts/WoltLabSuite/Core/Api/NodeTreeViews/GetNodes.ts
Original file line number Diff line number Diff line change
@@ -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 <http://opensource.org/licenses/lgpl-license.php>
* @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<string, string>,
): Promise<Response> {
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();
});
}
168 changes: 168 additions & 0 deletions ts/WoltLabSuite/Core/Component/NodeTreeView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* Provides the program logic for node tree views.
*
* @author Marcel Werk
* @copyright 2001-2026 WoltLab GmbH
* @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
* @since 6.3
*/

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<string, string>;
readonly #setPositionsEndpoint: string;
readonly #sortables = new Map<number, Sortable>();

constructor(
id: string,
viewClassName: string,
viewParameters: Map<string, string>,
setPositionsEndpoint: string = "",
) {
this.#id = id;
this.#viewClassName = viewClassName;
this.#viewParameters = viewParameters;
this.#setPositionsEndpoint = setPositionsEndpoint;

this.#initInteractions();
this.#initEventListeners();

if (this.#setPositionsEndpoint) {
this.#initializeSorting();
}
}

#initInteractions(): void {
wheneverFirstSeen(`#${this.#id} .nodeTreeView__item`, (node) => {
const content = node.querySelector<HTMLElement>(":scope > .nodeTreeView__item__content")!;
const containers = [content];

content.querySelectorAll<HTMLElement>(".dropdownToggle").forEach((element) => {
const dropdown = UiDropdownSimple.getDropdownMenu(element.dataset.target!);
if (dropdown) {
containers.push(dropdown);
}
});

for (const container of containers) {
container.querySelectorAll<HTMLButtonElement>("[data-interaction]").forEach((element) => {
element.addEventListener("click", () => {
node.dispatchEvent(
new CustomEvent("interaction:execute", {
detail: element.dataset,
bubbles: true,
}),
);
});
});
}
});
}

#showFooter(): void {
document.getElementById(`${this.#id}_footer`)!.hidden = false;
}

#hideFooter(): void {
document.getElementById(`${this.#id}_footer`)!.hidden = true;
}

async #setPositions(): Promise<void> {
const positions: Record<number, number[]> = {};
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) => {
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();
},
}),
);
});
}

async #reloadTree(): Promise<void> {
const { template } = await getNodes(this.#viewClassName, this.#viewParameters);
const rootList = document.querySelector<HTMLElement>(`#${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<void> {
const objectId = parseInt(item.dataset.objectId!);
const { template } = await getNode(this.#viewClassName, objectId, this.#viewParameters);
for (const list of item.querySelectorAll<HTMLElement>(".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", () => {
void this.#reloadTree();
});

nodeTreeView.addEventListener("interaction:invalidate", (event) => {
void this.#reloadNode(event.target as HTMLElement);
});

nodeTreeView.addEventListener("interaction:remove", (event) => {
const item = event.target as HTMLElement;
const childList = item.querySelector<HTMLElement>(":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();
});
}
}
1 change: 1 addition & 0 deletions ts/WoltLabSuite/Core/Ui/Sortable/List.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* @copyright 2001-2024 WoltLab GmbH
* @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
* @woltlabExcludeBundle tiny
* @deprecated 6.3 Use `AbstractNodeTreeView` instead.
*/

import * as Core from "../../Core";
Expand Down
4 changes: 3 additions & 1 deletion wcfsetup/install/files/acp/templates/menuItemAdd.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@
</ul>
</div>
</li>
<li>
{unsafe:$interactionContextMenu->render()}
</li>
{/if}
<li><a href="{link controller='MenuItemList' id=$menuID}{/link}" class="button">{icon name='list'} <span>{lang}wcf.acp.menu.item.list{/lang}</span></a></li>

{event name='contentHeaderNavigation'}
</ul>
Expand Down
Loading
Loading