Commit 794f3e61 authored by Oliver Hader's avatar Oliver Hader Committed by Benjamin Franzke
Browse files

[TASK] Replace FormEngine table wizard by custom element

The FormEngine table wizard in the backend is replace by
a HTML custom element which allows to avoid server-side
round-trips when manipulating table rows and columns.

Resolves: #91811
Releases: master
Change-Id: I8f9bc5b6c142d7492ff26461b4760eb68e132f2c
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/65048

Tested-by: Richard Haeser's avatarRichard Haeser <richard@richardhaeser.com>
Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: Benjamin Franzke's avatarBenjamin Franzke <bfr@qbus.de>
Reviewed-by: Richard Haeser's avatarRichard Haeser <richard@richardhaeser.com>
Reviewed-by: Benjamin Franzke's avatarBenjamin Franzke <bfr@qbus.de>
parent f08d8a04
/*
* 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 {html, css, customElement, property, LitElement, TemplateResult, CSSResult} from 'lit-element';
/**
* Module: TYPO3/CMS/Backend/Element/SpinnerElement
*
* @example
* <typo3-backend-spinner size="small"></typo3-backend-spinner>
* + attribute size can be one of small, medium, large
*/
@customElement('typo3-backend-spinner')
export class SpinnerElement extends LitElement {
@property({type: String}) size: string = 'small';
public static get styles(): CSSResult
{
return css`
:host {
display: block;
}
.spinner {
display: block;
margin: 2px;
border-style: solid;
border-color: #212121 #bababa #bababa;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.spinner.small {
border-width: 2px;
width: 10px;
height: 10px;
}
.spinner.medium {
border-width: 3px;
width: 14px;
height: 14px;
}
.spinner.large {
border-width: 4px;
width: 20px;
height: 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
}
public render(): TemplateResult {
return html`<div class="spinner ${this.size}"></div>`
}
}
/*
* 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 {html, customElement, property, LitElement, TemplateResult} from 'lit-element';
import {icon, lll} from 'TYPO3/CMS/Core/lit-helper';
/**
* Module: TYPO3/CMS/Backend/Element/TableWizardElement
*
* @example
* <typo3-backend-table-wizard table="[["quot;a"quot;,"quot;b"quot;],["quot;c"quot;,"quot;d"quot;]]">
* </typo3-backend-table-wizard>
*
* This is based on W3C custom elements ("web components") specification, see
* https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements
*/
@customElement('typo3-backend-table-wizard')
export class TableWizardElement extends LitElement {
@property({type: String}) type: string = 'textarea';
@property({type: Array}) table: string[][] = [];
@property({type: Number, attribute: 'append-rows'}) appendRows: number = 1;
@property({type: Object}) l10n: any = {};
private get firstRow(): string[] {
return this.table[0] || [];
}
public createRenderRoot(): HTMLElement | ShadowRoot {
// @todo Switch to Shadow DOM once Bootstrap CSS style can be applied correctly
// const renderRoot = this.attachShadow({mode: 'open'});
return this;
}
public render(): TemplateResult {
return this.renderTemplate();
}
private provideMinimalTable(): void {
if (this.table.length === 0 || this.firstRow.length === 0) {
// create a table with one row and one column
this.table = [
['']
];
}
}
private modifyTable(evt: Event, rowIndex: number, colIndex: number): void {
const target = evt.target as HTMLInputElement | HTMLTextAreaElement;
this.table[rowIndex][colIndex] = target.value;
this.requestUpdate();
}
private toggleType(evt: Event): void {
this.type = this.type === 'input' ? 'textarea' : 'input';
}
private moveColumn(evt: Event, col: number, target: number): void {
this.table = this.table.map((row: string[]): string[] => {
const temp = row.splice(col, 1);
row.splice(target, 0, ...temp);
return row;
});
this.requestUpdate();
}
private appendColumn(evt: Event, col: number): void {
this.table = this.table.map((row: string[]): string[] => {
row.splice(col + 1, 0, '');
return row;
});
this.requestUpdate();
}
private removeColumn(evt: Event, col: number): void {
this.table = this.table.map((row: string[]): string[] => {
row.splice(col, 1);
return row;
});
this.requestUpdate();
}
private moveRow(evt: Event, row: number, target: number): void {
const temp = this.table.splice(row, 1);
this.table.splice(target, 0, ...temp);
this.requestUpdate();
}
private appendRow(evt: Event, row: number): void {
let columns = this.firstRow.concat().fill('');
let rows = (new Array(this.appendRows)).fill(columns);
this.table.splice(row + 1, 0, ...rows);
this.requestUpdate();
}
private removeRow(evt: Event, row: number): void {
this.table.splice(row, 1);
this.requestUpdate();
}
private renderTemplate(): TemplateResult {
const colIndexes = Object.keys(this.firstRow).map((item: string) => parseInt(item, 10));
const lastColIndex = colIndexes[colIndexes.length - 1];
const lastRowIndex = this.table.length - 1;
return html`
<style>
:host, typo3-backend-table-wizard { display: inline-block; }
</style>
<div class="table-fit table-fit-inline-block">
<table class="table table-center">
<thead>
<th>${this.renderTypeButton()}</th>
${colIndexes.map((colIndex: number) => html`
<th>${this.renderColButtons(colIndex, lastColIndex)}</th>
`)}
</thead>
<tbody>
${this.table.map((row: string[], rowIndex: number) => html`
<tr>
<th>${this.renderRowButtons(rowIndex, lastRowIndex)}</th>
${row.map((value: string, colIndex: number) => html`
<td>${this.renderDataElement(value, rowIndex, colIndex)}</td>
`)}
</tr>
`)}
</tbody>
</table>
</div>
`;
}
private renderDataElement(value: string, rowIndex: number, colIndex: number): TemplateResult {
const modifyTable = (evt: Event) => this.modifyTable(evt, rowIndex, colIndex);
switch (this.type) {
case 'input':
return html`
<input class="form-control" type="text" name="TABLE[c][${rowIndex}][${colIndex}]"
@change="${modifyTable}" .value="${value.replace(/\n/g, '<br>')}">
`;
case 'textarea':
default:
return html`
<textarea class="form-control" rows="6" name="TABLE[c][${rowIndex}][${colIndex}]"
@change="${modifyTable}" .value="${value.replace(/<br[ ]*\/?>/g, '\n')}"></textarea>
`;
}
}
private renderTypeButton(): TemplateResult {
return html`
<span class="btn-group">
<button class="btn btn-default" type="button" title="${lll('table_smallFields')}"
@click="${(evt: Event) => this.toggleType(evt)}">
${icon(this.type === 'input' ? 'actions-chevron-expand' : 'actions-chevron-contract')}
</button>
</span>
`;
}
private renderColButtons(col: number, last: number): TemplateResult {
const leftButton = {
title: col === 0 ? lll('table_end') : lll('table_left'),
class: col === 0 ? 'double-right' : 'left',
target: col === 0 ? last : col - 1,
};
const rightButton = {
title: col === last ? lll('table_start') : lll('table_right'),
class: col === last ? 'double-left' : 'right',
target: col === last ? 0 : col + 1,
};
return html`
<span class="btn-group">
<button class="btn btn-default" type="button" title="${leftButton.title}"
@click="${(evt: Event) => this.moveColumn(evt, col, leftButton.target)}">
<span class="t3-icon fa fa-fw fa-angle-${leftButton.class}"></span>
</button>
<button class="btn btn-default" type="button" title="${rightButton.title}"
@click="${(evt: Event) => this.moveColumn(evt, col, rightButton.target)}">
<span class="t3-icon fa fa-fw fa-angle-${rightButton.class}"></span>
</button>
<button class="btn btn-default" type="button" title="${lll('table_removeColumn')}"
@click="${(evt: Event) => this.removeColumn(evt, col)}">
<span class="t3-icon fa fa-fw fa-trash"></span>
</button>
<button class="btn btn-default" type="button" title="${lll('table_addColumn')}"
@click="${(evt: Event) => this.appendColumn(evt, col)}">
<span class="t3-icon fa fa-fw fa-plus"></span>
</button>
</span>
`;
}
private renderRowButtons(row: number, last: number): TemplateResult {
const topButton = {
title: row === 0 ? lll('table_bottom') : lll('table_up'),
class: row === 0 ? 'double-down' : 'up',
target: row === 0 ? last : row - 1,
};
const bottomButton = {
title: row === last ? lll('table_top') : lll('table_down'),
class: row === last ? 'double-up' : 'down',
target: row === last ? 0 : row + 1,
};
return html`
<span class="btn-group${this.type === 'input' ? '' : '-vertical'}">
<button class="btn btn-default" type="button" title="${topButton.title}"
@click="${(evt: Event) => this.moveRow(evt, row, topButton.target)}">
<span class="t3-icon fa fa-fw fa-angle-${topButton.class}"></span>
</button>
<button class="btn btn-default" type="button" title="${bottomButton.title}"
@click="${(evt: Event) => this.moveRow(evt, row, bottomButton.target)}">
<span class="t3-icon fa fa-fw fa-angle-${bottomButton.class}"></span>
</button>
<button class="btn btn-default" type="button" title="${lll('table_removeRow')}"
@click="${(evt: Event) => this.removeRow(evt, row)}">
<span class="t3-icon fa fa-fw fa-trash"></span>
</button>
<button class="btn btn-default" type="button" title="${lll('table_addRow')}"
@click="${(evt: Event) => this.appendRow(evt, row)}">
<span class="t3-icon fa fa-fw fa-plus"></span>
</button>
</span>
`;
}
}
......@@ -11,12 +11,32 @@
* The TYPO3 project - inspiring people to share!
*/
import {render} from 'lit-html';
import {css} from 'lit-element';
import type {TemplateResult} from 'lit-html';
import {html, render, Part} from 'lit-html';
import {unsafeHTML} from 'lit-html/directives/unsafe-html';
import {until} from 'lit-html/directives/until';
import Icons = require('TYPO3/CMS/Backend/Icons');
import 'TYPO3/CMS/Backend/Element/SpinnerElement';
export const renderHTML = (result: TemplateResult): string => {
const anvil = document.createElement('div');
render(result, anvil);
return anvil.innerHTML;
};
export const lll = (key: string): string => {
if (!window.TYPO3 || !window.TYPO3.lang || typeof window.TYPO3.lang[key] !== 'string') {
return '';
}
return window.TYPO3.lang[key];
};
export const icon = (identifier: string, size: any = 'small') => {
// @todo Fetched and resolved icons should be stored in a session repository in `Icons`
const icon = Icons.getIcon(identifier, size).then((markup: string) => html`${unsafeHTML(markup)}`);
return html`${until(icon, html`<typo3-backend-spinner size="${size}"></typo3-backend-spinner>`)}`;
};
......@@ -119,6 +119,8 @@ class TableController extends AbstractWizardController
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$this->moduleTemplate = GeneralUtility::makeInstance(ModuleTemplate::class);
$this->moduleTemplate->getPageRenderer()->loadRequireJsModule('TYPO3/CMS/Backend/Element/TableWizardElement');
$this->moduleTemplate->getPageRenderer()->addInlineLanguageLabelFile('EXT:core/Resources/Private/Language/locallang_wizards.xlf', 'table_');
$this->getLanguageService()->includeLLFile('EXT:core/Resources/Private/Language/locallang_wizards.xlf');
$this->init($request);
......@@ -163,9 +165,9 @@ class TableController extends AbstractWizardController
$this->TABLECFG = $parsedBody['TABLE'] ?? $queryParams['TABLE'] ?? null;
// Setting options:
$this->xmlStorage = $this->P['params']['xmlOutput'];
$this->numNewRows = MathUtility::forceIntegerInRange($this->P['params']['numNewRows'], 1, 50, 5);
$this->numNewRows = MathUtility::forceIntegerInRange($this->P['params']['numNewRows'], 1, 10, 1);
// Textareas or input fields:
$this->inputStyle = isset($this->TABLECFG['textFields']) ? (bool)$this->TABLECFG['textFields'] : true;
$this->inputStyle = (bool)($this->TABLECFG['textFields'] ?? true);
$this->tableParsing_delimiter = '|';
$this->tableParsing_quote = '';
}
......@@ -329,109 +331,15 @@ class TableController extends AbstractWizardController
*/
protected function getTableWizard(array $configuration): string
{
// Traverse the rows:
$tRows = [];
$k = 0;
$countLines = count($configuration);
foreach ($configuration as $cellArr) {
if (is_array($cellArr)) {
// Initialize:
$cells = [];
$a = 0;
// Traverse the columns:
foreach ($cellArr as $cellContent) {
if ($this->inputStyle) {
$cells[] = '<input class="form-control" type="text" name="TABLE[c][' . ($k + 1) * 2 . '][' . ($a + 1) * 2 . ']" value="' . htmlspecialchars($cellContent) . '" />';
} else {
$cellContent = preg_replace('/<br[ ]?[\\/]?>/i', LF, $cellContent);
$cells[] = '<textarea class="form-control" rows="6" name="TABLE[c][' . ($k + 1) * 2 . '][' . ($a + 1) * 2 . ']">' . htmlspecialchars($cellContent) . '</textarea>';
}
// Increment counter:
$a++;
}
// CTRL panel for a table row (move up/down/around):
$onClick = 'document.wizardForm.action+=' . GeneralUtility::quoteJSvalue('#ANC_' . (($k + 1) * 2 - 2)) . ';';
$onClick = ' onclick="' . htmlspecialchars($onClick) . '"';
$ctrl = '';
if ($k !== 0) {
$ctrl .= '<button class="btn btn-default" name="TABLE[row_up][' . ($k + 1) * 2 . ']" title="' . htmlspecialchars($this->getLanguageService()->getLL('table_up')) . '"' . $onClick . '><span class="t3-icon fa fa-fw fa-angle-up"></span></button>';
} else {
$ctrl .= '<button class="btn btn-default" name="TABLE[row_bottom][' . ($k + 1) * 2 . ']" title="' . htmlspecialchars($this->getLanguageService()->getLL('table_bottom')) . '"' . $onClick . '><span class="t3-icon fa fa-fw fa-angle-double-down"></span></button>';
}
if ($k + 1 !== $countLines) {
$ctrl .= '<button class="btn btn-default" name="TABLE[row_down][' . ($k + 1) * 2 . ']" title="' . htmlspecialchars($this->getLanguageService()->getLL('table_down')) . '"' . $onClick . '><span class="t3-icon fa fa-fw fa-angle-down"></span></button>';
} else {
$ctrl .= '<button class="btn btn-default" name="TABLE[row_top][' . ($k + 1) * 2 . ']" title="' . htmlspecialchars($this->getLanguageService()->getLL('table_top')) . '"' . $onClick . '><span class="t3-icon fa fa-fw fa-angle-double-up"></span></button>';
}
$ctrl .= '<button class="btn btn-default" name="TABLE[row_remove][' . ($k + 1) * 2 . ']" title="' . htmlspecialchars($this->getLanguageService()->getLL('table_removeRow')) . '"' . $onClick . '><span class="t3-icon fa fa-fw fa-trash"></span></button>';
$ctrl .= '<button class="btn btn-default" name="TABLE[row_add][' . ($k + 1) * 2 . ']" title="' . htmlspecialchars($this->getLanguageService()->getLL('table_addRow')) . '"' . $onClick . '><span class="t3-icon fa fa-fw fa-plus"></span></button>';
$tRows[] = '
<tr>
<td>
<a name="ANC_' . ($k + 1) * 2 . '"></a>
<span class="btn-group' . ($this->inputStyle ? '' : '-vertical') . '">' . $ctrl . '</span>
</td>
<td>' . implode('</td>
<td>', $cells) . '</td>
</tr>';
// Increment counter:
$k++;
}
}
// CTRL panel for a table column (move left/right/around/delete)
$cells = [];
$cells[] = '';
// Finding first row:
$firstRow = reset($configuration);
if (is_array($firstRow)) {
$cols = count($firstRow);
for ($a = 1; $a <= $cols; $a++) {
$b = $a * 2;
$ctrl = '';
if ($a !== 1) {
$ctrl .= '<button class="btn btn-default" name="TABLE[col_left][' . $b . ']" title="' . htmlspecialchars($this->getLanguageService()->getLL('table_left')) . '"><span class="t3-icon fa fa-fw fa-angle-left"></span></button>';
} else {
$ctrl .= '<button class="btn btn-default" name="TABLE[col_end][' . $b . ']" title="' . htmlspecialchars($this->getLanguageService()->getLL('table_end')) . '"><span class="t3-icon fa fa-fw fa-angle-double-right"></span></button>';
}
if ($a != $cols) {
$ctrl .= '<button class="btn btn-default" name="TABLE[col_right][' . $b . ']" title="' . htmlspecialchars($this->getLanguageService()->getLL('table_right')) . '"><span class="t3-icon fa fa-fw fa-angle-right"></span></button>';
} else {
$ctrl .= '<button class="btn btn-default" name="TABLE[col_start][' . $b . ']" title="' . htmlspecialchars($this->getLanguageService()->getLL('table_start')) . '"><span class="t3-icon fa fa-fw fa-angle-double-left"></span></button>';
}
$ctrl .= '<button class="btn btn-default" name="TABLE[col_remove][' . $b . ']" title="' . htmlspecialchars($this->getLanguageService()->getLL('table_removeColumn')) . '"><span class="t3-icon fa fa-fw fa-trash"></span></button>';
$ctrl .= '<button class="btn btn-default" name="TABLE[col_add][' . $b . ']" title="' . htmlspecialchars($this->getLanguageService()->getLL('table_addColumn')) . '"><span class="t3-icon fa fa-fw fa-plus"></span></button>';
$cells[] = '<span class="btn-group">' . $ctrl . '</span>';
}
$tRows[] = '
<tfoot>
<tr>
<td>' . implode('</td>
<td>', $cells) . '</td>
</tr>
</tfoot>';
}
$content = '';
// Implode all table rows into a string, wrapped in table tags.
$content .= '
<!-- Table wizard -->
<div class="table-fit table-fit-inline-block">
<table id="typo3-tablewizard" class="table table-center">
' . implode('', $tRows) . '
</table>
</div>';
// Input type checkbox:
$content .= '
<!-- Input mode check box: -->
<div class="checkbox">
<input type="hidden" name="TABLE[textFields]" value="0" />
<label for="textFields">
<input type="checkbox" data-global-event="change" data-action-submit="$form" name="TABLE[textFields]" id="textFields" value="1"' . ($this->inputStyle ? ' checked="checked"' : '') . ' />
' . $this->getLanguageService()->getLL('table_smallFields') . '
</label>
</div>';
return $content;
return sprintf(
'<typo3-backend-table-wizard %s></typo3-backend-table-wizard>',
GeneralUtility::implodeAttributes([
'id' => 'typo3-tablewizard',
'type' => $this->inputStyle ? 'input' : 'textarea',
'append-rows' => $this->numNewRows,
'table' => GeneralUtility::jsonEncodeForHtmlAttribute($configuration, false),
], true)
);
}
/**
......@@ -440,114 +348,6 @@ class TableController extends AbstractWizardController
*/
protected function manipulateTable(): void
{
if ($this->TABLECFG['col_remove']) {
$kk = key($this->TABLECFG['col_remove']);
$cmd = 'col_remove';
} elseif ($this->TABLECFG['col_add']) {
$kk = key($this->TABLECFG['col_add']);
$cmd = 'col_add';
} elseif ($this->TABLECFG['col_start']) {
$kk = key($this->TABLECFG['col_start']);
$cmd = 'col_start';
} elseif ($this->TABLECFG['col_end']) {
$kk = key($this->TABLECFG['col_end']);
$cmd = 'col_end';
} elseif ($this->TABLECFG['col_left']) {
$kk = key($this->TABLECFG['col_left']);
$cmd = 'col_left';
} elseif ($this->TABLECFG['col_right']) {
$kk = key($this->TABLECFG['col_right']);
$cmd = 'col_right';
} elseif ($this->TABLECFG['row_remove']) {
$kk = key($this->TABLECFG['row_remove']);
$cmd = 'row_remove';
} elseif ($this->TABLECFG['row_add']) {
$kk = key($this->TABLECFG['row_add']);
$cmd = 'row_add';
} elseif ($this->TABLECFG['row_top']) {
$kk = key($this->TABLECFG['row_top']);
$cmd = 'row_top';
} elseif ($this->TABLECFG['row_bottom']) {
$kk = key($this->TABLECFG['row_bottom']);
$cmd = 'row_bottom';
} elseif ($this->TABLECFG['row_up']) {
$kk = key($this->TABLECFG['row_up']);
$cmd = 'row_up';
} elseif ($this->TABLECFG['row_down']) {
$kk = key($this->TABLECFG['row_down']);
$cmd = 'row_down';
} else {
$kk = '';
$cmd = '';
}
if ($cmd && MathUtility::canBeInterpretedAsInteger($kk)) {
if (strpos($cmd, 'row_') === 0) {
switch ($cmd) {
case 'row_remove':
unset($this->TABLECFG['c'][$kk]);
break;
case 'row_add':
for ($a = 1; $a <= $this->numNewRows; $a++) {
// Checking if set: The point is that any new row between existing rows
// will be TRUE after one row is added while if rows are added in the bottom
// of the table there will be no existing rows to stop the addition of new rows
// which means it will add up to $this->numNewRows rows then.
if (!isset($this->TABLECFG['c'][$kk + $a])) {
$this->TABLECFG['c'][$kk + $a] = [];
} else {
break;
}
}
break;
case 'row_top':
$this->TABLECFG['c'][1] = $this->TABLECFG['c'][$kk];
unset($this->TABLECFG['c'][$kk]);
break;
case 'row_bottom':
$this->TABLECFG['c'][10000000] = $this->TABLECFG['c'][$kk];
unset($this->TABLECFG['c'][$kk]);
break;
case 'row_up':
$this->TABLECFG['c'][$kk - 3] = $this->TABLECFG['c'][$kk];
unset($this->TABLECFG['c'][$kk]);
break;
case 'row_down':
$this->TABLECFG['c'][$kk + 3] = $this->TABLECFG['c'][$kk];
unset($this->TABLECFG['c'][$kk]);