Commit f6b2c333 authored by Benni Mack's avatar Benni Mack Committed by Benjamin Franzke
Browse files

[TASK] Refactor PageTree and FileStorage into custom HTMLElements

Native HTMLElements are more versatile than a the current
NavigationComponent interface. All custom actions can be dispatched
via events, that means the NavigationComponent interface,
as recently introduced in #93672, is removed again.

Resolves: #93685
Releases: master
Change-Id: I578810ce655d1da3f34bcb456967f62756793b24
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/68233


Tested-by: Richard Haeser's avatarRichard Haeser <richard@richardhaeser.com>
Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Benjamin Franzke's avatarBenjamin Franzke <bfr@qbus.de>
Reviewed-by: Richard Haeser's avatarRichard Haeser <richard@richardhaeser.com>
Reviewed-by: Benjamin Franzke's avatarBenjamin Franzke <bfr@qbus.de>
parent 27d14ac2
......@@ -47,9 +47,7 @@ class AjaxDataHandler {
* Refresh the page tree
*/
private static refreshPageTree(): void {
if (Viewport.NavigationContainer) {
Viewport.NavigationContainer.getComponentByName('PageTree')?.refresh();
}
top.document.dispatchEvent(new CustomEvent('typo3:pagetree:refresh'));
}
/**
......
......@@ -77,7 +77,12 @@ class ContextMenuActions {
*/
public static mountAsTreeRoot(table: string, uid: number): void {
if (table === 'pages') {
Viewport.NavigationContainer.getComponentByName('PageTree')?.apply((component: PageTree) => { component.setTemporaryMountPoint(uid); });
const event = new CustomEvent('typo3:pagetree:mountPoint', {
detail: {
pageId: uid
},
});
top.document.dispatchEvent(event);
}
}
......@@ -160,7 +165,7 @@ class ContextMenuActions {
+ '&data[' + table + '][' + uid + '][' + disableFieldName + ']=1'
+ '&redirect=' + ContextMenuActions.getReturnUrl(),
).done((): void => {
Viewport.NavigationContainer.getComponentByName('PageTree')?.refresh();
ContextMenuActions.refreshPageTree();
});
}
......@@ -175,7 +180,7 @@ class ContextMenuActions {
+ '&data[' + table + '][' + uid + '][' + disableFieldName + ']=0'
+ '&redirect=' + ContextMenuActions.getReturnUrl(),
).done((): void => {
Viewport.NavigationContainer.getComponentByName('PageTree')?.refresh();
ContextMenuActions.refreshPageTree();
});
}
......@@ -189,7 +194,7 @@ class ContextMenuActions {
+ '&data[' + table + '][' + uid + '][nav_hide]=0'
+ '&redirect=' + ContextMenuActions.getReturnUrl(),
).done((): void => {
Viewport.NavigationContainer.getComponentByName('PageTree')?.refresh();
ContextMenuActions.refreshPageTree();
});
}
......@@ -203,7 +208,7 @@ class ContextMenuActions {
+ '&data[' + table + '][' + uid + '][nav_hide]=1'
+ '&redirect=' + ContextMenuActions.getReturnUrl(),
).done((): void => {
Viewport.NavigationContainer.getComponentByName('PageTree')?.refresh();
ContextMenuActions.refreshPageTree();
});
}
......@@ -234,14 +239,11 @@ class ContextMenuActions {
if (e.target.getAttribute('name') === 'delete') {
const eventData = {component: 'contextmenu', action: 'delete', table, uid};
AjaxDataHandler.process('cmd[' + table + '][' + uid + '][delete]=1', eventData).then((): void => {
if (table === 'pages' && Viewport.NavigationContainer) {
if (table === 'pages') {
if (uid === top.fsMod.recentIds.web) {
Viewport.NavigationContainer.getComponentByName('PageTree')?.apply((pageTree: PageTree) => {
let node = pageTree.getFirstNode();
pageTree.selectNode(node);
});
top.document.dispatchEvent(new CustomEvent('typo3:pagetree:selectFirstNode'));
}
Viewport.NavigationContainer.getComponentByName('PageTree')?.refresh();
ContextMenuActions.refreshPageTree();
}
});
}
......@@ -350,7 +352,7 @@ class ContextMenuActions {
top.TYPO3.settings.RecordCommit.moduleUrl + url,
).done((): void => {
if (table === 'pages') {
Viewport.NavigationContainer.getComponentByName('PageTree')?.refresh();
ContextMenuActions.refreshPageTree();
}
});
};
......@@ -382,6 +384,10 @@ class ContextMenuActions {
Modal.dismiss();
});
}
private static refreshPageTree(): void {
top.document.dispatchEvent(new CustomEvent('typo3:pagetree:refresh'));
}
}
export = ContextMenuActions;
......@@ -210,7 +210,7 @@ class PageActions {
$inputFieldWrap.find('[data-action=cancel]').trigger('click');
this.$pageTitle.text($field.val());
this.initializePageTitleRenaming();
top.TYPO3.Backend.NavigationContainer.getComponentByName('PageTree')?.refresh();
top.document.dispatchEvent(new CustomEvent('typo3:pagetree:refresh'));
}).catch((): void => {
$inputFieldWrap.find('[data-action=cancel]').trigger('click');
});
......
......@@ -28,11 +28,11 @@ interface PageTreeSettings extends SvgTreeSettings {
export class PageTree extends SvgTree
{
public searchQuery: string = '';
public settings: PageTreeSettings;
protected networkErrorTitle: string = TYPO3.lang.pagetree_networkErrorTitle;
protected networkErrorMessage: string = TYPO3.lang.pagetree_networkErrorDesc;
private originalNodes: string = '';
private searchQuery: string = '';
private dragDrop: PageTreeDragDrop;
private nodeIsEdit: boolean;
public constructor() {
......
......@@ -11,8 +11,7 @@
* The TYPO3 project - inspiring people to share!
*/
import {render} from 'lit-html';
import {html, TemplateResult} from 'lit-element';
import {html, customElement, property, query, LitElement, TemplateResult} from 'lit-element';
import {lll} from 'TYPO3/CMS/Core/lit-helper';
import {PageTree} from './PageTree';
import {PageTreeDragDrop, ToolbarDragHandler} from './PageTreeDragDrop';
......@@ -20,20 +19,59 @@ import AjaxRequest from 'TYPO3/CMS/Core/Ajax/AjaxRequest';
import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse';
import {select as d3select} from 'd3-selection';
import DebounceEvent from 'TYPO3/CMS/Core/Event/DebounceEvent';
import {SvgTreeWrapper} from '../SvgTree';
import 'TYPO3/CMS/Backend/Element/IconElement';
import {NavigationComponent} from 'TYPO3/CMS/Backend/Viewport/NavigationComponent';
/**
* @exports TYPO3/CMS/Backend/PageTree/PageTreeElement
* This module defines the Custom Element for rendering the navigation component for an editable page tree
* including drag+drop, deletion, in-place editing and a custom toolbar for this component.
*
* It is used as custom element via "<typo3-backend-navigation-component-pagetree>".
*
* The navigationComponentName export is used by the NavigationContainer in order to
* create an instance of PageTreeNavigationComponent via document.createElement().
*/
export class PageTreeElement implements NavigationComponent {
private readonly tree: PageTree;
private static renderTemplate(): TemplateResult {
export const navigationComponentName: string = 'typo3-backend-navigation-component-pagetree';
const toolbarComponentName: string = 'typo3-backend-navigation-component-pagetree-toolbar';
@customElement(navigationComponentName)
export class PageTreeNavigationComponent extends LitElement {
// @todo: Migrate svg-tree-wrapper into a custom element
@query('.svg-tree-wrapper') treeWrapper: HTMLElement;
private readonly tree: PageTree = null;
public constructor() {
super();
this.tree = new PageTree();
}
connectedCallback(): void {
super.connectedCallback();
document.addEventListener('typo3:pagetree:refresh', this.refresh);
document.addEventListener('typo3:pagetree:mountPoint', this.setMountPoint);
document.addEventListener('typo3:pagetree:selectFirstNode', this.selectFirstNode);
}
disconnectedCallback(): void {
document.removeEventListener('typo3:pagetree:refresh', this.refresh);
document.removeEventListener('typo3:pagetree:mountPoint', this.setMountPoint);
document.removeEventListener('typo3:pagetree:selectFirstNode', this.selectFirstNode);
super.disconnectedCallback();
}
// disable shadow dom for now
protected createRenderRoot(): HTMLElement | ShadowRoot {
return this;
}
protected render(): TemplateResult {
return html`
<div id="typo3-pagetree" class="svg-tree">
<div>
<div id="typo3-pagetree-toolbar" class="svg-toolbar"></div>
<div id="typo3-pagetree-toolbar" class="svg-toolbar">
<typo3-backend-navigation-component-pagetree-toolbar .tree="${this.tree}"></typo3-backend-navigation-component-pagetree-toolbar>
</div>
<div id="typo3-pagetree-treeContainer" class="navigation-tree-container">
<div id="typo3-pagetree-tree" class="svg-tree-wrapper">
<div class="node-loader">
......@@ -48,120 +86,78 @@ export class PageTreeElement implements NavigationComponent {
</div>
`;
}
public constructor(selector: string) {
const targetEl = document.querySelector(selector);
// let SvgTree know it shall be visible
if (targetEl && targetEl.childNodes.length > 0) {
targetEl.querySelector('.svg-tree').dispatchEvent(new Event('svg-tree:visible'));
return;
}
render(PageTreeElement.renderTemplate(), targetEl);
const treeEl = <HTMLElement>targetEl.querySelector('.svg-tree-wrapper');
this.tree = new PageTree();
const dragDrop = new PageTreeDragDrop(this.tree);
protected firstUpdated() {
this.treeWrapper.dispatchEvent(new Event('svg-tree:visible'));
const configurationUrl = top.TYPO3.settings.ajaxUrls.page_tree_configuration;
(new AjaxRequest(configurationUrl)).get()
.then(async (response: AjaxResponse): Promise<void> => {
const configuration = await response.resolve('json');
const dataUrl = top.TYPO3.settings.ajaxUrls.page_tree_data;
const filterUrl = top.TYPO3.settings.ajaxUrls.page_tree_filter;
Object.assign(configuration, {
dataUrl: dataUrl,
filterUrl: filterUrl,
dataUrl: top.TYPO3.settings.ajaxUrls.page_tree_data,
filterUrl: top.TYPO3.settings.ajaxUrls.page_tree_filter,
showIcons: true
});
this.tree.initialize(treeEl, configuration, dragDrop);
// the toolbar relies on settings retrieved in this step
const toolbar = <HTMLElement>targetEl.querySelector('.svg-toolbar');
if (!toolbar.dataset.treeShowToolbar) {
const pageTreeToolbar = new Toolbar(dragDrop);
pageTreeToolbar.initialize(treeEl, toolbar);
toolbar.dataset.treeShowToolbar = 'true';
}
const dragDrop = new PageTreeDragDrop(this.tree);
// 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.treeWrapper.addEventListener('svg-tree:initialized', () => {
// set up toolbar now with updated settings
const toolbar = this.querySelector(toolbarComponentName) as Toolbar;
toolbar.requestUpdate('tree').then(() => toolbar.initializeDragDrop(dragDrop));
});
this.tree.initialize(this.treeWrapper, configuration, dragDrop);
});
}
public getName(): string {
return 'PageTree';
}
public refresh?(): void {
private refresh = (): void => {
this.tree.refreshOrFilterTree();
}
public select(item: any): void {
this.tree.selectNode(item);
private setMountPoint = (e: CustomEvent): void => {
this.tree.setTemporaryMountPoint(e.detail.pageId as number);
}
public apply(fn: Function): void {
fn(this.tree);
private selectFirstNode = (): void => {
const node = this.tree.nodes[0];
if (node) {
this.tree.selectNode(node);
}
}
}
class Toolbar {
@customElement(toolbarComponentName)
class Toolbar extends LitElement {
@property({type: PageTree}) tree: PageTree = null;
private settings = {
toolbarSelector: 'tree-toolbar',
searchInput: '.search-input',
filterTimeout: 450
};
private treeContainer: SvgTreeWrapper;
private targetEl: HTMLElement;
private tree: any;
private readonly dragDrop: any;
public constructor(dragDrop: PageTreeDragDrop) {
this.dragDrop = dragDrop;
}
public initialize(treeContainer: HTMLElement, toolbar: HTMLElement, settings: any = {}): void {
this.treeContainer = treeContainer;
this.targetEl = toolbar;
if (!this.treeContainer.dataset.svgTreeInitialized
|| typeof this.treeContainer.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());
return;
public initializeDragDrop(dragDrop: PageTreeDragDrop): void
{
if (this.tree.settings?.doktypes?.length) {
this.tree.settings.doktypes.forEach((item: any) => {
if (item.icon) {
const htmlElement = this.querySelector('[data-tree-icon="' + item.icon + '"]');
d3select(htmlElement).call(this.dragToolbar(item, dragDrop));
} else {
console.warn('Missing icon definition for doktype: ' + item.nodeType);
}
});
}
Object.assign(this.settings, settings);
this.render();
}
private refreshTree(): void {
this.tree.refreshOrFilterTree();
// disable shadow dom for now
protected createRenderRoot(): HTMLElement | ShadowRoot {
return this;
}
private search(inputEl: HTMLInputElement): void {
this.tree.searchQuery = inputEl.value.trim()
this.tree.refreshOrFilterTree();
this.tree.prepareDataForVisibleNodes();
this.tree.update();
}
private render(): void
protected firstUpdated(): void
{
this.tree = this.treeContainer.svgtree;
// @todo Better use initialize() settings, drop this assignment here
Object.assign(this.settings, this.tree.settings);
render(this.renderTemplate(), this.targetEl);
const d3Toolbar = d3select('.svg-toolbar');
this.tree.settings.doktypes.forEach((item: any) => {
if (item.icon) {
d3Toolbar
.selectAll('[data-tree-icon=' + item.icon + ']')
.call(this.dragToolbar(item));
} else {
console.warn('Missing icon definition for doktype: ' + item.nodeType);
}
});
const inputEl = this.targetEl.querySelector(this.settings.searchInput) as HTMLInputElement;
const inputEl = this.querySelector(this.settings.searchInput) as HTMLInputElement;
if (inputEl) {
new DebounceEvent('input', (evt: InputEvent) => {
this.search(evt.target as HTMLInputElement);
......@@ -177,10 +173,10 @@ class Toolbar {
}
}
private renderTemplate(): TemplateResult {
protected render(): TemplateResult {
/* eslint-disable @typescript-eslint/indent */
return html`
<div class="${this.settings.toolbarSelector}">
<div class="tree-toolbar">
<div class="svg-toolbar__menu">
<div class="svg-toolbar__search">
<input type="text" class="form-control form-control-sm search-input" placeholder="${lll('tree.searchTermInfo')}">
......@@ -190,7 +186,7 @@ class Toolbar {
</button>
</div>
<div class="svg-toolbar__submenu">
${this.tree.settings.doktypes && this.tree.settings.doktypes.length
${this.tree.settings?.doktypes?.length
? this.tree.settings.doktypes.map((item: any) => {
return html`
<div class="svg-toolbar__drag-node" data-tree-icon="${item.icon}" data-node-type="${item.nodeType}"
......@@ -206,12 +202,22 @@ class Toolbar {
`;
}
private refreshTree(): void {
this.tree.refreshOrFilterTree();
}
private search(inputEl: HTMLInputElement): void {
this.tree.searchQuery = inputEl.value.trim()
this.tree.refreshOrFilterTree();
this.tree.prepareDataForVisibleNodes();
this.tree.update();
}
/**
* Register Drag and drop for new elements of toolbar
* Returns method from d3drag
*/
private dragToolbar(item: any) {
return this.dragDrop.connectDragHandler(new ToolbarDragHandler(item, this.tree, this.dragDrop));
private dragToolbar(item: any, dragDrop: PageTreeDragDrop) {
return dragDrop.connectDragHandler(new ToolbarDragHandler(item, this.tree, dragDrop));
}
}
......@@ -11,25 +11,56 @@
* The TYPO3 project - inspiring people to share!
*/
import {render} from 'lit-html';
import {html, TemplateResult} from 'lit-element';
import {html, customElement, property, query, LitElement, TemplateResult} from 'lit-element';
import {lll} from 'TYPO3/CMS/Core/lit-helper';
import {FileStorageTree} from './FileStorageTree';
import DebounceEvent from 'TYPO3/CMS/Core/Event/DebounceEvent';
import {FileStorageTreeActions} from './FileStorageTreeActions';
import 'TYPO3/CMS/Backend/Element/IconElement';
import {NavigationComponent} from 'TYPO3/CMS/Backend/Viewport/NavigationComponent';
export const navigationComponentName: string = 'typo3-backend-navigation-component-filestoragetree';
const toolbarComponentName: string = 'typo3-backend-navigation-component-filestoragetree-toolbar';
/**
* Responsible for setting up the viewport for the Navigation Component for the File Tree
*/
export class FileStorageTreeContainer implements NavigationComponent {
private readonly tree: FileStorageTree;
private static renderTemplate(): TemplateResult {
@customElement(navigationComponentName)
export class FileStorageTreeNavigationComponent extends LitElement {
// @todo: Migrate svg-tree-wrapper into a custom element
@query('.svg-tree-wrapper') treeWrapper: HTMLElement;
private readonly tree: FileStorageTree = null;
public constructor() {
super();
this.tree = new FileStorageTree();
}
public connectedCallback(): void {
super.connectedCallback();
document.addEventListener('typo3:filestoragetree:refresh', this.refresh);
document.addEventListener('typo3:filestoragetree:selectFirstNode', this.selectFirstNode);
// event listener updating current tree state, this can be removed in TYPO3 v12
document.addEventListener('typo3:filelist:treeUpdateRequested', this.treeUpdateRequested);
}
public disconnectedCallback(): void {
document.removeEventListener('typo3:filestoragetree:refresh', this.refresh);
document.removeEventListener('typo3:filestoragetree:selectFirstNode', this.selectFirstNode);
document.removeEventListener('typo3:filelist:treeUpdateRequested', this.treeUpdateRequested);
super.disconnectedCallback();
}
// disable shadow dom for now
protected createRenderRoot(): HTMLElement | ShadowRoot {
return this;
}
protected render(): TemplateResult {
return html`
<div id="typo3-filestoragetree" class="svg-tree">
<div>
<div id="filestoragetree-toolbar" class="svg-toolbar"></div>
<typo3-backend-navigation-component-filestoragetree-toolbar .tree="${this.tree}" id="filestoragetree-toolbar" class="svg-toolbar"></typo3-backend-navigation-component-filestoragetree-toolbar>
<div class="navigation-tree-container">
<div id="typo3-filestoragetree-tree" class="svg-tree-wrapper">
<div class="node-loader">
......@@ -44,97 +75,53 @@ export class FileStorageTreeContainer implements NavigationComponent {
</div>
`;
}
public constructor(selector: string) {
const targetEl = document.querySelector(selector);
// let SvgTree know it shall be visible
if (targetEl && targetEl.childNodes.length > 0) {
targetEl.querySelector('.svg-tree').dispatchEvent(new Event('svg-tree:visible'));
return;
}
render(FileStorageTreeContainer.renderTemplate(), targetEl);
const treeEl = <HTMLElement>targetEl.querySelector('.svg-tree-wrapper');
this.tree = new FileStorageTree();
const actions = new FileStorageTreeActions(this.tree);
this.tree.initialize(treeEl, {
protected firstUpdated() {
this.treeWrapper.dispatchEvent(new Event('svg-tree:visible'));
this.tree.initialize(this.treeWrapper, {
dataUrl: top.TYPO3.settings.ajaxUrls.filestorage_tree_data,
filterUrl: top.TYPO3.settings.ajaxUrls.filestorage_tree_filter,
showIcons: true
}, actions);
// Activate the toolbar
const toolbar = <HTMLElement>targetEl.querySelector('.svg-toolbar');
new Toolbar(treeEl, toolbar);
// event listener updating current tree state
document.addEventListener('typo3:filelist:treeUpdateRequested', (evt: CustomEvent) => {
this.tree.selectNodeByIdentifier(evt.detail.payload.identifier);
});
}
public getName(): string {
return 'FileStorageTree';
}, new FileStorageTreeActions(this.tree));
}
public refresh?(): void {
private refresh = (): void => {
this.tree.refreshOrFilterTree();
}
public select(item: any): void {
this.tree.selectNode(item);
private selectFirstNode = (): void => {
const node = this.tree.nodes[0];
if (node) {
this.tree.selectNode(node);
}
}
public apply(fn: Function): void {
fn(this.tree);
// event listener updating current tree state, this can be removed in TYPO3 v12
private treeUpdateRequested = (evt: CustomEvent): void => {
this.tree.selectNodeByIdentifier(evt.detail.payload.identifier);
}
}
/**
* Contains the toolbar above the tree
* Creates the toolbar above the tree
*/
class Toolbar
{
@customElement(toolbarComponentName)
class Toolbar extends LitElement {
@property({type: FileStorageTree}) tree: FileStorageTree = null;
private settings = {
toolbarSelector: 'tree-toolbar',
searchInput: '.search-input',
filterTimeout: 450
};
private readonly treeContainer: any;
private readonly targetEl: HTMLElement;