Commit 6053476c authored by Oliver Bartsch's avatar Oliver Bartsch
Browse files

[FEATURE] Introduce entry points for TCA type "group"

To improve the workflow for editors while selecting records
or folders in TCA type "group" fields, a new field configuration
"entryPoints" is introduced. It can be used to define
the default page / folder, which should be selected when
opening the element browser. This can be configured for
all or for each table individually.

This configuration might be quite useful since TYPO3
systems usually use dedicated storage pages for record
types nowadays.

To further support site administrators, the new configuration
also allows the usage of the known TCA markers and additionally
is also added to FormEngine's "allowOverrideMatrix".

Resolves: #91077
Releases: main
Change-Id: Ie6e4e8675ff07288480bce69467805409defdc57
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/72911

Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Jochen's avatarJochen <rothjochen@gmail.com>
Tested-by: Oliver Bartsch's avatarOliver Bartsch <bo@cedev.de>
Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Reviewed-by: Jochen's avatarJochen <rothjochen@gmail.com>
Reviewed-by: Oliver Bartsch's avatarOliver Bartsch <bo@cedev.de>
parent 45ae6943
......@@ -110,11 +110,12 @@ export = (function() {
*
* @param {string} mode can be "db" or "file"
* @param {string} params additional params for the browser window
* @param {string} entryPoint the entry point, which should be expanded by default
*/
FormEngine.openPopupWindow = function(mode: string, params: string): JQuery {
FormEngine.openPopupWindow = function(mode: string, params: string, entryPoint: string): JQuery {
return Modal.advanced({
type: Modal.types.iframe,
content: FormEngine.browserUrl + '&mode=' + mode + '&bparams=' + params,
content: FormEngine.browserUrl + '&mode=' + mode + '&bparams=' + params + (entryPoint ? ('&' + (mode === 'db' ? 'expandPage' : 'expandFolder') + '=' + entryPoint) : ''),
size: Modal.sizes.large
});
};
......@@ -405,8 +406,9 @@ export = (function() {
const $me = $(e.currentTarget);
const mode = $me.data('mode');
const params = $me.data('params');
const entryPoint = $me.data('entryPoint');
FormEngine.openPopupWindow(mode, params);
FormEngine.openPopupWindow(mode, params, entryPoint);
}).on('click', '[data-formengine-field-change-event="click"]', (evt: Event) => {
const items = JSON.parse((evt.currentTarget as HTMLElement).dataset.formengineFieldChangeItems);
FormEngine.processOnFieldChange(items, evt);
......
......@@ -19,6 +19,8 @@ namespace TYPO3\CMS\Backend\Form\FieldControl;
use TYPO3\CMS\Backend\Form\AbstractNode;
use TYPO3\CMS\Backend\Form\InlineStackProcessor;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
......@@ -76,14 +78,80 @@ class ElementBrowser extends AbstractNode
// Remove any white-spaces from the allowed extension lists
$elementBrowserAllowed = implode(',', GeneralUtility::trimExplode(',', $allowed, true));
// Initialize link attributes
$linkAttributes = [
'class' => 't3js-element-browser',
'data-mode' => htmlspecialchars($elementBrowserType),
'data-params' => htmlspecialchars($elementName . '|||' . $elementBrowserAllowed . '|' . $objectPrefix),
];
// Add the default entry point - if found
$linkAttributes = $this->addEntryPoint($table, $fieldName, $config, $linkAttributes);
return [
'iconIdentifier' => 'actions-insert-record',
'title' => $title,
'linkAttributes' => [
'class' => 't3js-element-browser',
'data-mode' => htmlspecialchars($elementBrowserType),
'data-params' => htmlspecialchars($elementName . '|||' . $elementBrowserAllowed . '|' . $objectPrefix),
],
'linkAttributes' => $linkAttributes,
];
}
/**
* Try to resolve a configured default entry point - page / folder
* to be expanded - and add it to the link attributes if found.
*/
protected function addEntryPoint(string $table, string $fieldName, array $fieldConfig, array $linkAttributes): array
{
if (!isset($fieldConfig['entryPoints']) || !is_array($fieldConfig['entryPoints'])) {
// Early return in case no entry points are defined
return $linkAttributes;
}
// Fetch the configured default entry point (which might be a marker)
$entryPoint = (string)($fieldConfig['entryPoints']['_default'] ?? '');
// In case no default entry point is given, check if we deal with type=db and only one allowed table
if ($entryPoint === '') {
if (($fieldConfig['internal_type'] ?? '') === 'folder') {
// Return for internal type folder as this requires the "_default" key to be set
return $linkAttributes;
}
// Check for the allowed tables, if only one table is allowed check if an entry point is defined for it
$allowed = GeneralUtility::trimExplode(',', $fieldConfig['allowed'] ?? '', true);
if (count($allowed) === 1 && isset($fieldConfig['entryPoints'][$allowed[0]])) {
// Use the entry point for the single table as default
$entryPoint = (string)$fieldConfig['entryPoints'][$allowed[0]];
}
if ($entryPoint === '') {
// Return if still empty
return $linkAttributes;
}
}
// Check and resolve possible marker
if (str_starts_with($entryPoint, '###') && str_ends_with($entryPoint, '###')) {
if ($entryPoint === '###CURRENT_PID###') {
// Use the current pid
$entryPoint = (string)$this->data['effectivePid'];
} elseif ($entryPoint === '###SITEROOT###' && ($this->data['site'] ?? null) instanceof Site) {
// Use the root page id from the current site
$entryPoint = (string)$this->data['site']->getRootPageId();
} else {
// Check for special TSconfig marker
$TSconfig = BackendUtility::getTCEFORM_TSconfig($table, ['pid' => $this->data['effectivePid']]);
$keyword = substr($entryPoint, 3, -3);
if (str_starts_with($keyword, 'PAGE_TSCONFIG_')) {
$entryPoint = (string)($TSconfig[$fieldName][$keyword] ?? '');
} else {
$entryPoint = (string)($TSconfig['_' . $keyword] ?? '');
}
}
}
// Add the entry point to the link attribute - if resolved
if ($entryPoint !== '') {
$linkAttributes['data-entry-point'] = htmlspecialchars($entryPoint);
}
return $linkAttributes;
}
}
......@@ -18,9 +18,11 @@ declare(strict_types=1);
namespace TYPO3\CMS\Backend\Form\FieldWizard;
use TYPO3\CMS\Backend\Form\AbstractNode;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Imaging\Icon;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
......@@ -58,7 +60,7 @@ class TableList extends AbstractNode
$allowedTablesHtml[] = htmlspecialchars($label);
$allowedTablesHtml[] = '</span>';
} else {
$label = $languageService->sL($GLOBALS['TCA'][$tableName]['ctrl']['title']);
$label = $languageService->sL($GLOBALS['TCA'][$tableName]['ctrl']['title'] ?? '');
$icon = $iconFactory->getIconForRecord($tableName, [], Icon::SIZE_SMALL)->render();
if ((bool)($config['fieldControl']['elementBrowser']['disabled'] ?? false)) {
$allowedTablesHtml[] = '<span class="tablelist-item-nolink">';
......@@ -66,10 +68,20 @@ class TableList extends AbstractNode
$allowedTablesHtml[] = htmlspecialchars($label);
$allowedTablesHtml[] = '</span>';
} else {
$allowedTablesHtml[] = '<a href="#" class="btn btn-default t3js-element-browser" data-mode="db" data-params="' . htmlspecialchars($itemName . '|||' . $tableName) . '">';
// Initialize attributes
$attributes = [
'class' => 'btn btn-default t3js-element-browser',
'data-mode' => 'db',
'data-params' => $itemName . '|||' . $tableName,
];
// Add the entry point - if found
$attributes = $this->addEntryPoint($tableName, $config, $attributes);
$allowedTablesHtml[] = '<button ' . GeneralUtility::implodeAttributes($attributes, true) . '>';
$allowedTablesHtml[] = $icon;
$allowedTablesHtml[] = htmlspecialchars($label);
$allowedTablesHtml[] = '</a>';
$allowedTablesHtml[] = '</button>';
}
}
}
......@@ -83,6 +95,53 @@ class TableList extends AbstractNode
return $result;
}
/**
* Try to resolve a configured default entry point - page / folder
* to be expanded - and add it to the attributes if found.
*/
protected function addEntryPoint(string $tableName, array $fieldConfig, array $attributes): array
{
if (!isset($fieldConfig['entryPoints']) || !is_array($fieldConfig['entryPoints'])) {
// Early return in case no entry points are defined
return $attributes;
}
// Fetch the configured value (which might be a marker) - falls back to _default
$entryPoint = (string)($fieldConfig['entryPoints'][$tableName] ?? $fieldConfig['entryPoints']['_default'] ?? '');
if ($entryPoint === '') {
// In case no entry point exists for the given table and also no default is defined, return
return $attributes;
}
// Check and resolve possible marker
if (str_starts_with($entryPoint, '###') && str_ends_with($entryPoint, '###')) {
if ($entryPoint === '###CURRENT_PID###') {
// Use the current pid
$entryPoint = (string)$this->data['effectivePid'];
} elseif ($entryPoint === '###SITEROOT###' && ($this->data['site'] ?? null) instanceof Site) {
// Use the root page id from the current site
$entryPoint = (string)$this->data['site']->getRootPageId();
} else {
// Check for special TSconfig marker
$TSconfig = BackendUtility::getTCEFORM_TSconfig($this->data['tableName'], ['pid' => $this->data['effectivePid']]);
$keyword = substr($entryPoint, 3, -3);
if (str_starts_with($keyword, 'PAGE_TSCONFIG_')) {
$entryPoint = (string)($TSconfig[$this->data['fieldName']][$keyword] ?? '');
} else {
$entryPoint = (string)($TSconfig['_' . $keyword] ?? '');
}
}
}
// Add the entry point to the attribute - if resolved
if ($entryPoint !== '') {
$attributes['data-entry-point'] = $entryPoint;
}
return $attributes;
}
/**
* @return LanguageService
*/
......
......@@ -48,7 +48,7 @@ class FormEngineUtility
'check' => ['cols', 'readOnly'],
'select' => ['size', 'autoSizeMax', 'maxitems', 'minitems', 'readOnly', 'treeConfig', 'fileFolderConfig'],
'category' => ['size', 'maxitems', 'minitems', 'readOnly', 'treeConfig'],
'group' => ['size', 'autoSizeMax', 'max_size', 'maxitems', 'minitems', 'readOnly'],
'group' => ['size', 'autoSizeMax', 'max_size', 'maxitems', 'minitems', 'readOnly', 'entryPoints'],
'inline' => ['appearance', 'behaviour', 'foreign_label', 'foreign_selector', 'foreign_unique', 'maxitems', 'minitems', 'size', 'autoSizeMax', 'symmetric_label', 'readOnly'],
'imageManipulation' => ['ratios', 'cropVariants'],
];
......
......@@ -20,6 +20,7 @@ namespace TYPO3\CMS\Backend\Tests\Unit\Form\FieldControl;
use Prophecy\PhpUnit\ProphecyTrait;
use TYPO3\CMS\Backend\Form\FieldControl\ElementBrowser;
use TYPO3\CMS\Backend\Form\NodeFactory;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
class ElementBrowserTest extends UnitTestCase
......@@ -76,4 +77,145 @@ class ElementBrowserTest extends UnitTestCase
$result = $elementBrowser->render();
self::assertSame($result['linkAttributes']['data-params'], '|||be_users,be_groups|');
}
/**
* @test
* @dataProvider renderResolvesEntryPointDataProvider
*/
public function renderResolvesEntryPoint(array $config, string $expected): void
{
$nodeFactory = $this->prophesize(NodeFactory::class);
$elementBrowser = new ElementBrowser($nodeFactory->reveal(), [
'fieldName' => 'somefield',
'isInlineChild' => false,
'effectivePid' => 123,
'site' => new Site('some-site', 123, []),
'tableName' => 'tt_content',
'inlineStructure' => [],
'parameterArray' => [
'itemFormElName' => '',
'fieldConf' => [
'config' => $config,
],
],
]);
$result = $elementBrowser->render();
self::assertEquals($expected, $result['linkAttributes']['data-entry-point'] ?? '');
}
public function renderResolvesEntryPointDataProvider(): \Generator
{
yield 'Wildcard' => [
[
'allowed' => '*',
'entryPoints' => [
'_default' => 123,
],
],
'123',
];
yield 'One table' => [
[
'allowed' => 'pages',
'entryPoints' => [
'pages' => 123,
],
],
'123',
];
yield 'One table with default' => [
[
'allowed' => 'pages',
'entryPoints' => [
'_default' => 123,
],
],
'123',
];
yield 'One table with default and table definition' => [
[
'allowed' => 'pages',
'entryPoints' => [
'_default' => 123,
'pages' => 124,
],
],
'123',
];
yield 'One table with invalid configuration' => [
[
'allowed' => 'pages',
'entryPoints' => [
'some_table' => 123,
],
],
'',
];
yield 'Two tables without _defualt' => [
[
'allowed' => 'pages,some_table',
'entryPoints' => [
'pages' => 123,
'some_table' => 124,
],
],
'',
];
yield 'Two tables with _defualt' => [
[
'allowed' => 'pages,some_table',
'entryPoints' => [
'_default' => 123,
'pages' => 124,
'some_table' => 125,
],
],
'123',
];
yield 'Folder' => [
[
'internal_type' => 'folder',
'entryPoints' => [
'_default' => '1:/storage/',
],
],
'1:/storage/',
];
yield 'Folder without mandatory _default' => [
[
'internal_type' => 'folder',
'entryPoints' => [
'file' => 123,
],
],
'',
];
yield 'Entry point is escaped' => [
[
'internal_type' => 'folder',
'entryPoints' => [
'_default' => '1:/<script>alert(1)</script>/',
],
],
'1:/&lt;script&gt;alert(1)&lt;/script&gt;/',
];
yield 'Pid placeholder is resolved' => [
[
'allowed' => '*',
'entryPoints' => [
'_default' => '###CURRENT_PID###',
],
],
'123',
];
yield 'Site placeholder is resolved' => [
[
'allowed' => '*',
'entryPoints' => [
'_default' => '###SITEROOT###',
],
],
'123',
];
}
}
<?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\Tests\Unit\Form\FieldWizard;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use TYPO3\CMS\Backend\Form\FieldWizard\TableList;
use TYPO3\CMS\Backend\Form\NodeFactory;
use TYPO3\CMS\Core\Imaging\Icon;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
class TableListTest extends UnitTestCase
{
use ProphecyTrait;
/**
* @test
* @dataProvider renderResolvesEntryPointDataProvider
*/
public function renderResolvesEntryPoint(array $config, array $expected): void
{
$GLOBALS['TCA'] = [];
$languageServiceProphecy = $this->prophesize(LanguageService::class);
$languageServiceProphecy->sL(Argument::cetera())->willReturn('');
$GLOBALS['LANG'] = $languageServiceProphecy->reveal();
$iconProphecy = $this->prophesize(Icon::class);
$iconProphecy->render(Argument::any())->willReturn('icon html');
$iconFactoryProphecy = $this->prophesize(IconFactory::class);
$iconFactoryProphecy->getIconForRecord(Argument::cetera())->willReturn($iconProphecy->reveal());
GeneralUtility::addInstance(IconFactory::class, $iconFactoryProphecy->reveal());
$nodeFactory = $this->prophesize(NodeFactory::class);
$tableList = new TableList($nodeFactory->reveal(), [
'fieldName' => 'somefield',
'isInlineChild' => false,
'effectivePid' => 123,
'site' => new Site('some-site', 123, []),
'tableName' => 'tt_content',
'inlineStructure' => [],
'parameterArray' => [
'itemFormElName' => '',
'fieldConf' => [
'config' => $config,
],
],
]);
$result = $tableList->render();
if ($expected === []) {
self::assertStringNotContainsString('data-entry-point', $result['html']);
}
foreach ($expected as $value) {
self::assertStringContainsString($value, $result['html']);
}
}
public function renderResolvesEntryPointDataProvider(): \Generator
{
yield 'Wildcard' => [
[
'allowed' => '*',
'entryPoints' => [
'_default' => 123,
],
],
[],
];
yield 'One table' => [
[
'allowed' => 'pages',
'entryPoints' => [
'pages' => 123,
],
],
[
'data-params="|||pages" data-entry-point="123"',
],
];
yield 'One table with default' => [
[
'allowed' => 'pages',
'entryPoints' => [
'_default' => 123,
],
],
[
'data-params="|||pages" data-entry-point="123"',
],
];
yield 'One table with default and table definition' => [
[
'allowed' => 'pages',
'entryPoints' => [
'_default' => 123,
'pages' => 124,
],
],
[
'data-params="|||pages" data-entry-point="124"',
],
];
yield 'One table with invalid configuration' => [
[
'allowed' => 'pages',
'entryPoints' => [
'some_table' => 123,
],
],
[],
];
yield 'One table without entry point configuration' => [
[
'allowed' => 'pages',
],
[],
];
yield 'Two tables without _default' => [
[
'allowed' => 'pages,some_table',
'entryPoints' => [
'pages' => 123,
'some_table' => 124,
],
],
[
'data-params="|||pages" data-entry-point="123"',
'data-params="|||some_table" data-entry-point="124"',
],
];
yield 'Two tables with just _default' => [
[
'allowed' => 'pages,some_table',
'entryPoints' => [
'_default' => 123,
],
],
[
'data-params="|||pages" data-entry-point="123"',
'data-params="|||some_table" data-entry-point="123"',
],
];
yield 'Two tables with _default' => [
[
'allowed' => 'pages,some_table',
'entryPoints' => [
'_default' => 123,
'pages' => 124,
'some_table' => 125,
],
],
[
'data-params="|||pages" data-entry-point="124"',
'data-params="|||some_table" data-entry-point="125"',
],
];
yield 'Entry point is escaped' => [
[
'allowed' => 'pages',
'entryPoints' => [
'pages' => '<script>alert(1)</script>',
],
], [
'data-params="|||pages" data-entry-point="&lt;script&gt;alert(1)&lt;/script&gt;"',
],
];
yield 'Pid placeholder is resolved' => [
[
'allowed' => 'pages',
'entryPoints' => [
'_default' => '###CURRENT_PID###',
],
],
[
'data-params="|||pages" data-entry-point="123"',
],
];
yield 'Site placeholder is resolved' => [