Commit 71924929 authored by Oliver Bartsch's avatar Oliver Bartsch Committed by Benni Mack
Browse files

[TASK] Get rid of inline JavaScript in new content element wizard

This patch cleans up the NewContentElementController, used
for the "new content element wizard". All inline JavaScript
is removed, in favour of a custom web component and an improved
JavaScript module, handling the two modes (create content
with known colPos and create content while selecting the
desired colPos).

Previously, the wizard was triggered by a t3js-* class.
Since e.g. the fluid based page module related templates
might be overridden in extension code, a fallback layer
is present to still support this way of initialization
in v11.

Due to the necessary refactoring, some related bugs are fixed:

* Accessing the wizard via the context menu now works correctly
* The "saveAndClose" feature is now also respected in case the
  user has to choose the desired colPos
* Using a custom wizard endpoint (mod.newContentElementWizard.override)
  does no longer initialize the TYPO3 related JavaScript, which
  previously led to a JavaScript TypeError
* A duplicated clear "icon" in the elements filter is removed

Resolves: #95277
Resolves: #95375
Resolves: #95376
Releases: master
Change-Id: I6e9b260938c934222e479c1a93c69ba6f27eec4b
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/71318


Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Reviewed-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
parent dfeb6133
......@@ -22,6 +22,7 @@ import ModuleMenu = require('./ModuleMenu');
import Notification = require('TYPO3/CMS/Backend/Notification');
import Viewport = require('./Viewport');
import {ModuleStateStorage} from './Storage/ModuleStateStorage';
import {NewContentElementWizard} from 'TYPO3/CMS/Backend/NewContentElementWizard';
/**
* @exports TYPO3/CMS/Backend/ContextMenuActions
......@@ -107,6 +108,12 @@ class ContextMenuActions {
size: Modal.sizes.medium,
content: $wizardUrl,
severity: SeverityEnum.notice,
ajaxCallback: (): void => {
const currentModal: HTMLElement = Modal.currentModal.get(0);
if (currentModal && currentModal.querySelector('.t3-new-content-element-wizard-inner')) {
new NewContentElementWizard(currentModal);
}
}
});
}
}
......
......@@ -11,13 +11,40 @@
* The TYPO3 project - inspiring people to share!
*/
import DebounceEvent = require('TYPO3/CMS/Core/Event/DebounceEvent');
import Modal = require('TYPO3/CMS/Backend/Modal');
import Notification = require('TYPO3/CMS/Backend/Notification');
import RegularEvent = require('TYPO3/CMS/Core/Event/RegularEvent');
import './Input/Clearable';
import DebounceEvent = require('TYPO3/CMS/Core/Event/DebounceEvent');
import AjaxRequest from 'TYPO3/CMS/Core/Ajax/AjaxRequest';
import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse';
interface PositionMapArguments {
url: string,
defVals: Array<any>,
saveAndClose: boolean
}
export default class NewContentElementWizard {
private readonly context: Element;
private readonly searchField: HTMLInputElement;
enum ClassNames {
wizardWindow = 't3-new-content-element-wizard-window'
}
enum Selectors {
modalBodySelector = '.t3js-modal-body',
modalTabsSelector = '.t3js-tabs',
elementsFilterSelector = '.t3js-contentWizard-search',
noResultSelector = '.t3js-filter-noresult',
wizardWindowSelector = '.t3-new-content-element-wizard-window',
wizardElementSelector = '.t3js-media-new-content-element-wizard',
wizardElementWithTargetSelector = 'button[data-target]',
wizardElementWithPositionMapArugmentsSelector = 'button[data-position-map-arguments]'
}
/**
* Module: TYPO3/CMS/Backend/NewContentElementWizard
*/
export class NewContentElementWizard {
private readonly modal: HTMLElement;
private readonly elementsFilter: HTMLInputElement;
private static getTabIdentifier(tab: Element): string {
const tabLink = tab.querySelector('a') as HTMLAnchorElement;
......@@ -26,65 +53,90 @@ export default class NewContentElementWizard {
}
private static countVisibleContentElements(container: Element): number {
return container.querySelectorAll('.t3js-media-new-content-element-wizard:not(.hidden)').length;
return container.querySelectorAll(Selectors.wizardElementSelector + ':not(.hidden)').length;
}
constructor(context: JQuery) {
this.context = context.get(0);
this.searchField = this.context.querySelector('.t3js-contentWizard-search');
this.registerClearable();
this.registerEvents();
}
constructor(modal: HTMLElement) {
this.modal = modal;
this.elementsFilter = this.modal.querySelector(Selectors.elementsFilterSelector);
public focusSearchField(): void {
this.searchField.focus();
}
// Add new content element specific class to the modal body
this.modal.querySelector(Selectors.modalBodySelector)?.classList.add(ClassNames.wizardWindow);
private registerClearable(): void {
this.searchField.clearable({
onClear: (input: HTMLInputElement): void => {
input.value = '';
this.filterElements(input);
},
});
this.registerEvents();
}
private registerEvents(): void {
new RegularEvent('shown.bs.modal', (): void => {
this.elementsFilter.focus();
}).bindTo(this.modal);
new RegularEvent('keydown', (e: KeyboardEvent): void => {
const target = e.target as HTMLInputElement;
if (e.code === 'Escape') {
e.stopImmediatePropagation();
target.value = '';
}
}).bindTo(this.searchField);
}).bindTo(this.elementsFilter);
new DebounceEvent('keyup', (e: KeyboardEvent): void => {
this.filterElements(e.target as HTMLInputElement);
}, 150).bindTo(this.searchField);
}, 150).bindTo(this.elementsFilter);
new RegularEvent('submit', (e: Event): void => {
e.preventDefault();
}).bindTo(this.searchField.closest('form'));
new RegularEvent('search', (e: Event): void => {
this.filterElements(e.target as HTMLInputElement);
}).bindTo(this.elementsFilter);
new RegularEvent('click', (e: Event): void => {
new RegularEvent('click', (e: PointerEvent): void => {
e.preventDefault();
e.stopPropagation();
}).delegateTo(this.context, '.t3js-tabs .disabled');
}).delegateTo(this.modal, [Selectors.modalTabsSelector, '.disabled'].join(' '));
new RegularEvent('click', (e: PointerEvent, eventTarget: HTMLButtonElement): void => {
e.preventDefault();
const target: string = eventTarget.dataset.target;
if (!target) {
// Skip in case no target defined
return;
}
// Close modal and call target
Modal.dismiss();
top.list_frame.location.href = target;
}).delegateTo(this.modal, [Selectors.wizardWindowSelector, Selectors.wizardElementWithTargetSelector].join(' '));
new RegularEvent('click', (e: PointerEvent, eventTarget: HTMLButtonElement): void => {
e.preventDefault();
if (!eventTarget.dataset.positionMapArguments) {
// In case parameters are empty, skip this item
return;
}
const positionMapArguments: PositionMapArguments = JSON.parse(eventTarget.dataset.positionMapArguments);
if (!positionMapArguments.url) {
// In case no url is given in the parameters, skip the item
return;
}
(new AjaxRequest(positionMapArguments.url)).post({
defVals: positionMapArguments.defVals,
saveAndClose: positionMapArguments.saveAndClose ? '1' : '0'
}).then(async (response: AjaxResponse): Promise<any> => {
this.modal.querySelector(Selectors.wizardWindowSelector).innerHTML = await response.raw().text();
}).catch((): void => {
Notification.error('Could not load module data');
});
}).delegateTo(this.modal, [Selectors.wizardWindowSelector, Selectors.wizardElementWithPositionMapArugmentsSelector].join(' '));
}
private filterElements(inputField: HTMLInputElement): void {
const form = inputField.closest('form');
const tabContainer = form.querySelector('.t3js-tabs');
const nothingFoundAlert = form.querySelector('.t3js-filter-noresult');
const tabContainer = this.modal.querySelector(Selectors.modalTabsSelector);
const nothingFoundAlert = this.modal.querySelector(Selectors.noResultSelector);
form.querySelectorAll('.t3js-media-new-content-element-wizard').forEach((element: Element): void => {
this.modal.querySelectorAll(Selectors.wizardElementSelector).forEach((element: Element): void => {
// Clean up textContent by trimming and replacing consecutive spaces with a single space
const textContent = element.textContent.trim().replace(/\s+/g, ' ');
element.classList.toggle('hidden', inputField.value !== '' && !RegExp(inputField.value, 'i').test(textContent));
});
const visibleContentElements = NewContentElementWizard.countVisibleContentElements(form);
const visibleContentElements = NewContentElementWizard.countVisibleContentElements(this.modal);
tabContainer.parentElement.classList.toggle('hidden', visibleContentElements === 0);
nothingFoundAlert.classList.toggle('hidden', visibleContentElements > 0);
this.switchTabIfNecessary(tabContainer);
......@@ -122,7 +174,7 @@ export default class NewContentElementWizard {
}
private hasTabContent(tabIdentifier: string): boolean {
const tabContentContainer = this.context.querySelector(`#${tabIdentifier}`);
const tabContentContainer = this.modal.querySelector(`#${tabIdentifier}`);
return NewContentElementWizard.countVisibleContentElements(tabContentContainer) > 0;
}
......@@ -135,7 +187,7 @@ export default class NewContentElementWizard {
*/
private switchTab(tabContainerWrapper: HTMLElement, tabIdentifier: string): void {
const tabElement = tabContainerWrapper.querySelector(`a[href="#${tabIdentifier}"]`);
const tabContentElement = this.context.querySelector(`#${tabIdentifier}`);
const tabContentElement = this.modal.querySelector(`#${tabIdentifier}`);
tabContainerWrapper.querySelector('a.active').classList.remove('active');
tabContainerWrapper.querySelector('.tab-pane.active').classList.remove('active');
......
/*
* 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 {customElement, property} from 'lit/decorators';
import {html, LitElement, TemplateResult} from 'lit';
import Modal = require('TYPO3/CMS/Backend/Modal');
import {SeverityEnum} from 'TYPO3/CMS/Backend/Enum/Severity';
import {NewContentElementWizard} from 'TYPO3/CMS/Backend/NewContentElementWizard';
/**
* Module: TYPO3/CMS/Backend/NewContentElementWizardButton
*
* @example
* <typo3-backend-new-content-element-wizard-button url="link/to/endpoint" title="Wizard title" ></typo3-backend-new-content-element-wizard-button>
*/
@customElement('typo3-backend-new-content-element-wizard-button')
export class NewContentElementWizardButton extends LitElement {
@property({type: String}) url: string;
@property({type: String}) title: string;
private static handleModalContentLoaded(currentModal: HTMLElement): void {
if (!currentModal || !currentModal.querySelector('.t3-new-content-element-wizard-inner')) {
// Return in case modal is not defined or we deal with a custom wizard (mod.newContentElementWizard.override)
return;
}
// Initialize the wizard functions
new NewContentElementWizard(currentModal);
}
public constructor() {
super();
this.addEventListener('click', (e: Event): void => {
e.preventDefault();
this.renderWizard();
});
}
protected render(): TemplateResult {
return html`<slot></slot>`;
}
private renderWizard(): void
{
if (!this.url) {
// Return in case no url is defined
return;
}
Modal.advanced({
content: this.url,
title: this.title,
severity: SeverityEnum.notice,
size: Modal.sizes.medium,
type: Modal.types.ajax,
ajaxCallback: (): void => NewContentElementWizardButton.handleModalContentLoaded(Modal.currentModal[0])
});
}
}
......@@ -14,8 +14,11 @@
import { KeyTypesEnum } from './Enum/KeyTypes';
import $ from 'jquery';
import PersistentStorage = require('./Storage/Persistent');
import NewContentElement = require('./Wizard/NewContentElement');
import 'TYPO3/CMS/Backend/Element/IconElement';
import 'TYPO3/CMS/Backend/NewContentElementWizardButton';
import {renderNodes} from 'TYPO3/CMS/Core/lit-helper';
import {unsafeHTML} from 'lit/directives/unsafe-html';
import {html} from 'lit';
enum IdentifierEnum {
pageTitle = '.t3js-title-inlineedit',
......@@ -220,16 +223,25 @@ class PageActions {
/**
* Activate New Content Element Wizard
* @deprecated This is a fallback layer for extensions, still using the trigger class - Will be removed in v12
*/
private initializeNewContentElementWizard(): void {
if (document.querySelectorAll(IdentifierEnum.newButton).length) {
console.warn('Usage of the .t3js-toggle-new-content-element-wizard class is deprecated and will ' +
'be removed in v12. Use the typo3-backend-new-content-element-wizard-button web component instead.')
}
// Replace each element with the custom element (web component)
Array.from(document.querySelectorAll(IdentifierEnum.newButton)).forEach((element: HTMLElement): void => {
element.classList.remove('disabled');
});
$(IdentifierEnum.newButton).on('click', (e: JQueryEventObject): void => {
e.preventDefault();
const $me = $(e.currentTarget);
NewContentElement.wizard($me.attr('href'), $me.data('title'));
element.classList.remove(IdentifierEnum.newButton.substring(1), 'disabled');
const wizardButton: DocumentFragment = document.createDocumentFragment();
renderNodes(html`
<typo3-backend-new-content-element-wizard-button
title="${element.dataset.title || element.title || ''}"
url="${element instanceof HTMLAnchorElement ? element.href : element.dataset.target || ''}">
<button type="button" class="${element.classList.toString()}">${unsafeHTML(element.innerHTML)}</button>
</typo3-backend-new-content-element-wizard-button>
`).forEach((node: Node): Node => wizardButton.appendChild(node));
element.parentNode.replaceChild(wizardButton, element);
});
}
}
......
/*
* 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 {SeverityEnum} from '../Enum/Severity';
import Modal = require('../Modal');
import NewContentElementWizard from 'TYPO3/CMS/Backend/NewContentElementWizard';
/**
* Module: TYPO3/CMS/Backend/Wizard/NewContentElement
* NewContentElement JavaScript
* @exports TYPO3/CMS/Backend/Wizard/NewContentElement
*/
class NewContentElement {
public static wizard(url: string, title: string): void {
const $modal = Modal.advanced({
callback: (currentModal: JQuery) => {
currentModal.find('.t3js-modal-body').addClass('t3-new-content-element-wizard-window');
},
content: url,
severity: SeverityEnum.notice,
size: Modal.sizes.medium,
title,
type: Modal.types.ajax,
}).on('modal-loaded', (): void => {
// This rather works in local environments only
$modal.on('shown.bs.modal', (): void => {
const wizard = new NewContentElementWizard($modal);
wizard.focusSearchField();
});
}).on('shown.bs.modal', (): void => {
// This is the common case with any latency that the modal is rendered before the content is loaded
$modal.on('modal-loaded', (): void => {
const wizard = new NewContentElementWizard($modal);
wizard.focusSearchField();
});
});
}
}
export = NewContentElement;
......@@ -77,13 +77,6 @@ class NewContentElementController
*/
protected $uid_pid;
/**
* config of the wizard
*
* @var array
*/
protected $config;
/**
* @var array
*/
......@@ -112,20 +105,21 @@ class NewContentElementController
}
/**
* Constructor, initializing internal variables.
* Process incoming request and dispatch to the requested action
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
protected function init(ServerRequestInterface $request)
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$this->view = $this->getFluidTemplateObject();
$lang = $this->getLanguageService();
$lang->includeLLFile('EXT:core/Resources/Private/Language/locallang_misc.xlf');
$lang->includeLLFile('EXT:backend/Resources/Private/Language/locallang_db_new_content_el.xlf');
$parsedBody = $request->getParsedBody();
$queryParams = $request->getQueryParams();
$action = (string)($parsedBody['action'] ?? $queryParams['action'] ?? 'wizard');
if (!in_array($action, ['wizard', 'positionMap'], true)) {
return new HtmlResponse('Action not allowed', 400);
}
// Setting internal vars:
$this->id = (int)($parsedBody['id'] ?? $queryParams['id'] ?? 0);
$this->sys_language = (int)($parsedBody['sys_language_uid'] ?? $queryParams['sys_language_uid'] ?? 0);
......@@ -133,42 +127,33 @@ class NewContentElementController
$colPos = $parsedBody['colPos'] ?? $queryParams['colPos'] ?? null;
$this->colPos = $colPos === null ? null : (int)$colPos;
$this->uid_pid = (int)($parsedBody['uid_pid'] ?? $queryParams['uid_pid'] ?? 0);
$this->config = BackendUtility::getPagesTSconfig($this->id)['mod.']['wizards.']['newContentElement.']['wizardItems.'] ?? [];
// Setting up the context sensitive menu:
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/ContextMenu');
// Getting the current page and receiving access information (used in main())
// Getting the current page and receiving access information
$this->pageInfo = BackendUtility::readPageAccess($this->id, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW)) ?: [];
// Initializing the view by forwarding the requested action as template name
$this->view = $this->getFluidTemplateObject(ucfirst($action));
// Call action and return the response
return $this->{$action . 'Action'}($request);
}
/**
* Injects the request object for the current request or subrequest
* As this controller goes only through the main() method, it is rather simple for now
*
* @param ServerRequestInterface $request the current request
* @return ResponseInterface the response with the content
* Renders the wizard
*/
public function wizardAction(ServerRequestInterface $request): ResponseInterface
{
$this->init($request);
$this->prepareContent($request);
$this->prepareWizardContent($request);
return new HtmlResponse($this->view->render());
}
/**
* Create on-click event value.
*
* @return string
* Renders the position map
*/
protected function onClickInsertRecord(): string
public function positionMapAction(ServerRequestInterface $request): ResponseInterface
{
// $this->uid_pid can be negative (= pointing to tt_content record) or positive (= "page ID")
$location = (string)$this->uriBuilder->buildUriFromRoute('record_edit', [
'edit[tt_content][' . $this->uid_pid . ']' => 'new',
'defVals[tt_content][colPos]' => $this->colPos,
'defVals[tt_content][sys_language_uid]' => $this->sys_language,
'returnUrl' => $this->R_URI,
]);
return 'list_frame.location.href=' . GeneralUtility::quoteJSvalue($location) . '+document.editForm.defValues.value; return false;';
$this->preparePositionMap($request);
return new HtmlResponse($this->view->render());
}
/**
......@@ -176,26 +161,21 @@ class NewContentElementController
*
* @throws \UnexpectedValueException
*/
protected function prepareContent(ServerRequestInterface $request): void
protected function prepareWizardContent(ServerRequestInterface $request): void
{
$hasAccess = $this->id && $this->pageInfo !== [];
$this->view->assign('hasAccess', $hasAccess);
if (!$hasAccess) {
return;
}
// If a column is pre-set
if (isset($this->colPos)) {
$onClickEvent = $this->onClickInsertRecord();
} else {
$onClickEvent = '';
}
// ***************************
// Creating content
// ***************************
// Wizard
// Whether position selection must be performed (no colPos was yet defined)
$positionSelection = !isset($this->colPos);
$this->view->assign('positionSelection', $positionSelection);
// Get processed wizard items from configuration
$wizardItems = $this->getWizards();
// Wrapper for wizards
// Hook for manipulating wizardItems, wrapper, onClickEvent etc.
// Call hooks for manipulating the wizard items
foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms']['db_new_content_el']['wizardItemsHook'] ?? [] as $className) {
$hookObject = GeneralUtility::makeInstance($className);
if (!$hookObject instanceof NewContentElementWizardHookInterface) {
......@@ -207,18 +187,11 @@ class NewContentElementController
$hookObject->manipulateWizardItems($wizardItems, $this);
}
// Traverse items for the wizard.
// An item is either a header or an item rendered with a radio button and title/description and icon:
$cc = ($key = 0);
$key = 0;
$menuItems = [];
$this->view->assignMultiple([
'hasClickEvent' => $onClickEvent !== '',
'onClickEvent' => 'function goToalt_doc() { ' . $onClickEvent . '}',
]);
// Traverse items for the wizard.
// An item is either a header or an item rendered with title/description and icon:
foreach ($wizardItems as $wizardKey => $wInfo) {
$wizardOnClick = '';
if (isset($wInfo['header'])) {
$menuItems[] = [
'label' => $wInfo['header'] ?: '-',
......@@ -226,43 +199,59 @@ class NewContentElementController
];
$key = count($menuItems) - 1;
} else {
if (!$onClickEvent) {
// Radio button:
$wizardOnClick = 'document.editForm.defValues.value=unescape(' . GeneralUtility::quoteJSvalue(rawurlencode($wInfo['params'])) . '); window.location.hash=\'#sel2\';';
// Onclick action for icon/title:
$aOnClick = 'document.getElementsByName(\'tempB\')[' . $cc . '].checked=1;' . $wizardOnClick . 'return false;';
} else {
$aOnClick = "document.editForm.defValues.value=unescape('" . rawurlencode($wInfo['params']) . "');goToalt_doc();";
}
// Initialize the view variables for the item
$viewVariables = [
'wizardInformation' => $wInfo,
'wizardKey' => $wizardKey,
'icon' => $this->iconFactory->getIcon(($wInfo['iconIdentifier'] ?? ''), Icon::SIZE_DEFAULT, ($wInfo['iconOverlay'] ?? ''))->render(),
];
// Go to DataHandler directly instead of FormEngine - Only when colPos must not be selected
if (($wInfo['saveAndClose'] ?? false) && $onClickEvent !== '') {
$urlParams = [];
$id = StringUtility::getUniqueId('NEW');
parse_str($wInfo['params'], $urlParams);
$urlParams['data']['tt_content'][$id] = $urlParams['defVals']['tt_content'] ?? [];
$urlParams['data']['tt_content'][$id]['colPos'] = $this->colPos;
$urlParams['data']['tt_content'][$id]['pid'] = $this->uid_pid;