From 582b23ad633ee9246652b5dfc1277ce2db8ecc3c Mon Sep 17 00:00:00 2001 From: Oliver Bartsch <bo@cedev.de> Date: Fri, 2 Dec 2022 22:05:07 +0100 Subject: [PATCH] [FEATURE] Introduce TCA type "uuid" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new TCA type "uuid" is introduced, which allows to simplify the configuration when working with fields, containing a UUID. The functionality is based on the Uid symfony component. FormEngine will automatically create a UUID when non is defined yet. Same does the DataHandler in case an invalid UUID is defined, while the field is defined as "required", which is the default. The UUID is displayed as a readonly input field. Using the new TCA type, corresponding database columns are added automatically. Resolves: #100171 Releases: main Change-Id: Ic81d4cc0158e77988e38cdab6ddcc5d42aa47fcd Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/76891 Reviewed-by: Benni Mack <benni@typo3.org> Tested-by: Oliver Bartsch <bo@cedev.de> Reviewed-by: Oliver Bartsch <bo@cedev.de> Tested-by: Benni Mack <benni@typo3.org> Reviewed-by: Markus Klein <markus.klein@typo3.org> Tested-by: core-ci <typo3@b13.com> Tested-by: Frank Nägler <frank.naegler@typo3.com> Reviewed-by: Frank Nägler <frank.naegler@typo3.com> --- .../Classes/Form/Element/UuidElement.php | 119 +++++++++ .../Form/FormDataProvider/TcaRecordTitle.php | 1 + .../Classes/Form/FormDataProvider/TcaUuid.php | 49 ++++ .../backend/Classes/Form/NodeFactory.php | 1 + .../Form/Utility/FormEngineUtility.php | 1 + .../Classes/RecordList/DatabaseRecordList.php | 43 ++-- .../LiveSearch/DatabaseRecordProvider.php | 1 + .../Language/locallang_copytoclipboard.xlf | 3 + .../Unit/Form/Element/UuidElementTest.php | 164 +++++++++++++ .../Form/FormDataProvider/TcaUuidTest.php | 226 ++++++++++++++++++ .../core/Classes/DataHandling/DataHandler.php | 28 +++ .../Classes/DataHandling/TableColumnType.php | 1 + .../Database/Schema/DefaultTcaSchema.php | 19 ++ .../SearchTermRestriction.php | 1 + .../Configuration/DefaultConfiguration.php | 12 +- .../Feature-100171-IntroduceTCATypeUuid.rst | 65 +++++ .../Unit/DataHandling/DataHandlerTest.php | 44 ++++ typo3/sysext/core/composer.json | 1 + .../Generic/Mapper/DataMapFactoryTest.php | 1 + .../DatabaseIntegrityController.php | 2 + .../Classes/Form/Element/UuidElement.php | 80 ------- .../Classes/Hooks/DataHandlerHook.php | 47 ---- .../Configuration/TCA/sys_reaction.php | 3 +- typo3/sysext/reactions/composer.json | 1 - typo3/sysext/reactions/ext_localconf.php | 11 - typo3/sysext/reactions/ext_tables.sql | 1 - 26 files changed, 760 insertions(+), 165 deletions(-) create mode 100644 typo3/sysext/backend/Classes/Form/Element/UuidElement.php create mode 100644 typo3/sysext/backend/Classes/Form/FormDataProvider/TcaUuid.php create mode 100644 typo3/sysext/backend/Tests/Unit/Form/Element/UuidElementTest.php create mode 100644 typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/TcaUuidTest.php create mode 100644 typo3/sysext/core/Documentation/Changelog/12.3/Feature-100171-IntroduceTCATypeUuid.rst delete mode 100644 typo3/sysext/reactions/Classes/Form/Element/UuidElement.php delete mode 100644 typo3/sysext/reactions/Classes/Hooks/DataHandlerHook.php diff --git a/typo3/sysext/backend/Classes/Form/Element/UuidElement.php b/typo3/sysext/backend/Classes/Form/Element/UuidElement.php new file mode 100644 index 000000000000..23e29a80f09e --- /dev/null +++ b/typo3/sysext/backend/Classes/Form/Element/UuidElement.php @@ -0,0 +1,119 @@ +<?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 Symfony\Component\Uid\Uuid; +use TYPO3\CMS\Core\Imaging\Icon; +use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\MathUtility; +use TYPO3\CMS\Core\Utility\StringUtility; + +/** + * Render a readonly input field, which is filled with a UUID + */ +class UuidElement extends AbstractFormElement +{ + /** + * Default field information enabled for this element. + * + * @var array + */ + protected $defaultFieldInformation = [ + 'tcaDescription' => [ + 'renderType' => 'tcaDescription', + ], + ]; + + public function render(): array + { + $resultArray = $this->initializeResultArray(); + $parameterArray = $this->data['parameterArray']; + $itemValue = htmlspecialchars((string)$parameterArray['itemFormElValue'], ENT_QUOTES); + $config = $parameterArray['fieldConf']['config']; + $itemName = $parameterArray['itemFormElName']; + $fieldId = StringUtility::getUniqueId('formengine-uuid-'); + + if (!isset($config['required'])) { + $config['required'] = true; + } + + if ($config['required'] && !Uuid::isValid($itemValue)) { + // Note: This can only happen in case the TcaUuid data provider is not executed or a custom + // data provider has changed the value afterwards. Since this can only happen in user code, + // we throw an exception to inform the administrator about this misconfiguration. + throw new \RuntimeException( + 'Field "' . $this->data['fieldName'] . '" in table "' . $this->data['tableName'] . '" of type "uuid" defines the field to be required but does not contain a valid uuid. Make sure to properly generate a valid uuid value.', + 1678895476 + ); + } + + $width = $this->formMaxWidth( + MathUtility::forceIntegerInRange($config['size'] ?? $this->defaultInputWidth, $this->minimumInputWidth, $this->maxInputWidth) + ); + + $attributes = [ + 'id' => $fieldId, + 'name' => $itemName, + 'type' => 'text', + 'readonly' => 'readonly', + 'class' => 'form-control disabled', + 'data-formengine-input-name' => $itemName, + 'data-formengine-validation-rules' => $this->getValidationDataAsJsonString($config), + ]; + + $uuidElement = ' + <input value="' . $itemValue . '" + ' . GeneralUtility::implodeAttributes($attributes, true) . ' + />'; + + $fieldInformationResult = $this->renderFieldInformation(); + $fieldInformationHtml = $fieldInformationResult['html']; + $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false); + + if (($config['enableCopyToClipboard'] ?? true) !== false) { + $uuidElement = ' + <div class="input-group"> + ' . $uuidElement . ' + <typo3-copy-to-clipboard text="' . $itemValue . '"> + <button type="button" class="btn btn-default" title="' . htmlspecialchars(sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_copytoclipboard.xlf:copyToClipboard.title'), 'UUID')) . '"> + ' . $this->iconFactory->getIcon('actions-clipboard', Icon::SIZE_SMALL) . ' + </button> + </typo3-copy-to-clipboard> + </div>'; + + $resultArray['javaScriptModules'][] = JavaScriptModuleInstruction::create('@typo3/backend/copy-to-clipboard.js'); + } + + $html = []; + $html[] = '<div class="formengine-field-item t3js-formengine-field-item">'; + $html[] = $fieldInformationHtml; + $html[] = '<div class="form-control-wrap" style="max-width: ' . $width . 'px">'; + $html[] = '<div class="form-wizards-wrap">'; + $html[] = '<div class="form-wizards-element">'; + $html[] = $uuidElement; + $html[] = '</div>'; + $html[] = '</div>'; + $html[] = '</div>'; + $html[] = '</div>'; + + $resultArray['html'] = implode(LF, $html); + + return $resultArray; + } +} diff --git a/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaRecordTitle.php b/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaRecordTitle.php index 273964dff896..754a9b001802 100644 --- a/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaRecordTitle.php +++ b/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaRecordTitle.php @@ -177,6 +177,7 @@ class TcaRecordTitle implements FormDataProviderInterface break; case 'input': case 'number': + case 'uuid': $recordTitle = $rawValue ?? ''; break; case 'text': diff --git a/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaUuid.php b/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaUuid.php new file mode 100644 index 000000000000..0cbb8b504880 --- /dev/null +++ b/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaUuid.php @@ -0,0 +1,49 @@ +<?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\FormDataProvider; + +use Symfony\Component\Uid\Uuid; +use TYPO3\CMS\Backend\Form\FormDataProviderInterface; + +/** + * Generates and sets field value for type=uuid + */ +class TcaUuid implements FormDataProviderInterface +{ + public function addData(array $result): array + { + foreach ($result['processedTca']['columns'] as $fieldName => $fieldConfig) { + if (($fieldConfig['config']['type'] ?? '') !== 'uuid') { + continue; + } + + // Skip if field is already filled with a valid uuid + if (Uuid::isValid((string)($result['databaseRow'][$fieldName] ?? ''))) { + continue; + } + + $result['databaseRow'][$fieldName] = (string)match ((int)($fieldConfig['config']['version'] ?? 0)) { + 6 => Uuid::v6(), + 7 => Uuid::v7(), + default => Uuid::v4() + }; + } + + return $result; + } +} diff --git a/typo3/sysext/backend/Classes/Form/NodeFactory.php b/typo3/sysext/backend/Classes/Form/NodeFactory.php index 069f8f5949a1..eb9364e3c8a7 100644 --- a/typo3/sysext/backend/Classes/Form/NodeFactory.php +++ b/typo3/sysext/backend/Classes/Form/NodeFactory.php @@ -108,6 +108,7 @@ class NodeFactory 'passthrough' => Element\PassThroughElement::class, 'belayoutwizard' => Element\BackendLayoutWizardElement::class, 'json' => Element\JsonElement::class, + 'uuid' => Element\UuidElement::class, // Default classes to enrich single elements 'fieldControl' => NodeExpansion\FieldControl::class, diff --git a/typo3/sysext/backend/Classes/Form/Utility/FormEngineUtility.php b/typo3/sysext/backend/Classes/Form/Utility/FormEngineUtility.php index fc8b3715fd19..06500e94e373 100644 --- a/typo3/sysext/backend/Classes/Form/Utility/FormEngineUtility.php +++ b/typo3/sysext/backend/Classes/Form/Utility/FormEngineUtility.php @@ -51,6 +51,7 @@ class FormEngineUtility 'password' => ['size', 'readOnly'], 'datetime' => ['size', 'readOnly'], 'color' => ['size', 'readOnly'], + 'uuid' => ['size', 'enableCopyToClipboard'], 'text' => ['cols', 'rows', 'wrap', 'max', 'readOnly'], 'json' => ['cols', 'rows', 'readOnly'], 'check' => ['cols', 'readOnly'], diff --git a/typo3/sysext/backend/Classes/RecordList/DatabaseRecordList.php b/typo3/sysext/backend/Classes/RecordList/DatabaseRecordList.php index 245a4be3bbc7..ce1be6618e31 100644 --- a/typo3/sysext/backend/Classes/RecordList/DatabaseRecordList.php +++ b/typo3/sysext/backend/Classes/RecordList/DatabaseRecordList.php @@ -2389,15 +2389,7 @@ class DatabaseRecordList $expressionBuilder->eq($tablePidField, (int)$currentPid) ); } - } elseif ($fieldType === 'input' - || $fieldType === 'text' - || $fieldType === 'json' - || $fieldType === 'flex' - || $fieldType === 'email' - || $fieldType === 'link' - || $fieldType === 'slug' - || $fieldType === 'color' - ) { + } elseif ($this->isTextFieldType($fieldType)) { $constraints[] = $expressionBuilder->like( $fieldName, $queryBuilder->quote('%' . (int)$this->searchString . '%') @@ -2434,18 +2426,8 @@ class DatabaseRecordList ); } } - if ($fieldType === 'input' - || $fieldType === 'text' - || $fieldType === 'json' - || $fieldType === 'flex' - || $fieldType === 'email' - || $fieldType === 'link' - || $fieldType === 'slug' - || $fieldType === 'color' - ) { - if ($searchConstraint->count() !== 0) { - $constraints[] = $searchConstraint; - } + if ($this->isTextFieldType($fieldType) && $searchConstraint->count() !== 0) { + $constraints[] = $searchConstraint; } } } @@ -3156,7 +3138,7 @@ class DatabaseRecordList } /** - * Add a divider to the secondary cell gorup, if not already present + * Add a divider to the secondary cell group, if not already present */ protected function addDividerToCellGroup(array &$cells): void { @@ -3164,4 +3146,21 @@ class DatabaseRecordList $this->addActionToCellGroup($cells, '<hr class="dropdown-divider">', 'divider'); } } + + protected function isTextFieldType(string $fieldType): bool + { + $textFieldTypes = [ + 'input', + 'text', + 'json', + 'flex', + 'email', + 'link', + 'slug', + 'color', + 'uuid', + ]; + + return in_array($fieldType, $textFieldTypes, true); + } } diff --git a/typo3/sysext/backend/Classes/Search/LiveSearch/DatabaseRecordProvider.php b/typo3/sysext/backend/Classes/Search/LiveSearch/DatabaseRecordProvider.php index 0b25202935e5..c5db1b805198 100644 --- a/typo3/sysext/backend/Classes/Search/LiveSearch/DatabaseRecordProvider.php +++ b/typo3/sysext/backend/Classes/Search/LiveSearch/DatabaseRecordProvider.php @@ -346,6 +346,7 @@ final class DatabaseRecordProvider implements SearchProviderInterface 'link', 'color', 'slug', + 'uuid', ]; return in_array($fieldType, $searchableFieldTypes, true); diff --git a/typo3/sysext/backend/Resources/Private/Language/locallang_copytoclipboard.xlf b/typo3/sysext/backend/Resources/Private/Language/locallang_copytoclipboard.xlf index b26372b9ae12..4c9609bdd1c3 100644 --- a/typo3/sysext/backend/Resources/Private/Language/locallang_copytoclipboard.xlf +++ b/typo3/sysext/backend/Resources/Private/Language/locallang_copytoclipboard.xlf @@ -3,6 +3,9 @@ <file source-language="en" datatype="plaintext" original="EXT:backend/Resources/Private/Language/locallang_copytoclipboard.xlf" date="2021-06-01T12:43:12Z" product-name="backend"> <header/> <body> + <trans-unit id="copyToClipboard.title" resname="copyToClipboard.title"> + <source>Copy %s to clipboard</source> + </trans-unit> <trans-unit id="copyToClipboard.success" resname="copyToClipboard.success"> <source>Copied to clipboard</source> </trans-unit> diff --git a/typo3/sysext/backend/Tests/Unit/Form/Element/UuidElementTest.php b/typo3/sysext/backend/Tests/Unit/Form/Element/UuidElementTest.php new file mode 100644 index 000000000000..f8b7379388e2 --- /dev/null +++ b/typo3/sysext/backend/Tests/Unit/Form/Element/UuidElementTest.php @@ -0,0 +1,164 @@ +<?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\Element; + +use TYPO3\CMS\Backend\Form\Element\UuidElement; +use TYPO3\CMS\Backend\Form\NodeExpansion\FieldInformation; +use TYPO3\CMS\Backend\Form\NodeFactory; +use TYPO3\CMS\Core\Imaging\IconFactory; +use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\TestingFramework\Core\Unit\UnitTestCase; + +class UuidElementTest extends UnitTestCase +{ + protected bool $resetSingletonInstances = true; + + protected function setUp(): void + { + parent::setUp(); + $GLOBALS['LANG'] = $this->createMock(LanguageService::class); + } + + /** + * @test + */ + public function renderThrowsExceptionOnEmptyElementValue(): void + { + $data = [ + 'tableName' => 'aTable', + 'fieldName' => 'identifier', + 'parameterArray' => [ + 'itemFormElName' => 'identifier', + 'itemFormElValue' => '', + 'fieldConf' => [ + 'config' => [ + 'type' => 'uuid', + 'required' => true, + ], + ], + ], + ]; + + GeneralUtility::addInstance(IconFactory::class, $this->createMock(IconFactory::class)); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionCode(1678895476); + + (new UuidElement($this->createMock(NodeFactory::class), $data))->render(); + } + + /** + * @test + */ + public function renderThrowsExceptionOnInvalidUuid(): void + { + $data = [ + 'tableName' => 'aTable', + 'fieldName' => 'identifier', + 'parameterArray' => [ + 'itemFormElName' => 'identifier', + 'itemFormElValue' => '_-invalid-_', + 'fieldConf' => [ + 'config' => [ + 'type' => 'uuid', + 'required' => true, + ], + ], + ], + ]; + + GeneralUtility::addInstance(IconFactory::class, $this->createMock(IconFactory::class)); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionCode(1678895476); + + (new UuidElement($this->createMock(NodeFactory::class), $data))->render(); + } + + /** + * @test + */ + public function renderReturnsInputElementWithUuidAndCopyToClipboardButton(): void + { + $uuid = 'b3190536-1431-453e-afbb-25b8c5022513'; + $data = [ + 'tableName' => 'aTable', + 'fieldName' => 'identifier', + 'parameterArray' => [ + 'itemFormElName' => 'identifier', + 'itemFormElValue' => $uuid, + 'fieldConf' => [ + 'config' => [ + 'type' => 'uuid', + ], + ], + ], + ]; + + GeneralUtility::addInstance(IconFactory::class, $this->createMock(IconFactory::class)); + + $nodeFactoryMock = $this->createMock(NodeFactory::class); + $fieldInformationMock = $this->createMock(FieldInformation::class); + $fieldInformationMock->method('render')->willReturn(['html' => '']); + $nodeFactoryMock->method('create')->with(self::anything())->willReturn($fieldInformationMock); + + $subject = new UuidElement($nodeFactoryMock, $data); + $result = $subject->render(); + + self::assertEquals('@typo3/backend/copy-to-clipboard.js', $result['javaScriptModules'][0]->getName()); + self::assertMatchesRegularExpression('/<typo3-copy-to-clipboard.*text="' . $uuid . '"/s', $result['html']); + self::assertMatchesRegularExpression('/<input.*value="' . $uuid . '".*id="formengine-uuid-/s', $result['html']); + } + + /** + * @test + */ + public function renderReturnsInputElementWithUuidAndWithoutCopyToClipboardButton(): void + { + $uuid = 'b3190536-1431-453e-afbb-25b8c5022513'; + $data = [ + 'tableName' => 'aTable', + 'fieldName' => 'identifier', + 'parameterArray' => [ + 'itemFormElName' => 'identifier', + 'itemFormElValue' => $uuid, + 'fieldConf' => [ + 'config' => [ + 'type' => 'uuid', + 'enableCopyToClipboard' => false, + ], + ], + ], + ]; + + GeneralUtility::addInstance(IconFactory::class, $this->createMock(IconFactory::class)); + + $nodeFactoryMock = $this->createMock(NodeFactory::class); + $fieldInformationMock = $this->createMock(FieldInformation::class); + $fieldInformationMock->method('render')->willReturn(['html' => '']); + $nodeFactoryMock->method('create')->with(self::anything())->willReturn($fieldInformationMock); + + $subject = new UuidElement($nodeFactoryMock, $data); + $result = $subject->render(); + + self::assertEmpty($result['javaScriptModules']); + self::assertDoesNotMatchRegularExpression('/<typo3-copy-to-clipboard.*text="' . $uuid . '"/s', $result['html']); + self::assertMatchesRegularExpression('/<input.*value="' . $uuid . '".*id="formengine-uuid-/s', $result['html']); + } +} diff --git a/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/TcaUuidTest.php b/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/TcaUuidTest.php new file mode 100644 index 000000000000..303a93beea9e --- /dev/null +++ b/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/TcaUuidTest.php @@ -0,0 +1,226 @@ +<?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\FormDataProvider; + +use Symfony\Component\Uid\Uuid; +use TYPO3\CMS\Backend\Form\FormDataProvider\TcaUuid; +use TYPO3\TestingFramework\Core\Unit\UnitTestCase; + +class TcaUuidTest extends UnitTestCase +{ + public function resultArrayDataProvider(): \Generator + { + yield 'Only handles TCA type "uuid" records' => [ + [ + 'command' => 'new', + 'tableName' => 'aTable', + 'databaseRow' => [ + 'aField' => '', + ], + 'processedTca' => [ + 'columns' => [ + 'aField' => [ + 'config' => [ + 'type' => 'input', + ], + ], + ], + ], + ], + '', + ]; + yield 'Does not handle records with valid uuid value' => [ + [ + 'command' => 'edit', + 'tableName' => 'aTable', + 'databaseRow' => [ + 'aField' => 'b3190536-1431-453e-afbb-25b8c5022513', + ], + 'processedTca' => [ + 'columns' => [ + 'aField' => [ + 'config' => [ + 'type' => 'uuid', + ], + ], + ], + ], + ], + 'b3190536-1431-453e-afbb-25b8c5022513', + ]; + yield 'Does handle records with invalid uuid value' => [ + [ + 'command' => 'edit', + 'tableName' => 'aTable', + 'databaseRow' => [ + 'aField' => '_-invalid-_', + ], + 'processedTca' => [ + 'columns' => [ + 'aField' => [ + 'config' => [ + 'type' => 'uuid', + ], + ], + ], + ], + ], + 'b3190536-1431-453e-afbb-25b8c5022513', + ]; + } + + /** + * @test + */ + public function addDataDoesOnlyHandleTypeUuid(): void + { + $input = [ + 'command' => 'new', + 'tableName' => 'aTable', + 'databaseRow' => [ + 'aField' => '', + ], + 'processedTca' => [ + 'columns' => [ + 'aField' => [ + 'config' => [ + 'type' => 'input', + ], + ], + ], + ], + ]; + + self::assertSame('', (new TcaUuid())->addData($input)['databaseRow']['aField']); + } + + /** + * @test + */ + public function addDataDoesNotHandleFieldsWithValidUuidValue(): void + { + $input = [ + 'command' => 'new', + 'tableName' => 'aTable', + 'databaseRow' => [ + 'aField' => 'b3190536-1431-453e-afbb-25b8c5022513', + ], + 'processedTca' => [ + 'columns' => [ + 'aField' => [ + 'config' => [ + 'type' => 'uuid', + ], + ], + ], + ], + ]; + + self::assertSame('b3190536-1431-453e-afbb-25b8c5022513', (new TcaUuid())->addData($input)['databaseRow']['aField']); + } + + /** + * @test + */ + public function addDataCreatesValidUuidValueForInvalidUuid(): void + { + $input = [ + 'command' => 'new', + 'tableName' => 'aTable', + 'databaseRow' => [ + 'aField' => '_-invalid-_', + ], + 'processedTca' => [ + 'columns' => [ + 'aField' => [ + 'config' => [ + 'type' => 'uuid', + ], + ], + ], + ], + ]; + + self::assertFalse(Uuid::isValid($input['databaseRow']['aField'])); + self::assertTrue(Uuid::isValid((new TcaUuid())->addData($input)['databaseRow']['aField'])); + } + + /** + * @test + */ + public function addDataCreatesValidUuidValueForEmptyField(): void + { + $input = [ + 'command' => 'new', + 'tableName' => 'aTable', + 'databaseRow' => [ + 'aField' => '', + ], + 'processedTca' => [ + 'columns' => [ + 'aField' => [ + 'config' => [ + 'type' => 'uuid', + ], + ], + ], + ], + ]; + + self::assertFalse(Uuid::isValid($input['databaseRow']['aField'])); + self::assertTrue(Uuid::isValid((new TcaUuid())->addData($input)['databaseRow']['aField'])); + } + + /** + * @test + */ + public function addDataCreatesValidUuidValueWithDefinedVersion(): void + { + $input = [ + 'command' => 'new', + 'tableName' => 'aTable', + 'databaseRow' => [ + 'aField' => '', + ], + 'processedTca' => [ + 'columns' => [ + 'aField' => [ + 'config' => [ + 'type' => 'uuid', + ], + ], + ], + ], + ]; + + $input['processedTca']['columns']['aField']['config']['version'] = 6; + self::assertEquals(6, (int)(new TcaUuid())->addData($input)['databaseRow']['aField'][14]); + + $input['processedTca']['columns']['aField']['config']['version'] = 7; + self::assertEquals(7, (int)(new TcaUuid())->addData($input)['databaseRow']['aField'][14]); + + $input['processedTca']['columns']['aField']['config']['version'] = 4; + self::assertEquals(4, (int)(new TcaUuid())->addData($input)['databaseRow']['aField'][14]); + + $input['processedTca']['columns']['aField']['config']['version'] = 12345678; // Defaults to 4 + self::assertEquals(4, (int)(new TcaUuid())->addData($input)['databaseRow']['aField'][14]); + + unset($input['processedTca']['columns']['aField']['config']['version']); // Defaults to 4 + self::assertEquals(4, (int)(new TcaUuid())->addData($input)['databaseRow']['aField'][14]); + } +} diff --git a/typo3/sysext/core/Classes/DataHandling/DataHandler.php b/typo3/sysext/core/Classes/DataHandling/DataHandler.php index e5e1b0c41498..a151848ae419 100644 --- a/typo3/sysext/core/Classes/DataHandling/DataHandler.php +++ b/typo3/sysext/core/Classes/DataHandling/DataHandler.php @@ -21,6 +21,7 @@ use Doctrine\DBAL\Types\IntegerType; use Doctrine\DBAL\Types\JsonType; use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareTrait; +use Symfony\Component\Uid\Uuid; use TYPO3\CMS\Backend\Utility\BackendUtility; use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; use TYPO3\CMS\Core\Cache\CacheManager; @@ -1503,6 +1504,7 @@ class DataHandler implements LoggerAwareInterface 'text' => $this->checkValueForText($value, $tcaFieldConf, $table, $realPid, $field), 'group', 'folder', 'select' => $this->checkValueForGroupFolderSelect($res, $value, $tcaFieldConf, $table, $id, $status, $field), 'json' => $this->checkValueForJson($value, $tcaFieldConf), + 'uuid' => $this->checkValueForUuid((string)$value, $tcaFieldConf), 'passthrough', 'imageManipulation', 'user' => ['value' => $value], default => [], }; @@ -2385,6 +2387,32 @@ class DataHandler implements LoggerAwareInterface return $res; } + /** + * Evaluate "uuid" type values. Will create a new uuid in case + * an invalid uuid is provided and the field is marked as required. + * + * @param string $value The value to set. + * @param array $tcaFieldConf Field configuration from TCA + * + * @return array $res The result array. The processed value (if any!) is set in the "value" key. + */ + protected function checkValueForUuid(string $value, array $tcaFieldConf): array + { + if (Uuid::isValid($value)) { + return ['value' => $value]; + } + + if ($tcaFieldConf['required'] ?? true) { + return ['value' => (string)match ((int)($tcaFieldConf['version'] ?? 0)) { + 6 => Uuid::v6(), + 7 => Uuid::v7(), + default => Uuid::v4() + }]; + } + // Unset invalid uuid - in case a field value is not required + return []; + } + /** * Applies the filter methods from a column's TCA configuration to a value array. * diff --git a/typo3/sysext/core/Classes/DataHandling/TableColumnType.php b/typo3/sysext/core/Classes/DataHandling/TableColumnType.php index be263688ba13..a9bc50544221 100644 --- a/typo3/sysext/core/Classes/DataHandling/TableColumnType.php +++ b/typo3/sysext/core/Classes/DataHandling/TableColumnType.php @@ -51,6 +51,7 @@ final class TableColumnType extends Enumeration public const NUMBER = 'NUMBER'; public const FILE = 'FILE'; public const JSON = 'JSON'; + public const UUID = 'UUID'; /** * @param mixed $type diff --git a/typo3/sysext/core/Classes/Database/Schema/DefaultTcaSchema.php b/typo3/sysext/core/Classes/Database/Schema/DefaultTcaSchema.php index afb78963f107..98bb4bc45234 100644 --- a/typo3/sysext/core/Classes/Database/Schema/DefaultTcaSchema.php +++ b/typo3/sysext/core/Classes/Database/Schema/DefaultTcaSchema.php @@ -521,6 +521,25 @@ class DefaultTcaSchema ] ); } + + // Add uuid fields for all tables, defining uuid columns (TCA type=uuid) + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if ((string)($fieldConfig['config']['type'] ?? '') !== 'uuid' + || $this->isColumnDefinedForTable($tables, $tableName, $fieldName) + ) { + continue; + } + + $tables[$tablePosition]->addColumn( + $this->quote($fieldName), + 'string', + [ + 'length' => 36, + 'default' => '', + 'notnull' => true, + ] + ); + } } return $tables; diff --git a/typo3/sysext/core/Classes/Resource/Search/QueryRestrictions/SearchTermRestriction.php b/typo3/sysext/core/Classes/Resource/Search/QueryRestrictions/SearchTermRestriction.php index 8dcc458a1962..fd63a377c841 100644 --- a/typo3/sysext/core/Classes/Resource/Search/QueryRestrictions/SearchTermRestriction.php +++ b/typo3/sysext/core/Classes/Resource/Search/QueryRestrictions/SearchTermRestriction.php @@ -113,6 +113,7 @@ class SearchTermRestriction implements QueryRestrictionInterface || $fieldType === 'link' || $fieldType === 'color' || $fieldType === 'input' + || $fieldType === 'uuid' ) { $constraintsForParts[] = $searchConstraint; } diff --git a/typo3/sysext/core/Configuration/DefaultConfiguration.php b/typo3/sysext/core/Configuration/DefaultConfiguration.php index ed54d2a618c6..15beb76f3c89 100644 --- a/typo3/sysext/core/Configuration/DefaultConfiguration.php +++ b/typo3/sysext/core/Configuration/DefaultConfiguration.php @@ -604,10 +604,15 @@ return [ \TYPO3\CMS\Backend\Form\FormDataProvider\TcaText::class, ], ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaUuid::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaJson::class, + ], + ], \TYPO3\CMS\Backend\Form\FormDataProvider\TcaRadioItems::class => [ 'depends' => [ \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, - \TYPO3\CMS\Backend\Form\FormDataProvider\TcaJson::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaUuid::class, ], ], \TYPO3\CMS\Backend\Form\FormDataProvider\TcaCheckboxItems::class => [ @@ -854,6 +859,11 @@ return [ \TYPO3\CMS\Backend\Form\FormDataProvider\TcaText::class, ], ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaUuid::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaJson::class, + ], + ], \TYPO3\CMS\Backend\Form\FormDataProvider\TcaRadioItems::class => [ 'depends' => [ \TYPO3\CMS\Backend\Form\FormDataProvider\SiteResolving::class, diff --git a/typo3/sysext/core/Documentation/Changelog/12.3/Feature-100171-IntroduceTCATypeUuid.rst b/typo3/sysext/core/Documentation/Changelog/12.3/Feature-100171-IntroduceTCATypeUuid.rst new file mode 100644 index 000000000000..15c6707fd307 --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/12.3/Feature-100171-IntroduceTCATypeUuid.rst @@ -0,0 +1,65 @@ +.. include:: /Includes.rst.txt + +.. _feature-100171-1678869689: + +========================================== +Feature: #100171 - Introduce TCA type uuid +========================================== + +See :issue:`100171` + +Description +=========== + +In our effort of introducing dedicated TCA types for special use cases, +a new TCA field type called :php:`uuid` has been added to TYPO3 Core. +Its main purpose is to simplify the TCA configuration when working with +fields, containing a UUID. + +The TCA type :php:`uuid` features the following column configuration: + +- :php:`enableCopyToClipboard` +- :php:`fieldInformation` +- :php:`required`: Defaults to :php:`true` +- :php:`size` +- :php:`version` + +.. note:: + + In case :php:`enableCopyToClipboard` is set to :php:`true`, which is the + default, a button is rendered next to the input field, which allows to copy + the UUID to the operating system's clipboard. + +.. note:: + + The :php:`version` option defines the uuid version to be used. Allowed + values are `4`, `6` or `7`. The default is `4`. For more information + about the different versions, have a look at the corresponding + `symfony documentation`_. + +The following column configuration can be overwritten by Page TSconfig: + +- :typoscript:`size` +- :typoscript:`enableCopyToClipboard` + +An example configuration looks like the following: + +.. code-block:: php + + 'identifier' => [ + 'label' => 'My record identifier', + 'config' => [ + 'type' => 'uuid', + 'version' => 6, + ], + ], + +Impact +====== + +It's now possible to use a dedicated TCA type for rendering of a UUID field. +Using the new TCA type, corresponding database columns are added automatically. + +.. _symfony documentation: https://symfony.com/doc/current/components/uid.html#uuids + +.. index:: Backend, TCA, ext:backend diff --git a/typo3/sysext/core/Tests/Unit/DataHandling/DataHandlerTest.php b/typo3/sysext/core/Tests/Unit/DataHandling/DataHandlerTest.php index 734abd345c94..40bba7ee3113 100644 --- a/typo3/sysext/core/Tests/Unit/DataHandling/DataHandlerTest.php +++ b/typo3/sysext/core/Tests/Unit/DataHandling/DataHandlerTest.php @@ -18,6 +18,7 @@ declare(strict_types=1); namespace TYPO3\CMS\Core\Tests\Unit\DataHandling; use PHPUnit\Framework\MockObject\MockObject; +use Symfony\Component\Uid\Uuid; use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; use TYPO3\CMS\Core\Cache\CacheManager; use TYPO3\CMS\Core\Cache\Frontend\NullFrontend; @@ -1095,6 +1096,49 @@ class DataHandlerTest extends UnitTestCase ); } + /** + * @test + */ + public function checkValueForUuidReturnsValidUuidUnmodified(): void + { + self::assertEquals( + 'b3190536-1431-453e-afbb-25b8c5022513', + Uuid::isValid($this->subject->_call('checkValueForUuid', 'b3190536-1431-453e-afbb-25b8c5022513', [])['value']) + ); + } + + /** + * @test + */ + public function checkValueForUuidCreatesValidUuidValueForReqiredFieldsWithInvalidUuidGiven(): void + { + self::assertTrue(Uuid::isValid($this->subject->_call('checkValueForUuid', '', [])['value'])); + self::assertTrue(Uuid::isValid($this->subject->_call('checkValueForUuid', '-_invalid_-', [])['value'])); + } + + /** + * @test + */ + public function checkValueForUuidDiscardsInvalidUuidIfFieldIsNotRequired(): void + { + self::assertEmpty($this->subject->_call('checkValueForUuid', '', ['required' => false])); + self::assertEmpty($this->subject->_call('checkValueForUuid', '-_invalid_-', ['required' => false])); + } + + /** + * @test + */ + public function checkValueForUuidCreatesValidUuidValueWithDefinedVersion(): void + { + self::assertEquals(6, (int)$this->subject->_call('checkValueForUuid', '', ['version' => 6])['value'][14]); + self::assertEquals(7, (int)$this->subject->_call('checkValueForUuid', '', ['version' => 7])['value'][14]); + self::assertEquals(4, (int)$this->subject->_call('checkValueForUuid', '', ['version' => 4])['value'][14]); + // Defaults to 4 + self::assertEquals(4, (int)$this->subject->_call('checkValueForUuid', '', ['version' => 123456678])['value'][14]); + // Defaults to 4 + self::assertEquals(4, (int)$this->subject->_call('checkValueForUuid', '', [])['value'][14]); + } + /** * @test * @dataProvider referenceValuesAreCastedDataProvider diff --git a/typo3/sysext/core/composer.json b/typo3/sysext/core/composer.json index 6127790262e7..868bbd292b0a 100644 --- a/typo3/sysext/core/composer.json +++ b/typo3/sysext/core/composer.json @@ -68,6 +68,7 @@ "symfony/options-resolver": "^6.2", "symfony/rate-limiter": "^6.2", "symfony/routing": "^6.2", + "symfony/uid": "^6.2", "symfony/yaml": "^6.2", "typo3/class-alias-loader": "^1.1.4", "typo3/cms-cli": "^3.1", diff --git a/typo3/sysext/extbase/Tests/Unit/Persistence/Generic/Mapper/DataMapFactoryTest.php b/typo3/sysext/extbase/Tests/Unit/Persistence/Generic/Mapper/DataMapFactoryTest.php index a9d19203f5f8..27f24a5244d8 100644 --- a/typo3/sysext/extbase/Tests/Unit/Persistence/Generic/Mapper/DataMapFactoryTest.php +++ b/typo3/sysext/extbase/Tests/Unit/Persistence/Generic/Mapper/DataMapFactoryTest.php @@ -464,6 +464,7 @@ class DataMapFactoryTest extends UnitTestCase [['type' => 'number'], TableColumnType::NUMBER], [['type' => 'file'], TableColumnType::FILE], [['type' => 'json'], TableColumnType::JSON], + [['type' => 'uuid'], TableColumnType::UUID], ]; } diff --git a/typo3/sysext/lowlevel/Classes/Controller/DatabaseIntegrityController.php b/typo3/sysext/lowlevel/Classes/Controller/DatabaseIntegrityController.php index cc0f5a57e211..fe1014e76377 100644 --- a/typo3/sysext/lowlevel/Classes/Controller/DatabaseIntegrityController.php +++ b/typo3/sysext/lowlevel/Classes/Controller/DatabaseIntegrityController.php @@ -922,6 +922,7 @@ class DatabaseIntegrityController case 'password': case 'color': case 'json': + case 'uuid': default: $fields['type'] = 'text'; } @@ -2257,6 +2258,7 @@ class DatabaseIntegrityController case 'password': case 'color': case 'json': + case 'uuid': default: $this->fields[$fieldName]['type'] = 'text'; } diff --git a/typo3/sysext/reactions/Classes/Form/Element/UuidElement.php b/typo3/sysext/reactions/Classes/Form/Element/UuidElement.php deleted file mode 100644 index f9298e839bc0..000000000000 --- a/typo3/sysext/reactions/Classes/Form/Element/UuidElement.php +++ /dev/null @@ -1,80 +0,0 @@ -<?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\Reactions\Form\Element; - -use Symfony\Component\Uid\Uuid; -use TYPO3\CMS\Backend\Form\Element\AbstractFormElement; -use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\CMS\Core\Utility\StringUtility; - -/** - * Creates a readonly input element with a UUID. - * - * This is rendered for config type=user, renderType=uuid - * - * @internal This is a specific hook implementation and is not considered part of the Public TYPO3 API. - */ -class UuidElement extends AbstractFormElement -{ - /** - * Default field information enabled for this element. - * - * @var array - */ - protected $defaultFieldInformation = [ - 'tcaDescription' => [ - 'renderType' => 'tcaDescription', - ], - ]; - - public function render() - { - $resultArray = $this->initializeResultArray(); - $parameterArray = $this->data['parameterArray']; - $itemValue = $parameterArray['itemFormElValue'] ?: (string)Uuid::v4(); - $fieldId = StringUtility::getUniqueId('formengine-input-'); - - $attributes = [ - 'id' => $fieldId, - 'name' => htmlspecialchars($parameterArray['itemFormElName']), - 'size' => 40, - 'class' => 'form-control', - 'data-formengine-input-name' => htmlspecialchars($parameterArray['itemFormElName']), - ]; - - $fieldInformationResult = $this->renderFieldInformation(); - $fieldInformationHtml = $fieldInformationResult['html']; - $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false); - - $html = []; - $html[] = '<div class="formengine-field-item t3js-formengine-field-item">'; - $html[] = $fieldInformationHtml; - $html[] = '<div class="form-control-wrap" style="max-width: ' . $this->formMaxWidth($this->defaultInputWidth) . 'px">'; - $html[] = '<div class="form-wizards-wrap">'; - $html[] = '<div class="form-wizards-element">'; - $html[] = '<input type="text" readonly="readonly" disabled="disabled" value="' . htmlspecialchars($itemValue, ENT_QUOTES) . '" '; - $html[] = GeneralUtility::implodeAttributes($attributes, true); - $html[] = '/>'; - $html[] = '</div>'; - $html[] = '</div>'; - $html[] = '</div>'; - $html[] = '</div>'; - $resultArray['html'] = implode(LF, $html); - return $resultArray; - } -} diff --git a/typo3/sysext/reactions/Classes/Hooks/DataHandlerHook.php b/typo3/sysext/reactions/Classes/Hooks/DataHandlerHook.php deleted file mode 100644 index 46abba94c484..000000000000 --- a/typo3/sysext/reactions/Classes/Hooks/DataHandlerHook.php +++ /dev/null @@ -1,47 +0,0 @@ -<?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\Reactions\Hooks; - -use Symfony\Component\Uid\Uuid; -use TYPO3\CMS\Core\DataHandling\DataHandler; - -/** - * @internal This is a specific hook implementation and is not considered part of the Public TYPO3 API. - */ -class DataHandlerHook -{ - public function processDatamap_postProcessFieldArray($status, $table, $id, array &$fieldArray, DataHandler $dataHandler): void - { - // Only consider reactions - if ($table !== 'sys_reaction') { - return; - } - // Only consider new reactions - if ($status !== 'new') { - return; - } - // Create a UUID for a new reaction if non is present in the field array - if (!isset($fieldArray['identifier'])) { - $fieldArray['identifier'] = (string)Uuid::v4(); - } - // Create a valid UUID for a new reaction if given identifier is invalid - if (!Uuid::isValid($fieldArray['identifier'])) { - $fieldArray['identifier'] = (string)Uuid::v4(); - } - } -} diff --git a/typo3/sysext/reactions/Configuration/TCA/sys_reaction.php b/typo3/sysext/reactions/Configuration/TCA/sys_reaction.php index 7c3b5efd8817..04fbc47c4fdb 100644 --- a/typo3/sysext/reactions/Configuration/TCA/sys_reaction.php +++ b/typo3/sysext/reactions/Configuration/TCA/sys_reaction.php @@ -80,8 +80,7 @@ return [ 'label' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.identifier', 'description' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.identifier.description', 'config' => [ - 'type' => 'user', - 'renderType' => 'uuid', + 'type' => 'uuid', ], ], 'secret' => [ diff --git a/typo3/sysext/reactions/composer.json b/typo3/sysext/reactions/composer.json index fcd929e598db..70f8b320a88c 100644 --- a/typo3/sysext/reactions/composer.json +++ b/typo3/sysext/reactions/composer.json @@ -19,7 +19,6 @@ "sort-packages": true }, "require": { - "symfony/uid": "^6.2", "typo3/cms-core": "12.3.*@dev" }, "suggest": { diff --git a/typo3/sysext/reactions/ext_localconf.php b/typo3/sysext/reactions/ext_localconf.php index b25163e51c8b..72c7e317f5dc 100644 --- a/typo3/sysext/reactions/ext_localconf.php +++ b/typo3/sysext/reactions/ext_localconf.php @@ -3,8 +3,6 @@ declare(strict_types=1); use TYPO3\CMS\Reactions\Form\Element\FieldMapElement; -use TYPO3\CMS\Reactions\Form\Element\UuidElement; -use TYPO3\CMS\Reactions\Hooks\DataHandlerHook; defined('TYPO3') or die(); @@ -13,12 +11,3 @@ $GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1660911089] = [ 'priority' => 40, 'class' => FieldMapElement::class, ]; - -// @todo This should be a dedicated TCA type instead -$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1660911009] = [ - 'nodeName' => 'uuid', - 'priority' => 40, - 'class' => UuidElement::class, -]; - -$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass'][] = DataHandlerHook::class; diff --git a/typo3/sysext/reactions/ext_tables.sql b/typo3/sysext/reactions/ext_tables.sql index 87fc55ea9df2..3de1a2af69ea 100644 --- a/typo3/sysext/reactions/ext_tables.sql +++ b/typo3/sysext/reactions/ext_tables.sql @@ -4,7 +4,6 @@ CREATE TABLE sys_reaction ( name varchar(100) DEFAULT '' NOT NULL, reaction_type varchar(255) DEFAULT '' NOT NULL, - identifier varchar(36) DEFAULT '' NOT NULL, secret varchar(255) DEFAULT '' NOT NULL, impersonate_user int(11) unsigned DEFAULT '0' NOT NULL, table_name varchar(255) DEFAULT '' NOT NULL, -- GitLab