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:
Nicolas Grekas 2020-02-05 17:32:36 +01:00
commit 86573147b3
16 changed files with 147 additions and 0 deletions

View File

@ -16,6 +16,14 @@ EventDispatcher
* 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
---------------

View File

@ -16,6 +16,12 @@ EventDispatcher
* 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
---------------

View File

@ -466,6 +466,16 @@ class ButtonBuilder implements \IteratorAggregate, FormBuilderInterface
return $config;
}
/**
* Unsupported method.
*
* @throws BadMethodCallException
*/
public function setIsEmptyCallback(?callable $isEmptyCallback)
{
throw new BadMethodCallException('Buttons do not support "is empty" callback.');
}
/**
* Unsupported method.
*/
@ -738,6 +748,16 @@ class ButtonBuilder implements \IteratorAggregate, FormBuilderInterface
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.
*

View File

@ -6,6 +6,10 @@ CHANGELOG
* 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.
* 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
-----

View File

@ -60,6 +60,9 @@ class CheckboxType extends AbstractType
'empty_data' => $emptyData,
'compound' => false,
'false_values' => [null],
'is_empty_callback' => static function ($modelData): bool {
return false === $modelData;
},
]);
$resolver->setAllowedTypes('false_values', 'array');

View File

@ -15,6 +15,7 @@ use Symfony\Component\Form\Exception\LogicException;
use Symfony\Component\Form\Extension\Core\DataMapper\PropertyPathMapper;
use Symfony\Component\Form\Extension\Core\EventListener\TrimListener;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormConfigBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\Options;
@ -58,6 +59,14 @@ class FormType extends BaseType
if ($options['trim']) {
$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_html' => false,
'help_translation_parameters' => [],
'is_empty_callback' => null,
]);
$resolver->setAllowedTypes('label_attr', 'array');
@ -197,6 +207,7 @@ class FormType extends BaseType
$resolver->setAllowedTypes('help', ['string', 'null']);
$resolver->setAllowedTypes('help_attr', 'array');
$resolver->setAllowedTypes('help_html', 'bool');
$resolver->setAllowedTypes('is_empty_callback', ['null', 'callable']);
}
/**

View File

@ -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) ||
// arrays, countables
((\is_array($this->modelData) || $this->modelData instanceof \Countable) && 0 === \count($this->modelData)) ||

View File

@ -102,6 +102,7 @@ class FormConfigBuilder implements FormConfigBuilderInterface
private $autoInitialize = false;
private $options;
private $isEmptyCallback;
/**
* Creates an empty form configuration.
@ -461,6 +462,14 @@ class FormConfigBuilder implements FormConfigBuilderInterface
return \array_key_exists($name, $this->options) ? $this->options[$name] : $default;
}
/**
* {@inheritdoc}
*/
public function getIsEmptyCallback(): ?callable
{
return $this->isEmptyCallback;
}
/**
* {@inheritdoc}
*/
@ -761,6 +770,16 @@ class FormConfigBuilder implements FormConfigBuilderInterface
return $config;
}
/**
* {@inheritdoc}
*/
public function setIsEmptyCallback(?callable $isEmptyCallback)
{
$this->isEmptyCallback = $isEmptyCallback;
return $this;
}
/**
* Validates whether the given variable is a valid form name.
*

View File

@ -16,6 +16,8 @@ use Symfony\Component\PropertyAccess\PropertyPathInterface;
/**
* @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
{

View File

@ -18,6 +18,8 @@ use Symfony\Component\PropertyAccess\PropertyPathInterface;
* The configuration of a {@link Form} object.
*
* @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
{

View File

@ -2049,4 +2049,45 @@ class ChoiceTypeTest extends BaseTypeTest
'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
];
}
}

View File

@ -42,6 +42,7 @@
"help_html",
"help_translation_parameters",
"inherit_data",
"is_empty_callback",
"label",
"label_attr",
"label_format",

View File

@ -22,6 +22,7 @@ Symfony\Component\Form\Extension\Core\Type\ChoiceType (Block prefix: "choice")
help_html
help_translation_parameters
inherit_data
is_empty_callback
label
label_attr
label_format

View File

@ -22,6 +22,7 @@
"help_html",
"help_translation_parameters",
"inherit_data",
"is_empty_callback",
"label",
"label_attr",
"label_format",

View File

@ -24,6 +24,7 @@ Symfony\Component\Form\Extension\Core\Type\FormType (Block prefix: "form")
help_html
help_translation_parameters
inherit_data
is_empty_callback
label
label_attr
label_format

View File

@ -1097,6 +1097,21 @@ class SimpleFormTest extends AbstractFormTest
$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
{
return $this->getBuilder()->getForm();