Commit 0842cea9 authored by Andreas Fernandez's avatar Andreas Fernandez Committed by Frank Nägler
Browse files

[!!!][TASK] Refactor client-side IRRE

This patch refactors the IRRE handling on client side, the JavaScript
land has been rewritten to have a better structure and do less repeating
on-the-fly calculations. Each IRRE container is represented by a
independent instance of InlineControlContainer.

Most of the internally used `scriptCall` directives have been removed.
Currently, `scriptCall` can't get removed completely, as further
refactorings in different areas are required.

All of the "external" communication via `inline.foobar()` has been
replaced by a event-driven approach. This also affects ElementBrowser
windows, those use a minimalistic API based on postMessage.

Some code that was never evaluated in ElementBrowser is considered dead
and has been removed regarding inserting multiple items.

A new sorting library has been added in order to replace jqueryui piece
by piece.

Executed command:

    yarn add --dev sortablejs

On PHP side, some code has been removed as well since the rewritten client
code is event-based and doesn't depend on external calls anymore.

Resolves: #88182
Releases: master
Change-Id: I4176483d2882cef49fbaddb5e2e1914c1f76c908
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/59324

Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: Georg Ringer's avatarGeorg Ringer <georg.ringer@gmail.com>
Tested-by: Frank Nägler's avatarFrank Naegler <frank.naegler@typo3.org>
Reviewed-by: Georg Ringer's avatarGeorg Ringer <georg.ringer@gmail.com>
Reviewed-by: Frank Nägler's avatarFrank Naegler <frank.naegler@typo3.org>
parent 7c8ab513
...@@ -483,7 +483,8 @@ module.exports = function (grunt) { ...@@ -483,7 +483,8 @@ module.exports = function (grunt) {
// For the moment this is ok, because we stuck on version 1.11.4 which is very old // For the moment this is ok, because we stuck on version 1.11.4 which is very old
// the jquery ui stuff should be replaced by modern libs asap // the jquery ui stuff should be replaced by modern libs asap
// 'jquery-ui/sortable.js': 'jquery-ui/ui/sortable.js', // 'jquery-ui/sortable.js': 'jquery-ui/ui/sortable.js',
'jquery-ui/widget.js': 'jquery-ui/ui/widget.js' 'jquery-ui/widget.js': 'jquery-ui/ui/widget.js',
'Sortable.min.js': 'sortablejs/Sortable.min.js'
} }
} }
}, },
......
...@@ -205,6 +205,15 @@ div.t3-form-field-container:first-child .t3-form-field-label-flex { ...@@ -205,6 +205,15 @@ div.t3-form-field-container:first-child .t3-form-field-label-flex {
border-top: 0; border-top: 0;
} }
.form-irre-object {
transition: opacity 0.5s;
opacity: 1;
&--deleted {
opacity: 0 !important;
}
}
// //
// TCEforms Inline-Relational-Record-Editing // TCEforms Inline-Relational-Record-Editing
// //
......
...@@ -20,10 +20,7 @@ import moment = require('moment'); ...@@ -20,10 +20,7 @@ import moment = require('moment');
import NProgress = require('nprogress'); import NProgress = require('nprogress');
import Modal = require('./Modal'); import Modal = require('./Modal');
import Notification = require('./Notification'); import Notification = require('./Notification');
// Do not import TYPO3/CMS/Backend/jsfunc.inline because it is loaded in global scope import {MessageUtility} from './Utility/MessageUtility';
// Import here, will load it twice and produce JS errors.
// @TODO: Import later, after decoupling has been finished
// import 'TYPO3/CMS/Backend/jsfunc.inline';
/** /**
* Possible actions for conflicts w/ existing files * Possible actions for conflicts w/ existing files
...@@ -702,12 +699,12 @@ class DragUploader { ...@@ -702,12 +699,12 @@ class DragUploader {
* @param {UploadedFile} file * @param {UploadedFile} file
*/ */
public static addFileToIrre(irre_object: number, file: UploadedFile): void { public static addFileToIrre(irre_object: number, file: UploadedFile): void {
window.inline.delayedImportElement( const message = {
irre_object, objectGroup: irre_object,
'sys_file', table: 'sys_file',
file.uid, uid: file.uid,
'file', };
); MessageUtility.send(message);
} }
public static init(): void { public static init(): void {
......
/*
* 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 {AjaxDispatcher} from './../InlineRelation/AjaxDispatcher';
import {InlineResponseInterface} from './../InlineRelation/InlineResponseInterface';
import {MessageUtility} from '../../Utility/MessageUtility';
import * as $ from 'jquery';
import FormEngine = require('TYPO3/CMS/Backend/FormEngine');
import FormEngineValidation = require('TYPO3/CMS/Backend/FormEngineValidation');
import Icons = require('../../Icons');
import InfoWindow = require('../../InfoWindow');
import Modal = require('../../Modal');
import Notification = require('../../Notification');
import NProgress = require('nprogress');
import Severity = require('../../Severity');
import Sortable = require('Sortable');
import Utility = require('../../Utility');
enum Selectors {
toggleSelector = '[data-toggle="formengine-inline"]',
controlSectionSelector = '.t3js-formengine-irre-control',
createNewRecordButtonSelector = '.t3js-create-new-button',
createNewRecordBySelectorSelector = '.t3js-create-new-selector',
insertRecordButtonSelector = '.t3js-insert-record-button',
deleteRecordButtonSelector = '.t3js-editform-delete-inline-record',
enableDisableRecordButtonSelector = '.t3js-toggle-visibility-button',
infoWindowButton = '[data-action="infowindow"]',
synchronizeLocalizeRecordButtonSelector = '.t3js-synchronizelocalize-button',
uniqueValueSelectors = 'select.t3js-inline-unique',
revertUniqueness = '.t3js-revert-unique',
controlContainerButtons = '.t3js-inline-controls',
}
enum States {
new = 'inlineIsNewRecord',
visible = 'panel-visible',
collapsed = 'panel-collapsed',
}
enum Separators {
structureSeparator = '-',
}
enum SortDirections {
DOWN = 'down',
UP = 'up',
}
interface XhrQueue {
[key: string]: JQueryXHR;
}
interface ProgressQueue {
[key: string]: any;
}
interface Appearance {
expandSingle?: boolean;
useSortable?: boolean;
}
interface UniqueDefinition {
elTable: string;
field: string;
max: number;
possible: { [key: string]: string };
selector: string;
table: string;
type: string;
used: UniqueDefinitionCollection;
}
interface UniqueDefinitionCollection {
[key: string]: UniqueDefinitionUsed;
}
interface UniqueDefinitionUsed {
table: string;
uid: string | number;
}
class InlineControlContainer {
private container: HTMLElement = null;
private ajaxDispatcher: AjaxDispatcher = null;
private appearance: Appearance = null;
private xhrQueue: XhrQueue = {};
private progessQueue: ProgressQueue = {};
private noTitleString: string = (TYPO3.lang ? TYPO3.lang['FormEngine.noRecordTitle'] : '[No title]');
/**
* Checks whether an event target matches the given selector and returns the matching element.
* May be used in conjunction with event delegation.
*
* @param {EventTarget} eventTarget
* @param {string} selector
*/
private static getDelegatedEventTarget(eventTarget: EventTarget, selector: string): HTMLElement | null {
let targetElement: HTMLElement;
if ((targetElement = <HTMLElement>(<Element>eventTarget).closest(selector)) === null) {
if ((<Element>eventTarget).matches(selector)) {
targetElement = <HTMLElement>eventTarget;
}
}
return targetElement;
}
/**
* @param {string} objectId
* @return HTMLDivElement
*/
private static getInlineRecordContainer(objectId: string): HTMLDivElement {
return <HTMLDivElement>document.querySelector('[data-object-id="' + objectId + '"]');
}
/**
* @param {Event} e
*/
private static registerInfoButton(e: Event): void {
let target: HTMLElement;
if ((target = InlineControlContainer.getDelegatedEventTarget(
e.target,
Selectors.infoWindowButton)
) === null) {
return;
}
e.preventDefault();
e.stopImmediatePropagation();
InfoWindow.showItem(target.dataset.infoTable, target.dataset.infoUid);
}
/**
* @param {Event} e
*/
private static registerInsertRecordButton(e: Event): void {
let target: HTMLElement;
if ((target = InlineControlContainer.getDelegatedEventTarget(e.target, Selectors.insertRecordButtonSelector)) === null) {
return;
}
e.preventDefault();
e.stopImmediatePropagation();
const mode = target.dataset.mode;
const params = target.dataset.params;
FormEngine.openPopupWindow(mode, params);
}
/**
* @param {string} objectId
*/
private static toggleElement(objectId: string): void {
const recordContainer = InlineControlContainer.getInlineRecordContainer(objectId);
if (recordContainer.classList.contains(States.collapsed)) {
recordContainer.classList.remove(States.collapsed);
recordContainer.classList.add(States.visible);
} else {
recordContainer.classList.remove(States.visible);
recordContainer.classList.add(States.collapsed);
}
}
/**
* @param {string} objectId
* @return boolean
*/
private static isNewRecord(objectId: string): boolean {
const recordContainer = InlineControlContainer.getInlineRecordContainer(objectId);
return recordContainer.classList.contains(States.new);
}
/**
* @param {string} objectId
* @param {boolean} value
*/
private static updateExpandedCollapsedStateLocally(objectId: string, value: boolean): void {
const recordContainer = InlineControlContainer.getInlineRecordContainer(objectId);
const ucName = 'uc[inlineView]'
+ '[' + recordContainer.dataset.topmostParentTable + ']'
+ '[' + recordContainer.dataset.topmostParentUid + ']'
+ recordContainer.dataset.fieldName;
const ucFormObj = document.getElementsByName(ucName);
if (ucFormObj.length) {
(<HTMLInputElement>ucFormObj[0]).value = value ? '1' : '0';
}
}
/**
* @param {UniqueDefinitionCollection} hashmap
*/
private static getValuesFromHashMap(hashmap: UniqueDefinitionCollection): Array<any> {
return Object.keys(hashmap).map(key => hashmap[key]);
}
/**
* @param {HTMLSelectElement} selectElement
* @param {string} value
*/
private static removeSelectOptionByValue(selectElement: HTMLSelectElement, value: string): void {
const option = selectElement.querySelector('option[value="' + value + '"]');
if (option !== null) {
option.remove();
}
}
/**
* @param {HTMLSelectElement} selectElement
* @param {string} value
* @param {UniqueDefinition} unique
*/
private static reAddSelectOption(selectElement: HTMLSelectElement, value: string, unique: UniqueDefinition): void {
const options: Array<HTMLOptionElement> = Array.from(selectElement.querySelectorAll('option'));
let index: number = -1;
for (let possibleValue of Object.keys(unique.possible)) {
if (possibleValue === value) {
break;
}
for (let k = 0; k < options.length; ++k) {
const option = options[k];
if (option.value === possibleValue) {
index = k;
break;
}
}
}
if (index === -1) {
index = 0;
} else if (index < options.length) {
index++;
}
// recreate the <option> tag
const readdOption = document.createElement('option');
readdOption.text = unique.possible[value];
readdOption.value = value;
// add the <option> at the right position
selectElement.insertBefore(readdOption, selectElement.options[index]);
}
/**
* @param {string} elementId
*/
constructor(elementId: string) {
$((): void => {
this.container = <HTMLElement>document.querySelector('#' + elementId);
this.ajaxDispatcher = new AjaxDispatcher(this.container.dataset.objectGroup);
this.registerEvents();
});
}
private registerEvents(): void {
this.container.addEventListener('click', (e: Event): void => {
this.registerToggle(e);
this.registerSort(e);
this.registerCreateRecordButton(e);
this.registerCreateRecordBySelector(e);
InlineControlContainer.registerInsertRecordButton(e);
this.registerEnableDisableButton(e);
InlineControlContainer.registerInfoButton(e);
this.registerDeleteButton(e);
this.registerSynchronizeLocalize(e);
this.registerUniqueSelectFieldChanged(e);
this.registerRevertUniquenessAction(e);
});
window.addEventListener('message', this.handlePostMessage);
if (this.getAppearance().useSortable) {
const recordListContainer = <HTMLDivElement>document.querySelector('#' + this.container.getAttribute('id') + '_records');
// tslint:disable-next-line:no-unused-expression
new Sortable(recordListContainer, {
group: recordListContainer.getAttribute('id'),
handle: '.sortableHandle',
onSort: (): void => {
this.updateSorting();
},
});
}
}
/**
* @param {Event} e
*/
private registerToggle(e: Event): void {
if (InlineControlContainer.getDelegatedEventTarget(e.target, Selectors.controlSectionSelector)) {
// Abort click event in control section
return;
}
let target: HTMLElement;
if ((target = InlineControlContainer.getDelegatedEventTarget(e.target, Selectors.toggleSelector)) === null) {
return;
}
e.preventDefault();
e.stopImmediatePropagation();
this.loadRecordDetails(target.parentElement.dataset.objectId);
}
/**
* @param {Event} e
*/
private registerSort(e: Event): void {
let target: HTMLElement;
if ((target = InlineControlContainer.getDelegatedEventTarget(
e.target,
Selectors.controlSectionSelector + ' [data-action="sort"]')
) === null) {
return;
}
e.preventDefault();
e.stopImmediatePropagation();
this.changeSortingByButton(
(<HTMLDivElement>target.closest('[data-object-id]')).dataset.objectId,
<SortDirections>target.dataset.direction,
);
}
/**
* @param {Event} e
*/
private registerCreateRecordButton(e: Event): void {
let target: HTMLElement;
if ((target = InlineControlContainer.getDelegatedEventTarget(e.target, Selectors.createNewRecordButtonSelector)) === null) {
return;
}
e.preventDefault();
e.stopImmediatePropagation();
if (this.isBelowMax()) {
let objectId = this.container.dataset.objectGroup;
if (typeof target.dataset.recordUid !== 'undefined') {
objectId += Separators.structureSeparator + target.dataset.recordUid;
}
this.importRecord([objectId], target.dataset.recordUid);
}
}
/**
* @param {Event} e
*/
private registerCreateRecordBySelector(e: Event): void {
let target: HTMLElement;
if ((target = InlineControlContainer.getDelegatedEventTarget(e.target, Selectors.createNewRecordBySelectorSelector)) === null) {
return;
}
e.preventDefault();
e.stopImmediatePropagation();
const selectTarget = <HTMLSelectElement>target;
const recordUid = selectTarget.options[selectTarget.selectedIndex].getAttribute('value');
this.importRecord([this.container.dataset.objectGroup, recordUid]);
}
/**
* @param {MessageEvent} e
*/
private handlePostMessage = (e: MessageEvent): void => {
if (!MessageUtility.verifyOrigin(e.origin)) {
throw 'Denied message sent by ' + e.origin;
}
if (typeof e.data.objectGroup === 'undefined') {
throw 'No object group defined for message';
}
if (e.data.objectGroup !== this.container.dataset.objectGroup) {
// Received message isn't provisioned for current InlineControlContainer instance
return;
}
if (this.isUniqueElementUsed(parseInt(e.data.uid, 10), e.data.table)) {
Notification.error('There is already a relation to the selected element');
return;
}
this.importRecord([e.data.objectGroup, e.data.uid]);
}
/**
* @param {string} uid
* @param {string} markup
* @param {string} afterUid
* @param {string} selectedValue
*/
private createRecord(uid: string, markup: string, afterUid: string = null, selectedValue: string = null): void {
let objectId = this.container.dataset.objectGroup;
if (afterUid !== null) {
objectId += Separators.structureSeparator + afterUid;
}
if (afterUid !== null) {
InlineControlContainer.getInlineRecordContainer(objectId).insertAdjacentHTML('afterend', markup);
this.memorizeAddRecord(uid, afterUid, selectedValue);
} else {
document.querySelector('#' + this.container.getAttribute('id') + '_records').insertAdjacentHTML('beforeend', markup);
this.memorizeAddRecord(uid, null, selectedValue);
}
}
/**
* @param {Array} params
* @param {string} afterUid
*/
private importRecord(params: Array<any>, afterUid?: string): void {
const xhr = this.ajaxDispatcher.send(
this.ajaxDispatcher.newRequest(this.ajaxDispatcher.getEndpoint('record_inline_create'))
.withContext()
.withParams(params),
);
xhr.done((response: { [key: string]: any }): void => {
if (this.isBelowMax()) {
this.createRecord(
response.compilerInput.uid,
response.data,
typeof afterUid !== 'undefined' ? afterUid : null,
typeof response.compilerInput.childChildUid !== 'undefined' ? response.compilerInput.childChildUid : null,
);
FormEngine.reinitialize();
FormEngine.Validation.initializeInputFields();
FormEngine.Validation.validate();
}
});
}
/**
* @param {Event} e
*/
private registerEnableDisableButton(e: Event): void {
let target: HTMLElement;
if ((target = InlineControlContainer.getDelegatedEventTarget(
e.target,
Selectors.enableDisableRecordButtonSelector)
) === null) {
return;
}
e.preventDefault();
e.stopImmediatePropagation();
const objectId = (<HTMLDivElement>target.closest('[data-object-id]')).dataset.objectId;
const recordContainer = InlineControlContainer.getInlineRecordContainer(objectId);
const hiddenFieldName = 'data' + recordContainer.dataset.fieldName + '[' + target.dataset.hiddenField + ']';
const hiddenValueCheckBox = <HTMLInputElement>document.querySelector('[data-formengine-input-name="' + hiddenFieldName + '"');
const hiddenValueInput = <HTMLInputElement>document.querySelector('[name="' + hiddenFieldName + '"');
if (hiddenValueCheckBox !== null && hiddenValueInput !== null) {