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

[FEATURE] Introduce TCA type "category"

A new TCA type "category" is introduced, which
allows to simplify the configuration of category
TCA columns. Besides the benefit for integrators,
this allows to deprecate the CategoryRegistry
in the next step.

The new type can also be used for other use cases.
Therefore, the TCA option "relationship" is available
for this TCA type. Besides "manyToMany" (default), this
can also be set to "oneToOne" or "oneToMany".

Using the new type, FormEngine will always render a
category tree. This means, no additional `renderType`
is defined. In such case, TCA type "select" can
still be used as before, without any limitation. However,
all relevant places in core are adjusted in this patch.

The category element is rendered through a custom element
(web component), reducing inline javascript.

Resolves: #94622
Releases: master
Change-Id: I1b95c42288b070fa6bac114266f5ad246a045b21
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/69899

Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Reviewed-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
parent d34217be
/*
* 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 type {SelectTree} from './SelectTree';
import type {SelectTreeToolbar} from './SelectTreeToolbar';
import './SelectTree';
import './SelectTreeToolbar';
import 'TYPO3/CMS/Backend/Element/IconElement';
import {TreeNode} from 'TYPO3/CMS/Backend/Tree/TreeNode';
/**
* Module: TYPO3/CMS/Backend/FormEngine/Element/CategoryElement
*
* Functionality for the category element (renders a tree view)
*
* @example
* <typo3-formengine-element-category recordFieldId="some-id" treeWrapperId="some-id">
* ...
* </typo3-formengine-element-category>
*
* This is based on W3C custom elements ("web components") specification, see
* https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements
*/
class CategoryElement extends HTMLElement{
private recordField: HTMLInputElement = null;
private treeWrapper: HTMLElement = null;
private tree: SelectTree = null;
public connectedCallback(): void {
this.recordField = <HTMLInputElement>this.querySelector('#' + (this.getAttribute('recordFieldId') || '' as string));
this.treeWrapper = <HTMLElement>this.querySelector('#' + (this.getAttribute('treeWrapperId') || '' as string));
if (!this.recordField || !this.treeWrapper) {
return;
}
this.tree = document.createElement('typo3-backend-form-selecttree') as SelectTree;
this.tree.classList.add('svg-tree-wrapper');
this.tree.setup = {
id: this.treeWrapper.id,
dataUrl: this.generateDataUrl(),
readOnlyMode: this.recordField.dataset.readOnly,
input: this.recordField,
exclusiveNodesIdentifiers: this.recordField.dataset.treeExclusiveKeys,
validation: JSON.parse(this.recordField.dataset.formengineValidationRules)[0],
expandUpToLevel: this.recordField.dataset.treeExpandUpToLevel,
unselectableElements: [] as Array<any>
};
this.treeWrapper.append(this.tree);
this.registerTreeEventListeners();
}
private registerTreeEventListeners(): void {
this.tree.addEventListener('typo3:svg-tree:nodes-prepared', this.loadDataAfter);
this.tree.addEventListener('typo3:svg-tree:node-selected', this.selectNode);
this.tree.addEventListener('svg-tree:initialized', () => {
if (this.recordField.dataset.treeShowToolbar) {
const toolbarElement = document.createElement('typo3-backend-form-selecttree-toolbar') as SelectTreeToolbar;
toolbarElement.tree = this.tree;
this.tree.prepend(toolbarElement);
}
});
this.listenForVisibleTree();
}
/**
* If the category element is in an invisible tab, it needs to be rendered once the tab becomes visible.
*/
private listenForVisibleTree(): void {
if (!this.tree.offsetParent) {
// Search for the parents that are tab containers
const idOfTabContainer = this.tree.closest('.tab-pane').getAttribute('id');
if (idOfTabContainer) {
const btn = document.querySelector('[aria-controls="' + idOfTabContainer + '"]');
btn.addEventListener('shown.bs.tab', () => { this.tree.dispatchEvent(new Event('svg-tree:visible')); });
}
}
}
private generateDataUrl(): string {
return TYPO3.settings.ajaxUrls.record_tree_data + '&' + new URLSearchParams({
uid: this.recordField.dataset.uid,
command: this.recordField.dataset.command,
tableName: this.recordField.dataset.tablename,
fieldName: this.recordField.dataset.fieldname,
recordTypeValue: this.recordField.dataset.recordtypevalue,
flexFormSheetName: this.recordField.dataset.flexformsheetname,
flexFormFieldName: this.recordField.dataset.flexformfieldname,
flexFormContainerName: this.recordField.dataset.flexformcontainername,
dataStructureIdentifier: this.recordField.dataset.datastructureidentifier,
flexFormContainerFieldName: this.recordField.dataset.flexformcontainerfieldname,
flexFormContainerIdentifier: this.recordField.dataset.flexformcontaineridentifier,
flexFormSectionContainerIsNew: this.recordField.dataset.flexformsectioncontainerisnew,
});
}
private selectNode = (evt: CustomEvent) => {
const node = evt.detail.node as TreeNode;
this.updateAncestorsIndeterminateState(node);
// check all nodes again, to ensure correct display of indeterminate state
this.calculateIndeterminate(this.tree.nodes);
this.saveCheckboxes();
this.tree.setup.input.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
}
/**
* Resets the node.indeterminate for the whole tree.
* It's done once after loading data.
* Later indeterminate state is updated just for the subset of nodes
*/
private loadDataAfter = () => {
this.tree.nodes = this.tree.nodes.map((node: TreeNode) => {
node.indeterminate = false;
return node;
});
this.calculateIndeterminate(this.tree.nodes);
}
/**
* Sets a comma-separated list of selected nodes identifiers to configured input
*/
private saveCheckboxes = (): void => {
this.recordField.value = this.tree.getSelectedNodes().map((node: TreeNode): string => node.identifier).join(',');
}
/**
* Updates the indeterminate state for ancestors of the current node
*/
private updateAncestorsIndeterminateState(node: TreeNode): void {
// foreach ancestor except node itself
let indeterminate = false;
node.parents.forEach((index: number) => {
const node = this.tree.nodes[index];
node.indeterminate = (node.checked || node.indeterminate || indeterminate);
// check state for the next level
indeterminate = (node.checked || node.indeterminate || node.checked || node.indeterminate);
});
}
/**
* Sets indeterminate state for a subtree.
* It relays on the tree to have indeterminate state reset beforehand.
*/
private calculateIndeterminate(nodes: TreeNode[]): void {
nodes.forEach((node: TreeNode) => {
if ((node.checked || node.indeterminate) && node.parents && node.parents.length > 0) {
node.parents.forEach((parentNodeIndex: number) => {
nodes[parentNodeIndex].indeterminate = true;
});
}
});
}
}
window.customElements.define('typo3-formengine-element-category', CategoryElement);
...@@ -12,16 +12,12 @@ ...@@ -12,16 +12,12 @@
*/ */
import type {SelectTree} from './SelectTree'; import type {SelectTree} from './SelectTree';
import {Tooltip} from 'bootstrap'; import type {SelectTreeToolbar} from './SelectTreeToolbar';
import {html, LitElement, TemplateResult} from 'lit';
import {customElement} from 'lit/decorators';
import {lll} from 'TYPO3/CMS/Core/lit-helper';
import 'TYPO3/CMS/Backend/Element/IconElement';
import './SelectTree'; import './SelectTree';
import './SelectTreeToolbar';
import 'TYPO3/CMS/Backend/Element/IconElement';
import {TreeNode} from 'TYPO3/CMS/Backend/Tree/TreeNode'; import {TreeNode} from 'TYPO3/CMS/Backend/Tree/TreeNode';
const toolbarComponentName: string = 'typo3-backend-form-selecttree-toolbar';
export class SelectTreeElement { export class SelectTreeElement {
private readonly recordField: HTMLInputElement = null; private readonly recordField: HTMLInputElement = null;
private readonly tree: SelectTree = null; private readonly tree: SelectTree = null;
...@@ -46,9 +42,11 @@ export class SelectTreeElement { ...@@ -46,9 +42,11 @@ export class SelectTreeElement {
unselectableElements: [] as Array<any> unselectableElements: [] as Array<any>
}; };
this.tree.addEventListener('svg-tree:initialized', () => { this.tree.addEventListener('svg-tree:initialized', () => {
const toolbarElement = document.createElement(toolbarComponentName) as TreeToolbar; if (this.recordField.dataset.treeShowToolbar) {
toolbarElement.tree = this.tree; const toolbarElement = document.createElement('typo3-backend-form-selecttree-toolbar') as SelectTreeToolbar;
this.tree.prepend(toolbarElement); toolbarElement.tree = this.tree;
this.tree.prepend(toolbarElement);
}
}); });
this.tree.setup = settings; this.tree.setup = settings;
treeWrapper.append(this.tree); treeWrapper.append(this.tree);
...@@ -148,96 +146,3 @@ export class SelectTreeElement { ...@@ -148,96 +146,3 @@ export class SelectTreeElement {
}); });
} }
} }
@customElement(toolbarComponentName)
class TreeToolbar extends LitElement {
public tree: SelectTree;
private settings = {
collapseAllBtn: 'collapse-all-btn',
expandAllBtn: 'expand-all-btn',
searchInput: 'search-input',
toggleHideUnchecked: 'hide-unchecked-btn'
};
/**
* State of the hide unchecked toggle button
*
* @type {boolean}
*/
private hideUncheckedState: boolean = false;
// disable shadow dom for now
protected createRenderRoot(): HTMLElement | ShadowRoot {
return this;
}
protected firstUpdated(): void {
this.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((tooltipTriggerEl: HTMLElement) => new Tooltip(tooltipTriggerEl));
}
protected render(): TemplateResult {
return html`
<div class="tree-toolbar btn-toolbar">
<div class="input-group">
<span class="input-group-addon input-group-icon filter">
<typo3-backend-icon identifier="actions-filter" size="small"></typo3-backend-icon>
</span>
<input type="text" class="form-control ${this.settings.searchInput}" placeholder="${lll('tcatree.findItem')}" @input="${(evt: InputEvent) => this.filter(evt)}">
</div>
<div class="btn-group">
<button type="button" data-bs-toggle="tooltip" class="btn btn-default ${this.settings.expandAllBtn}" title="${lll('tcatree.expandAll')}" @click="${() => this.expandAll()}">
<typo3-backend-icon identifier="apps-pagetree-category-expand-all" size="small"></typo3-backend-icon>
</button>
<button type="button" data-bs-toggle="tooltip" class="btn btn-default ${this.settings.collapseAllBtn}" title="${lll('tcatree.collapseAll')}" @click="${() => this.collapseAll()}">
<typo3-backend-icon identifier="apps-pagetree-category-collapse-all" size="small"></typo3-backend-icon>
</button>
<button type="button" data-bs-toggle="tooltip" class="btn btn-default ${this.settings.toggleHideUnchecked}" title="${lll('tcatree.toggleHideUnchecked')}" @click="${() => this.toggleHideUnchecked()}">
<typo3-backend-icon identifier="apps-pagetree-category-toggle-hide-checked" size="small"></typo3-backend-icon>
</button>
</div>
</div>
`;
}
/**
* Collapse children of root node
*/
private collapseAll() {
this.tree.collapseAll();
}
/**
* Expand all nodes
*/
private expandAll() {
this.tree.expandAll();
}
private filter(event: InputEvent): void {
const inputEl = <HTMLInputElement>event.target;
this.tree.filter(inputEl.value.trim());
}
/**
* Show only checked items
*/
private toggleHideUnchecked(): void {
this.hideUncheckedState = !this.hideUncheckedState;
if (this.hideUncheckedState) {
this.tree.nodes.forEach((node: any) => {
if (node.checked) {
this.tree.showParents(node);
node.expanded = true;
node.hidden = false;
} else {
node.hidden = true;
node.expanded = false;
}
});
} else {
this.tree.nodes.forEach((node: any) => node.hidden = false);
}
this.tree.prepareDataForVisibleNodes();
this.tree.updateVisibleNodes();
}
}
/*
* 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 type {SelectTree} from './SelectTree';
import {Tooltip} from 'bootstrap';
import {html, LitElement, TemplateResult} from 'lit';
import {customElement} from 'lit/decorators';
import {lll} from 'TYPO3/CMS/Core/lit-helper';
@customElement('typo3-backend-form-selecttree-toolbar')
export class SelectTreeToolbar extends LitElement {
public tree: SelectTree;
private settings = {
collapseAllBtn: 'collapse-all-btn',
expandAllBtn: 'expand-all-btn',
searchInput: 'search-input',
toggleHideUnchecked: 'hide-unchecked-btn'
};
/**
* State of the hide unchecked toggle button
*
* @type {boolean}
*/
private hideUncheckedState: boolean = false;
// disable shadow dom for now
protected createRenderRoot(): HTMLElement | ShadowRoot {
return this;
}
protected firstUpdated(): void {
this.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((tooltipTriggerEl: HTMLElement) => new Tooltip(tooltipTriggerEl));
}
protected render(): TemplateResult {
return html`
<div class="tree-toolbar btn-toolbar">
<div class="input-group">
<span class="input-group-addon input-group-icon filter">
<typo3-backend-icon identifier="actions-filter" size="small"></typo3-backend-icon>
</span>
<input type="text" class="form-control ${this.settings.searchInput}" placeholder="${lll('tcatree.findItem')}" @input="${(evt: InputEvent) => this.filter(evt)}">
</div>
<div class="btn-group">
<button type="button" data-bs-toggle="tooltip" class="btn btn-default ${this.settings.expandAllBtn}" title="${lll('tcatree.expandAll')}" @click="${() => this.expandAll()}">
<typo3-backend-icon identifier="apps-pagetree-category-expand-all" size="small"></typo3-backend-icon>
</button>
<button type="button" data-bs-toggle="tooltip" class="btn btn-default ${this.settings.collapseAllBtn}" title="${lll('tcatree.collapseAll')}" @click="${() => this.collapseAll()}">
<typo3-backend-icon identifier="apps-pagetree-category-collapse-all" size="small"></typo3-backend-icon>
</button>
<button type="button" data-bs-toggle="tooltip" class="btn btn-default ${this.settings.toggleHideUnchecked}" title="${lll('tcatree.toggleHideUnchecked')}" @click="${() => this.toggleHideUnchecked()}">
<typo3-backend-icon identifier="apps-pagetree-category-toggle-hide-checked" size="small"></typo3-backend-icon>
</button>
</div>
</div>
`;
}
/**
* Collapse children of root node
*/
private collapseAll() {
this.tree.collapseAll();
}
/**
* Expand all nodes
*/
private expandAll() {
this.tree.expandAll();
}
private filter(event: InputEvent): void {
const inputEl = <HTMLInputElement>event.target;
this.tree.filter(inputEl.value.trim());
}
/**
* Show only checked items
*/
private toggleHideUnchecked(): void {
this.hideUncheckedState = !this.hideUncheckedState;
if (this.hideUncheckedState) {
this.tree.nodes.forEach((node: any) => {
if (node.checked) {
this.tree.showParents(node);
node.expanded = true;
node.hidden = false;
} else {
node.hidden = true;
node.expanded = false;
}
});
} else {
this.tree.nodes.forEach((node: any) => node.hidden = false);
}
this.tree.prepareDataForVisibleNodes();
this.tree.updateVisibleNodes();
}
}
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Backend\Form\Element;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Render the category element (a category "select" tree).
*/
class CategoryElement extends AbstractFormElement
{
private const MIN_ITEMS_COUNT = 5;
private const DEFAUTL_ITEMS_COUNT = 15;
private const ITEM_HEIGHT_BASE = 20;
/**
* @var array
*/
protected $defaultFieldInformation = [
'tcaDescription' => [
'renderType' => 'tcaDescription',
],
];
/**
* @var array
*/
protected $defaultFieldWizard = [
'localizationStateSelector' => [
'renderType' => 'localizationStateSelector',
],
];
/**
* Render the category tree
*/
public function render(): array
{
$resultArray = $this->initializeResultArray();
$fieldName = $this->data['fieldName'];
$tableName = $this->data['tableName'];
$parameterArray = $this->data['parameterArray'];
$formElementId = md5($parameterArray['itemFormElName']);
// Field configuration from TCA:
$config = $parameterArray['fieldConf']['config'];
$readOnly = (bool)($config['readOnly'] ?? false);
$expanded = (bool)($config['treeConfig']['appearance']['expandAll'] ?? false);
$showHeader = (bool)($config['treeConfig']['appearance']['showHeader'] ?? false);
$exclusiveKeys = $config['exclusiveKeys'] ?? '';
$height = ((int)($config['size'] ?? 0) > 0)
? max(self::MIN_ITEMS_COUNT, (int)$config['size'])
: self::DEFAUTL_ITEMS_COUNT;
$heightInPx = $height * self::ITEM_HEIGHT_BASE;
$treeWrapperId = 'tree_' . $formElementId;
$fieldId = 'tree_record_' . $formElementId;
$dataStructureIdentifier = '';
$flexFormSheetName = '';
$flexFormFieldName = '';
$flexFormContainerName = '';
$flexFormContainerIdentifier = '';
$flexFormContainerFieldName = '';
$flexFormSectionContainerIsNew = false;
if ($this->data['processedTca']['columns'][$fieldName]['config']['type'] === 'flex') {
$dataStructureIdentifier = $this->data['processedTca']['columns'][$fieldName]['config']['dataStructureIdentifier'];
if (isset($this->data['flexFormSheetName'])) {
$flexFormSheetName = $this->data['flexFormSheetName'];
}
if (isset($this->data['flexFormFieldName'])) {
$flexFormFieldName = $this->data['flexFormFieldName'];
}
if (isset($this->data['flexFormContainerName'])) {
$flexFormContainerName = $this->data['flexFormContainerName'];
}
if (isset($this->data['flexFormContainerFieldName'])) {
$flexFormContainerFieldName = $this->data['flexFormContainerFieldName'];
}
if (isset($this->data['flexFormContainerIdentifier'])) {
$flexFormContainerIdentifier = $this->data['flexFormContainerIdentifier'];
}
// Add a flag this is a tree in a new flex section container element. This is needed to initialize
// the databaseRow with this container again so the tree data provider is able to calculate tree items.
if (!empty($this->data['flexSectionContainerPreparation'])) {
$flexFormSectionContainerIsNew = true;
}
}
$fieldInformationResult = $this->renderFieldInformation();
$fieldInformationHtml = $fieldInformationResult['html'];
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false);
$fieldWizardResult = $this->renderFieldWizard();
$fieldWizardHtml = $fieldWizardResult['html'];
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldWizardResult, false);
if (!$readOnly && !empty($fieldWizardHtml)) {
$fieldWizardHtml = '<div class="form-wizards-items-bottom">' . $fieldWizardHtml . '</div>';
}
$recordElementAttributes = [
'id' => $fieldId,
'type' => 'hidden',
'class' => 'treeRecord',
'name' => $parameterArray['itemFormElName'],
'value' => implode(',', $parameterArray['itemFormElValue']),
'data-uid' => (int)$this->data['vanillaUid'],
'data-command' => $this->data['command'],
'data-fieldname' => $fieldName,
'data-tablename' => $tableName,
'data-read-only' => $readOnly,
'data-tree-show-toolbar' => $showHeader,
'data-recordtypevalue' => $this->data['recordTypeValue'],
'data-relatedfieldname' => $parameterArray['itemFormElName'],
'data-flexformsheetname' => $flexFormSheetName,