Commit 7321d709 authored by Nikita Hovratov's avatar Nikita Hovratov Committed by Stefan Bürk
Browse files

[FEATURE] Introduce TCA option "min"

The TCA option "min" allows to define a minimum
number of characters a text field should have. This
effectively adds a "minlength" attribute to the
input field. If you are short on characters, an
alert badge will show you how many characters are
missing. The FormEngine won't allow saving fields
which don't provide enough characters. Zero
characters (empty fields) are allowed, as this is
the same behaviour as the html attribute has. If
empty fields shouldn't be allowed, a combination
with "required=true" is necessary.

This option works for default type="input" fields
and type="text" fields, except if RTE is enabled.

DataHandler will also take care for server-side
validation and reset the value to an empty string,
if "min" is not reached.

Resolves: #92861
Releases: main
Change-Id: I985caf9c62e362a9015fde1513a9a5fba0622587
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/70196

Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Tested-by: Stefan Bürk's avatarStefan Bürk <stefan@buerk.tech>
Reviewed-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Stefan Bürk's avatarStefan Bürk <stefan@buerk.tech>
parent e576e861
......@@ -84,10 +84,20 @@ label {
.t3js-formengine-field-item {
position: relative;
> .t3js-charcounter {
> .t3js-charcounter-wrapper {
left: 0;
position: absolute;
top: 100%;
display: flex;
margin: 0 -2px;
.t3js-charcounter {
margin: 0 2px;
}
.t3js-charcounter-min {
margin: 0 2px;
}
}
}
......
......@@ -380,6 +380,13 @@ export default (function() {
}
}
break;
case 'min':
if (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) {
if (field.value.length > 0 && field.value.length < field.minLength) {
markParent = true;
}
}
break;
case 'null':
// unknown type null, we ignore it
break;
......
......@@ -515,7 +515,13 @@ export default (function() {
maxlengthProperties = FormEngine.getCharacterCounterProperties($field);
// append the counter only at focus to avoid cluttering the DOM
$parent.append($('<div />', {'class': 't3js-charcounter'}).append(
let $wrapper = $parent.find('.t3js-charcounter-wrapper');
if (!$wrapper.length) {
$wrapper = $('<div>');
$wrapper.addClass('t3js-charcounter-wrapper');
$parent.append($wrapper);
}
$wrapper.append($('<div />', {'class': 't3js-charcounter'}).append(
$('<span />', {'class': maxlengthProperties.labelClass}).text(TYPO3.lang['FormEngine.remainingCharacters'].replace('{0}', maxlengthProperties.remainingCharacters))
));
}).on('blur', (event: JQueryEventObject) => {
......@@ -563,6 +569,87 @@ export default (function() {
};
};
/**
* Initializes the left character count needed to reach the minimum value based on the field's minlength attribute
*/
FormEngine.initializeMinimumCharactersLeftViews = function () {
// Helper method as replacement for jQuery "parents".
const closest: Function = (el: ParentNode, fn: Function) => el && (fn(el) ? el : closest(el.parentNode, fn));
const addOrUpdateCounter = (minCharacterCountLeft: string, event: Event) => {
const parent = closest(event.currentTarget, (el: HTMLElement) => el.classList.contains('t3js-formengine-field-item'));
const counter = parent.querySelector('.t3js-charcounter-min');
const labelValue = TYPO3.lang['FormEngine.minCharactersLeft'].replace('{0}', minCharacterCountLeft);
if (counter) {
counter.querySelector('span').innerHTML = labelValue;
} else {
const counter = document.createElement('div');
counter.classList.add('t3js-charcounter-min');
const label = document.createElement('span');
label.classList.add('badge', 'badge-danger');
label.innerHTML = labelValue;
counter.append(label);
let wrapper = parent.querySelector('.t3js-charcounter-wrapper');
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.classList.add('t3js-charcounter-wrapper');
parent.append(wrapper);
}
wrapper.prepend(counter);
}
};
const removeCounter = (event: Event) => {
const parent = closest(event.currentTarget, (el: HTMLElement) => el.classList.contains('t3js-formengine-field-item'));
const counter = parent.querySelector('.t3js-charcounter-min');
if (counter) {
counter.remove();
}
};
const minlengthElements = document.querySelectorAll('[minlength]:not(.t3js-datetimepicker):not(.t3js-charcounter-min-initialized)');
minlengthElements.forEach((field: HTMLInputElement|HTMLTextAreaElement) => {
field.addEventListener('focus', (event) => {
const minCharacterCountLeft = FormEngine.getMinCharacterLeftCount(field);
if (minCharacterCountLeft > 0) {
addOrUpdateCounter(minCharacterCountLeft, event);
}
});
field.addEventListener('blur', removeCounter);
field.addEventListener('keyup', (event) => {
const minCharacterCountLeft = FormEngine.getMinCharacterLeftCount(field);
if (minCharacterCountLeft > 0) {
addOrUpdateCounter(minCharacterCountLeft, event);
} else {
removeCounter(event);
}
});
});
};
/**
* Get the properties required for proper rendering of the character counter
*
* @param {HTMLElement} field
* @returns number
*/
FormEngine.getMinCharacterLeftCount = function (field: HTMLInputElement|HTMLTextAreaElement) {
const text = field.value;
const minlength = field.minLength;
const currentFieldLength = text.length;
// minLength doesn't care about empty fields.
if (currentFieldLength === 0) {
return 0;
}
const numberOfLineBreaks = (text.match(/\n/g) || []).length; // count line breaks
const minimumCharactersLeft = minlength - currentFieldLength - numberOfLineBreaks;
return minimumCharactersLeft;
};
/**
* Initialize input / text field "null" checkbox CSS overlay if no placeholder is set.
*/
......@@ -624,6 +711,7 @@ export default (function() {
FormEngine.initializeNullNoPlaceholderCheckboxes();
FormEngine.initializeNullWithPlaceholderCheckboxes();
FormEngine.initializeLocalizationStateSelector();
FormEngine.initializeMinimumCharactersLeftViews();
FormEngine.initializeRemainingCharacterViews();
};
......
......@@ -191,6 +191,9 @@ abstract class AbstractNode implements NodeInterface, LoggerAwareInterface
if (!empty($config['required'])) {
$validationRules[] = ['type' => 'required'];
}
if (!empty($config['min'])) {
$validationRules[] = ['type' => 'min'];
}
return json_encode($validationRules);
}
}
......@@ -146,9 +146,13 @@ class InputTextElement extends AbstractFormElement
'data-formengine-input-name' => $itemName,
];
$maxLength = $config['max'] ?? 0;
if ((int)$maxLength > 0) {
$attributes['maxlength'] = (string)(int)$maxLength;
$maxLength = (int)($config['max'] ?? 0);
if ($maxLength > 0) {
$attributes['maxlength'] = (string)$maxLength;
}
$minLength = (int)($config['min'] ?? 0);
if ($minLength > 0 && ($maxLength === 0 || $minLength <= $maxLength)) {
$attributes['minlength'] = (string)$minLength;
}
if (!empty($config['placeholder'])) {
$attributes['placeholder'] = trim($config['placeholder']);
......
......@@ -181,8 +181,13 @@ class TextElement extends AbstractFormElement
}
$attributes['class'] = implode(' ', $classes);
if (isset($config['max']) && (int)$config['max'] > 0) {
$attributes['maxlength'] = (string)(int)$config['max'];
$maxLength = (int)($config['max'] ?? 0);
if ($maxLength > 0) {
$attributes['maxlength'] = (string)$maxLength;
}
$minlength = (int)($config['min'] ?? 0);
if ($minlength > 0 && ($maxLength === 0 || $minlength <= $maxLength)) {
$attributes['minlength'] = (string)$minlength;
}
if (!empty($config['placeholder'])) {
$attributes['placeholder'] = trim($config['placeholder']);
......
......@@ -210,6 +210,7 @@ class FormResultCompiler
'FormEngine.refreshRequiredTitle' => $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:mess.refreshRequired.title'),
'FormEngine.refreshRequiredContent' => $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:mess.refreshRequired.content'),
'FormEngine.remainingCharacters' => $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.remainingCharacters'),
'FormEngine.minCharactersLeft' => $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.minCharactersLeft'),
'label.confirm.delete_record.content' => $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:label.confirm.delete_record.content'),
'label.confirm.delete_record.title' => $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:label.confirm.delete_record.title'),
'buttons.confirm.delete_record.no' => $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:buttons.confirm.delete_record.no'),
......
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -1577,6 +1577,14 @@ class DataHandler implements LoggerAwareInterface
*/
protected function checkValueForText($value, $tcaFieldConf, $table, $realPid, $field)
{
$richtextEnabled = (bool)($tcaFieldConf['enableRichtext'] ?? false);
// Reset value to empty string, if less than "min" characters.
$min = $tcaFieldConf['min'] ?? 0;
if (!$richtextEnabled && $min > 0 && mb_strlen((string)$value) < $min) {
$value = '';
}
if (!$this->validateValueForRequired($tcaFieldConf, $value)) {
$valueArray = [];
} elseif (isset($tcaFieldConf['eval']) && $tcaFieldConf['eval'] !== '') {
......@@ -1599,7 +1607,7 @@ class DataHandler implements LoggerAwareInterface
if ($value === null) {
return $valueArray;
}
if (isset($tcaFieldConf['enableRichtext']) && (bool)$tcaFieldConf['enableRichtext'] === true) {
if ($richtextEnabled) {
$recordType = BackendUtility::getTCAtypeValue($table, $this->checkValue_currentRecord);
$richtextConfigurationProvider = GeneralUtility::makeInstance(Richtext::class);
$richtextConfiguration = $richtextConfigurationProvider->getConfiguration($table, $field, $realPid, $recordType, $tcaFieldConf);
......@@ -1628,6 +1636,12 @@ class DataHandler implements LoggerAwareInterface
$value = mb_substr((string)$value, 0, (int)$tcaFieldConf['max'], 'utf-8');
}
// Reset value to empty string, if less than "min" characters.
$min = $tcaFieldConf['min'] ?? 0;
if ($min > 0 && mb_strlen((string)$value) < $min) {
$value = '';
}
if (!$this->validateValueForRequired($tcaFieldConf, (string)$value)) {
$res = [];
} elseif (empty($tcaFieldConf['eval'])) {
......
.. include:: /Includes.rst.txt
============================================
Feature: #92861 - Introduce TCA option "min"
============================================
See :issue:`92861`
Description
===========
The new TCA option :php:`min` allows to define a minimum number of characters
for fields of type :php:`input` and :php:`text`. This option simply adds a
:html:`minlength` attribute to the input field. If at least one character is
typed in and the number of characters is less than :php:`min`, the FormEngine
marks the field as invalid, preventing the user to save the element.
When using :php:`min` in combination with :php:`max`, one has to make sure, the
:php:`min` value is less than or equal :php:`max`. Otherwise the option is
ignored.
Empty fields are not validated. If one needs to have non-empty values, it is
recommended to use :php:`required => true` in combination with :php:`min`.
.. note::
This option does not work for text fields, if RTE is enabled.
Impact
======
Integrators and developers are now able to define a minimum number of characters
a simple text or textarea field should have. Editors are forced to provide the
specified minimum amount of characters. An alert badge, similar to the one of
the max value, will show, how many characters are missing.
.. index:: Backend, TCA, ext:backend
......@@ -103,6 +103,9 @@ Do you want to continue WITHOUT saving?</source>
<trans-unit id="labels.remainingCharacters" resname="labels.remainingCharacters">
<source>Remaining characters: {0}</source>
</trans-unit>
<trans-unit id="labels.minCharactersLeft" resname="labels.minCharactersLeft">
<source>Characters missing: {0}</source>
</trans-unit>
<trans-unit id="labels.maxItemsAllowed" resname="labels.maxItemsAllowed">
<source>A maximum of {0} child records are allowed.</source>
</trans-unit>
......
"pages",,,,,,,,,,
,"uid","pid","sorting","deleted","t3_origuid","t3ver_wsid","t3ver_state","t3ver_stage","t3ver_oid","title"
,1,0,256,0,0,0,0,0,0,"MinValueTest"
<?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\Core\Tests\Functional\DataHandling\Regular;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\ActionService;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
/**
* Tests the TCA option "min".
*/
class MinValueTest extends FunctionalTestCase
{
protected array $testExtensionsToLoad = [
'typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_datahandler',
];
protected function setUp(): void
{
parent::setUp();
$this->importCSVDataSet(__DIR__ . '/DataSet/MinValuePages.csv');
$this->importCSVDataSet(__DIR__ . '/../../Fixtures/be_users.csv');
$this->setUpBackendUser(1);
Bootstrap::initializeLanguageObject();
}
public function valuesLowerThanMinResetToEmptyStringDataProvider(): iterable
{
yield 'Too few characters result in empty string' => [
'value' => 'Too short',
'expected' => '',
];
yield 'More than "min" characters stay the same' => [
'value' => 'This has enough length',
'expected' => 'This has enough length',
];
yield 'With unicode exact chars stays the same' => [
'value' => "123456789\u{1F421}",
'expected' => "123456789\u{1F421}",
];
yield 'With unicode too few chars results in empty' => [
'value' => "12345678\u{1F421}",
'expected' => '',
];
}
/**
* @test
* @dataProvider valuesLowerThanMinResetToEmptyStringDataProvider
*/
public function valuesLowerThanMinResetToEmptyString(string $string, string $expected): void
{
// Should work for type=input and type=text (except RTE).
$actionService = new ActionService();
$map = $actionService->createNewRecord('tt_content', 1, [
'tx_testdatahandler_input_minvalue' => $string,
'tx_testdatahandler_text_minvalue' => $string,
]);
$newRecordId = reset($map['tt_content']);
$newRecord = BackendUtility::getRecord('tt_content', $newRecordId);
self::assertEquals($expected, $newRecord['tx_testdatahandler_input_minvalue']);
self::assertEquals($expected, $newRecord['tx_testdatahandler_text_minvalue']);
}
/**
* @test
*/
public function minDoesNotWorkForRTE(): void
{
$actionService = new ActionService();
$map = $actionService->createNewRecord('tt_content', 1, [
'tx_testdatahandler_richttext_minvalue' => 'Not working',
]);
$newRecordId = reset($map['tt_content']);
$newRecord = BackendUtility::getRecord('tt_content', $newRecordId);
self::assertEquals('Not working', $newRecord['tx_testdatahandler_richttext_minvalue']);
}
/**
* @test
*/
public function minValueZeroIsIgnored(): void
{
$actionService = new ActionService();
$map = $actionService->createNewRecord('tt_content', 1, [
'tx_testdatahandler_input_minvalue_zero' => 'test123',
]);
$newRecordId = reset($map['tt_content']);
$newRecord = BackendUtility::getRecord('tt_content', $newRecordId);
self::assertEquals('test123', $newRecord['tx_testdatahandler_input_minvalue_zero']);
}
}
......@@ -100,10 +100,46 @@ defined('TYPO3') or die();
],
],
],
'tx_testdatahandler_input_minvalue' => [
'exclude' => true,
'label' => 'Normal input field with min value set to 10',
'config' => [
'type' => 'input',
'min' => 10,
],
],
'tx_testdatahandler_input_minvalue_zero' => [
'exclude' => true,
'label' => 'Normal input field with min value set to 0',
'config' => [
'type' => 'input',
'min' => 0,
],
],
'tx_testdatahandler_text_minvalue' => [
'exclude' => true,
'label' => 'Text field with min value set to 10',
'config' => [
'type' => 'text',
'min' => 10,
],
],
'tx_testdatahandler_richttext_minvalue' => [
'exclude' => true,
'label' => 'Richtext with min value set to 15 (invalid)',
'config' => [
'type' => 'text',
'min' => 15,
'enableRichtext' => true,
],
],
],
);
\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addToAllTCAtypes(
'tt_content',
'--div--;DataHandler Test, tx_testdatahandler_select, tx_testdatahandler_group'
'--div--;DataHandler Test,' .
'tx_testdatahandler_category,tx_testdatahandler_categories, tx_testdatahandler_select,tx_testdatahandler_select_dynamic, tx_testdatahandler_group,' .
'tx_testdatahandler_radio,tx_testdatahandler_checkbox, tx_testdatahandler_checkbox_with_eval,' .
'tx_testdatahandler_input_minvalue,tx_testdatahandler_input_minvalue_zero, tx_testdatahandler_text_minvalue,tx_testdatahandler_richttext_minvalue '
);
......@@ -2,12 +2,16 @@
# Table structure for table 'tt_content'
#
CREATE TABLE tt_content (
tx_testdatahandler_select text,
tx_testdatahandler_select_dynamic text,
tx_testdatahandler_group text,
tx_testdatahandler_radio text,
tx_testdatahandler_checkbox text,
tx_testdatahandler_checkbox_with_eval text
tx_testdatahandler_select text,
tx_testdatahandler_select_dynamic text,
tx_testdatahandler_group text,
tx_testdatahandler_radio text,
tx_testdatahandler_checkbox text,
tx_testdatahandler_checkbox_with_eval text,
tx_testdatahandler_input_minvalue text,
tx_testdatahandler_input_minvalue_zero text,
tx_testdatahandler_text_minvalue text,
tx_testdatahandler_richttext_minvalue text
);
#
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment