[TASK] Reduce inline JavaScript for refreshing backend components 75/64675/9
authorBenjamin Franzke <bfr@qbus.de>
Wed, 3 Jun 2020 18:41:26 +0000 (20:41 +0200)
committerOliver Hader <oliver.hader@typo3.org>
Wed, 15 Jul 2020 22:02:53 +0000 (00:02 +0200)
Left-hand module menu and top toolbar are refreshed using inline
JavaScript when e.g. an extension is de-/activated in extension
manager or users switch their backend language in setup module.

A new module `ImmediateActionElement` is introduced that
implements the Custom HTML Element `<typo3-immediate-action action="…">`.
The element immediately dispatches the action passed via the
action attribute once attached to the DOM.

We therefore drop the (currently unused) data-dispatch-immediately
attribute which was introduced in #91015, as we opt for a more
streamlined custom HTML element implementation now.

Resolves: #91191
Releases: master, 10.4
Change-Id: I2724c51da3ea9aea0556ac63e834368e48866dd4
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/64675
Tested-by: TYPO3com <noreply@typo3.com>
Tested-by: Oliver Hader <oliver.hader@typo3.org>
Reviewed-by: Oliver Hader <oliver.hader@typo3.org>
Build/Sources/TypeScript/backend/Resources/Public/TypeScript/ActionDispatcher.ts
Build/Sources/TypeScript/backend/Resources/Public/TypeScript/Element/ImmediateActionElement.ts [new file with mode: 0644]
Build/Sources/TypeScript/backend/Tests/Element/ImmediateActionElementTest.ts [new file with mode: 0644]
typo3/sysext/backend/Classes/Template/ModuleTemplate.php
typo3/sysext/backend/Resources/Public/JavaScript/ActionDispatcher.js
typo3/sysext/backend/Resources/Public/JavaScript/Element/ImmediateActionElement.js [new file with mode: 0644]
typo3/sysext/backend/Tests/JavaScript/Element/ImmediateActionElementTest.js [new file with mode: 0644]
typo3/sysext/extensionmanager/Classes/ViewHelpers/Be/TriggerViewHelper.php
typo3/sysext/setup/Classes/Controller/SetupModuleController.php

index c311607..84a941e 100644 (file)
@@ -80,9 +80,7 @@ class ActionDispatcher {
 
   private registerEvents(): void {
     new RegularEvent('click', this.handleClickEvent.bind(this))
-      .delegateTo(document, '[data-dispatch-action]:not([data-dispatch-immediately])');
-    document.querySelectorAll('[data-dispatch-action][data-dispatch-immediately]')
-      .forEach(this.delegateTo.bind(this));
+      .delegateTo(document, '[data-dispatch-action]');
   }
 
   private handleClickEvent(evt: Event, target: HTMLElement): void {
diff --git a/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/Element/ImmediateActionElement.ts b/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/Element/ImmediateActionElement.ts
new file mode 100644 (file)
index 0000000..454151f
--- /dev/null
@@ -0,0 +1,69 @@
+/*
+ * 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 moduleMenuApp = require('TYPO3/CMS/Backend/ModuleMenu');
+import viewportObject = require('TYPO3/CMS/Backend/Viewport');
+
+/**
+ * Module: TYPO3/CMS/Backend/Element/ImmediateActionElement
+ *
+ * @example
+ * <typo3-immediate-action action="TYPO3.ModuleMenu.App.refreshMenu"></typo3-immediate-action>
+ *
+ * This is based on W3C custom elements ("web components") specification, see
+ * https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements
+ */
+export class ImmediateActionElement extends HTMLElement {
+  private action: string;
+
+  private static getDelegate(action: string): Function {
+    switch (action) {
+      case 'TYPO3.ModuleMenu.App.refreshMenu':
+        return moduleMenuApp.App.refreshMenu.bind(moduleMenuApp);
+      case 'TYPO3.Backend.Topbar.refresh':
+        return viewportObject.Topbar.refresh.bind(viewportObject.Topbar);
+      default:
+        throw Error('Unknown action "' + action + '"');
+    }
+  }
+
+  /**
+   * Observed attributes handled by `attributeChangedCallback`.
+   */
+  public static get observedAttributes(): string[] {
+    return ['action'];
+  }
+
+  /**
+   * Custom element life-cycle callback initializing attributes.
+   */
+  public attributeChangedCallback(name: string, oldValue: string, newValue: string): void {
+    if (name === 'action') {
+      this.action = newValue;
+    }
+  }
+
+  /**
+   * Custom element life-cycle callback triggered when element
+   * becomes available in document ("connected to DOM").
+   */
+  public connectedCallback(): void {
+    if (!this.action) {
+      throw new Error('Missing mandatory action attribute');
+    }
+    // @todo similar to ActionDispatcher, it might be required to pass custom arguments
+    ImmediateActionElement.getDelegate(this.action).apply(null, []);
+  }
+}
+
+window.customElements.define('typo3-immediate-action', ImmediateActionElement);
diff --git a/Build/Sources/TypeScript/backend/Tests/Element/ImmediateActionElementTest.ts b/Build/Sources/TypeScript/backend/Tests/Element/ImmediateActionElementTest.ts
new file mode 100644 (file)
index 0000000..949fad7
--- /dev/null
@@ -0,0 +1,94 @@
+/*
+ * 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 {ImmediateActionElement} from 'TYPO3/CMS/Backend/Element/ImmediateActionElement';
+import moduleMenuApp = require('TYPO3/CMS/Backend/ModuleMenu');
+import viewportObject = require('TYPO3/CMS/Backend/Viewport');
+
+describe('TYPO3/CMS/Backend/Element/ImmediateActionElement:', () => {
+  let root: HTMLElement; // This will hold the actual element under test.
+
+  beforeEach((): void => {
+    root = document.createElement('div');
+    document.body.appendChild(root);
+  });
+
+  afterEach((): void => {
+    root.remove();
+    root = null;
+  });
+
+  it('dispatches action when created via constructor', () => {
+    const backup = viewportObject.Topbar.refresh;
+    const observer = {
+      callback: (): void => {
+        return;
+      },
+    };
+    spyOn(observer, 'callback').and.callThrough();
+    viewportObject.Topbar.refresh = observer.callback;
+    const element = new ImmediateActionElement;
+    element.setAttribute('action', 'TYPO3.Backend.Topbar.refresh');
+    expect(observer.callback).not.toHaveBeenCalled();
+    root.appendChild(element);
+    expect(observer.callback).toHaveBeenCalled();
+    viewportObject.Topbar.refresh = backup;
+  });
+
+  it('dispatches action when created via createElement', () => {
+    const backup = viewportObject.Topbar.refresh;
+    const observer = {
+      callback: (): void => {
+        return;
+      },
+    };
+    spyOn(observer, 'callback').and.callThrough();
+    viewportObject.Topbar.refresh = observer.callback;
+    const element = <ImmediateActionElement>document.createElement('typo3-immediate-action');
+    element.setAttribute('action', 'TYPO3.Backend.Topbar.refresh');
+    expect(observer.callback).not.toHaveBeenCalled();
+    root.appendChild(element);
+    expect(observer.callback).toHaveBeenCalled();
+    viewportObject.Topbar.refresh = backup;
+  });
+
+  it('dispatches action when created from string', () => {
+    const backup = moduleMenuApp.App.refreshMenu;
+    const observer = {
+      callback: (): void => {
+        return;
+      },
+    };
+    spyOn(observer, 'callback').and.callThrough();
+    moduleMenuApp.App.refreshMenu = observer.callback;
+    const element = document.createRange().createContextualFragment('<typo3-immediate-action action="TYPO3.ModuleMenu.App.refreshMenu"></typo3-immediate-action>').querySelector('typo3-immediate-action');
+    expect(observer.callback).not.toHaveBeenCalled();
+    root.appendChild(element);
+    expect(observer.callback).toHaveBeenCalled();
+    moduleMenuApp.App.refreshMenu = backup;
+  });
+
+  it('dispatches action when created via innerHTML', () => {
+    const backup = moduleMenuApp.App.refreshMenu;
+    const observer = {
+      callback: (): void => {
+        return;
+      },
+    };
+    spyOn(observer, 'callback').and.callThrough();
+    moduleMenuApp.App.refreshMenu = observer.callback;
+    root.innerHTML = '<typo3-immediate-action action="TYPO3.ModuleMenu.App.refreshMenu"></typo3-immediate-action>';
+    expect(observer.callback).toHaveBeenCalled();
+    moduleMenuApp.App.refreshMenu = backup;
+  });
+});
index f2c6d25..db20df2 100644 (file)
@@ -261,6 +261,7 @@ class ModuleTemplate
         }
         $this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/GlobalEventHandler');
         $this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/ActionDispatcher');
+        $this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/Element/ImmediateActionElement');
     }
 
     /**
index 2ce07a8..41ef888 100644 (file)
@@ -10,4 +10,4 @@
  *
  * The TYPO3 project - inspiring people to share!
  */
-define(["require","exports","TYPO3/CMS/Backend/InfoWindow","TYPO3/CMS/Core/Event/RegularEvent","TYPO3/CMS/Backend/Toolbar/ShortcutMenu","TYPO3/CMS/Core/DocumentService"],(function(t,e,a,s,i,n){"use strict";class r{constructor(){this.delegates={},this.createDelegates(),n.ready().then(()=>this.registerEvents())}static resolveArguments(t){if(t.dataset.dispatchArgs){const e=t.dataset.dispatchArgs.replace(/&quot;/g,'"'),a=JSON.parse(e);return a instanceof Array?r.trimItems(a):null}if(t.dataset.dispatchArgsList){const e=t.dataset.dispatchArgsList.split(",");return r.trimItems(e)}return null}static trimItems(t){return t.map(t=>t instanceof String?t.trim():t)}static enrichItems(t,e,a){return t.map(t=>t instanceof Object&&t.$event?t.$target?a:t.$event?e:void 0:t)}createDelegates(){this.delegates={"TYPO3.InfoWindow.showItem":a.showItem.bind(null),"TYPO3.ShortcutMenu.createShortcut":i.createShortcut.bind(i)}}registerEvents(){new s("click",this.handleClickEvent.bind(this)).delegateTo(document,"[data-dispatch-action]:not([data-dispatch-immediately])"),document.querySelectorAll("[data-dispatch-action][data-dispatch-immediately]").forEach(this.delegateTo.bind(this))}handleClickEvent(t,e){t.preventDefault(),this.delegateTo(e)}delegateTo(t){const e=t.dataset.dispatchAction,a=r.resolveArguments(t);this.delegates[e]&&this.delegates[e].apply(null,a||[])}}return new r}));
\ No newline at end of file
+define(["require","exports","TYPO3/CMS/Backend/InfoWindow","TYPO3/CMS/Core/Event/RegularEvent","TYPO3/CMS/Backend/Toolbar/ShortcutMenu","TYPO3/CMS/Core/DocumentService"],(function(t,e,s,n,r,a){"use strict";class i{constructor(){this.delegates={},this.createDelegates(),a.ready().then(()=>this.registerEvents())}static resolveArguments(t){if(t.dataset.dispatchArgs){const e=t.dataset.dispatchArgs.replace(/&quot;/g,'"'),s=JSON.parse(e);return s instanceof Array?i.trimItems(s):null}if(t.dataset.dispatchArgsList){const e=t.dataset.dispatchArgsList.split(",");return i.trimItems(e)}return null}static trimItems(t){return t.map(t=>t instanceof String?t.trim():t)}static enrichItems(t,e,s){return t.map(t=>t instanceof Object&&t.$event?t.$target?s:t.$event?e:void 0:t)}createDelegates(){this.delegates={"TYPO3.InfoWindow.showItem":s.showItem.bind(null),"TYPO3.ShortcutMenu.createShortcut":r.createShortcut.bind(r)}}registerEvents(){new n("click",this.handleClickEvent.bind(this)).delegateTo(document,"[data-dispatch-action]")}handleClickEvent(t,e){t.preventDefault(),this.delegateTo(e)}delegateTo(t){const e=t.dataset.dispatchAction,s=i.resolveArguments(t);this.delegates[e]&&this.delegates[e].apply(null,s||[])}}return new i}));
\ No newline at end of file
diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/Element/ImmediateActionElement.js b/typo3/sysext/backend/Resources/Public/JavaScript/Element/ImmediateActionElement.js
new file mode 100644 (file)
index 0000000..77f7ac9
--- /dev/null
@@ -0,0 +1,13 @@
+/*
+ * 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!
+ */
+define(["require","exports","TYPO3/CMS/Backend/ModuleMenu","TYPO3/CMS/Backend/Viewport"],(function(e,t,n,r){"use strict";Object.defineProperty(t,"__esModule",{value:!0});class a extends HTMLElement{static getDelegate(e){switch(e){case"TYPO3.ModuleMenu.App.refreshMenu":return n.App.refreshMenu.bind(n);case"TYPO3.Backend.Topbar.refresh":return r.Topbar.refresh.bind(r.Topbar);default:throw Error('Unknown action "'+e+'"')}}static get observedAttributes(){return["action"]}attributeChangedCallback(e,t,n){"action"===e&&(this.action=n)}connectedCallback(){if(!this.action)throw new Error("Missing mandatory action attribute");a.getDelegate(this.action).apply(null,[])}}t.ImmediateActionElement=a,window.customElements.define("typo3-immediate-action",a)}));
\ No newline at end of file
diff --git a/typo3/sysext/backend/Tests/JavaScript/Element/ImmediateActionElementTest.js b/typo3/sysext/backend/Tests/JavaScript/Element/ImmediateActionElementTest.js
new file mode 100644 (file)
index 0000000..ef783e8
--- /dev/null
@@ -0,0 +1,13 @@
+/*
+ * 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!
+ */
+define(["require","exports","TYPO3/CMS/Backend/Element/ImmediateActionElement","TYPO3/CMS/Backend/ModuleMenu","TYPO3/CMS/Backend/Viewport"],(function(e,a,t,c,n){"use strict";Object.defineProperty(a,"__esModule",{value:!0}),describe("TYPO3/CMS/Backend/Element/ImmediateActionElement:",()=>{let e;beforeEach(()=>{e=document.createElement("div"),document.body.appendChild(e)}),afterEach(()=>{e.remove(),e=null}),it("dispatches action when created via constructor",()=>{const a=n.Topbar.refresh,c={callback:()=>{}};spyOn(c,"callback").and.callThrough(),n.Topbar.refresh=c.callback;const l=new t.ImmediateActionElement;l.setAttribute("action","TYPO3.Backend.Topbar.refresh"),expect(c.callback).not.toHaveBeenCalled(),e.appendChild(l),expect(c.callback).toHaveBeenCalled(),n.Topbar.refresh=a}),it("dispatches action when created via createElement",()=>{const a=n.Topbar.refresh,t={callback:()=>{}};spyOn(t,"callback").and.callThrough(),n.Topbar.refresh=t.callback;const c=document.createElement("typo3-immediate-action");c.setAttribute("action","TYPO3.Backend.Topbar.refresh"),expect(t.callback).not.toHaveBeenCalled(),e.appendChild(c),expect(t.callback).toHaveBeenCalled(),n.Topbar.refresh=a}),it("dispatches action when created from string",()=>{const a=c.App.refreshMenu,t={callback:()=>{}};spyOn(t,"callback").and.callThrough(),c.App.refreshMenu=t.callback;const n=document.createRange().createContextualFragment('<typo3-immediate-action action="TYPO3.ModuleMenu.App.refreshMenu"></typo3-immediate-action>').querySelector("typo3-immediate-action");expect(t.callback).not.toHaveBeenCalled(),e.appendChild(n),expect(t.callback).toHaveBeenCalled(),c.App.refreshMenu=a}),it("dispatches action when created via innerHTML",()=>{const a=c.App.refreshMenu,t={callback:()=>{}};spyOn(t,"callback").and.callThrough(),c.App.refreshMenu=t.callback,e.innerHTML='<typo3-immediate-action action="TYPO3.ModuleMenu.App.refreshMenu"></typo3-immediate-action>',expect(t.callback).toHaveBeenCalled(),c.App.refreshMenu=a})})}));
\ No newline at end of file
index 008a892..fd5b9b8 100644 (file)
@@ -25,10 +25,10 @@ use TYPO3\CMS\Fluid\ViewHelpers\Be\AbstractBackendViewHelper;
  * = Examples =
  *
  * <code title="Simple">
- * <em:be.container triggers="{triggers}" />
+ * <em:be.trigger triggers="TYPO3.ModuleMenu.App.refreshMenu" />
  * </code>
  * <output>
- * Writes some JS inline code
+ * Writes custom HTML instruction tags
  * </output>
  *
  * @internal
@@ -36,6 +36,11 @@ use TYPO3\CMS\Fluid\ViewHelpers\Be\AbstractBackendViewHelper;
 class TriggerViewHelper extends AbstractBackendViewHelper
 {
     /**
+     * @var bool
+     */
+    protected $escapeOutput = false;
+
+    /**
      * Initializes the arguments
      */
     public function initializeArguments()
@@ -49,25 +54,26 @@ class TriggerViewHelper extends AbstractBackendViewHelper
      * menu when modules are loaded/unloaded.
      *
      * @return string This ViewHelper does not return any content
-     * @see \TYPO3\CMS\Core\Page\PageRenderer
      */
     public function render()
     {
-        $pageRenderer = $this->getPageRenderer();
+        $html = '';
         // Handle triggers
-        if (!empty($this->arguments['triggers'][AbstractController::TRIGGER_RefreshModuleMenu])) {
-            $pageRenderer->addJsInlineCode(
-                AbstractController::TRIGGER_RefreshModuleMenu,
-                'if (top && top.TYPO3.ModuleMenu.App) { top.TYPO3.ModuleMenu.App.refreshMenu(); }'
-            );
+        $triggers = $this->arguments['triggers'] ?? [];
+        if (!empty($triggers[AbstractController::TRIGGER_RefreshModuleMenu])) {
+            $html .= $this->buildInstructionDataTag('TYPO3.ModuleMenu.App.refreshMenu');
         }
-
-        if (!empty($this->arguments['triggers'][AbstractController::TRIGGER_RefreshTopbar])) {
-            $pageRenderer->addJsInlineCode(
-                AbstractController::TRIGGER_RefreshTopbar,
-                'if (top && top.TYPO3.Backend && top.TYPO3.Backend.Topbar) { top.TYPO3.Backend.Topbar.refresh(); }'
-            );
+        if (!empty($triggers[AbstractController::TRIGGER_RefreshTopbar])) {
+            $html .= $this->buildInstructionDataTag('TYPO3.Backend.Topbar.refresh');
         }
-        return '';
+        return $html;
+    }
+
+    protected function buildInstructionDataTag(string $dispatchAction): string
+    {
+        return sprintf(
+            '<typo3-immediate-action action="%s"></typo3-immediate-action>' . "\n",
+            htmlspecialchars($dispatchAction)
+        );
     }
 }
index 1f2fece..cd8c6a9 100644 (file)
@@ -361,22 +361,16 @@ class SetupModuleController
                 $this->storeIncomingData($postData);
             }
         }
-        $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
-        $this->content .= '<form action="' . (string)$uriBuilder->buildUriFromRoute('user_setup') . '" method="post" id="SetupModuleController" name="usersetup" enctype="multipart/form-data">';
         if ($this->languageUpdate) {
-            $this->moduleTemplate->addJavaScriptCode('languageUpdate', '
-                if (top && top.TYPO3.ModuleMenu.App) {
-                    top.TYPO3.ModuleMenu.App.refreshMenu();
-                }
-                if (top && top.TYPO3.Backend.Topbar) {
-                    top.TYPO3.Backend.Topbar.refresh();
-                }
-            ');
+            $this->content .= $this->buildInstructionDataTag('TYPO3.ModuleMenu.App.refreshMenu');
+            $this->content .= $this->buildInstructionDataTag('TYPO3.Backend.Topbar.refresh');
         }
         if ($this->pagetreeNeedsRefresh) {
             BackendUtility::setUpdateSignal('updatePageTree');
         }
-        // Use a wrapper div
+
+        $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
+        $this->content .= '<form action="' . (string)$uriBuilder->buildUriFromRoute('user_setup') . '" method="post" id="SetupModuleController" name="usersetup" enctype="multipart/form-data">';
         $this->content .= '<div id="user-setup-wrapper">';
         $this->content .= $this->moduleTemplate->header($this->getLanguageService()->getLL('UserSettings'));
         $this->addFlashMessages();
@@ -402,6 +396,14 @@ class SetupModuleController
         return new HtmlResponse($this->moduleTemplate->renderContent());
     }
 
+    protected function buildInstructionDataTag(string $dispatchAction): string
+    {
+        return sprintf(
+            '<typo3-immediate-action action="%s"></typo3-immediate-action>' . "\n",
+            htmlspecialchars($dispatchAction)
+        );
+    }
+
     /**
      * Create the panel of buttons for submitting the form or otherwise perform operations.
      */