From f835b728943fa5788739f1c67f8df7e690502a20 Mon Sep 17 00:00:00 2001 From: Frank Naegler <frank.naegler@typo3.org> Date: Thu, 9 Feb 2023 12:38:46 +0100 Subject: [PATCH] [FEATURE] Introduce TCA type "json" A new TCA type "json" is introduced, which allows to simplify the configuration when working with fields, containing JSON data. It replaces the previously introduced dbtype=json of TCA type "user" (see: #99226). FormEngine will display the JSON data using codemirror, in case EXT:t3editor is installed and enabled for the field. Otherwise, a standard textarea HTML element is used. Using the new type, corresponding database columns are added automatically. Resolves: #100088 Related: #99226 Releases: main Change-Id: I49ab049966a86696910a7c18a7c45be00c162ca7 Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/77798 Tested-by: core-ci <typo3@b13.com> Reviewed-by: Nikita Hovratov <nikita.h@live.de> Tested-by: Benni Mack <benni@typo3.org> Tested-by: Nikita Hovratov <nikita.h@live.de> Reviewed-by: Benni Mack <benni@typo3.org> --- .../form-engine/element/json-element.ts | 45 ++++ .../t3editor/element/code-mirror-element.ts | 16 +- .../Classes/Form/Element/JsonElement.php | 247 ++++++++++++++++++ .../TcaColumnsProcessPlaceholders.php | 1 + .../FormDataProvider/TcaInputPlaceholders.php | 1 + .../Classes/Form/FormDataProvider/TcaJson.php | 51 ++++ .../Form/FormDataProvider/TcaRecordTitle.php | 3 + .../backend/Classes/Form/NodeFactory.php | 1 + .../Form/Utility/FormEngineUtility.php | 1 + .../Classes/RecordList/DatabaseRecordList.php | 2 + .../LiveSearch/DatabaseRecordProvider.php | 1 + .../Classes/Utility/BackendUtility.php | 38 +-- .../form-engine/element/json-element.js | 13 + .../Unit/Form/Element/JsonElementTest.php | 118 +++++++++ .../Form/FormDataProvider/TcaJsonTest.php | 241 +++++++++++++++++ .../core/Classes/DataHandling/DataHandler.php | 42 ++- .../Classes/DataHandling/TableColumnType.php | 1 + .../Database/Schema/DefaultTcaSchema.php | 148 ++++++----- .../SearchTermRestriction.php | 1 + .../Configuration/DefaultConfiguration.php | 19 +- ...9226-IntroduceDbTypeJsonForTCATypeUser.rst | 8 + .../12.3/Feature-100088-NewTCATypeJson.rst | 58 ++++ ...-100088-RemoveDbTypeJsonForTCATypeUser.rst | 48 ++++ .../Unit/DataHandling/DataHandlerTest.php | 36 +++ .../Generic/Mapper/DataMapFactoryTest.php | 1 + .../DatabaseIntegrityController.php | 2 + .../Classes/Form/Element/FieldMapElement.php | 2 +- .../Overrides/sys_reaction_create_record.php | 4 +- typo3/sysext/reactions/ext_tables.sql | 1 - .../Configuration/Backend/T3editor/Modes.php | 4 + .../JavaScript/element/code-mirror-element.js | 6 +- 31 files changed, 1062 insertions(+), 98 deletions(-) create mode 100644 Build/Sources/TypeScript/backend/form-engine/element/json-element.ts create mode 100644 typo3/sysext/backend/Classes/Form/Element/JsonElement.php create mode 100644 typo3/sysext/backend/Classes/Form/FormDataProvider/TcaJson.php create mode 100644 typo3/sysext/backend/Resources/Public/JavaScript/form-engine/element/json-element.js create mode 100644 typo3/sysext/backend/Tests/Unit/Form/Element/JsonElementTest.php create mode 100644 typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/TcaJsonTest.php create mode 100644 typo3/sysext/core/Documentation/Changelog/12.3/Feature-100088-NewTCATypeJson.rst create mode 100644 typo3/sysext/core/Documentation/Changelog/12.3/Important-100088-RemoveDbTypeJsonForTCATypeUser.rst diff --git a/Build/Sources/TypeScript/backend/form-engine/element/json-element.ts b/Build/Sources/TypeScript/backend/form-engine/element/json-element.ts new file mode 100644 index 000000000000..0a3b678e48e1 --- /dev/null +++ b/Build/Sources/TypeScript/backend/form-engine/element/json-element.ts @@ -0,0 +1,45 @@ +/* + * 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 {Resizable} from './modifier/resizable'; +import {Tabbable} from './modifier/tabbable'; + +/** + * Module: @typo3/backend/form-engine/element/json-element + * + * Functionality for the json element + * + * @example + * <typo3-formengine-element-json recordFieldId="some-id"> + * ... + * </typo3-formengine-element-json> + * + * 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 JsonElement extends HTMLElement { + private element: HTMLTextAreaElement = null; + + public connectedCallback(): void { + this.element = document.getElementById((this.getAttribute('recordFieldId') || '' as string)) as HTMLTextAreaElement; + + if (!this.element) { + return; + } + + Resizable.enable(this.element); + Tabbable.enable(this.element); + } +} + +window.customElements.define('typo3-formengine-element-json', JsonElement); diff --git a/Build/Sources/TypeScript/t3editor/element/code-mirror-element.ts b/Build/Sources/TypeScript/t3editor/element/code-mirror-element.ts index b3aa02fd3326..f5a64d6d0eb2 100644 --- a/Build/Sources/TypeScript/t3editor/element/code-mirror-element.ts +++ b/Build/Sources/TypeScript/t3editor/element/code-mirror-element.ts @@ -13,7 +13,16 @@ import {LitElement, html, css} from 'lit'; import {customElement, property, state} from 'lit/decorators'; -import {EditorView, ViewUpdate, lineNumbers, highlightSpecialChars, drawSelection, keymap, KeyBinding} from '@codemirror/view'; +import { + EditorView, + ViewUpdate, + lineNumbers, + highlightSpecialChars, + drawSelection, + keymap, + KeyBinding, + placeholder +} from '@codemirror/view'; import {Extension, EditorState} from '@codemirror/state'; import {syntaxHighlighting, defaultHighlightStyle} from '@codemirror/language'; import {defaultKeymap, indentWithTab} from '@codemirror/commands'; @@ -47,6 +56,7 @@ export class CodeMirrorElement extends LitElement { @property({type: Boolean, reflect: true}) fullscreen: boolean = false; @property({type: String}) label: string; + @property({type: String}) placeholder: string; @property({type: String}) panel: string = 'bottom'; @state() editorView: EditorView = null; @@ -185,6 +195,10 @@ export class CodeMirrorElement extends LitElement { extensions.push(EditorState.readOnly.of(true)); } + if (this.placeholder) { + extensions.push(placeholder(this.placeholder)); + } + if (this.mode) { const modeImplementation = <Extension[]>await executeJavaScriptModuleInstruction(this.mode); extensions.push(...modeImplementation); diff --git a/typo3/sysext/backend/Classes/Form/Element/JsonElement.php b/typo3/sysext/backend/Classes/Form/Element/JsonElement.php new file mode 100644 index 000000000000..5551ad417023 --- /dev/null +++ b/typo3/sysext/backend/Classes/Form/Element/JsonElement.php @@ -0,0 +1,247 @@ +<?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\Page\JavaScriptModuleInstruction; +use TYPO3\CMS\Core\Utility\ExtensionManagementUtility; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\MathUtility; +use TYPO3\CMS\Core\Utility\StringUtility; +use TYPO3\CMS\T3editor\Registry\AddonRegistry; +use TYPO3\CMS\T3editor\Registry\ModeRegistry; +use TYPO3\CMS\T3editor\T3editor; + +/** + * Handles type=json elements. + * + * Renders either a code editor or a standard textarea. + */ +class JsonElement extends AbstractFormElement +{ + /** + * Default field information enabled for this element. + * + * @var array + */ + protected $defaultFieldInformation = [ + 'tcaDescription' => [ + 'renderType' => 'tcaDescription', + ], + ]; + + /** + * Default field wizards enabled for this element. + * + * @var array + */ + protected $defaultFieldWizard = [ + 'localizationStateSelector' => [ + 'renderType' => 'localizationStateSelector', + ], + 'otherLanguageContent' => [ + 'renderType' => 'otherLanguageContent', + 'after' => [ + 'localizationStateSelector', + ], + ], + 'defaultLanguageDifferences' => [ + 'renderType' => 'defaultLanguageDifferences', + 'after' => [ + 'otherLanguageContent', + ], + ], + ]; + + public function render(): array + { + $resultArray = $this->initializeResultArray(); + + $parameterArray = $this->data['parameterArray']; + $config = $parameterArray['fieldConf']['config']; + $readOnly = (bool)($config['readOnly'] ?? false); + $placeholder = trim((string)($config['placeholder'] ?? '')); + $enableCodeEditor = ($config['enableCodeEditor'] ?? true) && ExtensionManagementUtility::isLoaded('t3editor'); + + $itemValue = ''; + if (!empty($parameterArray['itemFormElValue'])) { + try { + $itemValue = (string)json_encode($parameterArray['itemFormElValue'], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); + } catch (\JsonException) { + } + } + + $width = null; + if ($config['cols'] ?? false) { + $width = $this->formMaxWidth(MathUtility::forceIntegerInRange($config['cols'], $this->minimumInputWidth, $this->maxInputWidth)); + } + + $rows = MathUtility::forceIntegerInRange(($config['rows'] ?? 5) ?: 5, 1, 20); + $originalRows = $rows; + if (($itemFormElementValueLength = strlen($itemValue)) > 80) { + $calculatedRows = MathUtility::forceIntegerInRange( + (int)round($itemFormElementValueLength / 40), + count(explode(LF, $itemValue)), + 20 + ); + if ($originalRows < $calculatedRows) { + $rows = $calculatedRows; + } + } + + $fieldInformationResult = $this->renderFieldInformation(); + $fieldInformationHtml = $fieldInformationResult['html']; + $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false); + + // Early return readonly display in case t3editor is not available + if ($readOnly && !$enableCodeEditor) { + $html = []; + $html[] = '<div class="formengine-field-item t3js-formengine-field-item">'; + $html[] = $fieldInformationHtml; + $html[] = '<div class="form-wizards-wrap">'; + $html[] = '<div class="form-wizards-element">'; + $html[] = '<div class="form-control-wrap"' . ($width ? ' style="max-width: ' . $width . 'px">' : '>'); + $html[] = '<textarea class="form-control text-monospace" rows="' . $rows . '" disabled>'; + $html[] = htmlspecialchars($itemValue); + $html[] = '</textarea>'; + $html[] = '</div>'; + $html[] = '</div>'; + $html[] = '</div>'; + $html[] = '</div>'; + $resultArray['html'] = implode(LF, $html); + return $resultArray; + } + + $fieldId = StringUtility::getUniqueId('formengine-json-'); + $itemName = (string)$parameterArray['itemFormElName']; + $attributes = [ + 'id' => $fieldId, + 'name' => $itemName, + 'wrap' => 'off', + 'rows' => (string)$rows, + 'class' => 'form-control text-monospace', + 'data-formengine-validation-rules' => $this->getValidationDataAsJsonString($config), + ]; + + if ($readOnly) { + $attributes['disabled'] = ''; + } + + if ($placeholder !== '') { + $attributes['placeholder'] = $placeholder; + } + + // Use CodeMirror if available + if ($enableCodeEditor) { + // Compile and register t3editor configuration + GeneralUtility::makeInstance(T3editor::class)->registerConfiguration(); + + $modeRegistry = GeneralUtility::makeInstance(ModeRegistry::class); + $mode = $modeRegistry->isRegistered('json') + ? $modeRegistry->getByFormatCode('json') + : $modeRegistry->getDefaultMode(); + + $addons = $keymaps = []; + foreach (GeneralUtility::makeInstance(AddonRegistry::class)->getAddons() as $addon) { + foreach ($addon->getCssFiles() as $cssFile) { + $resultArray['stylesheetFiles'][] = $cssFile; + } + if (($module = $addon->getModule())) { + $addons[] = $module; + } + if (($keymap = $addon->getKeymap())) { + $keymaps[] = $keymap; + } + } + + $codeMirrorConfig = [ + 'label' => $this->data['tableName'] . ' > ' . $this->data['fieldName'], + 'mode' => GeneralUtility::jsonEncodeForHtmlAttribute($mode->getModule(), false), + ]; + + if ($readOnly) { + $codeMirrorConfig['readonly'] = ''; + } + if ($placeholder !== '') { + $codeMirrorConfig['placeholder'] = $placeholder; + } + if ($addons !== []) { + $codeMirrorConfig['addons'] = GeneralUtility::jsonEncodeForHtmlAttribute($addons, false); + } + if ($keymaps !== []) { + $codeMirrorConfig['keymaps'] = GeneralUtility::jsonEncodeForHtmlAttribute($keymaps, false); + } + + $resultArray['javaScriptModules'][] = JavaScriptModuleInstruction::create('@typo3/t3editor/element/code-mirror-element.js'); + $editorHtml = ' + <typo3-t3editor-codemirror ' . GeneralUtility::implodeAttributes($codeMirrorConfig, true, true) . '> + <textarea ' . GeneralUtility::implodeAttributes($attributes, true, true) . '>' . htmlspecialchars($itemValue) . '</textarea> + <input type="hidden" name="target" value="0" /> + <input type="hidden" name="effectivePid" value="' . htmlspecialchars((string)($this->data['effectivePid'] ?? '0')) . '" /> + </typo3-t3editor-codemirror>'; + } else { + $attributes['class'] = implode(' ', array_merge(explode(' ', $attributes['class']), ['formengine-textarea', 't3js-enable-tab'])); + $resultArray['javaScriptModules'][] = JavaScriptModuleInstruction::create('@typo3/backend/form-engine/element/json-element.js'); + $editorHtml = ' + <typo3-formengine-element-json recordFieldId="' . htmlspecialchars($fieldId) . '"> + <textarea ' . GeneralUtility::implodeAttributes($attributes, true, true) . '>' . htmlspecialchars($itemValue) . '</textarea> + </typo3-formengine-element-json>'; + } + + $additionalHtml = []; + if (!$readOnly) { + $fieldControlResult = $this->renderFieldControl(); + $fieldControlHtml = $fieldControlResult['html']; + $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldControlResult, false); + + if (!empty($fieldControlHtml)) { + $additionalHtml[] = '<div class="form-wizards-items-aside form-wizards-items-aside--field-control">'; + $additionalHtml[] = '<div class="btn-group">'; + $additionalHtml[] = $fieldControlHtml; + $additionalHtml[] = '</div>'; + $additionalHtml[] = '</div>'; + } + + $fieldWizardResult = $this->renderFieldWizard(); + $fieldWizardHtml = $fieldWizardResult['html']; + $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldWizardResult, false); + + if (!empty($fieldWizardHtml)) { + $additionalHtml[] = '<div class="form-wizards-items-bottom">'; + $additionalHtml[] = $fieldWizardHtml; + $additionalHtml[] = '</div>'; + } + } + + $html = []; + $html[] = '<div class="formengine-field-item t3js-formengine-field-item">'; + $html[] = $fieldInformationHtml; + $html[] = '<div class="form-control-wrap"' . ($width ? ' style="max-width: ' . $width . 'px">' : '>'); + $html[] = '<div class="form-wizards-wrap">'; + $html[] = '<div class="form-wizards-element">'; + $html[] = $editorHtml; + $html[] = '</div>'; + $html[] = implode(LF, $additionalHtml); + $html[] = '</div>'; + $html[] = '</div>'; + $html[] = '</div>'; + + $resultArray['html'] = implode(LF, $html); + + return $resultArray; + } +} diff --git a/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaColumnsProcessPlaceholders.php b/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaColumnsProcessPlaceholders.php index d050dbfe61b8..fb98cfda35cc 100644 --- a/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaColumnsProcessPlaceholders.php +++ b/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaColumnsProcessPlaceholders.php @@ -44,6 +44,7 @@ class TcaColumnsProcessPlaceholders implements FormDataProviderInterface && $fieldConfig['config']['type'] !== 'password' && $fieldConfig['config']['type'] !== 'datetime' && $fieldConfig['config']['type'] !== 'color' + && $fieldConfig['config']['type'] !== 'json' ) ) { continue; diff --git a/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaInputPlaceholders.php b/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaInputPlaceholders.php index c6d64e9fd6e0..8075737399f0 100644 --- a/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaInputPlaceholders.php +++ b/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaInputPlaceholders.php @@ -51,6 +51,7 @@ class TcaInputPlaceholders implements FormDataProviderInterface && $fieldConfig['config']['type'] !== 'password' && $fieldConfig['config']['type'] !== 'datetime' && $fieldConfig['config']['type'] !== 'color' + && $fieldConfig['config']['type'] !== 'json' ) ) { continue; diff --git a/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaJson.php b/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaJson.php new file mode 100644 index 000000000000..a222480ddf82 --- /dev/null +++ b/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaJson.php @@ -0,0 +1,51 @@ +<?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 TYPO3\CMS\Backend\Form\FormDataProviderInterface; + +/** + * Resolve and prepare json data. + */ +class TcaJson extends AbstractDatabaseRecordProvider implements FormDataProviderInterface +{ + public function addData(array $result): array + { + // Currently only new records are considered + if ($result['command'] !== 'new') { + return $result; + } + + foreach ($result['processedTca']['columns'] as $fieldName => $fieldConfig) { + if (($fieldConfig['config']['type'] ?? '') !== 'json') { + continue; + } + + // Ensure that even for new records, the field is always an array - especially if a default value is defined + if (is_string($result['databaseRow'][$fieldName])) { + try { + $result['databaseRow'][$fieldName] = json_decode($result['databaseRow'][$fieldName], true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + $result['databaseRow'][$fieldName] = []; + } + } + } + + return $result; + } +} diff --git a/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaRecordTitle.php b/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaRecordTitle.php index 86ce0a6f0ea4..273964dff896 100644 --- a/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaRecordTitle.php +++ b/typo3/sysext/backend/Classes/Form/FormDataProvider/TcaRecordTitle.php @@ -193,6 +193,9 @@ class TcaRecordTitle implements FormDataProviderInterface break; case 'flex': // @todo: Check if and how a label could be generated from flex field data + break; + case 'json': + // @todo: Check if and how a label could be generated from json field data default: } diff --git a/typo3/sysext/backend/Classes/Form/NodeFactory.php b/typo3/sysext/backend/Classes/Form/NodeFactory.php index e0f88fb7e801..069f8f5949a1 100644 --- a/typo3/sysext/backend/Classes/Form/NodeFactory.php +++ b/typo3/sysext/backend/Classes/Form/NodeFactory.php @@ -107,6 +107,7 @@ class NodeFactory 'category' => Element\CategoryElement::class, 'passthrough' => Element\PassThroughElement::class, 'belayoutwizard' => Element\BackendLayoutWizardElement::class, + 'json' => Element\JsonElement::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 c18586f89c62..fc8b3715fd19 100644 --- a/typo3/sysext/backend/Classes/Form/Utility/FormEngineUtility.php +++ b/typo3/sysext/backend/Classes/Form/Utility/FormEngineUtility.php @@ -52,6 +52,7 @@ class FormEngineUtility 'datetime' => ['size', 'readOnly'], 'color' => ['size', 'readOnly'], 'text' => ['cols', 'rows', 'wrap', 'max', 'readOnly'], + 'json' => ['cols', 'rows', 'readOnly'], 'check' => ['cols', 'readOnly'], 'select' => ['size', 'autoSizeMax', 'maxitems', 'minitems', 'readOnly', 'treeConfig', 'fileFolderConfig'], 'category' => ['size', 'maxitems', 'minitems', 'readOnly', 'treeConfig'], diff --git a/typo3/sysext/backend/Classes/RecordList/DatabaseRecordList.php b/typo3/sysext/backend/Classes/RecordList/DatabaseRecordList.php index 4f496e2dfe3f..245a4be3bbc7 100644 --- a/typo3/sysext/backend/Classes/RecordList/DatabaseRecordList.php +++ b/typo3/sysext/backend/Classes/RecordList/DatabaseRecordList.php @@ -2391,6 +2391,7 @@ class DatabaseRecordList } } elseif ($fieldType === 'input' || $fieldType === 'text' + || $fieldType === 'json' || $fieldType === 'flex' || $fieldType === 'email' || $fieldType === 'link' @@ -2435,6 +2436,7 @@ class DatabaseRecordList } if ($fieldType === 'input' || $fieldType === 'text' + || $fieldType === 'json' || $fieldType === 'flex' || $fieldType === 'email' || $fieldType === 'link' diff --git a/typo3/sysext/backend/Classes/Search/LiveSearch/DatabaseRecordProvider.php b/typo3/sysext/backend/Classes/Search/LiveSearch/DatabaseRecordProvider.php index 545d12539736..0b25202935e5 100644 --- a/typo3/sysext/backend/Classes/Search/LiveSearch/DatabaseRecordProvider.php +++ b/typo3/sysext/backend/Classes/Search/LiveSearch/DatabaseRecordProvider.php @@ -340,6 +340,7 @@ final class DatabaseRecordProvider implements SearchProviderInterface $searchableFieldTypes = [ 'input', 'text', + 'json', 'flex', 'email', 'link', diff --git a/typo3/sysext/backend/Classes/Utility/BackendUtility.php b/typo3/sysext/backend/Classes/Utility/BackendUtility.php index 30435683019a..841a6bfe7550 100644 --- a/typo3/sysext/backend/Classes/Utility/BackendUtility.php +++ b/typo3/sysext/backend/Classes/Utility/BackendUtility.php @@ -1544,17 +1544,6 @@ class BackendUtility GeneralUtility::callUserFunction($_funcRef, $theColConf, $referenceObject); } - // For database type "JSON" the value in decoded state is most likely an array. This is not compatible with - // the "human-readable" processing and returning promise of this method. Thus, we ensure to handle value for - // this field as json encoded string. This should be the best readable version of the value data. - if ((string)($theColConf['dbType'] ?? '') === 'json' - && ((is_string($value) && !str_starts_with($value, '{') && !str_starts_with($value, '[')) - || !is_string($value)) - ) { - // @todo Consider to pretty print the json value, as this would match the "human readable" goal. - $value = \json_encode($value); - } - $l = ''; $lang = static::getLanguageService(); switch ((string)($theColConf['type'] ?? '')) { @@ -1727,6 +1716,22 @@ class BackendUtility } } break; + case 'json': + // For database type "JSON" the value in decoded state is most likely an array. This is not compatible with + // the "human-readable" processing and returning promise of this method. Thus, we ensure to handle value for + // this field as json encoded string. This should be the best readable version of the value data. + if ( + ( + is_string($value) + && !str_starts_with($value, '{') + && !str_starts_with($value, '[') + ) + || !is_string($value) + ) { + // @todo Consider to pretty print the json value, as this would match the "human readable" goal. + $value = json_encode($value); + } + // no break intended. default: if ($defaultPassthrough) { $l = $value; @@ -3461,17 +3466,14 @@ class BackendUtility public static function convertDatabaseRowValuesToPhp(string $table, array $row): array { $tableTca = $GLOBALS['TCA'][$table] ?? []; - if (!$tableTca) { + if (!is_array($tableTca) || $tableTca === []) { return $row; } $platform = static::getConnectionForTable($table)->getDatabasePlatform(); foreach ($row as $field => $value) { - if (($tableTca['columns'][$field]['config']['type'] ?? '') === 'user') { - $dbType = $GLOBALS['TCA'][$table]['columns'][$field]['config']['dbType'] ?? ''; - // @todo Only handle a specific dbType for now. - if ($dbType === 'json') { - $row[$field] = Type::getType($dbType)->convertToPHPValue($value, $platform); - } + // @todo Only handle specific TCA type=json + if (($tableTca['columns'][$field]['config']['type'] ?? '') === 'json') { + $row[$field] = Type::getType('json')->convertToPHPValue($value, $platform); } } return $row; diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/form-engine/element/json-element.js b/typo3/sysext/backend/Resources/Public/JavaScript/form-engine/element/json-element.js new file mode 100644 index 000000000000..17bd3e435a36 --- /dev/null +++ b/typo3/sysext/backend/Resources/Public/JavaScript/form-engine/element/json-element.js @@ -0,0 +1,13 @@ +/* + * 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{Resizable}from"@typo3/backend/form-engine/element/modifier/resizable.js";import{Tabbable}from"@typo3/backend/form-engine/element/modifier/tabbable.js";class JsonElement extends HTMLElement{constructor(){super(...arguments),this.element=null}connectedCallback(){this.element=document.getElementById(this.getAttribute("recordFieldId")||""),this.element&&(Resizable.enable(this.element),Tabbable.enable(this.element))}}window.customElements.define("typo3-formengine-element-json",JsonElement); \ No newline at end of file diff --git a/typo3/sysext/backend/Tests/Unit/Form/Element/JsonElementTest.php b/typo3/sysext/backend/Tests/Unit/Form/Element/JsonElementTest.php new file mode 100644 index 000000000000..486f6b2e6f45 --- /dev/null +++ b/typo3/sysext/backend/Tests/Unit/Form/Element/JsonElementTest.php @@ -0,0 +1,118 @@ +<?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\JsonElement; +use TYPO3\CMS\Backend\Form\NodeExpansion\FieldInformation; +use TYPO3\CMS\Backend\Form\NodeFactory; +use TYPO3\CMS\Core\Cache\CacheManager; +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; +use TYPO3\CMS\Core\Imaging\IconFactory; +use TYPO3\CMS\Core\Package\PackageManager; +use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\T3editor\Mode; +use TYPO3\CMS\T3editor\Registry\ModeRegistry; +use TYPO3\TestingFramework\Core\Unit\UnitTestCase; + +class JsonElementTest extends UnitTestCase +{ + protected bool $resetSingletonInstances = true; + + /** + * @test + */ + public function renderReturnsJsonInStandardTextarea(): void + { + $data = [ + 'parameterArray' => [ + 'itemFormElName' => 'config', + 'itemFormElValue' => ['foo' => 'bar'], + 'fieldConf' => [ + 'config' => [ + 'type' => 'json', + 'enableCodeEditor' => false, + 'placeholder' => 'placeholder', + ], + ], + ], + ]; + + 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 JsonElement($nodeFactoryMock, $data); + $result = $subject->render(); + + self::assertEquals('@typo3/backend/form-engine/element/json-element.js', $result['javaScriptModules'][0]->getName()); + self::assertStringContainsString('<typo3-formengine-element-json', $result['html']); + self::assertStringContainsString('placeholder="placeholder"', $result['html']); + self::assertStringContainsString('"foo": "bar"', $result['html']); + } + + /** + * @test + */ + public function renderReturnsJsonInCodeEditor(): void + { + $data = [ + 'tableName' => 'aTable', + 'fieldName' => 'aField', + 'parameterArray' => [ + 'itemFormElName' => 'config', + 'itemFormElValue' => ['foo' => 'bar'], + 'fieldConf' => [ + 'config' => [ + 'type' => 'json', + 'placeholder' => 'placeholder', + ], + ], + ], + ]; + + GeneralUtility::addInstance(IconFactory::class, $this->createMock(IconFactory::class)); + GeneralUtility::setSingletonInstance(PackageManager::class, $this->createMock(PackageManager::class)); + + $cacheManagerMock = $this->createMock(CacheManager::class); + $cacheMock = $this->createMock(FrontendInterface::class); + $cacheManagerMock->method('getCache')->with('assets')->willReturn($cacheMock); + $cacheMock->method('get')->withAnyParameters()->willReturn([]); + GeneralUtility::setSingletonInstance(CacheManager::class, $cacheManagerMock); + + $modeRegistryMock = $this->createMock(ModeRegistry::class); + $modeRegistryMock->method('getDefaultMode')->willReturn(new Mode(JavaScriptModuleInstruction::create('foo'))); + GeneralUtility::setSingletonInstance(ModeRegistry::class, $modeRegistryMock); + + $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 JsonElement($nodeFactoryMock, $data); + $result = $subject->render(); + + self::assertEquals('@typo3/t3editor/element/code-mirror-element.js', $result['javaScriptModules'][0]->getName()); + self::assertStringContainsString('<typo3-t3editor-codemirror', $result['html']); + self::assertStringContainsString('placeholder="placeholder"', $result['html']); + self::assertStringContainsString('"foo": "bar"', $result['html']); + } +} diff --git a/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/TcaJsonTest.php b/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/TcaJsonTest.php new file mode 100644 index 000000000000..e464fdb661b3 --- /dev/null +++ b/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/TcaJsonTest.php @@ -0,0 +1,241 @@ +<?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 TYPO3\CMS\Backend\Form\FormDataProvider\TcaJson; +use TYPO3\TestingFramework\Core\Unit\UnitTestCase; + +class TcaJsonTest extends UnitTestCase +{ + public function resultArrayDataProvider(): \Generator + { + yield 'Only handle new records' => [ + [ + 'command' => 'edit', + 'tableName' => 'aTable', + 'databaseRow' => [ + 'aField' => '{"foo":"bar"}', + ], + 'processedTca' => [ + 'columns' => [ + 'aField' => [ + 'config' => [ + 'type' => 'json', + ], + ], + ], + ], + ], + [ + 'command' => 'edit', + 'tableName' => 'aTable', + 'databaseRow' => [ + 'aField' => '{"foo":"bar"}', + ], + 'processedTca' => [ + 'columns' => [ + 'aField' => [ + 'config' => [ + 'type' => 'json', + ], + ], + ], + ], + ], + ]; + yield 'Only handle TCA type "json" records' => [ + [ + 'command' => 'new', + 'tableName' => 'aTable', + 'databaseRow' => [ + 'aField' => '{"foo":"bar"}', + ], + 'processedTca' => [ + 'columns' => [ + 'aField' => [ + 'config' => [ + 'type' => 'text', + ], + ], + ], + ], + ], + [ + 'command' => 'new', + 'tableName' => 'aTable', + 'databaseRow' => [ + 'aField' => '{"foo":"bar"}', + ], + 'processedTca' => [ + 'columns' => [ + 'aField' => [ + 'config' => [ + 'type' => 'text', + ], + ], + ], + ], + ], + ]; + yield 'Only handles string values' => [ + [ + 'command' => 'new', + 'tableName' => 'aTable', + 'databaseRow' => [ + 'aField' => ['foo' => 'bar'], + ], + 'processedTca' => [ + 'columns' => [ + 'aField' => [ + 'config' => [ + 'type' => 'json', + ], + ], + ], + ], + ], + [ + 'command' => 'new', + 'tableName' => 'aTable', + 'databaseRow' => [ + 'aField' => ['foo' => 'bar'], + ], + 'processedTca' => [ + 'columns' => [ + 'aField' => [ + 'config' => [ + 'type' => 'json', + ], + ], + ], + ], + ], + ]; + yield 'String values are properly decoded' => [ + [ + 'command' => 'new', + 'tableName' => 'aTable', + 'databaseRow' => [ + 'aField' => '{"foo":"bar"}', + ], + 'processedTca' => [ + 'columns' => [ + 'aField' => [ + 'config' => [ + 'type' => 'json', + ], + ], + ], + ], + ], + [ + 'command' => 'new', + 'tableName' => 'aTable', + 'databaseRow' => [ + 'aField' => ['foo' => 'bar'], + ], + 'processedTca' => [ + 'columns' => [ + 'aField' => [ + 'config' => [ + 'type' => 'json', + ], + ], + ], + ], + ], + ]; + yield 'Invalid values are handled properly' => [ + [ + 'command' => 'new', + 'tableName' => 'aTable', + 'databaseRow' => [ + 'aField' => '_-invalid-_', + ], + 'processedTca' => [ + 'columns' => [ + 'aField' => [ + 'config' => [ + 'type' => 'json', + ], + ], + ], + ], + ], + [ + 'command' => 'new', + 'tableName' => 'aTable', + 'databaseRow' => [ + 'aField' => [], + ], + 'processedTca' => [ + 'columns' => [ + 'aField' => [ + 'config' => [ + 'type' => 'json', + ], + ], + ], + ], + ], + ]; + yield 'Initialize empty values' => [ + [ + 'command' => 'new', + 'tableName' => 'aTable', + 'databaseRow' => [ + 'aField' => '', + ], + 'processedTca' => [ + 'columns' => [ + 'aField' => [ + 'config' => [ + 'type' => 'json', + ], + ], + ], + ], + ], + [ + 'command' => 'new', + 'tableName' => 'aTable', + 'databaseRow' => [ + 'aField' => [], + ], + 'processedTca' => [ + 'columns' => [ + 'aField' => [ + 'config' => [ + 'type' => 'json', + ], + ], + ], + ], + ], + ]; + } + + /** + * @test + * @dataProvider resultArrayDataProvider + */ + public function addDataDoesHandleJsonRecords(array $input, array $expected): void + { + self::assertSame($expected, (new TcaJson())->addData($input)); + } +} diff --git a/typo3/sysext/core/Classes/DataHandling/DataHandler.php b/typo3/sysext/core/Classes/DataHandling/DataHandler.php index 29719bdc50f2..e5e1b0c41498 100644 --- a/typo3/sysext/core/Classes/DataHandling/DataHandler.php +++ b/typo3/sysext/core/Classes/DataHandling/DataHandler.php @@ -1502,6 +1502,7 @@ class DataHandler implements LoggerAwareInterface 'slug' => $this->checkValueForSlug((string)$value, $tcaFieldConf, $table, $id, (int)$realPid, $field, $additionalData['incomingFieldArray'] ?? []), '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), 'passthrough', 'imageManipulation', 'user' => ['value' => $value], default => [], }; @@ -2268,6 +2269,42 @@ class DataHandler implements LoggerAwareInterface return $res; } + /** + * Evaluate "json" type values. + * + * @param array|string $value The value to set. + * @param array $tcaFieldConf Field configuration from TCA + * @return array The result array. The processed value (if any!) is set in the "value" key. + */ + protected function checkValueForJson(array|string $value, array $tcaFieldConf): array + { + if (is_string($value)) { + if ($value === '') { + $value = []; + } else { + try { + $value = json_decode($value, true, 512, JSON_THROW_ON_ERROR); + if ($value === null) { + // Unset value as it could not be decoded + return []; + } + } catch (\JsonException) { + // Unset value as it is invalid + return []; + } + } + } + + if (!$this->validateValueForRequired($tcaFieldConf, $value)) { + // Unset value as it is required + return []; + } + + return [ + 'value' => $value, + ]; + } + /** * Evaluates 'group', 'folder' or 'select' type values. * @@ -7701,13 +7738,10 @@ class DataHandler implements LoggerAwareInterface // Traverse array of values that was inserted into the database and compare with the actually stored value: $errors = []; foreach ($fieldArray as $key => $value) { - $tcaColumnConfig = $tcaTableColumns[$key]['config'] ?? []; - $columnType = $tcaColumnConfig['type'] ?? ''; - $columnDbType = $tcaColumnConfig['dbType'] ?? ''; if (!$this->checkStoredRecords_loose || $value || $row[$key]) { // @todo Check explicitly for one type is fishy. However needed to avoid array to string // conversion errors. Find a better way do handle this. - if ($columnType === 'user' && $columnDbType === 'json') { + if (($tcaTableColumns[$key]['config']['type'] ?? '') === 'json') { // To ensure a proper comparison we need to sort the array structure based on array keys // in a recursive manner. Otherwise, we would emit an error just because the ordering was // different. This must be done for value and the value in the row to be safe. diff --git a/typo3/sysext/core/Classes/DataHandling/TableColumnType.php b/typo3/sysext/core/Classes/DataHandling/TableColumnType.php index decd1894a050..be263688ba13 100644 --- a/typo3/sysext/core/Classes/DataHandling/TableColumnType.php +++ b/typo3/sysext/core/Classes/DataHandling/TableColumnType.php @@ -50,6 +50,7 @@ final class TableColumnType extends Enumeration public const COLOR = 'COLOR'; public const NUMBER = 'NUMBER'; public const FILE = 'FILE'; + public const JSON = 'JSON'; /** * @param mixed $type diff --git a/typo3/sysext/core/Classes/Database/Schema/DefaultTcaSchema.php b/typo3/sysext/core/Classes/Database/Schema/DefaultTcaSchema.php index 42d54f703622..e22d2838726a 100644 --- a/typo3/sysext/core/Classes/Database/Schema/DefaultTcaSchema.php +++ b/typo3/sysext/core/Classes/Database/Schema/DefaultTcaSchema.php @@ -422,87 +422,105 @@ class DefaultTcaSchema $tables[$tablePosition]->addIndex(['t3ver_oid', 't3ver_wsid'], 't3ver_oid'); } + // In the following, columns for TCA fields with a dedicated TCA type are + // added. In the unlikely case that no columns exist, we can skip the table. + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) { + continue; + } + // Add category fields for all tables, defining category columns (TCA type=category) - if (isset($tableDefinition['columns']) && is_array($tableDefinition['columns'])) { - foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { - if ((string)($fieldConfig['config']['type'] ?? '') !== 'category' - || $this->isColumnDefinedForTable($tables, $tableName, $fieldName) - ) { - continue; - } + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if ((string)($fieldConfig['config']['type'] ?? '') !== 'category' + || $this->isColumnDefinedForTable($tables, $tableName, $fieldName) + ) { + continue; + } - if (($fieldConfig['config']['relationship'] ?? '') === 'oneToMany') { - $tables[$tablePosition]->addColumn( - $this->quote($fieldName), - 'text', - [ - 'notnull' => false, - ] - ); - } else { - $tables[$tablePosition]->addColumn( - $this->quote($fieldName), - 'integer', - [ - 'default' => 0, - 'notnull' => true, - 'unsigned' => true, - ] - ); - } + if (($fieldConfig['config']['relationship'] ?? '') === 'oneToMany') { + $tables[$tablePosition]->addColumn( + $this->quote($fieldName), + 'text', + [ + 'notnull' => false, + ] + ); + } else { + $tables[$tablePosition]->addColumn( + $this->quote($fieldName), + 'integer', + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); } } // Add datetime fields for all tables, defining datetime columns (TCA type=datetime), except // those columns, which had already been added due to definition in "ctrl", e.g. "starttime". - if (isset($tableDefinition['columns']) && is_array($tableDefinition['columns'])) { - foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { - if ((string)($fieldConfig['config']['type'] ?? '') !== 'datetime' - || $this->isColumnDefinedForTable($tables, $tableName, $fieldName) - ) { - continue; - } - - if (in_array($fieldConfig['config']['dbType'] ?? '', QueryHelper::getDateTimeTypes(), true)) { - $tables[$tablePosition]->addColumn( - $this->quote($fieldName), - $fieldConfig['config']['dbType'], - [ - 'notnull' => false, - ] - ); - } else { - $tables[$tablePosition]->addColumn( - $this->quote($fieldName), - 'integer', - [ - 'default' => 0, - 'notnull' => !($fieldConfig['config']['nullable'] ?? false), - 'unsigned' => false, - ] - ); - } + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if ((string)($fieldConfig['config']['type'] ?? '') !== 'datetime' + || $this->isColumnDefinedForTable($tables, $tableName, $fieldName) + ) { + continue; } - } - - // Add slug fields for all tables, defining slug columns (TCA type=slug) - if (isset($tableDefinition['columns']) && is_array($tableDefinition['columns'])) { - foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { - if ((string)($fieldConfig['config']['type'] ?? '') !== 'slug' - || $this->isColumnDefinedForTable($tables, $tableName, $fieldName) - ) { - continue; - } + if (in_array($fieldConfig['config']['dbType'] ?? '', QueryHelper::getDateTimeTypes(), true)) { $tables[$tablePosition]->addColumn( $this->quote($fieldName), - 'string', + $fieldConfig['config']['dbType'], [ - 'length' => 2048, 'notnull' => false, ] ); + } else { + $tables[$tablePosition]->addColumn( + $this->quote($fieldName), + 'integer', + [ + 'default' => 0, + 'notnull' => !($fieldConfig['config']['nullable'] ?? false), + 'unsigned' => false, + ] + ); + } + } + + // Add slug fields for all tables, defining slug columns (TCA type=slug) + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if ((string)($fieldConfig['config']['type'] ?? '') !== 'slug' + || $this->isColumnDefinedForTable($tables, $tableName, $fieldName) + ) { + continue; + } + + $tables[$tablePosition]->addColumn( + $this->quote($fieldName), + 'string', + [ + 'length' => 2048, + 'notnull' => false, + ] + ); + } + + // Add json fields for all tables, defining json columns (TCA type=json) + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if ((string)($fieldConfig['config']['type'] ?? '') !== 'json' + || $this->isColumnDefinedForTable($tables, $tableName, $fieldName) + ) { + continue; } + + $tables[$tablePosition]->addColumn( + $this->quote($fieldName), + 'json', + [ + 'default' => '[]', + 'notnull' => true, + ] + ); } } diff --git a/typo3/sysext/core/Classes/Resource/Search/QueryRestrictions/SearchTermRestriction.php b/typo3/sysext/core/Classes/Resource/Search/QueryRestrictions/SearchTermRestriction.php index 46170355d4e8..8dcc458a1962 100644 --- a/typo3/sysext/core/Classes/Resource/Search/QueryRestrictions/SearchTermRestriction.php +++ b/typo3/sysext/core/Classes/Resource/Search/QueryRestrictions/SearchTermRestriction.php @@ -108,6 +108,7 @@ class SearchTermRestriction implements QueryRestrictionInterface // Assemble the search condition only if the field makes sense to be searched if ($fieldType === 'text' || $fieldType === 'flex' + || $fieldType === 'json' || $fieldType === 'email' || $fieldType === 'link' || $fieldType === 'color' diff --git a/typo3/sysext/core/Configuration/DefaultConfiguration.php b/typo3/sysext/core/Configuration/DefaultConfiguration.php index e73ed8e7b6f2..0e6a4e8620f3 100644 --- a/typo3/sysext/core/Configuration/DefaultConfiguration.php +++ b/typo3/sysext/core/Configuration/DefaultConfiguration.php @@ -597,10 +597,15 @@ return [ \TYPO3\CMS\Backend\Form\FormDataProvider\TcaFlexProcess::class, ], ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaJson::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaText::class, + ], + ], \TYPO3\CMS\Backend\Form\FormDataProvider\TcaRadioItems::class => [ 'depends' => [ \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, - \TYPO3\CMS\Backend\Form\FormDataProvider\TcaText::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaJson::class, ], ], \TYPO3\CMS\Backend\Form\FormDataProvider\TcaCheckboxItems::class => [ @@ -842,6 +847,11 @@ return [ \TYPO3\CMS\Backend\Form\FormDataProvider\SiteResolving::class, ], ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaJson::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaText::class, + ], + ], \TYPO3\CMS\Backend\Form\FormDataProvider\TcaRadioItems::class => [ 'depends' => [ \TYPO3\CMS\Backend\Form\FormDataProvider\SiteResolving::class, @@ -927,11 +937,16 @@ return [ \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsRemoveUnused::class, ], ], - \TYPO3\CMS\Backend\Form\FormDataProvider\TcaRadioItems::class => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaJson::class => [ 'depends' => [ \TYPO3\CMS\Backend\Form\FormDataProvider\TcaText::class, ], ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaRadioItems::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaJson::class, + ], + ], \TYPO3\CMS\Backend\Form\FormDataProvider\TcaCheckboxItems::class => [ 'depends' => [ \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsRemoveUnused::class, diff --git a/typo3/sysext/core/Documentation/Changelog/12.1/Feature-99226-IntroduceDbTypeJsonForTCATypeUser.rst b/typo3/sysext/core/Documentation/Changelog/12.1/Feature-99226-IntroduceDbTypeJsonForTCATypeUser.rst index 0b9f44980db8..03f462263fad 100644 --- a/typo3/sysext/core/Documentation/Changelog/12.1/Feature-99226-IntroduceDbTypeJsonForTCATypeUser.rst +++ b/typo3/sysext/core/Documentation/Changelog/12.1/Feature-99226-IntroduceDbTypeJsonForTCATypeUser.rst @@ -8,6 +8,14 @@ Feature: #99226 - Introduce dbType json for TCA type user See :issue:`99226` + +.. attention:: + + This TCA option is **no longer available**! It has been + :ref:`replaced <important-100088-1677950866>` by the dedicated + :ref:`json <feature-100088-1677965005>` TCA type. Do not use + this option in your installation, but use the new TCA type. + Description =========== diff --git a/typo3/sysext/core/Documentation/Changelog/12.3/Feature-100088-NewTCATypeJson.rst b/typo3/sysext/core/Documentation/Changelog/12.3/Feature-100088-NewTCATypeJson.rst new file mode 100644 index 000000000000..6512c6d5a96a --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/12.3/Feature-100088-NewTCATypeJson.rst @@ -0,0 +1,58 @@ +.. include:: /Includes.rst.txt + +.. _feature-100088-1677965005: + +====================================== +Feature: #100088 - New TCA type "json" +====================================== + +See :issue:`100088` + +Description +=========== + +In our effort of introducing dedicated TCA types for special use cases, +a new TCA field type called :php:`json` has been added to TYPO3 Core. +Its main purpose is to simplify the TCA configuration when working with +fields, containing JSON data. It therefore :ref:`replaces <important-100088-1677950866>` +the previously introduced :php:`dbtype=json` of TCA type :php:`user`. + +Using the new type, TYPO3 automatically takes care of adding the corresponding +database column. + +The TCA type :php:`json` features the following column configuration: + +- :php:`behaviour`: :php:`allowLanguageSynchronization` +- :php:`cols` +- :php:`default` +- :php:`enableCodeEditor` +- :php:`fieldControl` +- :php:`fieldInformation` +- :php:`fieldWizard` +- :php:`placeholder` +- :php:`readOnly` +- :php:`required` +- :php:`rows` + +.. note:: + + In case :php:`enableCodeEditor` is set to :php:`true`, which is the default + and the system extension `t3editor` is installed and active, the JSON value + is rendered in the corresponding code editor. Otherwise it is rendered in a + standard `textarea` HTML element. + +The following column configuration can be overwritten by Page TSconfig: + +- :typoscript:`cols` +- :typoscript:`rows` +- :typoscript:`readOnly` + + + +Impact +====== + +It's now possible to use a dedicated TCA type for rendering of JSON fields. +Using the new TCA type, corresponding database columns are added automatically. + +.. index:: Backend, PHP-API, TCA, ext:backend diff --git a/typo3/sysext/core/Documentation/Changelog/12.3/Important-100088-RemoveDbTypeJsonForTCATypeUser.rst b/typo3/sysext/core/Documentation/Changelog/12.3/Important-100088-RemoveDbTypeJsonForTCATypeUser.rst new file mode 100644 index 000000000000..2da9b3e11962 --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/12.3/Important-100088-RemoveDbTypeJsonForTCATypeUser.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _important-100088-1677950866: + +========================================================= +Important: #100088 - Remove dbType json for TCA type user +========================================================= + +See :issue:`100088` + +Description +=========== + +With :issue:`99226` the `dbType=json` option has been added for +TCA type `user`. After some reconsideration, it has been decided +to drop this option again in favor of the dedicated TCA type `json`. +Have a look to the according :ref:`changelog <feature-100088-1677965005>` +for further information. + +Since the `dbType` option has not been released in any LTS version yet, +the option is dropped without further deprecation. Also no TCA migration +is applied. + +In case you make already use of this `dbType` in your custom extension, +you need to migrate to the new TCA type. + +Example: + +.. code-block:: php + + // Before + 'myField' => [ + 'config' => [ + 'type' => 'user', + 'renderType' => 'myRenderType', + 'dbType' => 'json', + ], + ], + + // After + 'myField' => [ + 'config' => [ + 'type' => 'json', + 'renderType' => 'myRenderType', + ], + ], + +.. index:: Backend, PHP-API, TCA, ext:backend diff --git a/typo3/sysext/core/Tests/Unit/DataHandling/DataHandlerTest.php b/typo3/sysext/core/Tests/Unit/DataHandling/DataHandlerTest.php index 3a90eb3168e2..734abd345c94 100644 --- a/typo3/sysext/core/Tests/Unit/DataHandling/DataHandlerTest.php +++ b/typo3/sysext/core/Tests/Unit/DataHandling/DataHandlerTest.php @@ -1059,6 +1059,42 @@ class DataHandlerTest extends UnitTestCase self::assertSame($expectedResult, $this->subject->_call('checkValueForInput', null, ['type' => 'input', 'max' => 40], 'tt_content', 'NEW55c0e67f8f4d32.04974534', 89, 'table_caption')); } + public function checkValueForJsonDataProvider(): \Generator + { + yield 'Converts empty string to array' => [ + '', + ['value' => []], + ]; + yield 'Handles invalid JSON' => [ + '_-invalid-_', + [], + ]; + yield 'Decodes JSON' => [ + '{"foo":"bar"}', + ['value' => ['foo' => 'bar']], + ]; + yield 'Array is not decoded' => [ + ['foo' => 'bar'], + ['value' => ['foo' => 'bar']], + ]; + } + + /** + * @test + * @dataProvider checkValueForJsonDataProvider + */ + public function checkValueForJson(string|array $input, array $expected): void + { + self::assertSame( + $expected, + $this->subject->_call( + 'checkValueForJson', + $input, + ['type' => 'json'] + ) + ); + } + /** * @test * @dataProvider referenceValuesAreCastedDataProvider 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 aafc403c4ea4..a9d19203f5f8 100644 --- a/typo3/sysext/extbase/Tests/Unit/Persistence/Generic/Mapper/DataMapFactoryTest.php +++ b/typo3/sysext/extbase/Tests/Unit/Persistence/Generic/Mapper/DataMapFactoryTest.php @@ -463,6 +463,7 @@ class DataMapFactoryTest extends UnitTestCase [['type' => 'color'], TableColumnType::COLOR], [['type' => 'number'], TableColumnType::NUMBER], [['type' => 'file'], TableColumnType::FILE], + [['type' => 'json'], TableColumnType::JSON], ]; } diff --git a/typo3/sysext/lowlevel/Classes/Controller/DatabaseIntegrityController.php b/typo3/sysext/lowlevel/Classes/Controller/DatabaseIntegrityController.php index b6154a126240..c919db762756 100644 --- a/typo3/sysext/lowlevel/Classes/Controller/DatabaseIntegrityController.php +++ b/typo3/sysext/lowlevel/Classes/Controller/DatabaseIntegrityController.php @@ -928,6 +928,7 @@ class DatabaseIntegrityController case 'link': case 'password': case 'color': + case 'json': default: $fields['type'] = 'text'; } @@ -2264,6 +2265,7 @@ class DatabaseIntegrityController case 'link': case 'password': case 'color': + case 'json': default: $this->fields[$fieldName]['type'] = 'text'; } diff --git a/typo3/sysext/reactions/Classes/Form/Element/FieldMapElement.php b/typo3/sysext/reactions/Classes/Form/Element/FieldMapElement.php index 6475950e22ed..0f73eb0f810b 100644 --- a/typo3/sysext/reactions/Classes/Form/Element/FieldMapElement.php +++ b/typo3/sysext/reactions/Classes/Form/Element/FieldMapElement.php @@ -22,7 +22,7 @@ use TYPO3\CMS\Backend\Form\Element\AbstractFormElement; /** * Creates a dynamic element to add values to table fields. * - * This is rendered for config type=user, renderType=fieldMap + * This is rendered for config type=json, renderType=fieldMap * * @internal This is a specific hook implementation and is not considered part of the Public TYPO3 API. */ diff --git a/typo3/sysext/reactions/Configuration/TCA/Overrides/sys_reaction_create_record.php b/typo3/sysext/reactions/Configuration/TCA/Overrides/sys_reaction_create_record.php index c9adbb89e560..ec99b2b2677b 100644 --- a/typo3/sysext/reactions/Configuration/TCA/Overrides/sys_reaction_create_record.php +++ b/typo3/sysext/reactions/Configuration/TCA/Overrides/sys_reaction_create_record.php @@ -18,10 +18,8 @@ 'description' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.fields.description', 'displayCond' => 'FIELD:table_name:REQ:true', 'config' => [ - 'type' => 'user', + 'type' => 'json', 'renderType' => 'fieldMap', - 'dbType' => 'json', - 'default' => '{}', ], ], ] diff --git a/typo3/sysext/reactions/ext_tables.sql b/typo3/sysext/reactions/ext_tables.sql index e235882ddc8d..87fc55ea9df2 100644 --- a/typo3/sysext/reactions/ext_tables.sql +++ b/typo3/sysext/reactions/ext_tables.sql @@ -9,7 +9,6 @@ CREATE TABLE sys_reaction ( impersonate_user int(11) unsigned DEFAULT '0' NOT NULL, table_name varchar(255) DEFAULT '' NOT NULL, storage_pid int(11) unsigned DEFAULT '0' NOT NULL, - fields json NOT NULL, UNIQUE identifier_key (identifier), KEY index_source (reaction_type(5)) diff --git a/typo3/sysext/t3editor/Configuration/Backend/T3editor/Modes.php b/typo3/sysext/t3editor/Configuration/Backend/T3editor/Modes.php index ce387d59d109..8db36582ffc6 100644 --- a/typo3/sysext/t3editor/Configuration/Backend/T3editor/Modes.php +++ b/typo3/sysext/t3editor/Configuration/Backend/T3editor/Modes.php @@ -19,6 +19,10 @@ return [ 'module' => JavaScriptModuleInstruction::create('@codemirror/lang-javascript', 'javascript')->invoke(), 'extensions' => ['javascript'], ], + 'json' => [ + 'module' => JavaScriptModuleInstruction::create('@codemirror/lang-json', 'json')->invoke(), + 'extensions' => ['json'], + ], 'php' => [ 'module' => JavaScriptModuleInstruction::create('@codemirror/lang-php', 'php')->invoke(), 'extensions' => ['php', 'php5', 'php7', 'phps'], diff --git a/typo3/sysext/t3editor/Resources/Public/JavaScript/element/code-mirror-element.js b/typo3/sysext/t3editor/Resources/Public/JavaScript/element/code-mirror-element.js index 040021b701af..ed24d362c792 100644 --- a/typo3/sysext/t3editor/Resources/Public/JavaScript/element/code-mirror-element.js +++ b/typo3/sysext/t3editor/Resources/Public/JavaScript/element/code-mirror-element.js @@ -10,11 +10,11 @@ * * The TYPO3 project - inspiring people to share! */ -var __decorate=function(e,t,o,r){var i,l=arguments.length,n=l<3?t:null===r?r=Object.getOwnPropertyDescriptor(t,o):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,o,r);else for(var a=e.length-1;a>=0;a--)(i=e[a])&&(n=(l<3?i(n):l>3?i(t,o,n):i(t,o))||n);return l>3&&n&&Object.defineProperty(t,o,n),n};import{LitElement,html,css}from"lit";import{customElement,property,state}from"lit/decorators.js";import{EditorView,lineNumbers,highlightSpecialChars,drawSelection,keymap}from"@codemirror/view";import{EditorState}from"@codemirror/state";import{syntaxHighlighting,defaultHighlightStyle}from"@codemirror/language";import{defaultKeymap,indentWithTab}from"@codemirror/commands";import{oneDark}from"@codemirror/theme-one-dark";import{executeJavaScriptModuleInstruction,loadModule,resolveSubjectRef}from"@typo3/core/java-script-item-processor.js";import"@typo3/backend/element/spinner-element.js";let CodeMirrorElement=class extends LitElement{constructor(){super(...arguments),this.mode=null,this.addons=[],this.keymaps=[],this.lineDigits=0,this.autoheight=!1,this.nolazyload=!1,this.readonly=!1,this.fullscreen=!1,this.panel="bottom",this.editorView=null}render(){return html` +var __decorate=function(e,t,r,o){var i,l=arguments.length,n=l<3?t:null===o?o=Object.getOwnPropertyDescriptor(t,r):o;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,r,o);else for(var a=e.length-1;a>=0;a--)(i=e[a])&&(n=(l<3?i(n):l>3?i(t,r,n):i(t,r))||n);return l>3&&n&&Object.defineProperty(t,r,n),n};import{LitElement,html,css}from"lit";import{customElement,property,state}from"lit/decorators.js";import{EditorView,lineNumbers,highlightSpecialChars,drawSelection,keymap,placeholder}from"@codemirror/view";import{EditorState}from"@codemirror/state";import{syntaxHighlighting,defaultHighlightStyle}from"@codemirror/language";import{defaultKeymap,indentWithTab}from"@codemirror/commands";import{oneDark}from"@codemirror/theme-one-dark";import{executeJavaScriptModuleInstruction,loadModule,resolveSubjectRef}from"@typo3/core/java-script-item-processor.js";import"@typo3/backend/element/spinner-element.js";let CodeMirrorElement=class extends LitElement{constructor(){super(...arguments),this.mode=null,this.addons=[],this.keymaps=[],this.lineDigits=0,this.autoheight=!1,this.nolazyload=!1,this.readonly=!1,this.fullscreen=!1,this.panel="bottom",this.editorView=null}render(){return html` <div id="codemirror-parent" @keydown=${e=>this.onKeydown(e)}></div> ${this.label?html`<div class="panel panel-${this.panel}">${this.label}</div>`:""} ${null===this.editorView?html`<typo3-backend-spinner size="large" variant="dark"></typo3-backend-spinner>`:""} - `}firstUpdated(){if(this.nolazyload)return void this.initializeEditor(this.firstElementChild);const e={root:document.body};let t=new IntersectionObserver((e=>{e.forEach((e=>{e.intersectionRatio>0&&(t.unobserve(e.target),this.firstElementChild&&"textarea"===this.firstElementChild.nodeName.toLowerCase()&&this.initializeEditor(this.firstElementChild))}))}),e);t.observe(this)}onKeydown(e){e.ctrlKey&&e.altKey&&"f"===e.key&&(e.preventDefault(),this.fullscreen=!0),"Escape"===e.key&&this.fullscreen&&(e.preventDefault(),this.fullscreen=!1)}async initializeEditor(e){const t=EditorView.updateListener.of((t=>{t.docChanged&&(e.value=t.state.doc.toString(),e.dispatchEvent(new CustomEvent("change",{bubbles:!0})))}));this.lineDigits>0?this.style.setProperty("--rows",this.lineDigits.toString()):e.getAttribute("rows")&&this.style.setProperty("--rows",e.getAttribute("rows"));const o=[oneDark,t,lineNumbers(),highlightSpecialChars(),drawSelection(),EditorState.allowMultipleSelections.of(!0),syntaxHighlighting(defaultHighlightStyle,{fallback:!0})];if(this.readonly&&o.push(EditorState.readOnly.of(!0)),this.mode){const e=await executeJavaScriptModuleInstruction(this.mode);o.push(...e)}this.addons.length>0&&o.push(...await Promise.all(this.addons.map((e=>executeJavaScriptModuleInstruction(e)))));const r=[...defaultKeymap,indentWithTab];if(this.keymaps.length>0){const e=await Promise.all(this.keymaps.map((e=>loadModule(e).then((t=>resolveSubjectRef(t,e))))));e.forEach((e=>r.push(...e)))}o.push(keymap.of(r)),this.editorView=new EditorView({state:EditorState.create({doc:e.value,extensions:o}),parent:this.renderRoot.querySelector("#codemirror-parent"),root:this.renderRoot})}};CodeMirrorElement.styles=css` + `}firstUpdated(){if(this.nolazyload)return void this.initializeEditor(this.firstElementChild);const e={root:document.body};let t=new IntersectionObserver((e=>{e.forEach((e=>{e.intersectionRatio>0&&(t.unobserve(e.target),this.firstElementChild&&"textarea"===this.firstElementChild.nodeName.toLowerCase()&&this.initializeEditor(this.firstElementChild))}))}),e);t.observe(this)}onKeydown(e){e.ctrlKey&&e.altKey&&"f"===e.key&&(e.preventDefault(),this.fullscreen=!0),"Escape"===e.key&&this.fullscreen&&(e.preventDefault(),this.fullscreen=!1)}async initializeEditor(e){const t=EditorView.updateListener.of((t=>{t.docChanged&&(e.value=t.state.doc.toString(),e.dispatchEvent(new CustomEvent("change",{bubbles:!0})))}));this.lineDigits>0?this.style.setProperty("--rows",this.lineDigits.toString()):e.getAttribute("rows")&&this.style.setProperty("--rows",e.getAttribute("rows"));const r=[oneDark,t,lineNumbers(),highlightSpecialChars(),drawSelection(),EditorState.allowMultipleSelections.of(!0),syntaxHighlighting(defaultHighlightStyle,{fallback:!0})];if(this.readonly&&r.push(EditorState.readOnly.of(!0)),this.placeholder&&r.push(placeholder(this.placeholder)),this.mode){const e=await executeJavaScriptModuleInstruction(this.mode);r.push(...e)}this.addons.length>0&&r.push(...await Promise.all(this.addons.map((e=>executeJavaScriptModuleInstruction(e)))));const o=[...defaultKeymap,indentWithTab];if(this.keymaps.length>0){const e=await Promise.all(this.keymaps.map((e=>loadModule(e).then((t=>resolveSubjectRef(t,e))))));e.forEach((e=>o.push(...e)))}r.push(keymap.of(o)),this.editorView=new EditorView({state:EditorState.create({doc:e.value,extensions:r}),parent:this.renderRoot.querySelector("#codemirror-parent"),root:this.renderRoot})}};CodeMirrorElement.styles=css` :host { display: flex; flex-direction: column; @@ -77,4 +77,4 @@ var __decorate=function(e,t,o,r){var i,l=arguments.length,n=l<3?t:null===r?r=Obj border-bottom-width: 1px; order: -1; } - `,__decorate([property({type:Object})],CodeMirrorElement.prototype,"mode",void 0),__decorate([property({type:Array})],CodeMirrorElement.prototype,"addons",void 0),__decorate([property({type:Array})],CodeMirrorElement.prototype,"keymaps",void 0),__decorate([property({type:Number})],CodeMirrorElement.prototype,"lineDigits",void 0),__decorate([property({type:Boolean,reflect:!0})],CodeMirrorElement.prototype,"autoheight",void 0),__decorate([property({type:Boolean})],CodeMirrorElement.prototype,"nolazyload",void 0),__decorate([property({type:Boolean})],CodeMirrorElement.prototype,"readonly",void 0),__decorate([property({type:Boolean,reflect:!0})],CodeMirrorElement.prototype,"fullscreen",void 0),__decorate([property({type:String})],CodeMirrorElement.prototype,"label",void 0),__decorate([property({type:String})],CodeMirrorElement.prototype,"panel",void 0),__decorate([state()],CodeMirrorElement.prototype,"editorView",void 0),CodeMirrorElement=__decorate([customElement("typo3-t3editor-codemirror")],CodeMirrorElement);export{CodeMirrorElement}; \ No newline at end of file + `,__decorate([property({type:Object})],CodeMirrorElement.prototype,"mode",void 0),__decorate([property({type:Array})],CodeMirrorElement.prototype,"addons",void 0),__decorate([property({type:Array})],CodeMirrorElement.prototype,"keymaps",void 0),__decorate([property({type:Number})],CodeMirrorElement.prototype,"lineDigits",void 0),__decorate([property({type:Boolean,reflect:!0})],CodeMirrorElement.prototype,"autoheight",void 0),__decorate([property({type:Boolean})],CodeMirrorElement.prototype,"nolazyload",void 0),__decorate([property({type:Boolean})],CodeMirrorElement.prototype,"readonly",void 0),__decorate([property({type:Boolean,reflect:!0})],CodeMirrorElement.prototype,"fullscreen",void 0),__decorate([property({type:String})],CodeMirrorElement.prototype,"label",void 0),__decorate([property({type:String})],CodeMirrorElement.prototype,"placeholder",void 0),__decorate([property({type:String})],CodeMirrorElement.prototype,"panel",void 0),__decorate([state()],CodeMirrorElement.prototype,"editorView",void 0),CodeMirrorElement=__decorate([customElement("typo3-t3editor-codemirror")],CodeMirrorElement);export{CodeMirrorElement}; \ No newline at end of file -- GitLab