Commit a06d6d7e authored by Benni Mack's avatar Benni Mack Committed by Richard Haeser
Browse files

[TASK] De-duplicate Tree code for filtering

This change optimizes the SVG Tree implementations (again),
this time focussing on using proper constructors for SVG
tree and its derivatives (SelectTree, PageTree, FileStorageTree).

In addition, the search + filter logic is now moved
into the base class, in order to reduce duplicate code,
and to re-add features (next steps) that were not
implemented in TYPO3 v9 when re-writing the page tree,
such as highlighting filtered results.

In addition, unused properties and settings are
removed.

This change marks one of the final changes for
reworking the SVG Tree implementation, afterwards
allowing us to move towards:
* native DOM events in favor of d3-dispatch
* custom elements instead of wrapper methods
* decouple "top." and "document." based settings into the Container classes

Resolves: #93701
Releases: master
Change-Id: I55733b8c2d0a84ca263ac6e77d7d0bac30877e25
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/68332


Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Georg Ringer's avatarGeorg Ringer <georg.ringer@gmail.com>
Tested-by: Richard Haeser's avatarRichard Haeser <richard@richardhaeser.com>
Reviewed-by: Georg Ringer's avatarGeorg Ringer <georg.ringer@gmail.com>
Reviewed-by: Richard Haeser's avatarRichard Haeser <richard@richardhaeser.com>
parent 110480e5
......@@ -30,7 +30,7 @@ export class SelectTree extends SvgTree
exclusiveNodesIdentifiers: '',
validation: {},
readOnlyMode: false,
showIcons: false,
showIcons: true,
marginTop: 15,
nodeHeight: 20,
indentWidth: 16,
......@@ -40,23 +40,20 @@ export class SelectTree extends SvgTree
defaultProperties: {},
expandUpToLevel: null as any,
};
/**
* Exclusive node which is currently selected
*/
private exclusiveSelectedNode: TreeNode = null;
public initialize(selector: HTMLElement, settings: any): boolean {
if (!super.initialize(selector, settings)) {
return false;
}
constructor() {
super();
this.addIcons();
this.dispatch.on('updateNodes.selectTree', (nodes: TreeNodeSelection) => this.updateNodes(nodes));
this.dispatch.on('loadDataAfter.selectTree', () => this.loadDataAfter());
this.dispatch.on('updateSvg.selectTree', (nodes: TreeNodeSelection) => this.renderCheckbox(nodes));
this.dispatch.on('nodeSelectedAfter.selectTree', (node: TreeNode) => this.nodeSelectedAfter(node));
this.dispatch.on('prepareLoadedNode.selectTree', (node: TreeNode) => this.prepareLoadedNode(node));
return true;
}
/**
......@@ -105,6 +102,42 @@ export class SelectTree extends SvgTree
});
}
public filter(searchTerm?: string|null): void {
this.searchTerm = searchTerm;
if (this.nodes.length) {
this.nodes[0].expanded = false;
}
const regex = new RegExp(searchTerm, 'i');
this.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.prepareDataForVisibleNodes();
this.update();
}
/**
* Finds and show all parents of node
*/
public showParents(node: any): void {
if (node.parents.length === 0) {
return;
}
const parent = this.nodes[node.parents[0]];
parent.hidden = false;
// expand parent node
parent.expanded = true;
this.showParents(parent);
}
/**
* Check whether node can be selected.
* In some cases (e.g. selecting a parent) it should not be possible to select
......
......@@ -28,10 +28,10 @@ export class SelectTreeElement {
this.treeWrapper = <HTMLElement>document.getElementById(treeWrapperId);
this.recordField = <HTMLInputElement>document.getElementById(treeRecordFieldId);
this.tree = new SelectTree();
this.tree.dispatch.on('nodeSelectedAfter.requestUpdate', () => { callback(); } );
const settings = {
dataUrl: this.generateRequestUrl(),
showIcons: true,
readOnlyMode: parseInt(this.recordField.dataset.readOnly, 10) === 1,
input: this.recordField,
exclusiveNodesIdentifiers: this.recordField.dataset.treeExclusiveKeys,
......@@ -45,7 +45,6 @@ export class SelectTreeElement {
this.treeWrapper.prepend(toolbarElement);
});
this.tree.initialize(this.treeWrapper, settings);
this.tree.dispatch.on('nodeSelectedAfter.requestUpdate', () => { callback(); } );
this.listenForVisibleTree();
}
......@@ -116,7 +115,7 @@ class TreeToolbar extends LitElement {
<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)}">
<input type="text" class="form-control ${this.settings.searchInput}" placeholder="${lll('tcatree.findItem')}" @input="${(evt: InputEvent) => this.filter(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()}">
......@@ -132,41 +131,24 @@ class TreeToolbar extends LitElement {
</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();
private filter(event: InputEvent): void {
const inputEl = <HTMLInputElement>event.target;
this.tree.filter(inputEl.value.trim());
}
/**
......@@ -177,7 +159,7 @@ class TreeToolbar extends LitElement {
if (this.hideUncheckedState) {
this.tree.nodes.forEach((node: any) => {
if (node.checked) {
this.showParents(node);
this.tree.showParents(node);
node.expanded = true;
node.hidden = false;
} else {
......@@ -191,18 +173,4 @@ class TreeToolbar extends LitElement {
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);
}
}
......@@ -30,8 +30,6 @@ export class PageTree extends SvgTree
public nodeIsEdit: boolean;
protected networkErrorTitle: string = TYPO3.lang.pagetree_networkErrorTitle;
protected networkErrorMessage: string = TYPO3.lang.pagetree_networkErrorDesc;
protected searchQuery: string = '';
private originalNodes: string = '';
private dragDrop: PageTreeDragDrop;
public constructor() {
......@@ -54,19 +52,14 @@ export class PageTree extends SvgTree
readableRootline: '',
isMountPoint: false,
};
}
public initialize(selector: HTMLElement, settings: any, dragDrop?: PageTreeDragDrop): boolean {
if (!super.initialize(selector, settings)) {
return false;
}
this.dispatch.on('nodeSelectedAfter.pageTree', (node: TreeNode) => this.nodeSelectedAfter(node));
this.dispatch.on('nodeRightClick.pageTree', (node: TreeNode) => this.nodeRightClick(node));
this.dispatch.on('prepareLoadedNode.pageTree', (node: TreeNode) => this.prepareLoadedNode(node));
this.dragDrop = dragDrop;
}
return true;
public initialize(selector: HTMLElement, settings: any, dragDrop?: PageTreeDragDrop) {
super.initialize(selector, settings);
this.dragDrop = dragDrop;
}
public sendChangeCommand(data: any): void {
......@@ -189,43 +182,6 @@ export class PageTree extends SvgTree
this.update();
}
public refreshOrFilterTree(searchQuery?: string|null): void {
if (typeof searchQuery === 'string') {
this.searchQuery = searchQuery;
}
if (this.searchQuery !== '') {
this.filterTree();
} else {
this.refreshTree();
}
}
public resetFilter(): void {
this.searchQuery = '';
if (this.originalNodes.length > 0) {
let currentlySelected = this.getSelectedNodes()[0];
if (typeof currentlySelected === 'undefined') {
this.refreshTree();
return;
}
this.nodes = JSON.parse(this.originalNodes);
this.originalNodes = '';
// re-select the node from the identifier because the nodes have been updated
const currentlySelectedNode = this.nodes.find((node: TreeNode) => {
return node.stateIdentifier === currentlySelected.stateIdentifier;
});
if (currentlySelectedNode) {
this.selectNode(currentlySelectedNode);
} else {
this.refreshTree();
}
} else {
this.refreshTree();
}
this.prepareDataForVisibleNodes();
this.update();
}
/**
* Make the DOM element of the node given as parameter focusable and focus it
*/
......@@ -255,28 +211,6 @@ export class PageTree extends SvgTree
}
}
protected filterTree() {
this.nodesAddPlaceholder();
(new AjaxRequest(this.settings.filterUrl + '&q=' + this.searchQuery))
.get({cache: 'no-cache'})
.then((response: AjaxResponse) => response.resolve())
.then((json) => {
let nodes = Array.isArray(json) ? json : [];
if (nodes.length > 0) {
if (this.originalNodes === '') {
this.originalNodes = JSON.stringify(this.nodes);
}
this.replaceData(nodes);
}
this.nodesRemovePlaceholder();
})
.catch((error: any) => {
this.errorNotification(error, false)
this.nodesRemovePlaceholder();
throw error;
});
}
/**
* Loads child nodes via Ajax (used when expanding a collapsed node)
*
......@@ -455,5 +389,4 @@ export class PageTree extends SvgTree
.node()
.select();
}
}
......@@ -207,14 +207,13 @@ class Toolbar extends LitElement {
const inputEl = this.querySelector(this.settings.searchInput) as HTMLInputElement;
if (inputEl) {
new DebounceEvent('input', (evt: InputEvent) => {
this.search(evt.target as HTMLInputElement);
const el = evt.target as HTMLInputElement;
this.tree.filter(el.value.trim());
}, this.settings.filterTimeout).bindTo(inputEl);
inputEl.focus();
inputEl.clearable({
onClear: () => {
this.tree.resetFilter();
this.tree.prepareDataForVisibleNodes();
this.tree.update();
}
});
}
......@@ -253,12 +252,6 @@ class Toolbar extends LitElement {
this.tree.refreshOrFilterTree();
}
private search(inputEl: HTMLInputElement): void {
this.tree.refreshOrFilterTree(inputEl.value.trim());
this.tree.prepareDataForVisibleNodes();
this.tree.update();
}
/**
* Register Drag and drop for new elements of toolbar
* Returns method from d3drag
......
......@@ -104,11 +104,6 @@ export class SvgTree {
protected icons: {[keys: string]: SvgTreeDataIcon};
/**
* Wrapper of svg element
*/
protected d3wrapper: TreeWrapperSelection<SvgTreeWrapper> = null;
/**
* SVG <defs> container wrapping all icon definitions
*/
......@@ -134,6 +129,8 @@ export class SvgTree {
protected viewportHeight: number = 0;
protected scrollTop: number = 0;
protected scrollBottom: number = 0;
protected searchTerm: string|null = null;
protected unfilteredNodes: string = '';
/**
* @todo: use generic labels
......@@ -141,20 +138,7 @@ export class SvgTree {
protected networkErrorTitle: string = TYPO3.lang.pagetree_networkErrorTitle;
protected networkErrorMessage: string = TYPO3.lang.pagetree_networkErrorDesc;
/**
* Initializes the tree component - created basic markup, loads and renders data
*
* @param {HTMLElement} selector
* @param {Object} settings
*/
public initialize(selector: HTMLElement, settings: any): boolean {
// Do nothing if already initialized
if (selector.dataset.svgTreeInitialized) {
return false;
}
Object.assign(this.settings, settings);
this.wrapper = selector;
constructor() {
this.dispatch = d3dispatch.dispatch(
'updateNodes',
'updateSvg',
......@@ -163,7 +147,17 @@ export class SvgTree {
'nodeSelectedAfter',
'nodeRightClick'
);
}
/**
* Initializes the tree component - created basic markup, loads and renders data
*
* @param {HTMLElement} selector
* @param {Object} settings
*/
public initialize(selector: HTMLElement, settings: any): void {
Object.assign(this.settings, settings);
this.wrapper = selector;
/**
* Create element:
*
......@@ -175,8 +169,7 @@ export class SvgTree {
* </g>
* </svg>
*/
this.d3wrapper = d3selection.select(this.wrapper);
this.svg = this.d3wrapper.append('svg')
this.svg = d3selection.select(this.wrapper).append('svg')
.attr('version', '1.1')
.attr('width', '100%')
.on('mouseover', () => this.isOverSvg = true)
......@@ -216,11 +209,8 @@ export class SvgTree {
this.update();
});
this.wrapper.svgtree = this;
this.wrapper.dataset.svgTreeInitialized = 'true';
this.wrapper.dispatchEvent(new Event('svg-tree:initialized'));
this.resize();
return true;
}
/**
......@@ -315,7 +305,8 @@ export class SvgTree {
}
/**
* Set parameters like node parents, parentsStateIdentifier, checked
* Set parameters like node parents, parentsStateIdentifier, checked.
* Usually called when data is loaded initially or replaced completely.
*
* @param {Node[]} nodes
*/
......@@ -431,6 +422,14 @@ export class SvgTree {
this.loadData();
}
public refreshOrFilterTree(): void {
if (this.searchTerm !== '') {
this.filter(this.searchTerm);
} else {
this.refreshTree();
}
}
/**
* Expand all nodes and refresh view
*/
......@@ -605,7 +604,6 @@ export class SvgTree {
});
}
this.dispatch.call('updateNodes', this, nodes);
}
......@@ -653,6 +651,61 @@ export class SvgTree {
this.dispatch.call('nodeSelectedAfter', this, node);
}
public filter(searchTerm?: string|null): void {
if (typeof searchTerm === 'string') {
this.searchTerm = searchTerm;
}
this.nodesAddPlaceholder();
if (this.searchTerm) {
(new AjaxRequest(this.settings.filterUrl + '&q=' + this.searchTerm))
.get({cache: 'no-cache'})
.then((response: AjaxResponse) => response.resolve())
.then((json) => {
let nodes = Array.isArray(json) ? json : [];
if (nodes.length > 0) {
if (this.unfilteredNodes === '') {
this.unfilteredNodes = JSON.stringify(this.nodes);
}
this.replaceData(nodes);
}
this.nodesRemovePlaceholder();
})
.catch((error: any) => {
this.errorNotification(error, false)
this.nodesRemovePlaceholder();
throw error;
});
} else {
// restore original state without filters
this.resetFilter();
}
}
public resetFilter(): void
{
this.searchTerm = '';
if (this.unfilteredNodes.length > 0) {
let currentlySelected = this.getSelectedNodes()[0];
if (typeof currentlySelected === 'undefined') {
this.refreshTree();
return;
}
this.nodes = JSON.parse(this.unfilteredNodes);
this.unfilteredNodes = '';
// re-select the node from the identifier because the nodes have been updated
const currentlySelectedNode = this.getNodeByIdentifier(currentlySelected.stateIdentifier);
if (currentlySelectedNode) {
this.selectNode(currentlySelectedNode);
} else {
this.refreshTree();
}
} else {
this.refreshTree();
}
this.prepareDataForVisibleNodes();
this.update();
}
/**
* Displays a notification message and refresh nodes
*/
......@@ -778,6 +831,15 @@ export class SvgTree {
return 'node identifier-' + node.stateIdentifier;
}
/**
* Finds node by its stateIdentifier (e.g. "0_360")
*/
protected getNodeByIdentifier(identifier: string): TreeNode|null {
return this.nodes.find((node: TreeNode) => {
return node.stateIdentifier === identifier;
});
}
/**
* Computes the tree node-bg class
*/
......
......@@ -12,7 +12,7 @@
*/
import AjaxRequest from 'TYPO3/CMS/Core/Ajax/AjaxRequest';
import {SvgTree, SvgTreeSettings, TreeNodeSelection} from '../SvgTree';
import {SvgTree, TreeNodeSelection} from '../SvgTree';
import {TreeNode} from '../Tree/TreeNode';
import ContextMenu = require('../ContextMenu');
import Persistent from '../Storage/Persistent';
......@@ -20,12 +20,8 @@ import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse';
import {FileStorageTreeNodeDragHandler, FileStorageTreeActions} from './FileStorageTreeActions';
export class FileStorageTree extends SvgTree {
public settings: SvgTreeSettings;
public searchQuery: string = '';
protected networkErrorTitle: string = TYPO3.lang.tree_networkError;
protected networkErrorMessage: string = TYPO3.lang.tree_networkErrorDescription;
private originalNodes: string = '';
private actionHandler: FileStorageTreeActions;
public constructor() {
......@@ -46,18 +42,14 @@ export class FileStorageTree extends SvgTree {
class: '',
readableRootline: ''
};
}
public initialize(selector: HTMLElement, settings: any, actionHandler?: FileStorageTreeActions): boolean {
if (!super.initialize(selector, settings)) {
return false;
}
this.dispatch.on('nodeSelectedAfter.fileStorageTree', (node: TreeNode) => this.nodeSelectedAfter(node));
this.dispatch.on('nodeRightClick.fileStorageTree', (node: TreeNode) => this.nodeRightClick(node));
this.dispatch.on('prepareLoadedNode.fileStorageTree', (node: TreeNode) => this.prepareLoadedNode(node));
}
public initialize(selector: HTMLElement, settings: any, actionHandler?: FileStorageTreeActions): void {
super.initialize(selector, settings);
this.actionHandler = actionHandler;
return true;
}
public hideChildren(node: TreeNode): void {
......@@ -110,60 +102,6 @@ export class FileStorageTree extends SvgTree {
}
}
public filterTree() {
this.nodesAddPlaceholder();
(new AjaxRequest(this.settings.filterUrl + '&q=' + this.searchQuery))
.get({cache: 'no-cache'})
.then((response) => {
return response.resolve();
})