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 ...@@ -30,7 +30,7 @@ export class SelectTree extends SvgTree
exclusiveNodesIdentifiers: '', exclusiveNodesIdentifiers: '',
validation: {}, validation: {},
readOnlyMode: false, readOnlyMode: false,
showIcons: false, showIcons: true,
marginTop: 15, marginTop: 15,
nodeHeight: 20, nodeHeight: 20,
indentWidth: 16, indentWidth: 16,
...@@ -40,23 +40,20 @@ export class SelectTree extends SvgTree ...@@ -40,23 +40,20 @@ export class SelectTree extends SvgTree
defaultProperties: {}, defaultProperties: {},
expandUpToLevel: null as any, expandUpToLevel: null as any,
}; };
/** /**
* Exclusive node which is currently selected * Exclusive node which is currently selected
*/ */
private exclusiveSelectedNode: TreeNode = null; private exclusiveSelectedNode: TreeNode = null;
public initialize(selector: HTMLElement, settings: any): boolean { constructor() {
if (!super.initialize(selector, settings)) { super();
return false;
}
this.addIcons(); this.addIcons();
this.dispatch.on('updateNodes.selectTree', (nodes: TreeNodeSelection) => this.updateNodes(nodes)); this.dispatch.on('updateNodes.selectTree', (nodes: TreeNodeSelection) => this.updateNodes(nodes));
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)); this.dispatch.on('prepareLoadedNode.selectTree', (node: TreeNode) => this.prepareLoadedNode(node));
return true;
} }
/** /**
...@@ -105,6 +102,42 @@ export class SelectTree extends SvgTree ...@@ -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. * Check whether node can be selected.
* In some cases (e.g. selecting a parent) it should not be possible to select * In some cases (e.g. selecting a parent) it should not be possible to select
......
...@@ -28,10 +28,10 @@ export class SelectTreeElement { ...@@ -28,10 +28,10 @@ export class SelectTreeElement {
this.treeWrapper = <HTMLElement>document.getElementById(treeWrapperId); this.treeWrapper = <HTMLElement>document.getElementById(treeWrapperId);
this.recordField = <HTMLInputElement>document.getElementById(treeRecordFieldId); this.recordField = <HTMLInputElement>document.getElementById(treeRecordFieldId);
this.tree = new SelectTree(); this.tree = new SelectTree();
this.tree.dispatch.on('nodeSelectedAfter.requestUpdate', () => { callback(); } );
const settings = { const settings = {
dataUrl: this.generateRequestUrl(), dataUrl: this.generateRequestUrl(),
showIcons: 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,
...@@ -45,7 +45,6 @@ export class SelectTreeElement { ...@@ -45,7 +45,6 @@ export class SelectTreeElement {
this.treeWrapper.prepend(toolbarElement); this.treeWrapper.prepend(toolbarElement);
}); });
this.tree.initialize(this.treeWrapper, settings); this.tree.initialize(this.treeWrapper, settings);
this.tree.dispatch.on('nodeSelectedAfter.requestUpdate', () => { callback(); } );
this.listenForVisibleTree(); this.listenForVisibleTree();
} }
...@@ -116,7 +115,7 @@ class TreeToolbar extends LitElement { ...@@ -116,7 +115,7 @@ class TreeToolbar extends LitElement {
<span class="input-group-addon input-group-icon filter"> <span class="input-group-addon input-group-icon filter">
<typo3-backend-icon identifier="actions-filter" size="small"></typo3-backend-icon> <typo3-backend-icon identifier="actions-filter" size="small"></typo3-backend-icon>
</span> </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>
<div class="btn-group"> <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()}"> <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 { ...@@ -132,41 +131,24 @@ class TreeToolbar extends LitElement {
</div> </div>
`; `;
} }
/** /**
* Collapse children of root node * Collapse children of root node
*/ */
private collapseAll() { private collapseAll() {
this.tree.collapseAll(); this.tree.collapseAll();
}; }
/** /**
* Expand all nodes * Expand all nodes
*/ */
private expandAll() { private expandAll() {
this.tree.expandAll(); this.tree.expandAll();
}; }
private search(event: InputEvent): void { private filter(event: InputEvent): void {
const inputEl = <HTMLInputElement>event.target; const inputEl = <HTMLInputElement>event.target;
if (this.tree.nodes.length) { this.tree.filter(inputEl.value.trim());
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();
} }
/** /**
...@@ -177,7 +159,7 @@ class TreeToolbar extends LitElement { ...@@ -177,7 +159,7 @@ class TreeToolbar extends LitElement {
if (this.hideUncheckedState) { if (this.hideUncheckedState) {
this.tree.nodes.forEach((node: any) => { this.tree.nodes.forEach((node: any) => {
if (node.checked) { if (node.checked) {
this.showParents(node); this.tree.showParents(node);
node.expanded = true; node.expanded = true;
node.hidden = false; node.hidden = false;
} else { } else {
...@@ -191,18 +173,4 @@ class TreeToolbar extends LitElement { ...@@ -191,18 +173,4 @@ class TreeToolbar extends LitElement {
this.tree.prepareDataForVisibleNodes(); this.tree.prepareDataForVisibleNodes();
this.tree.update(); 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 ...@@ -30,8 +30,6 @@ export class PageTree extends SvgTree
public nodeIsEdit: boolean; public nodeIsEdit: boolean;
protected networkErrorTitle: string = TYPO3.lang.pagetree_networkErrorTitle; protected networkErrorTitle: string = TYPO3.lang.pagetree_networkErrorTitle;
protected networkErrorMessage: string = TYPO3.lang.pagetree_networkErrorDesc; protected networkErrorMessage: string = TYPO3.lang.pagetree_networkErrorDesc;
protected searchQuery: string = '';
private originalNodes: string = '';
private dragDrop: PageTreeDragDrop; private dragDrop: PageTreeDragDrop;
public constructor() { public constructor() {
...@@ -54,19 +52,14 @@ export class PageTree extends SvgTree ...@@ -54,19 +52,14 @@ export class PageTree extends SvgTree
readableRootline: '', readableRootline: '',
isMountPoint: false, 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('nodeSelectedAfter.pageTree', (node: TreeNode) => this.nodeSelectedAfter(node));
this.dispatch.on('nodeRightClick.pageTree', (node: TreeNode) => this.nodeRightClick(node)); this.dispatch.on('nodeRightClick.pageTree', (node: TreeNode) => this.nodeRightClick(node));
this.dispatch.on('prepareLoadedNode.pageTree', (node: TreeNode) => this.prepareLoadedNode(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 { public sendChangeCommand(data: any): void {
...@@ -189,43 +182,6 @@ export class PageTree extends SvgTree ...@@ -189,43 +182,6 @@ export class PageTree extends SvgTree
this.update(); 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 * Make the DOM element of the node given as parameter focusable and focus it
*/ */
...@@ -255,28 +211,6 @@ export class PageTree extends SvgTree ...@@ -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) * Loads child nodes via Ajax (used when expanding a collapsed node)
* *
...@@ -455,5 +389,4 @@ export class PageTree extends SvgTree ...@@ -455,5 +389,4 @@ export class PageTree extends SvgTree
.node() .node()
.select(); .select();
} }
} }
...@@ -207,14 +207,13 @@ class Toolbar extends LitElement { ...@@ -207,14 +207,13 @@ class Toolbar extends LitElement {
const inputEl = this.querySelector(this.settings.searchInput) as HTMLInputElement; const inputEl = this.querySelector(this.settings.searchInput) as HTMLInputElement;
if (inputEl) { if (inputEl) {
new DebounceEvent('input', (evt: InputEvent) => { 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); }, this.settings.filterTimeout).bindTo(inputEl);
inputEl.focus(); inputEl.focus();
inputEl.clearable({ inputEl.clearable({
onClear: () => { onClear: () => {
this.tree.resetFilter(); this.tree.resetFilter();
this.tree.prepareDataForVisibleNodes();
this.tree.update();
} }
}); });
} }
...@@ -253,12 +252,6 @@ class Toolbar extends LitElement { ...@@ -253,12 +252,6 @@ class Toolbar extends LitElement {
this.tree.refreshOrFilterTree(); 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 * Register Drag and drop for new elements of toolbar
* Returns method from d3drag * Returns method from d3drag
......
...@@ -104,11 +104,6 @@ export class SvgTree { ...@@ -104,11 +104,6 @@ export class SvgTree {
protected icons: {[keys: string]: SvgTreeDataIcon}; protected icons: {[keys: string]: SvgTreeDataIcon};
/**
* Wrapper of svg element
*/
protected d3wrapper: TreeWrapperSelection<SvgTreeWrapper> = null;
/** /**
* SVG <defs> container wrapping all icon definitions * SVG <defs> container wrapping all icon definitions
*/ */
...@@ -134,6 +129,8 @@ export class SvgTree { ...@@ -134,6 +129,8 @@ export class SvgTree {
protected viewportHeight: number = 0; protected viewportHeight: number = 0;
protected scrollTop: number = 0; protected scrollTop: number = 0;
protected scrollBottom: number = 0; protected scrollBottom: number = 0;
protected searchTerm: string|null = null;
protected unfilteredNodes: string = '';
/** /**
* @todo: use generic labels * @todo: use generic labels
...@@ -141,20 +138,7 @@ export class SvgTree { ...@@ -141,20 +138,7 @@ export class SvgTree {
protected networkErrorTitle: string = TYPO3.lang.pagetree_networkErrorTitle; protected networkErrorTitle: string = TYPO3.lang.pagetree_networkErrorTitle;
protected networkErrorMessage: string = TYPO3.lang.pagetree_networkErrorDesc; protected networkErrorMessage: string = TYPO3.lang.pagetree_networkErrorDesc;
/** constructor() {
* 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;
this.dispatch = d3dispatch.dispatch( this.dispatch = d3dispatch.dispatch(
'updateNodes', 'updateNodes',
'updateSvg', 'updateSvg',
...@@ -163,7 +147,17 @@ export class SvgTree { ...@@ -163,7 +147,17 @@ export class SvgTree {
'nodeSelectedAfter', 'nodeSelectedAfter',
'nodeRightClick' '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: * Create element:
* *
...@@ -175,8 +169,7 @@ export class SvgTree { ...@@ -175,8 +169,7 @@ export class SvgTree {
* </g> * </g>
* </svg> * </svg>
*/ */
this.d3wrapper = d3selection.select(this.wrapper); this.svg = d3selection.select(this.wrapper).append('svg')
this.svg = this.d3wrapper.append('svg')
.attr('version', '1.1') .attr('version', '1.1')
.attr('width', '100%') .attr('width', '100%')
.on('mouseover', () => this.isOverSvg = true) .on('mouseover', () => this.isOverSvg = true)
...@@ -216,11 +209,8 @@ export class SvgTree { ...@@ -216,11 +209,8 @@ export class SvgTree {
this.update(); this.update();
}); });
this.wrapper.svgtree = this;
this.wrapper.dataset.svgTreeInitialized = 'true';
this.wrapper.dispatchEvent(new Event('svg-tree:initialized')); this.wrapper.dispatchEvent(new Event('svg-tree:initialized'));
this.resize(); this.resize();
return true;
} }
/** /**
...@@ -315,7 +305,8 @@ export class SvgTree { ...@@ -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 * @param {Node[]} nodes
*/ */
...@@ -431,6 +422,14 @@ export class SvgTree { ...@@ -431,6 +422,14 @@ export class SvgTree {
this.loadData(); this.loadData();
} }
public refreshOrFilterTree(): void {
if (this.searchTerm !== '') {
this.filter(this.searchTerm);
} else {
this.refreshTree();