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'); ...@@ -22,6 +22,7 @@ import ModuleMenu = require('./ModuleMenu');
import Notification = require('TYPO3/CMS/Backend/Notification'); import Notification = require('TYPO3/CMS/Backend/Notification');
import Viewport = require('./Viewport'); import Viewport = require('./Viewport');
import {ModuleStateStorage} from './Storage/ModuleStateStorage'; import {ModuleStateStorage} from './Storage/ModuleStateStorage';
import {NewContentElementWizard} from 'TYPO3/CMS/Backend/NewContentElementWizard';
/** /**
* @exports TYPO3/CMS/Backend/ContextMenuActions * @exports TYPO3/CMS/Backend/ContextMenuActions
...@@ -107,6 +108,12 @@ class ContextMenuActions { ...@@ -107,6 +108,12 @@ class ContextMenuActions {
size: Modal.sizes.medium, size: Modal.sizes.medium,
content: $wizardUrl, content: $wizardUrl,
severity: SeverityEnum.notice, 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 @@ ...@@ -11,13 +11,40 @@
* The TYPO3 project - inspiring people to share! * 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 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 { enum ClassNames {
private readonly context: Element; wizardWindow = 't3-new-content-element-wizard-window'
private readonly searchField: HTMLInputElement; }
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 { private static getTabIdentifier(tab: Element): string {
const tabLink = tab.querySelector('a') as HTMLAnchorElement; const tabLink = tab.querySelector('a') as HTMLAnchorElement;
...@@ -26,65 +53,90 @@ export default class NewContentElementWizard { ...@@ -26,65 +53,90 @@ export default class NewContentElementWizard {
} }
private static countVisibleContentElements(container: Element): number { 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) { constructor(modal: HTMLElement) {
this.context = context.get(0); this.modal = modal;
this.searchField = this.context.querySelector('.t3js-contentWizard-search'); this.elementsFilter = this.modal.querySelector(Selectors.elementsFilterSelector);
this.registerClearable();
this.registerEvents();
}
public focusSearchField(): void { // Add new content element specific class to the modal body
this.searchField.focus(); this.modal.querySelector(Selectors.modalBodySelector)?.classList.add(ClassNames.wizardWindow);
}
private registerClearable(): void { this.registerEvents();
this.searchField.clearable({
onClear: (input: HTMLInputElement): void => {
input.value = '';
this.filterElements(input);
},
});
} }
private registerEvents(): void { private registerEvents(): void {
new RegularEvent('shown.bs.modal', (): void => {
this.elementsFilter.focus();
}).bindTo(this.modal);
new RegularEvent('keydown', (e: KeyboardEvent): void => { new RegularEvent('keydown', (e: KeyboardEvent): void => {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
if (e.code === 'Escape') { if (e.code === 'Escape') {
e.stopImmediatePropagation(); e.stopImmediatePropagation();
target.value = ''; target.value = '';
} }
}).bindTo(this.searchField); }).bindTo(this.elementsFilter);
new DebounceEvent('keyup', (e: KeyboardEvent): void => { new DebounceEvent('keyup', (e: KeyboardEvent): void => {
this.filterElements(e.target as HTMLInputElement); this.filterElements(e.target as HTMLInputElement);
}, 150).bindTo(this.searchField); }, 150).bindTo(this.elementsFilter);
new RegularEvent('submit', (e: Event): void => { new RegularEvent('search', (e: Event): void => {
e.preventDefault(); this.filterElements(e.target as HTMLInputElement);
}).bindTo(this.searchField.closest('form')); }).bindTo(this.elementsFilter);
new RegularEvent('click', (e: Event): void => { new RegularEvent('click', (e: PointerEvent): void => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); 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 { private filterElements(inputField: HTMLInputElement): void {
const form = inputField.closest('form'); const tabContainer = this.modal.querySelector(Selectors.modalTabsSelector);
const tabContainer = form.querySelector('.t3js-tabs'); const nothingFoundAlert = this.modal.querySelector(Selectors.noResultSelector);
const nothingFoundAlert = form.querySelector('.t3js-filter-noresult');
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 // Clean up textContent by trimming and replacing consecutive spaces with a single space
const textContent = element.textContent.trim().replace(/\s+/g, ' '); const textContent = element.textContent.trim().replace(/\s+/g, ' ');
element.classList.toggle('hidden', inputField.value !== '' && !RegExp(inputField.value, 'i').test(textContent)); 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); tabContainer.parentElement.classList.toggle('hidden', visibleContentElements === 0);
nothingFoundAlert.classList.toggle('hidden', visibleContentElements > 0); nothingFoundAlert.classList.toggle('hidden', visibleContentElements > 0);
this.switchTabIfNecessary(tabContainer); this.switchTabIfNecessary(tabContainer);
...@@ -122,7 +174,7 @@ export default class NewContentElementWizard { ...@@ -122,7 +174,7 @@ export default class NewContentElementWizard {
} }
private hasTabContent(tabIdentifier: string): boolean { private hasTabContent(tabIdentifier: string): boolean {
const tabContentContainer = this.context.querySelector(`#${tabIdentifier}`); const tabContentContainer = this.modal.querySelector(`#${tabIdentifier}`);
return NewContentElementWizard.countVisibleContentElements(tabContentContainer) > 0; return NewContentElementWizard.countVisibleContentElements(tabContentContainer) > 0;
} }
...@@ -135,7 +187,7 @@ export default class NewContentElementWizard { ...@@ -135,7 +187,7 @@ export default class NewContentElementWizard {
*/ */
private switchTab(tabContainerWrapper: HTMLElement, tabIdentifier: string): void { private switchTab(tabContainerWrapper: HTMLElement, tabIdentifier: string): void {
const tabElement = tabContainerWrapper.querySelector(`a[href="#${tabIdentifier}"]`); 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('a.active').classList.remove('active');
tabContainerWrapper.querySelector('.tab-pane.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 @@ ...@@ -14,8 +14,11 @@
import { KeyTypesEnum } from './Enum/KeyTypes'; import { KeyTypesEnum } from './Enum/KeyTypes';
import $ from 'jquery'; import $ from 'jquery';
import PersistentStorage = require('./Storage/Persistent'); import PersistentStorage = require('./Storage/Persistent');
import NewContentElement = require('./Wizard/NewContentElement');
import 'TYPO3/CMS/Backend/Element/IconElement'; 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 { enum IdentifierEnum {
pageTitle = '.t3js-title-inlineedit', pageTitle = '.t3js-title-inlineedit',
...@@ -220,16 +223,25 @@ class PageActions { ...@@ -220,16 +223,25 @@ class PageActions {
/** /**
* Activate New Content Element Wizard * 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 { 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 => { Array.from(document.querySelectorAll(IdentifierEnum.newButton)).forEach((element: HTMLElement): void => {
element.classList.remove('disabled'); element.classList.remove(IdentifierEnum.newButton.substring(1), 'disabled');
}); const wizardButton: DocumentFragment = document.createDocumentFragment();
$(IdentifierEnum.newButton).on('click', (e: JQueryEventObject): void => { renderNodes(html`
e.preventDefault(); <typo3-backend-new-content-element-wizard-button
title="${element.dataset.title || element.title || ''}"
const $me = $(e.currentTarget); url="${element instanceof HTMLAnchorElement ? element.href : element.dataset.target || ''}">
NewContentElement.wizard($me.attr('href'), $me.data('title')); <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 ...@@ -77,13 +77,6 @@ class NewContentElementController
*/ */
protected $uid_pid; protected $uid_pid;
/**
* config of the wizard
*
* @var array
*/
protected $config;
/** /**
* @var array * @var array
*/ */
...@@ -112,20 +105,21 @@ class NewContentElementController ...@@ -112,20 +105,21 @@ class NewContentElementController
} }
/** /**
* Constructor, initializing internal variables. * Process incoming request and dispatch to the requested action
* *
* @param ServerRequestInterface $request * @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(); $parsedBody = $request->getParsedBody();
$queryParams = $request->getQueryParams(); $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: // Setting internal vars:
$this->id = (int)($parsedBody['id'] ?? $queryParams['id'] ?? 0); $this->id = (int)($parsedBody['id'] ?? $queryParams['id'] ?? 0);
$this->sys_language = (int)($parsedBody['sys_language_uid'] ?? $queryParams['sys_language_uid'] ?? 0); $this->sys_language = (int)($parsedBody['sys_language_uid'] ?? $queryParams['sys_language_uid'] ?? 0);
...@@ -133,42 +127,33 @@ class NewContentElementController ...@@ -133,42 +127,33 @@ class NewContentElementController
$colPos = $parsedBody['colPos'] ?? $queryParams['colPos'] ?? null; $colPos = $parsedBody['colPos'] ?? $queryParams['colPos'] ?? null;
$this->colPos = $colPos === null ? null : (int)$colPos; $this->colPos = $colPos === null ? null : (int)$colPos;
$this->uid_pid = (int)($parsedBody['uid_pid'] ?? $queryParams['uid_pid'] ?? 0); $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: // Getting the current page and receiving access information
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/ContextMenu');
// Getting the current page and receiving access information (used in main())
$this->pageInfo = BackendUtility::readPageAccess($this->id, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW)) ?: []; $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 * Renders the wizard
* 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
*/ */
public function wizardAction(ServerRequestInterface $request): ResponseInterface public function wizardAction(ServerRequestInterface $request): ResponseInterface
{ {
$this->init($request); $this->prepareWizardContent($request);
$this->prepareContent($request);
return new HtmlResponse($this->view->render()); return new HtmlResponse($this->view->render());
} }
/** /**
* Create on-click event value. * Renders the position map
*
* @return string
*/ */
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") $this->preparePositionMap($request);
$location = (string)$this->uriBuilder->buildUriFromRoute('record_edit', [ return new HtmlResponse($this->view->render());
'edit[tt_content][' . $this->uid_pid . ']' => 'new',
'defVals[tt_content][colPos]' => $this->colPos,