feature #32747 [Form] Add "is empty callback" to form config (fancyweb)
This PR was merged into the 5.1-dev branch.
Discussion
----------
[Form] Add "is empty callback" to form config
| Q | A
| ------------- | ---
| Branch? | master
| Bug fix? | no
| New feature? | yes
| BC breaks? | no
| Deprecations? | no
| Tests pass? | yes
| Fixed tickets | https://github.com/symfony/symfony/issues/31572 for 4.4+
| License | MIT
| Doc PR | -
This PR introduces a new feature that allow to resolve a bug.
Currently, the `isEmpty()` behavior of the `Form` class is the same whatever its configuration. That prevents us to specify a different behavior by form type.
But I think that some form types should have dedicated empty values. For example, the `CheckboxType` model data either resolves to `true` (checked) or `false` (unchecked). But `false` is not an empty value in the `Form::isEmpty()` method, so a `CheckboxType` form can never be empty. `false` should not be in that list because for other form types, it's perfectly fine that it's not considered as an empty value.
The problem is better seen in https://github.com/symfony/symfony/issues/31572 with a `ChoiceType` that is never considered as empty (when no radio button is checked).
Being able to specify the "is empty" behavior by form type would also allow users to define their own logic in their custom form types + probably define it ourselves in all our form types in order to get rid of the default common behavior.
Commits
-------
7bfc27e7cf
[Form] Add "is empty callback" to form config
This commit is contained in:
commit
86573147b3
|
@ -16,6 +16,14 @@ EventDispatcher
|
||||||
|
|
||||||
* Deprecated `LegacyEventDispatcherProxy`. Use the event dispatcher without the proxy.
|
* Deprecated `LegacyEventDispatcherProxy`. Use the event dispatcher without the proxy.
|
||||||
|
|
||||||
|
Form
|
||||||
|
----
|
||||||
|
|
||||||
|
* Implementing the `FormConfigInterface` without implementing the `getIsEmptyCallback()` method
|
||||||
|
is deprecated. The method will be added to the interface in 6.0.
|
||||||
|
* Implementing the `FormConfigBuilderInterface` without implementing the `setIsEmptyCallback()` method
|
||||||
|
is deprecated. The method will be added to the interface in 6.0.
|
||||||
|
|
||||||
FrameworkBundle
|
FrameworkBundle
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,12 @@ EventDispatcher
|
||||||
|
|
||||||
* Removed `LegacyEventDispatcherProxy`. Use the event dispatcher without the proxy.
|
* Removed `LegacyEventDispatcherProxy`. Use the event dispatcher without the proxy.
|
||||||
|
|
||||||
|
Form
|
||||||
|
----
|
||||||
|
|
||||||
|
* Added the `getIsEmptyCallback()` method to the `FormConfigInterface`.
|
||||||
|
* Added the `setIsEmptyCallback()` method to the `FormConfigBuilderInterface`.
|
||||||
|
|
||||||
FrameworkBundle
|
FrameworkBundle
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
|
|
|
@ -466,6 +466,16 @@ class ButtonBuilder implements \IteratorAggregate, FormBuilderInterface
|
||||||
return $config;
|
return $config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsupported method.
|
||||||
|
*
|
||||||
|
* @throws BadMethodCallException
|
||||||
|
*/
|
||||||
|
public function setIsEmptyCallback(?callable $isEmptyCallback)
|
||||||
|
{
|
||||||
|
throw new BadMethodCallException('Buttons do not support "is empty" callback.');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unsupported method.
|
* Unsupported method.
|
||||||
*/
|
*/
|
||||||
|
@ -738,6 +748,16 @@ class ButtonBuilder implements \IteratorAggregate, FormBuilderInterface
|
||||||
return \array_key_exists($name, $this->options) ? $this->options[$name] : $default;
|
return \array_key_exists($name, $this->options) ? $this->options[$name] : $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsupported method.
|
||||||
|
*
|
||||||
|
* @throws BadMethodCallException
|
||||||
|
*/
|
||||||
|
public function getIsEmptyCallback(): ?callable
|
||||||
|
{
|
||||||
|
throw new BadMethodCallException('Buttons do not support "is empty" callback.');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unsupported method.
|
* Unsupported method.
|
||||||
*
|
*
|
||||||
|
|
|
@ -6,6 +6,10 @@ CHANGELOG
|
||||||
|
|
||||||
* The `view_timezone` option defaults to the `model_timezone` if no `reference_date` is configured.
|
* The `view_timezone` option defaults to the `model_timezone` if no `reference_date` is configured.
|
||||||
* Added default `inputmode` attribute to Search, Email and Tel form types.
|
* Added default `inputmode` attribute to Search, Email and Tel form types.
|
||||||
|
* Implementing the `FormConfigInterface` without implementing the `getIsEmptyCallback()` method
|
||||||
|
is deprecated. The method will be added to the interface in 6.0.
|
||||||
|
* Implementing the `FormConfigBuilderInterface` without implementing the `setIsEmptyCallback()` method
|
||||||
|
is deprecated. The method will be added to the interface in 6.0.
|
||||||
|
|
||||||
5.0.0
|
5.0.0
|
||||||
-----
|
-----
|
||||||
|
|
|
@ -60,6 +60,9 @@ class CheckboxType extends AbstractType
|
||||||
'empty_data' => $emptyData,
|
'empty_data' => $emptyData,
|
||||||
'compound' => false,
|
'compound' => false,
|
||||||
'false_values' => [null],
|
'false_values' => [null],
|
||||||
|
'is_empty_callback' => static function ($modelData): bool {
|
||||||
|
return false === $modelData;
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$resolver->setAllowedTypes('false_values', 'array');
|
$resolver->setAllowedTypes('false_values', 'array');
|
||||||
|
|
|
@ -15,6 +15,7 @@ use Symfony\Component\Form\Exception\LogicException;
|
||||||
use Symfony\Component\Form\Extension\Core\DataMapper\PropertyPathMapper;
|
use Symfony\Component\Form\Extension\Core\DataMapper\PropertyPathMapper;
|
||||||
use Symfony\Component\Form\Extension\Core\EventListener\TrimListener;
|
use Symfony\Component\Form\Extension\Core\EventListener\TrimListener;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\Form\FormConfigBuilderInterface;
|
||||||
use Symfony\Component\Form\FormInterface;
|
use Symfony\Component\Form\FormInterface;
|
||||||
use Symfony\Component\Form\FormView;
|
use Symfony\Component\Form\FormView;
|
||||||
use Symfony\Component\OptionsResolver\Options;
|
use Symfony\Component\OptionsResolver\Options;
|
||||||
|
@ -58,6 +59,14 @@ class FormType extends BaseType
|
||||||
if ($options['trim']) {
|
if ($options['trim']) {
|
||||||
$builder->addEventSubscriber(new TrimListener());
|
$builder->addEventSubscriber(new TrimListener());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!method_exists($builder, 'setIsEmptyCallback')) {
|
||||||
|
@trigger_error(sprintf('Not implementing the "%s::setIsEmptyCallback()" method in "%s" is deprecated since Symfony 5.1.', FormConfigBuilderInterface::class, \get_class($builder)), E_USER_DEPRECATED);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder->setIsEmptyCallback($options['is_empty_callback']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -190,6 +199,7 @@ class FormType extends BaseType
|
||||||
'help_attr' => [],
|
'help_attr' => [],
|
||||||
'help_html' => false,
|
'help_html' => false,
|
||||||
'help_translation_parameters' => [],
|
'help_translation_parameters' => [],
|
||||||
|
'is_empty_callback' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$resolver->setAllowedTypes('label_attr', 'array');
|
$resolver->setAllowedTypes('label_attr', 'array');
|
||||||
|
@ -197,6 +207,7 @@ class FormType extends BaseType
|
||||||
$resolver->setAllowedTypes('help', ['string', 'null']);
|
$resolver->setAllowedTypes('help', ['string', 'null']);
|
||||||
$resolver->setAllowedTypes('help_attr', 'array');
|
$resolver->setAllowedTypes('help_attr', 'array');
|
||||||
$resolver->setAllowedTypes('help_html', 'bool');
|
$resolver->setAllowedTypes('help_html', 'bool');
|
||||||
|
$resolver->setAllowedTypes('is_empty_callback', ['null', 'callable']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -726,6 +726,18 @@ class Form implements \IteratorAggregate, FormInterface, ClearableErrorsInterfac
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!method_exists($this->config, 'getIsEmptyCallback')) {
|
||||||
|
@trigger_error(sprintf('Not implementing the "%s::getIsEmptyCallback()" method in "%s" is deprecated since Symfony 5.1.', FormConfigInterface::class, \get_class($this->config)), E_USER_DEPRECATED);
|
||||||
|
|
||||||
|
$isEmptyCallback = null;
|
||||||
|
} else {
|
||||||
|
$isEmptyCallback = $this->config->getIsEmptyCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $isEmptyCallback) {
|
||||||
|
return $isEmptyCallback($this->modelData);
|
||||||
|
}
|
||||||
|
|
||||||
return FormUtil::isEmpty($this->modelData) ||
|
return FormUtil::isEmpty($this->modelData) ||
|
||||||
// arrays, countables
|
// arrays, countables
|
||||||
((\is_array($this->modelData) || $this->modelData instanceof \Countable) && 0 === \count($this->modelData)) ||
|
((\is_array($this->modelData) || $this->modelData instanceof \Countable) && 0 === \count($this->modelData)) ||
|
||||||
|
|
|
@ -102,6 +102,7 @@ class FormConfigBuilder implements FormConfigBuilderInterface
|
||||||
|
|
||||||
private $autoInitialize = false;
|
private $autoInitialize = false;
|
||||||
private $options;
|
private $options;
|
||||||
|
private $isEmptyCallback;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an empty form configuration.
|
* Creates an empty form configuration.
|
||||||
|
@ -461,6 +462,14 @@ class FormConfigBuilder implements FormConfigBuilderInterface
|
||||||
return \array_key_exists($name, $this->options) ? $this->options[$name] : $default;
|
return \array_key_exists($name, $this->options) ? $this->options[$name] : $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function getIsEmptyCallback(): ?callable
|
||||||
|
{
|
||||||
|
return $this->isEmptyCallback;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritdoc}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
|
@ -761,6 +770,16 @@ class FormConfigBuilder implements FormConfigBuilderInterface
|
||||||
return $config;
|
return $config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function setIsEmptyCallback(?callable $isEmptyCallback)
|
||||||
|
{
|
||||||
|
$this->isEmptyCallback = $isEmptyCallback;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates whether the given variable is a valid form name.
|
* Validates whether the given variable is a valid form name.
|
||||||
*
|
*
|
||||||
|
|
|
@ -16,6 +16,8 @@ use Symfony\Component\PropertyAccess\PropertyPathInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author Bernhard Schussek <bschussek@gmail.com>
|
* @author Bernhard Schussek <bschussek@gmail.com>
|
||||||
|
*
|
||||||
|
* @method $this setIsEmptyCallback(callable|null $isEmptyCallback) Sets the callback that will be called to determine if the model data of the form is empty or not - not implementing it is deprecated since Symfony 5.1
|
||||||
*/
|
*/
|
||||||
interface FormConfigBuilderInterface extends FormConfigInterface
|
interface FormConfigBuilderInterface extends FormConfigInterface
|
||||||
{
|
{
|
||||||
|
|
|
@ -18,6 +18,8 @@ use Symfony\Component\PropertyAccess\PropertyPathInterface;
|
||||||
* The configuration of a {@link Form} object.
|
* The configuration of a {@link Form} object.
|
||||||
*
|
*
|
||||||
* @author Bernhard Schussek <bschussek@gmail.com>
|
* @author Bernhard Schussek <bschussek@gmail.com>
|
||||||
|
*
|
||||||
|
* @method callable|null getIsEmptyCallback() Returns a callable that takes the model data as argument and that returns if it is empty or not - not implementing it is deprecated since Symfony 5.1
|
||||||
*/
|
*/
|
||||||
interface FormConfigInterface
|
interface FormConfigInterface
|
||||||
{
|
{
|
||||||
|
|
|
@ -2049,4 +2049,45 @@ class ChoiceTypeTest extends BaseTypeTest
|
||||||
'Multiple expanded' => [true, true],
|
'Multiple expanded' => [true, true],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider expandedIsEmptyWhenNoRealChoiceIsSelectedProvider
|
||||||
|
*/
|
||||||
|
public function testExpandedIsEmptyWhenNoRealChoiceIsSelected(bool $expected, $submittedData, bool $multiple, bool $required, $placeholder)
|
||||||
|
{
|
||||||
|
$options = [
|
||||||
|
'expanded' => true,
|
||||||
|
'choices' => [
|
||||||
|
'foo' => 'bar',
|
||||||
|
],
|
||||||
|
'multiple' => $multiple,
|
||||||
|
'required' => $required,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$multiple) {
|
||||||
|
$options['placeholder'] = $placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
$form = $this->factory->create(static::TESTED_TYPE, null, $options);
|
||||||
|
|
||||||
|
$form->submit($submittedData);
|
||||||
|
|
||||||
|
$this->assertSame($expected, $form->isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expandedIsEmptyWhenNoRealChoiceIsSelectedProvider()
|
||||||
|
{
|
||||||
|
// Some invalid cases are voluntarily not tested:
|
||||||
|
// - multiple with placeholder
|
||||||
|
// - required with placeholder
|
||||||
|
return [
|
||||||
|
'Nothing submitted / single / not required / without a placeholder -> should be empty' => [true, null, false, false, null],
|
||||||
|
'Nothing submitted / single / not required / with a placeholder -> should not be empty' => [false, null, false, false, 'ccc'], // It falls back on the placeholder
|
||||||
|
'Nothing submitted / single / required / without a placeholder -> should be empty' => [true, null, false, true, null],
|
||||||
|
'Nothing submitted / single / required / with a placeholder -> should be empty' => [true, null, false, true, 'ccc'],
|
||||||
|
'Nothing submitted / multiple / not required / without a placeholder -> should be empty' => [true, null, true, false, null],
|
||||||
|
'Nothing submitted / multiple / required / without a placeholder -> should be empty' => [true, null, true, true, null],
|
||||||
|
'Placeholder submitted / single / not required / with a placeholder -> should not be empty' => [false, '', false, false, 'ccc'], // The placeholder is a selected value
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,6 +42,7 @@
|
||||||
"help_html",
|
"help_html",
|
||||||
"help_translation_parameters",
|
"help_translation_parameters",
|
||||||
"inherit_data",
|
"inherit_data",
|
||||||
|
"is_empty_callback",
|
||||||
"label",
|
"label",
|
||||||
"label_attr",
|
"label_attr",
|
||||||
"label_format",
|
"label_format",
|
||||||
|
|
|
@ -22,6 +22,7 @@ Symfony\Component\Form\Extension\Core\Type\ChoiceType (Block prefix: "choice")
|
||||||
help_html
|
help_html
|
||||||
help_translation_parameters
|
help_translation_parameters
|
||||||
inherit_data
|
inherit_data
|
||||||
|
is_empty_callback
|
||||||
label
|
label
|
||||||
label_attr
|
label_attr
|
||||||
label_format
|
label_format
|
||||||
|
|
|
@ -22,6 +22,7 @@
|
||||||
"help_html",
|
"help_html",
|
||||||
"help_translation_parameters",
|
"help_translation_parameters",
|
||||||
"inherit_data",
|
"inherit_data",
|
||||||
|
"is_empty_callback",
|
||||||
"label",
|
"label",
|
||||||
"label_attr",
|
"label_attr",
|
||||||
"label_format",
|
"label_format",
|
||||||
|
|
|
@ -24,6 +24,7 @@ Symfony\Component\Form\Extension\Core\Type\FormType (Block prefix: "form")
|
||||||
help_html
|
help_html
|
||||||
help_translation_parameters
|
help_translation_parameters
|
||||||
inherit_data
|
inherit_data
|
||||||
|
is_empty_callback
|
||||||
label
|
label
|
||||||
label_attr
|
label_attr
|
||||||
label_format
|
label_format
|
||||||
|
|
|
@ -1097,6 +1097,21 @@ class SimpleFormTest extends AbstractFormTest
|
||||||
$form->setData('foo');
|
$form->setData('foo');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testIsEmptyCallback()
|
||||||
|
{
|
||||||
|
$config = new FormConfigBuilder('foo', null, $this->dispatcher);
|
||||||
|
|
||||||
|
$config->setIsEmptyCallback(function ($modelData): bool { return 'ccc' === $modelData; });
|
||||||
|
$form = new Form($config);
|
||||||
|
$form->setData('ccc');
|
||||||
|
$this->assertTrue($form->isEmpty());
|
||||||
|
|
||||||
|
$config->setIsEmptyCallback(function (): bool { return false; });
|
||||||
|
$form = new Form($config);
|
||||||
|
$form->setData(null);
|
||||||
|
$this->assertFalse($form->isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
protected function createForm(): FormInterface
|
protected function createForm(): FormInterface
|
||||||
{
|
{
|
||||||
return $this->getBuilder()->getForm();
|
return $this->getBuilder()->getForm();
|
||||||
|
|
Reference in New Issue