Commit 38325243 authored by Benni Mack's avatar Benni Mack Committed by Georg Ringer
Browse files

[TASK] Separate concerns in SVGTree implementations

This patch provides the following changes in order to
have better abstractions and encapsulate the dependencies
of each implementation.

1. SelectTree (Category Tree)

The TreeToolbar is now clearly coupled into the SelectTree
and built as LitElement inside the SelectTreeElement.

SelectTree now contains all relevant functionality
regarding selecting multiple items, and shows settings
(SelectTreeSettings) which were only used in SelectTree.

This is especially relevant for the multiple item selection,
and the exclusiveKey handling.

2. PageTree + FileStorageTree
Obsolete public methods are removed, and visibility is changed.
Filtering is now separated in favor of a non-public property.
By using Types (in e.g. PageTreeDragDrop) the visibility is also adapted
Obsolete wrapper methods are removed.

Resolves: #93692
Releases: master
Change-Id: Ifead098bb80e37d5c7ad5e1d0eabda14aa7087ce
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/68303


Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Richard Haeser's avatarRichard Haeser <richard@richardhaeser.com>
Tested-by: Georg Ringer's avatarGeorg Ringer <georg.ringer@gmail.com>
Reviewed-by: Richard Haeser's avatarRichard Haeser <richard@richardhaeser.com>
Reviewed-by: Georg Ringer's avatarGeorg Ringer <georg.ringer@gmail.com>
parent 370de7f9
...@@ -12,23 +12,39 @@ ...@@ -12,23 +12,39 @@
*/ */
import * as d3selection from 'd3-selection'; import * as d3selection from 'd3-selection';
import {SvgTree, TreeNodeSelection} from '../../SvgTree'; import {SvgTree, SvgTreeSettings, TreeNodeSelection} from '../../SvgTree';
import {TreeNode} from '../../Tree/TreeNode'; import {TreeNode} from '../../Tree/TreeNode';
import FormEngineValidation = require('TYPO3/CMS/Backend/FormEngineValidation'); import FormEngineValidation = require('TYPO3/CMS/Backend/FormEngineValidation');
interface SelectTreeSettings extends SvgTreeSettings {
exclusiveNodesIdentifiers: '';
validation: {[keys: string]: any};
unselectableElements: Array<any>,
readOnlyMode: false
}
export class SelectTree extends SvgTree export class SelectTree extends SvgTree
{ {
public constructor() { public settings: SelectTreeSettings = {
super(); unselectableElements: [],
this.settings.showCheckboxes = true; exclusiveNodesIdentifiers: '',
} validation: {},
readOnlyMode: false,
showIcons: false,
marginTop: 15,
nodeHeight: 20,
indentWidth: 16,
width: 300,
duration: 400,
dataUrl: '',
defaultProperties: {},
expandUpToLevel: null as any,
};
/** /**
* SelectTree initialization * Exclusive node which is currently selected
*
* @param {HTMLElement} selector
* @param {Object} settings
*/ */
private exclusiveSelectedNode: TreeNode = null;
public initialize(selector: HTMLElement, settings: any): boolean { public initialize(selector: HTMLElement, settings: any): boolean {
if (!super.initialize(selector, settings)) { if (!super.initialize(selector, settings)) {
return false; return false;
...@@ -39,8 +55,32 @@ export class SelectTree extends SvgTree ...@@ -39,8 +55,32 @@ export class SelectTree extends SvgTree
this.dispatch.on('loadDataAfter.selectTree', () => this.loadDataAfter()); this.dispatch.on('loadDataAfter.selectTree', () => this.loadDataAfter());
this.dispatch.on('updateSvg.selectTree', (nodes: TreeNodeSelection) => this.renderCheckbox(nodes)); this.dispatch.on('updateSvg.selectTree', (nodes: TreeNodeSelection) => this.renderCheckbox(nodes));
this.dispatch.on('nodeSelectedAfter.selectTree', (node: TreeNode) => this.nodeSelectedAfter(node)); this.dispatch.on('nodeSelectedAfter.selectTree', (node: TreeNode) => this.nodeSelectedAfter(node));
this.dispatch.on('prepareLoadedNode.selectTree', (node: TreeNode) => this.prepareLoadedNode(node));
return true; return true;
}; }
/**
* Node selection logic (triggered by different events)
*/
public selectNode(node: TreeNode): void {
if (!this.isNodeSelectable(node)) {
return;
}
const checked = node.checked;
this.handleExclusiveNodeSelection(node);
if (this.settings.validation && this.settings.validation.maxItems) {
if (!checked && this.getSelectedNodes().length >= this.settings.validation.maxItems) {
return;
}
}
node.checked = !checked;
this.dispatch.call('nodeSelectedAfter', this, node);
this.update();
}
/** /**
* Function relays on node.indeterminate state being up to date * Function relays on node.indeterminate state being up to date
...@@ -48,9 +88,6 @@ export class SelectTree extends SvgTree ...@@ -48,9 +88,6 @@ export class SelectTree extends SvgTree
* @param {Selection} nodes * @param {Selection} nodes
*/ */
public updateNodes(nodes: TreeNodeSelection): void { public updateNodes(nodes: TreeNodeSelection): void {
if (!this.settings.showCheckboxes) {
return;
}
nodes nodes
.selectAll('.tree-check use') .selectAll('.tree-check use')
.attr('visibility', function(this: SVGUseElement, node: TreeNode): string { .attr('visibility', function(this: SVGUseElement, node: TreeNode): string {
...@@ -68,45 +105,66 @@ export class SelectTree extends SvgTree ...@@ -68,45 +105,66 @@ export class SelectTree extends SvgTree
}); });
} }
/**
* Check whether node can be selected.
* In some cases (e.g. selecting a parent) it should not be possible to select
* element (as it's own parent).
*/
protected isNodeSelectable(node: TreeNode): boolean {
return !this.settings.readOnlyMode && this.settings.unselectableElements.indexOf(node.identifier) === -1;
}
/**
* Check if a node has all information to be used.
*/
private prepareLoadedNode(node: TreeNode): void {
// create stateIdentifier if doesn't exist (for category tree)
if (!node.stateIdentifier) {
const parentId = (node.parents.length) ? node.parents[node.parents.length - 1] : node.identifier;
node.stateIdentifier = parentId + '_' + node.identifier;
}
if (node.selectable === false) {
this.settings.unselectableElements.push(node.identifier);
}
}
/** /**
* Adds svg elements for checkbox rendering. * Adds svg elements for checkbox rendering.
* *
* @param {Selection} nodeSelection ENTER selection (only new DOM objects) * @param {Selection} nodeSelection ENTER selection (only new DOM objects)
*/ */
private renderCheckbox(nodeSelection: TreeNodeSelection): void { private renderCheckbox(nodeSelection: TreeNodeSelection): void {
if (this.settings.showCheckboxes) { this.textPosition = 50;
this.textPosition = 50;
// this can be simplified to single "use" element with changing href on click
// this can be simplified to single "use" element with changing href on click // when we drop IE11 on WIN7 support
// when we drop IE11 on WIN7 support const g = nodeSelection.filter((node: TreeNode) => {
const g = nodeSelection.filter((node: TreeNode) => { // do not render checkbox if node is not selectable
// do not render checkbox if node is not selectable return this.isNodeSelectable(node) || Boolean(node.checked);
return this.isNodeSelectable(node) || Boolean(node.checked); })
}) .append('g')
.append('g') .attr('class', 'tree-check')
.attr('class', 'tree-check') .on('click', (evt: MouseEvent, node: TreeNode) => this.selectNode(node));
.on('click', (evt: MouseEvent, node: TreeNode) => this.selectNode(node));
g.append('use')
g.append('use') .attr('x', 28)
.attr('x', 28) .attr('y', -8)
.attr('y', -8) .attr('visibility', 'hidden')
.attr('visibility', 'hidden') .attr('class', 'icon-check')
.attr('class', 'icon-check') .attr('xlink:href', '#icon-check');
.attr('xlink:href', '#icon-check'); g.append('use')
g.append('use') .attr('x', 28)
.attr('x', 28) .attr('y', -8)
.attr('y', -8) .attr('visibility', 'hidden')
.attr('visibility', 'hidden') .attr('class', 'icon-checked')
.attr('class', 'icon-checked') .attr('xlink:href', '#icon-checked');
.attr('xlink:href', '#icon-checked'); g.append('use')
g.append('use') .attr('x', 28)
.attr('x', 28) .attr('y', -8)
.attr('y', -8) .attr('visibility', 'hidden')
.attr('visibility', 'hidden') .attr('class', 'icon-indeterminate')
.attr('class', 'icon-indeterminate') .attr('xlink:href', '#icon-indeterminate');
.attr('xlink:href', '#icon-indeterminate'); }
}
};
/** /**
* Updates the indeterminate state for ancestors of the current node * Updates the indeterminate state for ancestors of the current node
...@@ -175,6 +233,30 @@ export class SelectTree extends SvgTree ...@@ -175,6 +233,30 @@ export class SelectTree extends SvgTree
.map((node: TreeNode): string => node.identifier); .map((node: TreeNode): string => node.identifier);
} }
/**
* Handle exclusive nodes functionality
* If a node is one of the exclusiveNodesIdentifiers list,
* all other nodes has to be unselected before selecting this node.
*
* @param {Node} node
*/
private handleExclusiveNodeSelection(node: TreeNode): void {
const exclusiveKeys = this.settings.exclusiveNodesIdentifiers.split(',');
if (this.settings.exclusiveNodesIdentifiers.length && node.checked === false) {
if (exclusiveKeys.indexOf('' + node.identifier) > -1) {
// this key is exclusive, so uncheck all others
this.disableSelectedNodes();
this.exclusiveSelectedNode = node;
} else if (exclusiveKeys.indexOf('' + node.identifier) === -1 && this.exclusiveSelectedNode) {
// current node is not exclusive, but other exclusive node is already selected
this.exclusiveSelectedNode.checked = false;
this.dispatch.call('nodeSelectedAfter', this, this.exclusiveSelectedNode);
this.exclusiveSelectedNode = null;
}
}
}
/** /**
* Add icons imitating checkboxes * Add icons imitating checkboxes
*/ */
......
...@@ -12,32 +12,41 @@ ...@@ -12,32 +12,41 @@
*/ */
import {SelectTree} from './SelectTree'; import {SelectTree} from './SelectTree';
import {TreeToolbar} from './TreeToolbar'; import {Tooltip} from 'bootstrap';
import {html, customElement, LitElement, TemplateResult} from 'lit-element';
import {lll} from 'TYPO3/CMS/Core/lit-helper';
import 'TYPO3/CMS/Backend/Element/IconElement';
const toolbarComponentName: string = 'typo3-backend-form-selecttree-toolbar';
export class SelectTreeElement { export class SelectTreeElement {
private readonly treeWrapper: HTMLElement = null; private readonly treeWrapper: HTMLElement = null;
private readonly recordField: HTMLInputElement = null; private readonly recordField: HTMLInputElement = null;
private readonly tree: SelectTree = null;
constructor(treeWrapperId: string, treeRecordFieldId: string, callback: Function) { constructor(treeWrapperId: string, treeRecordFieldId: string, callback: Function) {
this.treeWrapper = <HTMLElement>document.getElementById(treeWrapperId); this.treeWrapper = <HTMLElement>document.getElementById(treeWrapperId);
this.recordField = <HTMLInputElement>document.getElementById(treeRecordFieldId); this.recordField = <HTMLInputElement>document.getElementById(treeRecordFieldId);
const tree = new SelectTree(); this.tree = new SelectTree();
const settings = { const settings = {
dataUrl: this.generateRequestUrl(), dataUrl: this.generateRequestUrl(),
showIcons: true, showIcons: true,
showCheckboxes: true,
readOnlyMode: parseInt(this.recordField.dataset.readOnly, 10) === 1, readOnlyMode: parseInt(this.recordField.dataset.readOnly, 10) === 1,
input: this.recordField, input: this.recordField,
exclusiveNodesIdentifiers: this.recordField.dataset.treeExclusiveKeys, exclusiveNodesIdentifiers: this.recordField.dataset.treeExclusiveKeys,
validation: JSON.parse(this.recordField.dataset.formengineValidationRules)[0], validation: JSON.parse(this.recordField.dataset.formengineValidationRules)[0],
expandUpToLevel: this.recordField.dataset.treeExpandUpToLevel, expandUpToLevel: this.recordField.dataset.treeExpandUpToLevel,
unselectableElements: [] as Array<any>
}; };
tree.initialize(this.treeWrapper, settings); this.treeWrapper.addEventListener('svg-tree:initialized', () => {
tree.dispatch.on('nodeSelectedAfter.requestUpdate', () => { callback(); } ); const toolbarElement = document.createElement(toolbarComponentName) as TreeToolbar;
toolbarElement.tree = this.tree;
this.treeWrapper.prepend(toolbarElement);
});
this.tree.initialize(this.treeWrapper, settings);
this.tree.dispatch.on('nodeSelectedAfter.requestUpdate', () => { callback(); } );
this.listenForVisibleTree(); this.listenForVisibleTree();
new TreeToolbar(this.treeWrapper);
} }
/** /**
...@@ -73,3 +82,127 @@ export class SelectTreeElement { ...@@ -73,3 +82,127 @@ export class SelectTreeElement {
return TYPO3.settings.ajaxUrls.record_tree_data + '&' + new URLSearchParams(params); return TYPO3.settings.ajaxUrls.record_tree_data + '&' + new URLSearchParams(params);
} }
} }
@customElement(toolbarComponentName)
class TreeToolbar extends LitElement {
public tree: SelectTree;
private settings = {
collapseAllBtn: 'collapse-all-btn',
expandAllBtn: 'expand-all-btn',
searchInput: 'search-input',
toggleHideUnchecked: 'hide-unchecked-btn'
};
/**
* State of the hide unchecked toggle button
*
* @type {boolean}
*/
private hideUncheckedState: boolean = false;
// disable shadow dom for now
protected createRenderRoot(): HTMLElement | ShadowRoot {
return this;
}
protected firstUpdated(): void {
this.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((tooltipTriggerEl: HTMLElement) => new Tooltip(tooltipTriggerEl));
}
protected render(): TemplateResult {
return html`
<div class="tree-toolbar btn-toolbar">
<div class="input-group">
<span class="input-group-addon input-group-icon filter">
<typo3-backend-icon identifier="actions-filter" size="small"></typo3-backend-icon>
</span>
<input type="text" class="form-control ${this.settings.searchInput}" placeholder="${lll('tcatree.findItem')}" @input="${(evt: InputEvent) => this.search(evt)}">
</div>
<div class="btn-group">
<button type="button" data-bs-toggle="tooltip" class="btn btn-default ${this.settings.expandAllBtn}" title="${lll('tcatree.expandAll')}" @click="${() => this.expandAll()}">
<typo3-backend-icon identifier="apps-pagetree-category-expand-all" size="small"></typo3-backend-icon>
</button>
<button type="button" data-bs-toggle="tooltip" class="btn btn-default ${this.settings.collapseAllBtn}" title="${lll('tcatree.collapseAll')}" @click="${() => this.collapseAll()}">
<typo3-backend-icon identifier="apps-pagetree-category-collapse-all" size="small"></typo3-backend-icon>
</button>
<button type="button" data-bs-toggle="tooltip" class="btn btn-default ${this.settings.toggleHideUnchecked}" title="${lll('tcatree.toggleHideUnchecked')}" @click="${() => this.toggleHideUnchecked()}">
<typo3-backend-icon identifier="apps-pagetree-category-toggle-hide-checked" size="small"></typo3-backend-icon>
</button>
</div>
</div>
`;
}
/**
* Collapse children of root node
*/
private collapseAll() {
this.tree.collapseAll();
};
/**
* Expand all nodes
*/
private expandAll() {
this.tree.expandAll();
};
private search(event: InputEvent): void {
const inputEl = <HTMLInputElement>event.target;
if (this.tree.nodes.length) {
this.tree.nodes[0].expanded = false;
}
const name = inputEl.value.trim()
const regex = new RegExp(name, 'i');
this.tree.nodes.forEach((node: any) => {
if (regex.test(node.name)) {
this.showParents(node);
node.expanded = true;
node.hidden = false;
} else {
node.hidden = true;
node.expanded = false;
}
});
this.tree.prepareDataForVisibleNodes();
this.tree.update();
}
/**
* Show only checked items
*/
private toggleHideUnchecked(): void {
this.hideUncheckedState = !this.hideUncheckedState;
if (this.hideUncheckedState) {
this.tree.nodes.forEach((node: any) => {
if (node.checked) {
this.showParents(node);
node.expanded = true;
node.hidden = false;
} else {
node.hidden = true;
node.expanded = false;
}
});
} else {
this.tree.nodes.forEach((node: any) => node.hidden = false);
}
this.tree.prepareDataForVisibleNodes();
this.tree.update();
}
/**
* Finds and show all parents of node
*/
private showParents(node: any): void {
if (node.parents.length === 0) {
return;
}
const parent = this.tree.nodes[node.parents[0]];
parent.hidden = false;
// expand parent node
parent.expanded = true;
this.showParents(parent);
}
}
/*
* 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!
*/
import {Tooltip} from 'bootstrap';
import {render} from 'lit-html';
import {html, TemplateResult} from 'lit-element';
import {lll} from 'TYPO3/CMS/Core/lit-helper';
import 'TYPO3/CMS/Backend/Element/IconElement';
/**
* @exports TYPO3/CMS/Backend/FormEngine/Element/TreeToolbar
*/
export class TreeToolbar
{
private settings = {
toolbarSelector: 'tree-toolbar btn-toolbar',
collapseAllBtn: 'collapse-all-btn',
expandAllBtn: 'expand-all-btn',
searchInput: 'search-input',
toggleHideUnchecked: 'hide-unchecked-btn'
};
private readonly treeContainer: HTMLElement;
private tree: any;
/**
* State of the hide unchecked toggle button
*
* @type {boolean}
*/
private hideUncheckedState: boolean = false;
public constructor(treeContainer: HTMLElement, settings: any = {}) {
this.treeContainer = treeContainer;
Object.assign(this.settings, settings);
if (!this.treeContainer.dataset.svgTreeInitialized
|| typeof (this.treeContainer as any).svgtree !== 'object'
) {
//both toolbar and tree are loaded independently through require js,
//so we don't know which is loaded first
//in case of toolbar being loaded first, we wait for an event from svgTree
this.treeContainer.addEventListener('svg-tree:initialized', this.render.bind(this));
} else {
this.render();
}
}
/**
* Collapse children of root node
*/
private collapseAll() {
this.tree.collapseAll();
};
/**
* Expand all nodes
*/
private expandAll() {
this.tree.expandAll();
};
private search(event: InputEvent): void {
const inputEl = <HTMLInputElement>event.target;
if (this.tree.nodes.length) {
this.tree.nodes[0].open = false;
}
const name = inputEl.value.trim()
const regex = new RegExp(name, 'i');
this.tree.nodes.forEach((node: any) => {
if (regex.test(node.name)) {
this.showParents(node);
node.open = true;
node.hidden = false;
} else {
node.hidden = true;
node.open = false;
}
});
this.tree.prepareDataForVisibleNodes();
this.tree.update();
}
/**
* Show only checked items
*/
private toggleHideUnchecked(): void {