From 9cd9d05e4b44071fe3da4717c8725fe5da20cc10 Mon Sep 17 00:00:00 2001 From: Benjamin Kott <benjamin.kott@outlook.com> Date: Fri, 23 Feb 2024 14:29:13 +0100 Subject: [PATCH] [FEATURE] Introduce tree node status information We've enhanced the backend tree component by extending tree nodes to incorporate status information. These details serve to indicate the status of nodes and provide supplementary information. For instance, if a page undergoes changes within a workspace, it will now display an indicator on the respective tree node. Additionally, the status is appended to the node's title. This enhancement not only improves visual clarity but also enhances information accessibility. Each node can accommodate multiple status information, prioritized by severity and urgency. Critical messages take precedence over other status notifications. Resolves: #103186 Releases: main Change-Id: Ia117c78f2bfde2fd8a2d0af35b3a2de021e866cb Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/83086 Tested-by: Benjamin Franzke <ben@bnf.dev> Tested-by: Andreas Kienast <a.fernandez@scripting-base.de> Reviewed-by: Andreas Kienast <a.fernandez@scripting-base.de> Reviewed-by: Benjamin Franzke <ben@bnf.dev> Tested-by: core-ci <typo3@b13.com> --- Build/Sources/Sass/component/_tree.scss | 7 +++ .../backend/tree/page-tree-element.ts | 1 + .../TypeScript/backend/tree/tree-node.ts | 10 ++++ Build/Sources/TypeScript/backend/tree/tree.ts | 52 ++++++++++++++++++- .../Controller/FileStorage/TreeController.php | 1 + .../FormSelectTreeAjaxController.php | 1 + .../Controller/Page/TreeController.php | 1 + .../Dto/Tree/Status/StatusInformation.php | 39 ++++++++++++++ .../backend/Classes/Dto/Tree/TreeItem.php | 6 +++ .../backend/Resources/Public/Css/backend.css | 1 + .../JavaScript/tree/page-tree-element.js | 2 +- .../Resources/Public/JavaScript/tree/tree.js | 16 ++++-- ...186-IntroduceTreeNodeStatusInformation.rst | 48 +++++++++++++++++ .../PageTreeItemsHighlighter.php | 22 +++++++- .../Resources/Private/Language/locallang.xlf | 9 ++++ .../PageTreeItemsHighlighterTest.php | 31 +++++++++-- 16 files changed, 235 insertions(+), 12 deletions(-) create mode 100644 typo3/sysext/backend/Classes/Dto/Tree/Status/StatusInformation.php create mode 100644 typo3/sysext/core/Documentation/Changelog/13.1/Feature-103186-IntroduceTreeNodeStatusInformation.rst diff --git a/Build/Sources/Sass/component/_tree.scss b/Build/Sources/Sass/component/_tree.scss index fc53f018c6e6..0a3093a5c453 100644 --- a/Build/Sources/Sass/component/_tree.scss +++ b/Build/Sources/Sass/component/_tree.scss @@ -312,6 +312,13 @@ background-color: var(--typo3-component-match-highlight-bg); } +.node-information { + display: flex; + gap: .15rem; + padding-inline-start: .25rem; + opacity: .75; +} + .node-action { display: none; cursor: pointer; diff --git a/Build/Sources/TypeScript/backend/tree/page-tree-element.ts b/Build/Sources/TypeScript/backend/tree/page-tree-element.ts index 82ff0db909e2..3639a14832c1 100644 --- a/Build/Sources/TypeScript/backend/tree/page-tree-element.ts +++ b/Build/Sources/TypeScript/backend/tree/page-tree-element.ts @@ -573,6 +573,7 @@ class PageTreeToolbar extends TreeToolbar { tooltip: '', type: 'PageTreeItem', doktype: item.nodeType, + statusInformation: [], }; this.tree.draggingNode = newNode; this.tree.nodeDragMode = TreeNodeCommandEnum.NEW; diff --git a/Build/Sources/TypeScript/backend/tree/tree-node.ts b/Build/Sources/TypeScript/backend/tree/tree-node.ts index dfd7825e0856..7c3a0a1a4576 100644 --- a/Build/Sources/TypeScript/backend/tree/tree-node.ts +++ b/Build/Sources/TypeScript/backend/tree/tree-node.ts @@ -25,6 +25,15 @@ export enum TreeNodePositionEnum { AFTER = 'after' } + +export interface TreeNodeStatusInformation { + label: string, + icon: string, + severity: number, + overlayIcon: string, + priority: number +} + /** * Represents a single node in the tree that is rendered. */ @@ -48,6 +57,7 @@ export interface TreeNodeInterface { deletable: boolean, icon: string, overlayIcon: string, + statusInformation: Array<TreeNodeStatusInformation>, // Calculated Internal __treeIdentifier: string, diff --git a/Build/Sources/TypeScript/backend/tree/tree.ts b/Build/Sources/TypeScript/backend/tree/tree.ts index 0b89451d0b24..a3d90ce96c64 100644 --- a/Build/Sources/TypeScript/backend/tree/tree.ts +++ b/Build/Sources/TypeScript/backend/tree/tree.ts @@ -15,7 +15,8 @@ import { html, LitElement, TemplateResult, nothing } from 'lit'; import { property, state, query } from 'lit/decorators'; import { repeat } from 'lit/directives/repeat'; import { styleMap } from 'lit/directives/style-map'; -import { TreeNodeInterface, TreeNodeCommandEnum, TreeNodePositionEnum } from './tree-node'; +import { ifDefined } from 'lit/directives/if-defined'; +import { TreeNodeInterface, TreeNodeCommandEnum, TreeNodePositionEnum, TreeNodeStatusInformation } from './tree-node'; import AjaxRequest from '@typo3/core/ajax/ajax-request'; import Notification from '../notification'; import { KeyTypesEnum as KeyTypes } from '../enum/key-types'; @@ -24,6 +25,7 @@ import '@typo3/backend/element/icon-element'; import ClientStorage from '@typo3/backend/storage/client'; import { DataTransferTypes } from '@typo3/backend/enum/data-transfer-types'; import type { DragTooltipMetadata } from '@typo3/backend/drag-tooltip'; +import Severity from '@typo3/backend/severity'; interface TreeNodeStatus { expanded: boolean @@ -610,6 +612,7 @@ export class Tree extends LitElement { ${this.createNodeGuides(node)} ${this.createNodeLoader(node) || this.createNodeToggle(node) || nothing} ${this.createNodeContent(node)} + ${this.createNodeStatusInformation(node)} ${this.createNodeDeleteDropZone(node)} </div> `)} @@ -1011,6 +1014,30 @@ export class Tree extends LitElement { </div>`; } + protected createNodeStatusInformation(node: TreeNodeInterface): TemplateResult + { + const statusInformation = this.getNodeStatusInformation(node); + if (statusInformation.length === 0) { + return html`${nothing}`; + } + + const firstInformation = statusInformation[0]; + const severityClass = Severity.getCssClass(firstInformation.severity); + const iconIdentifier = firstInformation.icon !== '' ? firstInformation.icon : 'actions-dot'; + const overlayIconIdentifier = firstInformation.overlayIcon !== '' ? firstInformation.overlayIcon : undefined; + + return html` + <span class="node-information"> + <typo3-backend-icon + class="text-${severityClass}" + identifier=${iconIdentifier} + overlay=${ifDefined(overlayIconIdentifier)} + size="small" + ></typo3-backend-icon> + </span> + `; + } + protected createNodeDeleteDropZone(node: TreeNodeInterface): TemplateResult { return this.draggingNode === node && node.deletable @@ -1142,6 +1169,21 @@ export class Tree extends LitElement { return classList; } + protected getNodeStatusInformation(node: TreeNodeInterface): TreeNodeStatusInformation[] { + if (node.statusInformation.length === 0) { + return []; + } + + const statusInformation = node.statusInformation.sort((a, b) => { + if (a.severity !== b.severity) { + return b.severity - a.severity; + } + return b.priority - a.priority; + }); + + return statusInformation; + } + protected getNodeDepth(node: TreeNodeInterface): number { return node.depth; } @@ -1225,7 +1267,13 @@ export class Tree extends LitElement { } protected getNodeTitle(node: TreeNodeInterface): string { - return node.tooltip ? node.tooltip : 'uid=' + node.identifier + ' ' + node.name; + let baseNodeTitle = node.tooltip ? node.tooltip : 'uid=' + node.identifier + ' ' + node.name; + const statusInformation = this.getNodeStatusInformation(node); + if (statusInformation.length) { + baseNodeTitle += '; ' + statusInformation.map(node => node.label).join('; '); + } + + return baseNodeTitle; } /** diff --git a/typo3/sysext/backend/Classes/Controller/FileStorage/TreeController.php b/typo3/sysext/backend/Classes/Controller/FileStorage/TreeController.php index 9618e682b5e8..5343b1e0ce17 100644 --- a/typo3/sysext/backend/Classes/Controller/FileStorage/TreeController.php +++ b/typo3/sysext/backend/Classes/Controller/FileStorage/TreeController.php @@ -162,6 +162,7 @@ class TreeController loaded: (bool)($item['loaded'] ?? false), icon: $item['icon'], overlayIcon: $item['overlayIcon'], + statusInformation: (array)($item['statusInformation'] ?? []), ), pathIdentifier: (string)($item['pathIdentifier'] ?? ''), storage: (int)($item['storage'] ?? 0), diff --git a/typo3/sysext/backend/Classes/Controller/FormSelectTreeAjaxController.php b/typo3/sysext/backend/Classes/Controller/FormSelectTreeAjaxController.php index 3601f028371a..f2e8826dc0f6 100644 --- a/typo3/sysext/backend/Classes/Controller/FormSelectTreeAjaxController.php +++ b/typo3/sysext/backend/Classes/Controller/FormSelectTreeAjaxController.php @@ -210,6 +210,7 @@ class FormSelectTreeAjaxController loaded: true, icon: (string)($item['icon'] ?? ''), overlayIcon: (string)($item['overlayIcon'] ?? ''), + statusInformation: (array)($item['statusInformation'] ?? []), ), checked: (bool)($item['checked'] ?? false), selectable: (bool)($item['selectable'] ?? false), diff --git a/typo3/sysext/backend/Classes/Controller/Page/TreeController.php b/typo3/sysext/backend/Classes/Controller/Page/TreeController.php index 4a8a9c972673..65227c5e4a59 100644 --- a/typo3/sysext/backend/Classes/Controller/Page/TreeController.php +++ b/typo3/sysext/backend/Classes/Controller/Page/TreeController.php @@ -634,6 +634,7 @@ class TreeController deletable: (bool)($item['deletable'] ?? false), icon: (string)($item['icon'] ?? ''), overlayIcon: (string)($item['overlayIcon'] ?? ''), + statusInformation: (array)($item['statusInformation'] ?? []), ), // PageTreeItem doktype: (int)($item['doktype'] ?? ''), diff --git a/typo3/sysext/backend/Classes/Dto/Tree/Status/StatusInformation.php b/typo3/sysext/backend/Classes/Dto/Tree/Status/StatusInformation.php new file mode 100644 index 000000000000..75a89256e6c4 --- /dev/null +++ b/typo3/sysext/backend/Classes/Dto/Tree/Status/StatusInformation.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Backend\Dto\Tree\Status; + +use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity; + +/** + * @internal + */ +final readonly class StatusInformation implements \JsonSerializable +{ + public function __construct( + public string $label, + public ContextualFeedbackSeverity $severity = ContextualFeedbackSeverity::INFO, + public int $priority = 0, + public string $icon = '', + public string $overlayIcon = '', + ) {} + + public function jsonSerialize(): array + { + return get_object_vars($this); + } +} diff --git a/typo3/sysext/backend/Classes/Dto/Tree/TreeItem.php b/typo3/sysext/backend/Classes/Dto/Tree/TreeItem.php index 89ce903fb8af..0d376f115886 100644 --- a/typo3/sysext/backend/Classes/Dto/Tree/TreeItem.php +++ b/typo3/sysext/backend/Classes/Dto/Tree/TreeItem.php @@ -17,11 +17,16 @@ declare(strict_types=1); namespace TYPO3\CMS\Backend\Dto\Tree; +use TYPO3\CMS\Backend\Dto\Tree\Status\StatusInformation; + /** * @internal */ final readonly class TreeItem implements \JsonSerializable { + /** + * @param StatusInformation[] $statusInformation + **/ public function __construct( public string $identifier, public string $parentIdentifier, @@ -37,6 +42,7 @@ final readonly class TreeItem implements \JsonSerializable public string $icon, public string $overlayIcon, public string $note = '', + public array $statusInformation = [], public bool $editable = false, public bool $deletable = false, ) {} diff --git a/typo3/sysext/backend/Resources/Public/Css/backend.css b/typo3/sysext/backend/Resources/Public/Css/backend.css index 22a9cd4fcafc..3ea3e7090ae8 100644 --- a/typo3/sysext/backend/Resources/Public/Css/backend.css +++ b/typo3/sysext/backend/Resources/Public/Css/backend.css @@ -3374,6 +3374,7 @@ to{opacity:1;transform:translate3d(0,0,0)} .node-note{margin-top:-.65em;font-size:10px;opacity:.65} .node-edit{display:flex;flex-grow:1;width:100%;padding:0;padding-inline-start:calc(.25rem - 1px);border:1px solid var(--tree-node-border-color);color:var(--typo3-component-color);background:var(--typo3-component-bg);outline:0} .node-highlight-text{color:var(--typo3-component-match-highlight-color);background-color:var(--typo3-component-match-highlight-bg)} +.node-information{display:flex;gap:.15rem;padding-inline-start:.25rem;opacity:.75} .node-action{display:none;cursor:pointer} .node:hover .node-action{display:flex} .node-dropzone-delete{position:absolute;display:flex;justify-content:center;align-items:center;inset-inline-end:0;inset-block-start:0;height:100%;padding:0 .5rem;color:var(--tree-drag-dropzone-delete-color);background-color:var(--tree-drag-dropzone-delete-bg);gap:.25rem;z-index:1} diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/tree/page-tree-element.js b/typo3/sysext/backend/Resources/Public/JavaScript/tree/page-tree-element.js index b1578543bdf9..15e9e7094ec2 100644 --- a/typo3/sysext/backend/Resources/Public/JavaScript/tree/page-tree-element.js +++ b/typo3/sysext/backend/Resources/Public/JavaScript/tree/page-tree-element.js @@ -79,4 +79,4 @@ var __decorate=function(e,t,o,n){var a,r=arguments.length,i=r<3?t:null===n?n=Obj </ul> </div> </div> - `}handleDragStart(e,t){const o={__hidden:!1,__indeterminate:!1,__loading:!1,__processed:!1,__treeDragAction:"",__treeIdentifier:"",__treeParents:[""],__parents:[""],__x:0,__y:0,deletable:!1,depth:0,editable:!0,expanded:!1,hasChildren:!1,icon:t.icon,overlayIcon:"",identifier:"NEW"+Math.floor(1e9*Math.random()).toString(16),loaded:!1,name:"",note:"",parentIdentifier:"",prefix:"",recordType:"pages",suffix:"",tooltip:"",type:"PageTreeItem",doktype:t.nodeType};this.tree.draggingNode=o,this.tree.nodeDragMode=TreeNodeCommandEnum.NEW,e.dataTransfer.clearData();const n={statusIconIdentifier:this.tree.getNodeDragStatusIcon(),tooltipIconIdentifier:t.icon,tooltipLabel:t.title};e.dataTransfer.setData(DataTransferTypes.dragTooltip,JSON.stringify(n)),e.dataTransfer.setData(DataTransferTypes.newTreenode,JSON.stringify(o)),e.dataTransfer.effectAllowed="move"}};__decorate([property({type:EditablePageTree})],PageTreeToolbar.prototype,"tree",void 0),PageTreeToolbar=__decorate([customElement("typo3-backend-navigation-component-pagetree-toolbar")],PageTreeToolbar); \ No newline at end of file + `}handleDragStart(e,t){const o={__hidden:!1,__indeterminate:!1,__loading:!1,__processed:!1,__treeDragAction:"",__treeIdentifier:"",__treeParents:[""],__parents:[""],__x:0,__y:0,deletable:!1,depth:0,editable:!0,expanded:!1,hasChildren:!1,icon:t.icon,overlayIcon:"",identifier:"NEW"+Math.floor(1e9*Math.random()).toString(16),loaded:!1,name:"",note:"",parentIdentifier:"",prefix:"",recordType:"pages",suffix:"",tooltip:"",type:"PageTreeItem",doktype:t.nodeType,statusInformation:[]};this.tree.draggingNode=o,this.tree.nodeDragMode=TreeNodeCommandEnum.NEW,e.dataTransfer.clearData();const n={statusIconIdentifier:this.tree.getNodeDragStatusIcon(),tooltipIconIdentifier:t.icon,tooltipLabel:t.title};e.dataTransfer.setData(DataTransferTypes.dragTooltip,JSON.stringify(n)),e.dataTransfer.setData(DataTransferTypes.newTreenode,JSON.stringify(o)),e.dataTransfer.effectAllowed="move"}};__decorate([property({type:EditablePageTree})],PageTreeToolbar.prototype,"tree",void 0),PageTreeToolbar=__decorate([customElement("typo3-backend-navigation-component-pagetree-toolbar")],PageTreeToolbar); \ No newline at end of file diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/tree/tree.js b/typo3/sysext/backend/Resources/Public/JavaScript/tree/tree.js index 3dc165346b5d..ace5876850ad 100644 --- a/typo3/sysext/backend/Resources/Public/JavaScript/tree/tree.js +++ b/typo3/sysext/backend/Resources/Public/JavaScript/tree/tree.js @@ -10,7 +10,7 @@ * * The TYPO3 project - inspiring people to share! */ -var __decorate=function(e,t,i,o){var n,s=arguments.length,r=s<3?t:null===o?o=Object.getOwnPropertyDescriptor(t,i):o;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)r=Reflect.decorate(e,t,i,o);else for(var d=e.length-1;d>=0;d--)(n=e[d])&&(r=(s<3?n(r):s>3?n(t,i,r):n(t,i))||r);return s>3&&r&&Object.defineProperty(t,i,r),r};import{html,LitElement,nothing}from"lit";import{property,state,query}from"lit/decorators.js";import{repeat}from"lit/directives/repeat.js";import{styleMap}from"lit/directives/style-map.js";import{TreeNodeCommandEnum,TreeNodePositionEnum}from"@typo3/backend/tree/tree-node.js";import AjaxRequest from"@typo3/core/ajax/ajax-request.js";import Notification from"@typo3/backend/notification.js";import{KeyTypesEnum as KeyTypes}from"@typo3/backend/enum/key-types.js";import"@typo3/backend/element/icon-element.js";import ClientStorage from"@typo3/backend/storage/client.js";import{DataTransferTypes}from"@typo3/backend/enum/data-transfer-types.js";export class Tree extends LitElement{constructor(){super(...arguments),this.setup=null,this.settings={showIcons:!1,width:300,dataUrl:"",filterUrl:"",defaultProperties:{},expandUpToLevel:null,actions:[]},this.nodes=[],this.currentScrollPosition=0,this.currentVisibleHeight=0,this.searchTerm=null,this.loading=!1,this.hoveredNode=null,this.nodeDragAllowed=!1,this.isOverRoot=!1,this.nodeDragPosition=null,this.nodeDragMode=null,this.draggingNode=null,this.nodeHeight=32,this.indentWidth=20,this.displayNodes=[],this.focusedNode=null,this.editingNode=null,this.openNodeTimeout={targetNode:null,timeout:null},this.unfilteredNodes="",this.muteErrorNotifications=!1,this.networkErrorTitle=top.TYPO3.lang.tree_networkError,this.networkErrorMessage=top.TYPO3.lang.tree_networkErrorDescription,this.allowNodeEdit=!1,this.allowNodeDrag=!1,this.allowNodeSorting=!1}getNodeFromElement(e){return null!==e&&"treeId"in e.dataset?this.getNodeByTreeIdentifier(e.dataset.treeId):null}getElementFromNode(e){return this.querySelector('[data-tree-id="'+this.getNodeTreeIdentifier(e)+'"]')}hideChildren(e){e.expanded=!1,this.saveNodeStatus(e),this.dispatchEvent(new CustomEvent("typo3:tree:expand-toggle",{detail:{node:e}}))}async showChildren(e){e.expanded=!0,await this.loadChildren(e),this.saveNodeStatus(e),this.dispatchEvent(new CustomEvent("typo3:tree:expand-toggle",{detail:{node:e}}))}getDataUrl(e=null){return null===e?this.settings.dataUrl:this.settings.dataUrl+"&parent="+e.identifier+"&depth="+e.depth}getFilterUrl(){return this.settings.filterUrl+"&q="+this.searchTerm}async loadData(){this.loading=!0,this.nodes=this.prepareNodes(await this.fetchData()),this.loading=!1}async fetchData(e=null){try{const t=await new AjaxRequest(this.getDataUrl(e)).get({cache:"no-cache"});let i=await t.resolve();if(!Array.isArray(i))return[];null!==e&&(i=i.filter((t=>t.identifier!==e.identifier)),i.unshift(e)),i=this.enhanceNodes(i),null!==e&&i.shift();const o=await Promise.all(i.map((async e=>{const t=e.__parents.join("_"),o=i.find((e=>e.__treeIdentifier===t))||null,n=null===o||o.expanded;if(!e.loaded&&e.hasChildren&&e.expanded&&n){const t=await this.fetchData(e);return e.loaded=!0,[e,...t]}return[e]})));return o.flat()}catch(e){return this.errorNotification(e),[]}}async loadChildren(e){try{if(e.loaded)return void await Promise.all(this.nodes.filter((t=>t.__parents.join("_")===e.__treeIdentifier&&!t.loaded&&t.hasChildren&&t.expanded)).map((e=>this.loadChildren(e))));e.__loading=!0;const t=await this.fetchData(e),i=this.nodes.indexOf(e)+1;let o=0;for(let t=i;t<this.nodes.length&&!(this.nodes[t].depth<=e.depth);++t)o++;this.nodes.splice(i,o,...t),e.__loading=!1,e.loaded=!0}catch(t){throw this.errorNotification(t),e.__loading=!1,t}}getIdentifier(){return this.id??this.setup.id}getLocalStorageIdentifier(){return"tree-state-"+this.getIdentifier()}getNodeStatus(e){return(JSON.parse(ClientStorage.get(this.getLocalStorageIdentifier()))??{})[e.__treeIdentifier]??{expanded:!1}}saveNodeStatus(e){const t=JSON.parse(ClientStorage.get(this.getLocalStorageIdentifier()))??{};t[e.__treeIdentifier]={expanded:e.expanded},ClientStorage.set(this.getLocalStorageIdentifier(),JSON.stringify(t))}refreshOrFilterTree(){""!==this.searchTerm?this.filter(this.searchTerm):this.loadData()}selectFirstNode(){const e=this.getFirstNode();this.selectNode(e,!0),this.focusNode(e)}selectNode(e,t=!0){this.isNodeSelectable(e)&&(this.resetSelectedNodes(),e.checked=!0,this.dispatchEvent(new CustomEvent("typo3:tree:node-selected",{detail:{node:e,propagate:t}})))}async focusNode(e){this.focusedNode=e;const t=this.getElementFromNode(this.focusedNode);t?t.focus():(this.requestUpdate(),this.updateComplete.then((()=>{this.getElementFromNode(this.focusedNode)?.focus()})))}async editNode(e){this.isNodeEditable(e)&&(this.editingNode=e,this.requestUpdate(),this.updateComplete.then((()=>{const e=this.getElementFromNode(this.editingNode)?.querySelector(".node-edit");e&&(e.focus(),e.select())})))}async deleteNode(e){e.deletable?this.handleNodeDelete(e):console.error("The Node cannot be deleted.")}async moveNode(e,t,i){this.handleNodeMove(e,t,i)}async addNode(e,t,i){let o=this.nodes.indexOf(t);const n=i===TreeNodePositionEnum.INSIDE?t:this.getParentNode(t),s=this.enhanceNodes([n,{...e,depth:n?n.depth+1:0}]).pop();n&&(t.hasChildren&&!t.expanded&&await this.showChildren(t),t.hasChildren||(t.hasChildren=!0,t.expanded=!0)),i!==TreeNodePositionEnum.INSIDE&&i!==TreeNodePositionEnum.AFTER||o++,this.nodes.splice(o,0,s),this.handleNodeAdd(s,t,i)}async removeNode(e){const t=this.nodes.indexOf(e);t>-1&&this.nodes.splice(t,1)}filter(e){"string"==typeof e&&(this.searchTerm=e),this.searchTerm&&this.settings.filterUrl?(this.loading=!0,new AjaxRequest(this.getFilterUrl()).get({cache:"no-cache"}).then((e=>e.resolve())).then((e=>{const t=Array.isArray(e)?e:[];t.length>0&&(""===this.unfilteredNodes&&(this.unfilteredNodes=JSON.stringify(this.nodes)),this.nodes=this.enhanceNodes(t))})).catch((e=>{throw this.errorNotification(e),e})).then((()=>{this.loading=!1}))):(this.resetFilter(),this.loading=!1)}resetFilter(){if(this.searchTerm="",this.unfilteredNodes.length>0){const e=this.getSelectedNodes()[0];if(void 0===e)return void this.loadData();this.nodes=this.enhanceNodes(JSON.parse(this.unfilteredNodes)),this.unfilteredNodes="";const t=this.getNodeByTreeIdentifier(e.__treeIdentifier);t?this.selectNode(t,!1):this.loadData()}else this.loadData()}errorNotification(e=null){if(!this.muteErrorNotifications)if(Array.isArray(e))e.forEach((e=>{Notification.error(e.title,e.message)}));else{let t=this.networkErrorTitle;e&&e.target&&(e.target.status||e.target.statusText)&&(t+=" - "+(e.target.status||"")+" "+(e.target.statusText||"")),Notification.error(t,this.networkErrorMessage)}}getSelectedNodes(){return this.nodes.filter((e=>e.checked))}getNodeByTreeIdentifier(e){return this.nodes.find((t=>t.__treeIdentifier===e))}getNodeDragStatusIcon(){return this.nodeDragMode===TreeNodeCommandEnum.DELETE?"actions-delete":this.nodeDragMode===TreeNodeCommandEnum.NEW?"actions-add":this.nodeDragPosition===TreeNodePositionEnum.BEFORE?"apps-pagetree-drag-move-above":this.nodeDragPosition===TreeNodePositionEnum.INSIDE?"apps-pagetree-drag-move-into":this.nodeDragPosition===TreeNodePositionEnum.AFTER?"apps-pagetree-drag-move-below":"actions-ban"}prepareNodes(e){const t=new CustomEvent("typo3:tree:nodes-prepared",{detail:{nodes:e},bubbles:!1});return this.dispatchEvent(t),t.detail.nodes}enhanceNodes(e){const t=e.reduce(((e,t)=>{if(!0===t.__processed)return[...e,t];(t=Object.assign({},this.settings.defaultProperties,t)).__parents=[];const i=t.depth>0?e.findLast((e=>e.depth<t.depth)):null;i&&(t.__parents=[...i.__parents,i.identifier]),t.__treeIdentifier=t.identifier,t.__loading=!1,t.__treeParents=[],i&&(t.__treeIdentifier=i.__treeIdentifier+"_"+t.__treeIdentifier,t.__treeParents=[...i.__treeParents,i.__treeIdentifier]),t.expanded=!0===t.expanded||(null!==this.settings.expandUpToLevel?t.depth<this.settings.expandUpToLevel:Boolean(this.getNodeStatus(t).expanded)),t.__processed=!0;const o=this;return[...e,new Proxy(t,{set:(e,t,i)=>(e[t]!==i&&(e[t]=i,o.requestUpdate()),!0)})]}),[]);return 1===t.filter((e=>0===e.depth)).length&&(t[0].expanded=!0),t}createRenderRoot(){return this}render(){const e=this.loading?html` +var __decorate=function(e,t,i,o){var n,s=arguments.length,r=s<3?t:null===o?o=Object.getOwnPropertyDescriptor(t,i):o;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)r=Reflect.decorate(e,t,i,o);else for(var d=e.length-1;d>=0;d--)(n=e[d])&&(r=(s<3?n(r):s>3?n(t,i,r):n(t,i))||r);return s>3&&r&&Object.defineProperty(t,i,r),r};import{html,LitElement,nothing}from"lit";import{property,state,query}from"lit/decorators.js";import{repeat}from"lit/directives/repeat.js";import{styleMap}from"lit/directives/style-map.js";import{ifDefined}from"lit/directives/if-defined.js";import{TreeNodeCommandEnum,TreeNodePositionEnum}from"@typo3/backend/tree/tree-node.js";import AjaxRequest from"@typo3/core/ajax/ajax-request.js";import Notification from"@typo3/backend/notification.js";import{KeyTypesEnum as KeyTypes}from"@typo3/backend/enum/key-types.js";import"@typo3/backend/element/icon-element.js";import ClientStorage from"@typo3/backend/storage/client.js";import{DataTransferTypes}from"@typo3/backend/enum/data-transfer-types.js";import Severity from"@typo3/backend/severity.js";export class Tree extends LitElement{constructor(){super(...arguments),this.setup=null,this.settings={showIcons:!1,width:300,dataUrl:"",filterUrl:"",defaultProperties:{},expandUpToLevel:null,actions:[]},this.nodes=[],this.currentScrollPosition=0,this.currentVisibleHeight=0,this.searchTerm=null,this.loading=!1,this.hoveredNode=null,this.nodeDragAllowed=!1,this.isOverRoot=!1,this.nodeDragPosition=null,this.nodeDragMode=null,this.draggingNode=null,this.nodeHeight=32,this.indentWidth=20,this.displayNodes=[],this.focusedNode=null,this.editingNode=null,this.openNodeTimeout={targetNode:null,timeout:null},this.unfilteredNodes="",this.muteErrorNotifications=!1,this.networkErrorTitle=top.TYPO3.lang.tree_networkError,this.networkErrorMessage=top.TYPO3.lang.tree_networkErrorDescription,this.allowNodeEdit=!1,this.allowNodeDrag=!1,this.allowNodeSorting=!1}getNodeFromElement(e){return null!==e&&"treeId"in e.dataset?this.getNodeByTreeIdentifier(e.dataset.treeId):null}getElementFromNode(e){return this.querySelector('[data-tree-id="'+this.getNodeTreeIdentifier(e)+'"]')}hideChildren(e){e.expanded=!1,this.saveNodeStatus(e),this.dispatchEvent(new CustomEvent("typo3:tree:expand-toggle",{detail:{node:e}}))}async showChildren(e){e.expanded=!0,await this.loadChildren(e),this.saveNodeStatus(e),this.dispatchEvent(new CustomEvent("typo3:tree:expand-toggle",{detail:{node:e}}))}getDataUrl(e=null){return null===e?this.settings.dataUrl:this.settings.dataUrl+"&parent="+e.identifier+"&depth="+e.depth}getFilterUrl(){return this.settings.filterUrl+"&q="+this.searchTerm}async loadData(){this.loading=!0,this.nodes=this.prepareNodes(await this.fetchData()),this.loading=!1}async fetchData(e=null){try{const t=await new AjaxRequest(this.getDataUrl(e)).get({cache:"no-cache"});let i=await t.resolve();if(!Array.isArray(i))return[];null!==e&&(i=i.filter((t=>t.identifier!==e.identifier)),i.unshift(e)),i=this.enhanceNodes(i),null!==e&&i.shift();const o=await Promise.all(i.map((async e=>{const t=e.__parents.join("_"),o=i.find((e=>e.__treeIdentifier===t))||null,n=null===o||o.expanded;if(!e.loaded&&e.hasChildren&&e.expanded&&n){const t=await this.fetchData(e);return e.loaded=!0,[e,...t]}return[e]})));return o.flat()}catch(e){return this.errorNotification(e),[]}}async loadChildren(e){try{if(e.loaded)return void await Promise.all(this.nodes.filter((t=>t.__parents.join("_")===e.__treeIdentifier&&!t.loaded&&t.hasChildren&&t.expanded)).map((e=>this.loadChildren(e))));e.__loading=!0;const t=await this.fetchData(e),i=this.nodes.indexOf(e)+1;let o=0;for(let t=i;t<this.nodes.length&&!(this.nodes[t].depth<=e.depth);++t)o++;this.nodes.splice(i,o,...t),e.__loading=!1,e.loaded=!0}catch(t){throw this.errorNotification(t),e.__loading=!1,t}}getIdentifier(){return this.id??this.setup.id}getLocalStorageIdentifier(){return"tree-state-"+this.getIdentifier()}getNodeStatus(e){return(JSON.parse(ClientStorage.get(this.getLocalStorageIdentifier()))??{})[e.__treeIdentifier]??{expanded:!1}}saveNodeStatus(e){const t=JSON.parse(ClientStorage.get(this.getLocalStorageIdentifier()))??{};t[e.__treeIdentifier]={expanded:e.expanded},ClientStorage.set(this.getLocalStorageIdentifier(),JSON.stringify(t))}refreshOrFilterTree(){""!==this.searchTerm?this.filter(this.searchTerm):this.loadData()}selectFirstNode(){const e=this.getFirstNode();this.selectNode(e,!0),this.focusNode(e)}selectNode(e,t=!0){this.isNodeSelectable(e)&&(this.resetSelectedNodes(),e.checked=!0,this.dispatchEvent(new CustomEvent("typo3:tree:node-selected",{detail:{node:e,propagate:t}})))}async focusNode(e){this.focusedNode=e;const t=this.getElementFromNode(this.focusedNode);t?t.focus():(this.requestUpdate(),this.updateComplete.then((()=>{this.getElementFromNode(this.focusedNode)?.focus()})))}async editNode(e){this.isNodeEditable(e)&&(this.editingNode=e,this.requestUpdate(),this.updateComplete.then((()=>{const e=this.getElementFromNode(this.editingNode)?.querySelector(".node-edit");e&&(e.focus(),e.select())})))}async deleteNode(e){e.deletable?this.handleNodeDelete(e):console.error("The Node cannot be deleted.")}async moveNode(e,t,i){this.handleNodeMove(e,t,i)}async addNode(e,t,i){let o=this.nodes.indexOf(t);const n=i===TreeNodePositionEnum.INSIDE?t:this.getParentNode(t),s=this.enhanceNodes([n,{...e,depth:n?n.depth+1:0}]).pop();n&&(t.hasChildren&&!t.expanded&&await this.showChildren(t),t.hasChildren||(t.hasChildren=!0,t.expanded=!0)),i!==TreeNodePositionEnum.INSIDE&&i!==TreeNodePositionEnum.AFTER||o++,this.nodes.splice(o,0,s),this.handleNodeAdd(s,t,i)}async removeNode(e){const t=this.nodes.indexOf(e);t>-1&&this.nodes.splice(t,1)}filter(e){"string"==typeof e&&(this.searchTerm=e),this.searchTerm&&this.settings.filterUrl?(this.loading=!0,new AjaxRequest(this.getFilterUrl()).get({cache:"no-cache"}).then((e=>e.resolve())).then((e=>{const t=Array.isArray(e)?e:[];t.length>0&&(""===this.unfilteredNodes&&(this.unfilteredNodes=JSON.stringify(this.nodes)),this.nodes=this.enhanceNodes(t))})).catch((e=>{throw this.errorNotification(e),e})).then((()=>{this.loading=!1}))):(this.resetFilter(),this.loading=!1)}resetFilter(){if(this.searchTerm="",this.unfilteredNodes.length>0){const e=this.getSelectedNodes()[0];if(void 0===e)return void this.loadData();this.nodes=this.enhanceNodes(JSON.parse(this.unfilteredNodes)),this.unfilteredNodes="";const t=this.getNodeByTreeIdentifier(e.__treeIdentifier);t?this.selectNode(t,!1):this.loadData()}else this.loadData()}errorNotification(e=null){if(!this.muteErrorNotifications)if(Array.isArray(e))e.forEach((e=>{Notification.error(e.title,e.message)}));else{let t=this.networkErrorTitle;e&&e.target&&(e.target.status||e.target.statusText)&&(t+=" - "+(e.target.status||"")+" "+(e.target.statusText||"")),Notification.error(t,this.networkErrorMessage)}}getSelectedNodes(){return this.nodes.filter((e=>e.checked))}getNodeByTreeIdentifier(e){return this.nodes.find((t=>t.__treeIdentifier===e))}getNodeDragStatusIcon(){return this.nodeDragMode===TreeNodeCommandEnum.DELETE?"actions-delete":this.nodeDragMode===TreeNodeCommandEnum.NEW?"actions-add":this.nodeDragPosition===TreeNodePositionEnum.BEFORE?"apps-pagetree-drag-move-above":this.nodeDragPosition===TreeNodePositionEnum.INSIDE?"apps-pagetree-drag-move-into":this.nodeDragPosition===TreeNodePositionEnum.AFTER?"apps-pagetree-drag-move-below":"actions-ban"}prepareNodes(e){const t=new CustomEvent("typo3:tree:nodes-prepared",{detail:{nodes:e},bubbles:!1});return this.dispatchEvent(t),t.detail.nodes}enhanceNodes(e){const t=e.reduce(((e,t)=>{if(!0===t.__processed)return[...e,t];(t=Object.assign({},this.settings.defaultProperties,t)).__parents=[];const i=t.depth>0?e.findLast((e=>e.depth<t.depth)):null;i&&(t.__parents=[...i.__parents,i.identifier]),t.__treeIdentifier=t.identifier,t.__loading=!1,t.__treeParents=[],i&&(t.__treeIdentifier=i.__treeIdentifier+"_"+t.__treeIdentifier,t.__treeParents=[...i.__treeParents,i.__treeIdentifier]),t.expanded=!0===t.expanded||(null!==this.settings.expandUpToLevel?t.depth<this.settings.expandUpToLevel:Boolean(this.getNodeStatus(t).expanded)),t.__processed=!0;const o=this;return[...e,new Proxy(t,{set:(e,t,i)=>(e[t]!==i&&(e[t]=i,o.requestUpdate()),!0)})]}),[]);return 1===t.filter((e=>0===e.depth)).length&&(t[0].expanded=!0),t}createRenderRoot(){return this}render(){const e=this.loading?html` <div class="nodes-loader"> <div class="nodes-loader-inner"> <typo3-backend-icon identifier="spinner-circle" size="medium"></typo3-backend-icon> @@ -61,6 +61,7 @@ var __decorate=function(e,t,i,o){var n,s=arguments.length,r=s<3?t:null===o?o=Obj ${this.createNodeGuides(e)} ${this.createNodeLoader(e)||this.createNodeToggle(e)||nothing} ${this.createNodeContent(e)} + ${this.createNodeStatusInformation(e)} ${this.createNodeDeleteDropZone(e)} </div> `))} @@ -108,7 +109,16 @@ var __decorate=function(e,t,i,o){var n,s=arguments.length,r=s<3?t:null===o?o=Obj <div class="node-label"> <div class="node-name" .innerHTML="${t}"></div> ${e.note?html`<div class="node-note">${e.note}</div>`:nothing} - </div>`}createNodeDeleteDropZone(e){return this.draggingNode===e&&e.deletable?html` + </div>`}createNodeStatusInformation(e){const t=this.getNodeStatusInformation(e);if(0===t.length)return html`${nothing}`;const i=t[0],o=Severity.getCssClass(i.severity),n=""!==i.icon?i.icon:"actions-dot",s=""!==i.overlayIcon?i.overlayIcon:void 0;return html` + <span class="node-information"> + <typo3-backend-icon + class="text-${o}" + identifier=${n} + overlay=${ifDefined(s)} + size="small" + ></typo3-backend-icon> + </span> + `}createNodeDeleteDropZone(e){return this.draggingNode===e&&e.deletable?html` <div class="node-dropzone-delete" data-tree-dropzone="delete"> <typo3-backend-icon identifier="actions-delete" size="small"></typo3-backend-icon> ${TYPO3.lang.deleteItem} @@ -121,4 +131,4 @@ var __decorate=function(e,t,i,o){var n,s=arguments.length,r=s<3?t:null===o?o=Obj @keydown="${i=>{const o=i.key;if([KeyTypes.ENTER,KeyTypes.TAB].includes(o)){const o=i.target.value.trim();this.editingNode=null,this.requestUpdate(),o!==e.name&&""!==o?(this.handleNodeEdit(e,o),this.focusNode(e)):t===TreeNodeCommandEnum.NEW&&""===o?this.removeNode(e):this.focusNode(e)}else[KeyTypes.ESCAPE].includes(o)&&(this.editingNode=null,this.requestUpdate(),t===TreeNodeCommandEnum.NEW?this.removeNode(e):this.focusNode(e))}}" value="${e.name}" /> - `}async handleNodeEdit(e,t){console.error("The function Tree->handleNodeEdit is not implemented.")}handleNodeDelete(e){console.error("The function Tree->handleNodeDelete is not implemented.")}handleNodeMove(e,t,i){console.error("The function Tree->handleNodeMove is not implemented.")}async handleNodeAdd(e,t,i){console.error("The function Tree->handleNodeAdd is not implemented.")}createNodeContentAction(e){return html`${nothing}`}createDataTransferItemsFromNode(e){throw new Error("The function Tree->createDataTransferItemFromNode is not implemented.")}getNodeIdentifier(e){return e.identifier}getNodeTreeIdentifier(e){return e.__treeIdentifier}getNodeParentTreeIdentifier(e){return e.__parents.join("_")}getNodeClasses(e){const t=["node"];return e.checked&&t.push("node-selected"),this.draggingNode===e&&t.push("node-dragging"),t}getNodeDepth(e){return e.depth}getNodeChildren(e){return e.hasChildren?this.displayNodes.filter((t=>e===this.getParentNode(t))):[]}getNodeSetsize(e){if(0===e.depth)return this.displayNodes.filter((e=>0===e.depth)).length;const t=this.getParentNode(e);return this.getNodeChildren(t).length}getNodePositionInSet(e){const t=this.getParentNode(e);let i=[];return 0===e.depth?i=this.displayNodes.filter((e=>0===e.depth)):null!==t&&(i=this.getNodeChildren(t)),i.indexOf(e)+1}getFirstNode(){return this.displayNodes.length?this.displayNodes[0]:null}getPreviousNode(e){const t=this.displayNodes.indexOf(e)-1;return this.displayNodes[t]?this.displayNodes[t]:null}getNextNode(e){const t=this.displayNodes.indexOf(e)+1;return this.displayNodes[t]?this.displayNodes[t]:null}getLastNode(){return this.displayNodes.length?this.displayNodes[this.displayNodes.length-1]:null}getParentNode(e){return e.__parents.length?this.getNodeByTreeIdentifier(this.getNodeParentTreeIdentifier(e)):null}getNodeTitle(e){return e.tooltip?e.tooltip:"uid="+e.identifier+" "+e.name}handleNodeToggle(e){e.expanded?this.hideChildren(e):this.showChildren(e)}isRTL(){return"rtl"===window.getComputedStyle(document.documentElement).getPropertyValue("direction")}handleKeyboardInteraction(e){if(null!==this.editingNode)return;if(!1===[KeyTypes.ENTER,KeyTypes.SPACE,KeyTypes.END,KeyTypes.HOME,KeyTypes.LEFT,KeyTypes.UP,KeyTypes.RIGHT,KeyTypes.DOWN].includes(e.key))return;const t=e.target,i=this.getNodeFromElement(t);if(null===i)return;const o=this.getParentNode(i),n=this.getFirstNode(),s=this.getPreviousNode(i),r=this.getNextNode(i),d=this.getLastNode();switch(e.preventDefault(),e.key){case KeyTypes.HOME:null!==n&&(this.scrollNodeIntoVisibleArea(n),this.focusNode(n));break;case KeyTypes.END:null!==d&&(this.scrollNodeIntoVisibleArea(d),this.focusNode(d));break;case KeyTypes.UP:null!==s&&(this.scrollNodeIntoVisibleArea(s),this.focusNode(s));break;case KeyTypes.DOWN:null!==r&&(this.scrollNodeIntoVisibleArea(r),this.focusNode(r));break;case KeyTypes.LEFT:i.expanded?i.hasChildren&&this.hideChildren(i):o&&(this.scrollNodeIntoVisibleArea(o),this.focusNode(o));break;case KeyTypes.RIGHT:i.expanded&&r?(this.scrollNodeIntoVisibleArea(r),this.focusNode(r)):i.hasChildren&&this.showChildren(i);break;case KeyTypes.ENTER:case KeyTypes.SPACE:this.selectNode(i)}}scrollNodeIntoVisibleArea(e){const t=e.__y,i=e.__y+this.nodeHeight,o=t>=this.currentScrollPosition,n=i<=this.currentScrollPosition+this.currentVisibleHeight;if(!(o&&n)){let e=this.currentScrollPosition;o||n?o?n||(e=i-this.currentVisibleHeight):e=t:e=i-this.currentVisibleHeight,e<0&&(e=0),this.root.scrollTo({top:e})}}registerUnloadHandler(){try{if(!window.frameElement)return;window.addEventListener("pagehide",(()=>this.muteErrorNotifications=!0),{once:!0})}catch(e){console.error("Failed to check the existence of window.frameElement – using a foreign origin?")}}}__decorate([property({type:Object})],Tree.prototype,"setup",void 0),__decorate([state()],Tree.prototype,"settings",void 0),__decorate([query(".nodes-root")],Tree.prototype,"root",void 0),__decorate([state()],Tree.prototype,"nodes",void 0),__decorate([state()],Tree.prototype,"currentScrollPosition",void 0),__decorate([state()],Tree.prototype,"currentVisibleHeight",void 0),__decorate([state()],Tree.prototype,"searchTerm",void 0),__decorate([state()],Tree.prototype,"loading",void 0),__decorate([state()],Tree.prototype,"hoveredNode",void 0),__decorate([state()],Tree.prototype,"nodeDragAllowed",void 0); \ No newline at end of file + `}async handleNodeEdit(e,t){console.error("The function Tree->handleNodeEdit is not implemented.")}handleNodeDelete(e){console.error("The function Tree->handleNodeDelete is not implemented.")}handleNodeMove(e,t,i){console.error("The function Tree->handleNodeMove is not implemented.")}async handleNodeAdd(e,t,i){console.error("The function Tree->handleNodeAdd is not implemented.")}createNodeContentAction(e){return html`${nothing}`}createDataTransferItemsFromNode(e){throw new Error("The function Tree->createDataTransferItemFromNode is not implemented.")}getNodeIdentifier(e){return e.identifier}getNodeTreeIdentifier(e){return e.__treeIdentifier}getNodeParentTreeIdentifier(e){return e.__parents.join("_")}getNodeClasses(e){const t=["node"];return e.checked&&t.push("node-selected"),this.draggingNode===e&&t.push("node-dragging"),t}getNodeStatusInformation(e){if(0===e.statusInformation.length)return[];return e.statusInformation.sort(((e,t)=>e.severity!==t.severity?t.severity-e.severity:t.priority-e.priority))}getNodeDepth(e){return e.depth}getNodeChildren(e){return e.hasChildren?this.displayNodes.filter((t=>e===this.getParentNode(t))):[]}getNodeSetsize(e){if(0===e.depth)return this.displayNodes.filter((e=>0===e.depth)).length;const t=this.getParentNode(e);return this.getNodeChildren(t).length}getNodePositionInSet(e){const t=this.getParentNode(e);let i=[];return 0===e.depth?i=this.displayNodes.filter((e=>0===e.depth)):null!==t&&(i=this.getNodeChildren(t)),i.indexOf(e)+1}getFirstNode(){return this.displayNodes.length?this.displayNodes[0]:null}getPreviousNode(e){const t=this.displayNodes.indexOf(e)-1;return this.displayNodes[t]?this.displayNodes[t]:null}getNextNode(e){const t=this.displayNodes.indexOf(e)+1;return this.displayNodes[t]?this.displayNodes[t]:null}getLastNode(){return this.displayNodes.length?this.displayNodes[this.displayNodes.length-1]:null}getParentNode(e){return e.__parents.length?this.getNodeByTreeIdentifier(this.getNodeParentTreeIdentifier(e)):null}getNodeTitle(e){let t=e.tooltip?e.tooltip:"uid="+e.identifier+" "+e.name;const i=this.getNodeStatusInformation(e);return i.length&&(t+="; "+i.map((e=>e.label)).join("; ")),t}handleNodeToggle(e){e.expanded?this.hideChildren(e):this.showChildren(e)}isRTL(){return"rtl"===window.getComputedStyle(document.documentElement).getPropertyValue("direction")}handleKeyboardInteraction(e){if(null!==this.editingNode)return;if(!1===[KeyTypes.ENTER,KeyTypes.SPACE,KeyTypes.END,KeyTypes.HOME,KeyTypes.LEFT,KeyTypes.UP,KeyTypes.RIGHT,KeyTypes.DOWN].includes(e.key))return;const t=e.target,i=this.getNodeFromElement(t);if(null===i)return;const o=this.getParentNode(i),n=this.getFirstNode(),s=this.getPreviousNode(i),r=this.getNextNode(i),d=this.getLastNode();switch(e.preventDefault(),e.key){case KeyTypes.HOME:null!==n&&(this.scrollNodeIntoVisibleArea(n),this.focusNode(n));break;case KeyTypes.END:null!==d&&(this.scrollNodeIntoVisibleArea(d),this.focusNode(d));break;case KeyTypes.UP:null!==s&&(this.scrollNodeIntoVisibleArea(s),this.focusNode(s));break;case KeyTypes.DOWN:null!==r&&(this.scrollNodeIntoVisibleArea(r),this.focusNode(r));break;case KeyTypes.LEFT:i.expanded?i.hasChildren&&this.hideChildren(i):o&&(this.scrollNodeIntoVisibleArea(o),this.focusNode(o));break;case KeyTypes.RIGHT:i.expanded&&r?(this.scrollNodeIntoVisibleArea(r),this.focusNode(r)):i.hasChildren&&this.showChildren(i);break;case KeyTypes.ENTER:case KeyTypes.SPACE:this.selectNode(i)}}scrollNodeIntoVisibleArea(e){const t=e.__y,i=e.__y+this.nodeHeight,o=t>=this.currentScrollPosition,n=i<=this.currentScrollPosition+this.currentVisibleHeight;if(!(o&&n)){let e=this.currentScrollPosition;o||n?o?n||(e=i-this.currentVisibleHeight):e=t:e=i-this.currentVisibleHeight,e<0&&(e=0),this.root.scrollTo({top:e})}}registerUnloadHandler(){try{if(!window.frameElement)return;window.addEventListener("pagehide",(()=>this.muteErrorNotifications=!0),{once:!0})}catch(e){console.error("Failed to check the existence of window.frameElement – using a foreign origin?")}}}__decorate([property({type:Object})],Tree.prototype,"setup",void 0),__decorate([state()],Tree.prototype,"settings",void 0),__decorate([query(".nodes-root")],Tree.prototype,"root",void 0),__decorate([state()],Tree.prototype,"nodes",void 0),__decorate([state()],Tree.prototype,"currentScrollPosition",void 0),__decorate([state()],Tree.prototype,"currentVisibleHeight",void 0),__decorate([state()],Tree.prototype,"searchTerm",void 0),__decorate([state()],Tree.prototype,"loading",void 0),__decorate([state()],Tree.prototype,"hoveredNode",void 0),__decorate([state()],Tree.prototype,"nodeDragAllowed",void 0); \ No newline at end of file diff --git a/typo3/sysext/core/Documentation/Changelog/13.1/Feature-103186-IntroduceTreeNodeStatusInformation.rst b/typo3/sysext/core/Documentation/Changelog/13.1/Feature-103186-IntroduceTreeNodeStatusInformation.rst new file mode 100644 index 000000000000..328aed3d27de --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/13.1/Feature-103186-IntroduceTreeNodeStatusInformation.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _feature-103186-1708686767: + +========================================================= +Feature: #103186 - Introduce tree node status information +========================================================= + +See :issue:`103186` + +Description +=========== + +We've enhanced the backend tree component by extending tree nodes to +incorporate status information. These details serve to indicate the +status of nodes and provide supplementary information. + +For instance, if a page undergoes changes within a workspace, it will +now display an indicator on the respective tree node. Additionally, +the status is appended to the node's title. This enhancement not only +improves visual clarity but also enhances information accessibility. + +Each node can accommodate multiple status information, prioritized by +severity and urgency. Critical messages take precedence over other +status notifications. + +.. code-block:: php + + new TreeItem( + ... + statusInformation: [ + new StatusInformation( + label: 'A warning message', + severity: ContextualFeedbackSeverity::WARNING, + priority: 0, + icon: 'actions-dot', + overlayIcon: '' + ) + ] + ), + +Impact +====== + +Tree nodes can now have status information. Workspace changes are +now reflected in the title of the node in addition to the indicator. + +.. index:: Backend, JavaScript, ext:backend diff --git a/typo3/sysext/workspaces/Classes/EventListener/PageTreeItemsHighlighter.php b/typo3/sysext/workspaces/Classes/EventListener/PageTreeItemsHighlighter.php index 564638f36cb8..09381509a4b3 100644 --- a/typo3/sysext/workspaces/Classes/EventListener/PageTreeItemsHighlighter.php +++ b/typo3/sysext/workspaces/Classes/EventListener/PageTreeItemsHighlighter.php @@ -18,8 +18,11 @@ declare(strict_types=1); namespace TYPO3\CMS\Workspaces\EventListener; use TYPO3\CMS\Backend\Controller\Event\AfterPageTreeItemsPreparedEvent; +use TYPO3\CMS\Backend\Dto\Tree\Status\StatusInformation; use TYPO3\CMS\Core\Attribute\AsEventListener; use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity; use TYPO3\CMS\Core\Versioning\VersionState; use TYPO3\CMS\Workspaces\Service\WorkspaceService; @@ -56,14 +59,24 @@ final class PageTreeItemsHighlighter || VersionState::tryFrom($page['t3ver_state'] ?? 0) === VersionState::NEW_PLACEHOLDER ) ) { - $item['class'] = 'ver-element ver-versions'; + $label = $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang.xlf:status.has_changes'); + if (VersionState::tryFrom($page['t3ver_state'] ?? 0) === VersionState::NEW_PLACEHOLDER) { + $label = $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang.xlf:status.is_new'); + } + $item['statusInformation'][] = new StatusInformation( + label: $label, + severity: ContextualFeedbackSeverity::WARNING, + ); } elseif ( $this->workspaceService->hasPageRecordVersions( $workspaceId, (int)(($page['t3ver_oid'] ?? 0) ?: ($page['uid'] ?? 0)) ) ) { - $item['class'] = 'ver-versions'; + $item['statusInformation'][] = new StatusInformation( + label: $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang.xlf:status.contains_changes'), + severity: ContextualFeedbackSeverity::WARNING, + ); } } unset($item); @@ -71,6 +84,11 @@ final class PageTreeItemsHighlighter $event->setItems($items); } + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } + protected function getBackendUser(): BackendUserAuthentication { return $GLOBALS['BE_USER']; diff --git a/typo3/sysext/workspaces/Resources/Private/Language/locallang.xlf b/typo3/sysext/workspaces/Resources/Private/Language/locallang.xlf index 6afc8f35dc23..375a9c2e57df 100644 --- a/typo3/sysext/workspaces/Resources/Private/Language/locallang.xlf +++ b/typo3/sysext/workspaces/Resources/Private/Language/locallang.xlf @@ -384,6 +384,15 @@ <trans-unit id="workingTable.stages" resname="workingTable.stages"> <source>Stages</source> </trans-unit> + <trans-unit id="status.has_changes" resname="status.has_changes"> + <source>Record was changed in this workspace</source> + </trans-unit> + <trans-unit id="status.contains_changes" resname="status.contains_changes"> + <source>Record contains version records</source> + </trans-unit> + <trans-unit id="status.is_new" resname="status.is_new"> + <source>Record was created in this workspace</source> + </trans-unit> </body> </file> </xliff> diff --git a/typo3/sysext/workspaces/Tests/Functional/EventListener/PageTreeItemsHighlighterTest.php b/typo3/sysext/workspaces/Tests/Functional/EventListener/PageTreeItemsHighlighterTest.php index 46a91c33101d..a64423d5d5ec 100644 --- a/typo3/sysext/workspaces/Tests/Functional/EventListener/PageTreeItemsHighlighterTest.php +++ b/typo3/sysext/workspaces/Tests/Functional/EventListener/PageTreeItemsHighlighterTest.php @@ -18,11 +18,14 @@ declare(strict_types=1); namespace TYPO3\CMS\Workspaces\Tests\Functional\EventListener; use TYPO3\CMS\Backend\Controller\Event\AfterPageTreeItemsPreparedEvent; +use TYPO3\CMS\Backend\Dto\Tree\Status\StatusInformation; use TYPO3\CMS\Core\Context\Context; use TYPO3\CMS\Core\Context\WorkspaceAspect; use TYPO3\CMS\Core\Http\ServerRequest; use TYPO3\CMS\Core\Http\Uri; +use TYPO3\CMS\Core\Localization\LanguageService; use TYPO3\CMS\Core\Localization\LanguageServiceFactory; +use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Workspaces\EventListener\PageTreeItemsHighlighter; use TYPO3\CMS\Workspaces\Service\WorkspaceService; @@ -43,7 +46,7 @@ final class PageTreeItemsHighlighterTest extends FunctionalTestCase /** * @test */ - public function classesAreAppliedToPageItems(): void + public function statusInformationAddedToPageItems(): void { $this->setWorkspaceId(91); $this->importCSVDataSet(__DIR__ . '/../Fixtures/pages.csv'); @@ -130,13 +133,33 @@ final class PageTreeItemsHighlighterTest extends FunctionalTestCase (new PageTreeItemsHighlighter(new WorkspaceService()))($afterPageTreeItemsPreparedEvent); $expected = $input; - $expected[1]['class'] = 'ver-versions'; - $expected[4]['class'] = 'ver-element ver-versions'; - $expected[6]['class'] = 'ver-element ver-versions'; + $expected[1]['statusInformation'] = [ + new StatusInformation( + label: $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang.xlf:status.contains_changes'), + severity: ContextualFeedbackSeverity::WARNING + ), + ]; + $expected[4]['statusInformation'] = [ + new StatusInformation( + label: $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang.xlf:status.has_changes'), + severity: ContextualFeedbackSeverity::WARNING + ), + ]; + $expected[6]['statusInformation'] = [ + new StatusInformation( + label: $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang.xlf:status.is_new'), + severity: ContextualFeedbackSeverity::WARNING + ), + ]; self::assertEquals($expected, $afterPageTreeItemsPreparedEvent->getItems()); } + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } + protected function setWorkspaceId(int $workspaceId): void { $GLOBALS['BE_USER']->workspace = $workspaceId; -- GitLab