Commit b5f34156 authored by Oliver Hader's avatar Oliver Hader Committed by Oliver Hader
Browse files

[TASK] Reduce inline JavaScript in ext:setup

This change aims to reduce the amount of inline JavaScript by
removing `onchange` or `onclick` events and dynamically created
JavaScript code/settings.

* moves inline JavaScript for avatar handling to new SetupModule
* avoids using configuration options `onClick`, `onClickLabels`
  and `confirmData.jsCodeAfterOk` which contain inline JavaScript
* introduces configuration options `conformationData.eventName`
  and `clickData.eventName` to substitute mentioned deprecations
* adds PSR-14 `AddJavaScriptModulesEvent` which allows to apply
  custom RequireJS modules to handle mentioned new custom events

Resolves: #91132
Releases: master, 10.4
Change-Id: Ia68d0c473db862e0381671604347bd15ec89be35
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/64627


Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
Reviewed-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
parent 55eb17f2
/*
* 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 {ModalResponseEvent} from 'TYPO3/CMS/Backend/ModalInterface';
import {MessageUtility} from 'TYPO3/CMS/Backend/Utility/MessageUtility';
import RegularEvent = require('TYPO3/CMS/Core/Event/RegularEvent');
/**
* Module: TYPO3/CMS/Setup/SetupModule
* @exports TYPO3/CMS/Setup/SetupModule
*/
class SetupModule {
private avatarWindowRef: Window;
private static handleConfirmationResponse(evt: ModalResponseEvent): void {
if (evt.detail.result && evt.detail.payload === 'resetConfiguration') {
const input: HTMLInputElement = document.querySelector('#setValuesToDefault');
input.value = '1';
input.form.submit();
}
}
private static hideElement(element: HTMLElement): void {
element.style.display = 'none';
}
constructor() {
new RegularEvent('setup:confirmation:response', SetupModule.handleConfirmationResponse)
.delegateTo(document, '[data-event-name="setup:confirmation:response"]');
new RegularEvent('click', (e: Event, element: HTMLElement): void => {
const clickEvent = new CustomEvent(
element.dataset.eventName, {
bubbles: true,
detail: {payload: element.dataset.eventPayload}
});
element.dispatchEvent(clickEvent);
}).delegateTo(document, '[data-event="click"][data-event-name]');
document.querySelectorAll('[data-setup-avatar-field]')
.forEach((fieldElement: HTMLElement): void => {
const fieldName = fieldElement.dataset.setupAvatarField;
const clearElement = document.getElementById('clear_button_' + fieldName);
const addElement = document.getElementById('add_button_' + fieldName);
addElement.addEventListener('click', (): void => this.avatarOpenFileBrowser(fieldName, addElement.dataset.setupAvatarUrl));
clearElement && clearElement.addEventListener('click', (): void => this.avatarClearExistingImage(fieldName));
});
if (document.querySelector('[data-setup-avatar-field]') !== null) {
this.initializeMessageListener();
}
}
private initializeMessageListener(): void {
window.addEventListener('message', (evt: MessageEvent): void => {
if (!MessageUtility.verifyOrigin(evt.origin)) {
throw new Error('Denied message sent by ' + evt.origin);
}
if (evt.data.actionName === 'typo3:foreignRelation:insert') {
if (typeof evt.data.objectGroup === 'undefined') {
throw new Error('No object group defined for message');
}
const avatarMatches = evt.data.objectGroup.match(/^avatar-(.+)$/);
if (avatarMatches === null) {
// Received message isn't provisioned for current InlineControlContainer instance
return;
}
this.avatarSetFileUid(avatarMatches[1], evt.data.uid);
}
});
}
private avatarOpenFileBrowser(fieldName: string, uri: string) {
uri = uri.replace('__IDENTIFIER__', 'avatar-' + fieldName);
this.avatarWindowRef = window.open(uri, 'Typo3WinBrowser', 'height=650,width=800,status=0,menubar=0,resizable=1,scrollbars=1');
this.avatarWindowRef.focus();
}
private avatarClearExistingImage(fieldName: string) {
const fieldElement = document.getElementById('field_' + fieldName) as HTMLInputElement;
const imageElement = document.getElementById('image_' + fieldName);
const clearElement = document.getElementById('clear_button_' + fieldName);
clearElement && SetupModule.hideElement(clearElement);
imageElement && SetupModule.hideElement(imageElement);
fieldElement.value = 'delete';
}
private avatarSetFileUid(fieldName: string, fileUid: string) {
this.avatarClearExistingImage(fieldName);
const fieldElement = document.getElementById('field_' + fieldName) as HTMLInputElement;
const addElement = document.getElementById('add_button_' + fieldName);
fieldElement.value = fileUid;
addElement.classList.remove('btn-default');
addElement.classList.add('btn-info');
if (this.avatarWindowRef instanceof Window && !this.avatarWindowRef.closed) {
this.avatarWindowRef.close();
this.avatarWindowRef = null;
}
}
}
export = new SetupModule();
......@@ -98,6 +98,10 @@
"../typo3/sysext/scheduler/Resources/Public/JavaScript/*",
"scheduler/Resources/Public/TypeScript/*"
],
"TYPO3/CMS/Setup/*": [
"../typo3/sysext/setup/Resources/Public/JavaScript/*",
"setup/Resources/Public/TypeScript/*"
],
"TYPO3/CMS/T3editor/*": [
"../typo3/sysext/t3editor/Resources/Public/JavaScript/*",
"t3editor/Resources/Public/TypeScript/*"
......
.. include:: ../../Includes.txt
==================================================================
Feature: #91132 - Introduce User Settings JavaScript Modules Event
==================================================================
See :issue:`91132`
Description
===========
JavaScript events in custom User Settings Configuration options shall
not be placed as inline JavaScript anymore, but utilize a dedicated
JavaScript module to handle custom events
(see :ref:`Important #91132 <changelog-Important-91132-AvoidJavaScriptInUserSettingsConfigurationOptions>`)
This new PSR-14 event is introduced:
* :php:`\TYPO3\CMS\SetupEvent\AddJavaScriptModulesEvent`
These public methods are exposed:
* :php:`public function addModule(string $moduleName): void`
* :php:`public function getModules(): array`
:php:`$moduleName` refers to the JavaScript module to be loaded with RequireJS
(e.g. `TYPO3/CMS/MyExtension/CustomUserSettingsModule`).
Example
=======
A listener using mentioned PSR-14 event could look like the following.
.. rst-class:: bignums
1. Register listener
:file:`typo3conf/my-extension/Configuration/Services.yaml`
.. code-block:: yaml
services:
MyVendor\MyExtension\EventListener\CustomUserSettingsListener:
tags:
- name: event.listener
identifier: 'myExtension/CustomUserSettingsListener'
event: TYPO3\CMS\SetupEvent\AddJavaScriptModulesEvent
2. Implement Listener to load JavaScript module `TYPO3/CMS/MyExtension/CustomUserSettingsModule`
.. code-block:: php
namespace MyVendor\MyExtension\EventListener;
use TYPO3\CMS\SetupEvent\AddJavaScriptModulesEvent;
class CustomUserSettingsListener
{
// name of JavaScript module to be loaded
private const MODULE_NAME = 'TYPO3/CMS/MyExtension/CustomUserSettingsModule';
public function __invoke(AddJavaScriptModulesEvent $event): void
{
$javaScriptModuleName = 'TYPO3/CMS/MyExtension/CustomUserSettings';
if (in_array(self::MODULE_NAME, $event->getModules(), true)) {
return;
}
$event->addModule(self::MODULE_NAME);
}
}
Related
=======
- :ref:`changelog-Important-91132-AvoidJavaScriptInUserSettingsConfigurationOptions`
.. index:: PHP-API, ext:core
.. include:: ../../Includes.txt
===========================================================================
Important: #91132 - Avoid JavaScript in User Settings Configuration options
===========================================================================
See :issue:`91132`
Description
===========
User Settings Configuration options for buttons `onClick` and `onClickLabels`
(used to generate inline JavaScript `onclick` event) and `confirmData.jsCodeAfterOk`
(used to execute a JavaScript callback in modal confirmations) should be omitted.
New options `clickData.eventName` and `conformationData.eventName` should be used
containing an individual event name that has to be handled individually using a
static JavaScript module.
This step is advised to reduce the amount of inline JavaScript code towards
better support for Content-Security-Policy headers.
Applications having custom changes in :php:`$GLOBALS['TYPO3_USER_SETTINGS']`
and using mentioned options `onClick*` or ``confirmData.jsCodeAfterOk`.
The following example show a potential migration path to avoid inline JavaScript.
.. code-block:: php
$GLOBALS['TYPO3_USER_SETTINGS'] = [
'columns' => [
'customButton' => [
'type' => 'button',
'onClick' => 'alert("clicked the button")',
'confirm' => true,
'confirmData' => [
'message' => 'Please confirm...',
'jsCodeAfterOk' => 'alert("confirmed the modal dialog")',
]
],
// ...
The above configuration can be replace by the the following.
.. code-block:: php
$GLOBALS['TYPO3_USER_SETTINGS'] = [
'columns' => [
'customButton' => [
'type' => 'button',
'clickData' => [
'eventName' => 'setup:customButton:clicked',
],
'confirm' => true,
'confirmData' => [
'message' => 'Please confirm...',
'eventName' => 'setup:customButton:confirmed',
]
],
// ...
Events declared in corresponding `eventName` options have to be handled by
a custom static JavaScript module. Following snippets show the relevant parts:
.. code-block:: javascript
document.querySelectorAll('[data-event-name]')
.forEach((element: HTMLElement) => {
element.addEventListener('setup:customButton:clicked', (evt: Event) => {
alert('clicked the button');
});
});
document.querySelectorAll('[data-event-name]')
.forEach((element: HTMLElement) => {
element.addEventListener('setup:customButton:confirmed', (evt: Event) => {
evt.detail.result && alert('confirmed the modal dialog');
});
});
PSR-14 event :php:`\TYPO3\CMS\Setup\Event\AddJavaScriptModulesEvent` can be used
to inject a JavaScript module to handle those custom JavaScript events.
.. index:: Backend, NotScanned, ext:setup
......@@ -15,6 +15,7 @@
namespace TYPO3\CMS\Setup\Controller;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Backend\Avatar\DefaultAvatarProvider;
......@@ -36,12 +37,14 @@ use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Localization\Locales;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Resource\Exception\FileDoesNotExistException;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\SysLog\Action\Setting as SystemLogSettingAction;
use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
use TYPO3\CMS\Core\SysLog\Type as SystemLogType;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Setup\Event\AddJavaScriptModulesEvent;
/**
* Script class for the Setup module
......@@ -143,16 +146,26 @@ class SetupModuleController
*/
protected $moduleTemplate;
/**
* @var EventDispatcherInterface
*/
protected $eventDispatcher;
/**
* Instantiate the form protection before a simulated user is initialized.
*
* @param EventDispatcherInterface $eventDispatcher
*/
public function __construct()
public function __construct(EventDispatcherInterface $eventDispatcher)
{
$this->eventDispatcher = $eventDispatcher;
$this->moduleTemplate = GeneralUtility::makeInstance(ModuleTemplate::class);
$this->formProtection = FormProtectionFactory::get();
$pageRenderer = $this->moduleTemplate->getPageRenderer();
$pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/Modal');
$pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/FormEngine');
$pageRenderer->loadRequireJsModule('TYPO3/CMS/Setup/SetupModule');
$this->processAdditionalJavaScriptModules($pageRenderer);
$pageRenderer->addInlineSetting('FormEngine', 'formName', 'editform');
$pageRenderer->addInlineLanguageLabelArray([
'FormEngine.remainingCharacters' => $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.remainingCharacters'),
......@@ -160,6 +173,16 @@ class SetupModuleController
$pageRenderer->addJsFile('EXT:backend/Resources/Public/JavaScript/md5.js');
}
protected function processAdditionalJavaScriptModules(PageRenderer $pageRenderer): void
{
$event = new AddJavaScriptModulesEvent();
/** @var AddJavaScriptModulesEvent $event */
$event = $this->eventDispatcher->dispatch($event);
foreach ($event->getModules() as $moduleName) {
$pageRenderer->loadRequireJsModule($moduleName);
}
}
/**
* Initializes the module for display of the settings form.
*/
......@@ -420,6 +443,7 @@ class SetupModuleController
protected function renderUserSetup()
{
$backendUser = $this->getBackendUser();
$uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
$html = '';
$result = [];
$firstTabLabel = '';
......@@ -527,7 +551,25 @@ class SetupModuleController
$html = GeneralUtility::callUserFunction($config['userFunc'], $config, $this);
break;
case 'button':
if ($config['onClick']) {
if (!empty($config['clickData'])) {
$clickData = $config['clickData'];
$buttonAttributes = [
'type' => 'button',
'class' => 'btn btn-default',
'aria-labelledby' => 'label_' . htmlspecialchars($fieldName),
'value' => $this->getLabel($config['buttonlabel'], '', false),
];
if (isset($clickData['eventName'])) {
$buttonAttributes['data-event'] = 'click';
$buttonAttributes['data-event-name'] = htmlspecialchars($clickData['eventName']);
$buttonAttributes['data-event-payload'] = htmlspecialchars($fieldName);
}
$html = '<br><input '
. GeneralUtility::implodeAttributes($buttonAttributes, false) . ' />';
} elseif (!empty($config['onClick'])) {
/**
* @deprecated Will be removed in TYPO3 v12.0
*/
$onClick = $config['onClick'];
if ($config['onClickLabels']) {
foreach ($config['onClickLabels'] as $key => $labelclick) {
......@@ -542,12 +584,28 @@ class SetupModuleController
}
if (!empty($config['confirm'])) {
$confirmData = $config['confirmData'];
$html = '<br><input class="btn btn-default t3js-modal-trigger" type="button"'
. ' value="' . $this->getLabel($config['buttonlabel'], '', false) . '"'
. ' data-href="javascript:' . htmlspecialchars($confirmData['jsCodeAfterOk']) . '"'
. ' data-severity="warning"'
. ' data-title="' . $this->getLabel($config['label'], '', false) . '"'
. ' data-content="' . $this->getLabel($confirmData['message'], '', false) . '" />';
// cave: values must be processed by `htmlspecialchars()`
$buttonAttributes = [
'type' => 'button',
'class' => 'btn btn-default t3js-modal-trigger',
'data-severity' => 'warning',
'data-title' => $this->getLabel($config['label'], '', false),
'data-content' => $this->getLabel($confirmData['message'], '', false),
'value' => htmlspecialchars($this->getLabel($config['buttonlabel'], '', false)),
];
if (isset($confirmData['eventName'])) {
$buttonAttributes['data-event'] = 'confirm';
$buttonAttributes['data-event-name'] = htmlspecialchars($confirmData['eventName']);
$buttonAttributes['data-event-payload'] = htmlspecialchars($fieldName);
}
if (isset($confirmData['jsCodeAfterOk'])) {
/**
* @deprecated Will be removed in TYPO3 v12.0
*/
$buttonAttributes['data-href'] = 'javascript:' . htmlspecialchars($confirmData['jsCodeAfterOk']);
}
$html = '<br><input '
. GeneralUtility::implodeAttributes($buttonAttributes, false) . ' />';
}
break;
case 'avatar':
......@@ -569,25 +627,23 @@ class SetupModuleController
}
$html .= '<input id="field_' . htmlspecialchars($fieldName) . '" type="hidden" ' .
'name="data' . $dataAdd . '[' . htmlspecialchars($fieldName) . ']"' . $more .
' value="' . (int)$avatarFileUid . '" />';
' value="' . (int)$avatarFileUid . '" data-setup-avatar-field="' . htmlspecialchars($fieldName) . '" />';
$html .= '<div class="btn-group">';
$iconFactory = GeneralUtility::makeInstance(IconFactory::class);
if ($avatarFileUid) {
$html .=
'<button type="button" id="clear_button_' . htmlspecialchars($fieldName) . '" aria-label="' . htmlspecialchars($this->getLanguageService()->getLL('avatar.clear')) . '" '
. 'onclick="clearExistingImage(); return false;" class="btn btn-default">'
. ' class="btn btn-default">'
. $iconFactory->getIcon('actions-delete', Icon::SIZE_SMALL)
. '</button>';
}
$html .=
'<button type="button" id="add_button_' . htmlspecialchars($fieldName) . '" class="btn btn-default btn-add-avatar"'
. ' aria-label="' . htmlspecialchars($this->getLanguageService()->getLL('avatar.openFileBrowser')) . '"'
. ' onclick="openFileBrowser();return false;">'
. $iconFactory->getIcon('actions-insert-record', Icon::SIZE_SMALL)
. ' data-setup-avatar-url="' . htmlspecialchars((string)$uriBuilder->buildUriFromRoute('wizard_element_browser', ['mode' => 'file', 'bparams' => '||||__IDENTIFIER__'])) . '"'
. '>' . $iconFactory->getIcon('actions-insert-record', Icon::SIZE_SMALL)
. '</button></div>';
$this->addAvatarButtonJs($fieldName);
break;
default:
$html = '';
......@@ -926,58 +982,6 @@ class SetupModuleController
}
}
/**
* Add JavaScript to for browse files button
*
* @param string $fieldName
*/
protected function addAvatarButtonJs($fieldName)
{
$uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
$this->moduleTemplate->addJavaScriptCode('avatar-button', '
var browserWin="";
require([\'TYPO3/CMS/Backend/Utility/MessageUtility\'], function(MessageUtility) {
window.addEventListener(\'message\', function (e) {
MessageUtility.MessageUtility.verifyOrigin
if (!MessageUtility.MessageUtility.verifyOrigin(e.origin)) {
throw \'Denied message sent by \' + e.origin;
}
if (e.data.actionName === \'typo3:foreignRelation:insert\') {
if (typeof e.data.objectGroup === \'undefined\') {
throw \'No object group defined for message\';
}
if (e.data.objectGroup !== \'dummy\') {
// Received message isn\'t provisioned for current InlineControlContainer instance
return;
}
setFileUid(\'avatar\', e.data.uid);
}
});
});
function openFileBrowser() {
var url = ' . GeneralUtility::quoteJSvalue((string)$uriBuilder->buildUriFromRoute('wizard_element_browser', ['mode' => 'file', 'bparams' => '||||dummy'])) . ';
browserWin = window.open(url,"Typo3WinBrowser","height=650,width=800,status=0,menubar=0,resizable=1,scrollbars=1");
browserWin.focus();
}
function clearExistingImage() {
$(' . GeneralUtility::quoteJSvalue('#image_' . htmlspecialchars($fieldName)) . ').hide();
$(' . GeneralUtility::quoteJSvalue('#clear_button_' . htmlspecialchars($fieldName)) . ').hide();
$(' . GeneralUtility::quoteJSvalue('#field_' . htmlspecialchars($fieldName)) . ').val(\'delete\');
}
function setFileUid(field, fileUid) {
clearExistingImage();
$(' . GeneralUtility::quoteJSvalue('#field_' . htmlspecialchars($fieldName)) . ').val(fileUid);
$(' . GeneralUtility::quoteJSvalue('#add_button_' . htmlspecialchars($fieldName)) . ').removeClass(\'btn-default\').addClass(\'btn-info\');
browserWin.close();
}
');
}
/**
* Returns the current BE user.
*
......
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Setup\Event;
/**
* Collects additional JavaScript modules to be loaded in SetupModuleController.
*/
final class AddJavaScriptModulesEvent
{
/**
* @var string[]
*/
private $modules = [];
public function addModule(string $moduleName): void
{
if (in_array($moduleName, $this->modules, true)) {
return;
}
$this->modules[] = $moduleName;
}
/**
* @return string[]
*/
public function getModules(): array
{
return $this->modules;
}
}
......@@ -6,3 +6,7 @@ services:
TYPO3\CMS\Setup\:
resource: '../Classes/*'
TYPO3\CMS\Setup\Controller\SetupModuleController:
shared: false
public: true
/*
* 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