Commit 768285b3 authored by Andreas Fernandez's avatar Andreas Fernandez Committed by Benni Mack
Browse files

[TASK] Refactor client-side FlexForm handling

This patch refactors the client-side rendering of FlexForm sections. Now,
there are two modules in place:

- FlexFormSectionContainer
  This module handles the "outer" part of a section, e.g. to toggle all
  containers or to revalidate FormEngine if a container is removed.

- FlexFormContainerContainer
  The FlexFormContainerContainer represents an individual container being
  a child of a section.

In order to streamline the UI, the look & feel has been changed to be
more similar to IRRE.

Resolves: #93453
Releases: master
Change-Id: Ica373a6dbed4b470725267eac221bf839b23ec0f
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/67589


Tested-by: Richard Haeser's avatarRichard Haeser <richard@richardhaeser.com>
Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Reviewed-by: Richard Haeser's avatarRichard Haeser <richard@richardhaeser.com>
Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
parent c3e4c1e7
......@@ -74,7 +74,7 @@ $panel-lg-padding: 35px;
@include transition(all 0.25s ease-in-out);
}
.collapsed {
&.collapsed {
.caret {
transform: rotate(-90deg);
}
......
......@@ -7,28 +7,17 @@
}
.sortableHandle {
cursor: move;
cursor: move !important;
}
//
// TCEforms Sections
//
.t3-flex-section {
clear: both;
margin: 5px 0;
}
.t3-form-field-add-flexsection {
border-top: 1px solid #cdcdcd;
padding: 10px 5px 5px 0;
}
.t3-form-flex,
.t3-form-field-container-flexsections {
margin: 5px 0;
clear: both;
}
// preview image in sys_file records
img.t3-tceforms-sysfile-imagepreview {
float: left;
......
/*
* 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 {Collapse} from 'bootstrap';
import SecurityUtility from 'TYPO3/CMS/Core/SecurityUtility';
import FlexFormSectionContainer from './FlexFormSectionContainer';
import Modal = require('TYPO3/CMS/Backend/Modal');
import RegularEvent from 'TYPO3/CMS/Core/Event/RegularEvent';
import Severity = require('TYPO3/CMS/Backend/Severity');
enum Selectors {
toggleSelector = '[data-bs-toggle="flexform-inline"]',
actionFieldSelector = '.t3js-flex-control-action',
toggleFieldSelector = '.t3js-flex-control-toggle',
controlSectionSelector = '.t3js-formengine-irre-control',
sectionContentContainerSelector = '.t3js-flex-section-content',
deleteContainerButtonSelector = '.t3js-delete',
contentPreviewSelector = '.content-preview',
}
interface ContainerStatus {
id: string;
collapsed: boolean;
}
class FlexFormContainerContainer {
private readonly securityUtility: SecurityUtility;
private readonly parentContainer: FlexFormSectionContainer;
private readonly container: HTMLElement;
private readonly containerContent: HTMLElement;
private readonly containerId: string;
private readonly panelHeading: HTMLElement;
private readonly panelButton: HTMLElement;
private readonly toggleField: HTMLInputElement;
private static getCollapseInstance(container: HTMLElement): Collapse {
return Collapse.getInstance(container) ?? new Collapse(container, {toggle: false})
}
constructor(parentContainer: FlexFormSectionContainer, container: HTMLElement) {
this.securityUtility = new SecurityUtility();
this.parentContainer = parentContainer;
this.container = container;
this.containerContent = container.querySelector(Selectors.sectionContentContainerSelector);
this.containerId = container.dataset.flexformContainerId;
this.panelHeading = container.querySelector('[data-bs-target="#flexform-container-' + this.containerId + '"]');
this.panelButton = this.panelHeading.querySelector('[aria-controls="flexform-container-' + this.containerId + '"]');
this.toggleField = container.querySelector(Selectors.toggleFieldSelector);
this.registerEvents();
this.generatePreview();
}
public getStatus(): ContainerStatus {
return {
id: this.containerId,
collapsed: this.panelButton.getAttribute('aria-expanded') === 'false',
}
}
private registerEvents(): void {
if (this.parentContainer.isRestructuringAllowed()) {
this.registerDelete();
}
this.registerToggle();
this.registerPanelToggle();
}
private registerDelete(): void {
new RegularEvent('click', (): void => {
const title = TYPO3.lang['flexform.section.delete.title'] || 'Delete this container?';
const content = TYPO3.lang['flexform.section.delete.message'] || 'Are you sure you want to delete this container?';
const $modal = Modal.confirm(title, content, Severity.warning, [
{
text: TYPO3.lang['buttons.confirm.delete_record.no'] || 'Cancel',
active: true,
btnClass: 'btn-default',
name: 'no',
},
{
text: TYPO3.lang['buttons.confirm.delete_record.yes'] || 'Yes, delete this container',
btnClass: 'btn-warning',
name: 'yes',
},
]);
$modal.on('button.clicked', (modalEvent: Event): void => {
if ((modalEvent.target as HTMLAnchorElement).name === 'yes') {
const actionField = this.container.querySelector(Selectors.actionFieldSelector) as HTMLInputElement;
actionField.value = 'DELETE';
this.container.appendChild(actionField);
this.container.classList.add('t3-flex-section--deleted');
new RegularEvent('transitionend', (): void => {
this.container.classList.add('hidden');
const event = new CustomEvent('formengine:flexform:container-deleted', {
detail: {
containerId: this.containerId
}
});
this.parentContainer.getContainer().dispatchEvent(event);
}).bindTo(this.container);
}
Modal.dismiss();
});
}).bindTo(this.container.querySelector(Selectors.deleteContainerButtonSelector));
}
private registerToggle(): void {
new RegularEvent('click', (): void => {
FlexFormContainerContainer.getCollapseInstance(this.containerContent).toggle();
this.generatePreview();
}).delegateTo(this.container, `${Selectors.toggleSelector} .form-irre-header-cell:not(${Selectors.controlSectionSelector}`);
}
private registerPanelToggle(): void {
['hide.bs.collapse', 'show.bs.collapse'].forEach((eventName: string): void => {
new RegularEvent(eventName, (e: Event): void => {
const collapseTriggered = e.type === 'hide.bs.collapse';
this.toggleField.value = collapseTriggered ? '1' : '0';
this.panelButton.setAttribute('aria-expanded', collapseTriggered ? 'false' : 'true');
this.panelHeading.classList.toggle('collapsed', collapseTriggered);
}).bindTo(this.containerContent);
});
}
private generatePreview(): void {
let previewContent = '';
if (this.getStatus().collapsed) {
const formFields: NodeListOf<HTMLInputElement|HTMLTextAreaElement> = this.containerContent.querySelectorAll('input[type="text"], textarea');
for (let field of formFields) {
let content = this.securityUtility.stripHtml(field.value);
if (content.length > 50) {
content = content.substring(0, 50) + '...';
}
previewContent += (previewContent ? ' / ' : '') + content;
}
}
this.panelHeading.querySelector(Selectors.contentPreviewSelector).textContent = previewContent;
}
}
export = FlexFormContainerContainer;
/*
* 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 {Collapse} from 'bootstrap';
import Sortable from 'sortablejs';
import AjaxRequest from 'TYPO3/CMS/Core/Ajax/AjaxRequest';
import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse';
import DocumentService = require('TYPO3/CMS/Core/DocumentService');
import FlexFormContainerContainer from './FlexFormContainerContainer';
import FormEngine = require('TYPO3/CMS/Backend/FormEngine');
import RegularEvent from 'TYPO3/CMS/Core/Event/RegularEvent';
enum Selectors {
toggleAllSelector = '.t3-form-flexsection-toggle',
addContainerSelector = '.t3js-flex-container-add',
actionFieldSelector = '.t3js-flex-control-action',
sectionContainerSelector = '.t3js-flex-section',
sectionContentContainerSelector = '.t3js-flex-section-content',
sortContainerButtonSelector = '.t3js-sortable-handle',
}
class FlexFormSectionContainer {
private readonly sectionContainerId: string;
private container: HTMLElement;
private sectionContainer: HTMLElement;
private allowRestructure: boolean = false;
private flexformContainerContainers: FlexFormContainerContainer[] = [];
private static getCollapseInstance(container: HTMLElement): Collapse {
return Collapse.getInstance(container) ?? new Collapse(container, {toggle: false})
}
/**
* @param {string} elementId
*/
constructor(elementId: string) {
this.sectionContainerId = elementId;
DocumentService.ready().then((document: Document): void => {
this.container = <HTMLElement>document.getElementById(elementId);
this.sectionContainer = this.container.querySelector(this.container.dataset.section) as HTMLElement;
this.allowRestructure = this.sectionContainer.dataset.t3FlexAllowRestructure === '1';
this.registerEvents();
this.registerContainers();
});
}
public getContainer(): HTMLElement {
return this.container;
}
public isRestructuringAllowed(): boolean {
return this.allowRestructure;
}
private registerEvents(): void {
if (this.allowRestructure) {
this.registerSortable();
this.registerContainerDeleted();
}
this.registerToggleAll();
this.registerCreateNewContainer();
this.registerPanelToggle();
}
private registerContainers(): void {
const sectionContainerContainers: NodeListOf<HTMLElement> = this.container.querySelectorAll(Selectors.sectionContainerSelector);
for (let sectionContainerContainer of sectionContainerContainers) {
this.flexformContainerContainers.push(new FlexFormContainerContainer(this, sectionContainerContainer));
}
this.updateToggleAllState();
}
private getToggleAllButton(): HTMLButtonElement {
return this.container.querySelector(Selectors.toggleAllSelector) as HTMLButtonElement;
}
private registerSortable(): void {
new Sortable(this.sectionContainer, {
group: this.sectionContainer.id,
handle: Selectors.sortContainerButtonSelector,
onSort: this.updateSorting,
});
}
private updateSorting = (e: Sortable.SortableEvent): void => {
const actionFields: NodeListOf<HTMLInputElement> = this.container.querySelectorAll(Selectors.actionFieldSelector);
actionFields.forEach((element: HTMLInputElement, key: number): void => {
element.value = key.toString();
});
this.updateToggleAllState();
this.flexformContainerContainers.splice(e.newIndex, 0, this.flexformContainerContainers.splice(e.oldIndex, 1)[0]);
document.dispatchEvent(new Event('formengine:flexform:sorting-changed'));
}
private registerToggleAll(): void {
new RegularEvent('click', (e: Event): void => {
const trigger = e.target as HTMLButtonElement;
const showAll = trigger.dataset.expandAll === 'true';
const collapsibles: NodeListOf<HTMLElement> = this.container.querySelectorAll(Selectors.sectionContentContainerSelector);
for (let collapsible of collapsibles) {
if (showAll) {
FlexFormSectionContainer.getCollapseInstance(collapsible).show();
} else {
FlexFormSectionContainer.getCollapseInstance(collapsible).hide();
}
}
}).bindTo(this.getToggleAllButton());
}
private registerCreateNewContainer(): void {
new RegularEvent('click', (e: Event, target: HTMLElement): void => {
e.preventDefault();
this.createNewContainer(target.dataset);
}).delegateTo(this.container, Selectors.addContainerSelector);
}
private createNewContainer(dataset: DOMStringMap): void {
(new AjaxRequest(TYPO3.settings.ajaxUrls.record_flex_container_add)).post({
vanillaUid: dataset.vanillauid,
databaseRowUid: dataset.databaserowuid,
command: dataset.command,
tableName: dataset.tablename,
fieldName: dataset.fieldname,
recordTypeValue: dataset.recordtypevalue,
dataStructureIdentifier: JSON.parse(dataset.datastructureidentifier),
flexFormSheetName: dataset.flexformsheetname,
flexFormFieldName: dataset.flexformfieldname,
flexFormContainerName: dataset.flexformcontainername,
}).then(async (response: AjaxResponse): Promise<any> => {
const data = await response.resolve();
const createdContainer = new DOMParser().parseFromString(data.html, 'text/html').body.firstElementChild as HTMLElement;
this.flexformContainerContainers.push(new FlexFormContainerContainer(this, createdContainer));
const sectionContainer = document.querySelector(dataset.target);
sectionContainer.insertAdjacentElement('beforeend', createdContainer);
if (data.scriptCall && data.scriptCall.length > 0) {
$.each(data.scriptCall, function (index: number, value: string): void {
// eslint-disable-next-line no-eval
eval(value);
});
}
if (data.stylesheetFiles && data.stylesheetFiles.length > 0) {
$.each(data.stylesheetFiles, function (index: number, stylesheetFile: string): void {
let element = document.createElement('link');
element.rel = 'stylesheet';
element.type = 'text/css';
element.href = stylesheetFile;
document.head.appendChild(element);
});
}
this.updateToggleAllState();
FormEngine.reinitialize();
FormEngine.Validation.initializeInputFields();
FormEngine.Validation.validate(sectionContainer);
});
}
private registerContainerDeleted(): void {
new RegularEvent('formengine:flexform:container-deleted', (e: CustomEvent): void => {
const deletedContainerId = e.detail.containerId;
this.flexformContainerContainers = this.flexformContainerContainers.filter(
flexformContainerContainer => flexformContainerContainer.getStatus().id !== deletedContainerId
);
FormEngine.Validation.validate(this.container);
this.updateToggleAllState();
}).bindTo(this.container);
}
private registerPanelToggle(): void {
['hide.bs.collapse', 'show.bs.collapse'].forEach((eventName: string): void => {
new RegularEvent(eventName, (): void => {
this.updateToggleAllState();
}).delegateTo(this.container, Selectors.sectionContentContainerSelector);
});
}
private updateToggleAllState(): void {
if (this.flexformContainerContainers.length > 0) {
const firstContainer = this.flexformContainerContainers.find(Boolean);
this.getToggleAllButton().dataset.expandAll = firstContainer.getStatus().collapsed === true ? 'true' : 'false'
}
}
}
export = FlexFormSectionContainer;
/*
* 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!
*/
export interface FlexFormElementOptions {
deleteIconSelector: string;
sectionSelector: string;
sectionContentSelector: string;
sectionHeaderSelector: string;
sectionHeaderPreviewSelector: string;
sectionActionInputFieldSelector: string;
sectionToggleInputFieldSelector: string;
sectionToggleIconOpenSelector: string;
sectionToggleIconCloseSelector: string;
sectionToggleButtonSelector: string;
flexFormToggleAllSectionsSelector: string;
sectionDeletedClass: string;
allowRestructure: boolean;
flexformId: boolean | string;
}
/*
* 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!
*/
/**
* Module: TYPO3/CMS/Backend/FormEngineFlexForm
* Contains all JS functions related to TYPO3 Flexforms
* available under the latest jQuery version
* can be used by $('myflexform').t3FormEngineFlexFormElement({options});, all .t3-flex-form containers will be called on load
*
* currently TYPO3.FormEngine.FlexFormElement represents one Flexform element
* which can contain one ore more sections
*/
import $ from 'jquery';
import Sortable from 'sortablejs';
import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse';
import AjaxRequest = require('TYPO3/CMS/Core/Ajax/AjaxRequest');
import {FlexFormElementOptions} from './FormEngine/FlexForm/FlexFormElementOptions';
import FormEngine = require('TYPO3/CMS/Backend/FormEngine');
import Modal = require('TYPO3/CMS/Backend/Modal');
/**
*
* @param {HTMLElement} el
* @param {Object} options
* @constructor
* @exports TYPO3/CMS/Backend/FormEngineFlexForm
*/
class FlexFormElement {
// setting some default values
private static defaults: FlexFormElementOptions = {
deleteIconSelector: '.t3js-delete',
sectionSelector: '.t3js-flex-section',
sectionContentSelector: '.t3js-flex-section-content',
sectionHeaderSelector: '.t3js-flex-section-header',
sectionHeaderPreviewSelector: '.t3js-flex-section-header-preview',
sectionActionInputFieldSelector: '.t3js-flex-control-action',
sectionToggleInputFieldSelector: '.t3js-flex-control-toggle',
sectionToggleIconOpenSelector: '.t3js-flex-control-toggle-icon-open',
sectionToggleIconCloseSelector: '.t3js-flex-control-toggle-icon-close',
sectionToggleButtonSelector: '[data-toggle="formengine-flex"]',
flexFormToggleAllSectionsSelector: '.t3js-form-field-toggle-flexsection',
sectionDeletedClass: 't3js-flex-section-deleted',
allowRestructure: false, // whether the form can be modified
flexformId: false,
};
private $el: JQuery;
// shorthand options notation
private opts: FlexFormElementOptions;
constructor(private el: HTMLElement, options: FlexFormElementOptions) {
const that = this;
// store DOM element and jQuery object for later use
this.el = el;
this.$el = $(el);
// remove any existing backups
const old_this = this.$el.data('TYPO3.FormEngine.FlexFormElement');
if (typeof old_this !== 'undefined') {
this.$el.removeData('TYPO3.FormEngine.FlexFormElement');
}
// add a reverse reference to the DOM element
this.$el.data('TYPO3.FormEngine.FlexFormElement', this);
if (!options) {
options = FlexFormElement.defaults;
}
// set some values from existing properties
options.allowRestructure = <boolean>this.$el.data('t3-flex-allow-restructure');
options.flexformId = this.$el.attr('id');
// store options and merge with default options
this.opts = $.extend({}, FlexFormElement.defaults, options);
// initialize events
this.initializeEvents();
// generate the preview text if a section is hidden on load
this.$el.find(this.opts.sectionSelector).each(function (this: HTMLElement): void {
that.generateSectionPreview($(this));
});
return this;
}
/**
* init all events related to the flexform. As this method is called multiple times,
* some handlers need to be off'ed first to prevent event stacking.
*/
public initializeEvents(): this {
// Toggling all sections on/off by clicking all toggle buttons of each section
this.$el.prev(this.opts.flexFormToggleAllSectionsSelector).off('<