diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/form.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/form.php index 94845bd4ab..04f118f9b3 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/form.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/form.php @@ -126,7 +126,12 @@ return static function (ContainerConfigurator $container) { ->args([service('request_stack')]) ->set('form.type_extension.form.validator', FormTypeValidatorExtension::class) - ->args([service('validator')]) + ->args([ + service('validator'), + true, + service('twig.form.renderer')->ignoreOnInvalid(), + service('translator')->ignoreOnInvalid(), + ]) ->tag('form.type_extension', ['extended-type' => FormType::class]) ->set('form.type_extension.repeated.validator', RepeatedTypeValidatorExtension::class) diff --git a/src/Symfony/Component/Form/CHANGELOG.md b/src/Symfony/Component/Form/CHANGELOG.md index 439a5c9007..4ea9e689da 100644 --- a/src/Symfony/Component/Form/CHANGELOG.md +++ b/src/Symfony/Component/Form/CHANGELOG.md @@ -4,7 +4,8 @@ CHANGELOG 5.2.0 ----- -* added `FormErrorNormalizer` + * added `FormErrorNormalizer` + * Added support for using the `{{ label }}` placeholder in constraint messages, which is replaced in the `ViolationMapper` by the corresponding field form label. 5.1.0 ----- diff --git a/src/Symfony/Component/Form/Extension/Validator/Type/FormTypeValidatorExtension.php b/src/Symfony/Component/Form/Extension/Validator/Type/FormTypeValidatorExtension.php index 30dd714923..aa6a8e4030 100644 --- a/src/Symfony/Component/Form/Extension/Validator/Type/FormTypeValidatorExtension.php +++ b/src/Symfony/Component/Form/Extension/Validator/Type/FormTypeValidatorExtension.php @@ -15,9 +15,11 @@ use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\Extension\Validator\EventListener\ValidationListener; use Symfony\Component\Form\Extension\Validator\ViolationMapper\ViolationMapper; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormRendererInterface; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Validator\ValidatorInterface; +use Symfony\Contracts\Translation\TranslatorInterface; /** * @author Bernhard Schussek @@ -28,10 +30,10 @@ class FormTypeValidatorExtension extends BaseValidatorExtension private $violationMapper; private $legacyErrorMessages; - public function __construct(ValidatorInterface $validator, bool $legacyErrorMessages = true) + public function __construct(ValidatorInterface $validator, bool $legacyErrorMessages = true, FormRendererInterface $formRenderer = null, TranslatorInterface $translator = null) { $this->validator = $validator; - $this->violationMapper = new ViolationMapper(); + $this->violationMapper = new ViolationMapper($formRenderer, $translator); $this->legacyErrorMessages = $legacyErrorMessages; } diff --git a/src/Symfony/Component/Form/Extension/Validator/ValidatorExtension.php b/src/Symfony/Component/Form/Extension/Validator/ValidatorExtension.php index 6c1d9bb905..3a5728a827 100644 --- a/src/Symfony/Component/Form/Extension/Validator/ValidatorExtension.php +++ b/src/Symfony/Component/Form/Extension/Validator/ValidatorExtension.php @@ -13,9 +13,11 @@ namespace Symfony\Component\Form\Extension\Validator; use Symfony\Component\Form\AbstractExtension; use Symfony\Component\Form\Extension\Validator\Constraints\Form; +use Symfony\Component\Form\FormRendererInterface; use Symfony\Component\Validator\Constraints\Traverse; use Symfony\Component\Validator\Mapping\ClassMetadata; use Symfony\Component\Validator\Validator\ValidatorInterface; +use Symfony\Contracts\Translation\TranslatorInterface; /** * Extension supporting the Symfony Validator component in forms. @@ -25,9 +27,11 @@ use Symfony\Component\Validator\Validator\ValidatorInterface; class ValidatorExtension extends AbstractExtension { private $validator; + private $formRenderer; + private $translator; private $legacyErrorMessages; - public function __construct(ValidatorInterface $validator, bool $legacyErrorMessages = true) + public function __construct(ValidatorInterface $validator, bool $legacyErrorMessages = true, FormRendererInterface $formRenderer = null, TranslatorInterface $translator = null) { $this->legacyErrorMessages = $legacyErrorMessages; @@ -43,6 +47,8 @@ class ValidatorExtension extends AbstractExtension $metadata->addConstraint(new Traverse(false)); $this->validator = $validator; + $this->formRenderer = $formRenderer; + $this->translator = $translator; } public function loadTypeGuesser() @@ -53,7 +59,7 @@ class ValidatorExtension extends AbstractExtension protected function loadTypeExtensions() { return [ - new Type\FormTypeValidatorExtension($this->validator, $this->legacyErrorMessages), + new Type\FormTypeValidatorExtension($this->validator, $this->legacyErrorMessages, $this->formRenderer, $this->translator), new Type\RepeatedTypeValidatorExtension(), new Type\SubmitTypeValidatorExtension(), ]; diff --git a/src/Symfony/Component/Form/Extension/Validator/ViolationMapper/ViolationMapper.php b/src/Symfony/Component/Form/Extension/Validator/ViolationMapper/ViolationMapper.php index 12abef214b..3888e30586 100644 --- a/src/Symfony/Component/Form/Extension/Validator/ViolationMapper/ViolationMapper.php +++ b/src/Symfony/Component/Form/Extension/Validator/ViolationMapper/ViolationMapper.php @@ -13,21 +13,28 @@ namespace Symfony\Component\Form\Extension\Validator\ViolationMapper; use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormRendererInterface; use Symfony\Component\Form\Util\InheritDataAwareIterator; use Symfony\Component\PropertyAccess\PropertyPathBuilder; use Symfony\Component\PropertyAccess\PropertyPathIterator; use Symfony\Component\PropertyAccess\PropertyPathIteratorInterface; use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Contracts\Translation\TranslatorInterface; /** * @author Bernhard Schussek */ class ViolationMapper implements ViolationMapperInterface { - /** - * @var bool - */ - private $allowNonSynchronized; + private $formRenderer; + private $translator; + private $allowNonSynchronized = false; + + public function __construct(FormRendererInterface $formRenderer = null, TranslatorInterface $translator = null) + { + $this->formRenderer = $formRenderer; + $this->translator = $translator; + } /** * {@inheritdoc} @@ -124,9 +131,49 @@ class ViolationMapper implements ViolationMapperInterface // Only add the error if the form is synchronized if ($this->acceptsErrors($scope)) { + $labelFormat = $scope->getConfig()->getOption('label_format'); + + if (null !== $labelFormat) { + $label = str_replace( + [ + '%name%', + '%id%', + ], + [ + $scope->getName(), + (string) $scope->getPropertyPath(), + ], + $labelFormat + ); + } else { + $label = $scope->getConfig()->getOption('label'); + } + + if (null === $label && null !== $this->formRenderer) { + $label = $this->formRenderer->humanize($scope->getName()); + } elseif (null === $label) { + $label = $scope->getName(); + } + + if (false !== $label && null !== $this->translator) { + $label = $this->translator->trans( + $label, + $scope->getConfig()->getOption('label_translation_parameters', []), + $scope->getConfig()->getOption('translation_domain') + ); + } + + $message = $violation->getMessage(); + $messageTemplate = $violation->getMessageTemplate(); + + if (false !== $label) { + $message = str_replace('{{ label }}', $label, $message); + $messageTemplate = str_replace('{{ label }}', $label, $messageTemplate); + } + $scope->addError(new FormError( - $violation->getMessage(), - $violation->getMessageTemplate(), + $message, + $messageTemplate, $violation->getParameters(), $violation->getPlural(), $violation diff --git a/src/Symfony/Component/Form/Tests/Extension/Validator/ViolationMapper/ViolationMapperTest.php b/src/Symfony/Component/Form/Tests/Extension/Validator/ViolationMapper/ViolationMapperTest.php index 100c54ad46..547be2ff37 100644 --- a/src/Symfony/Component/Form/Tests/Extension/Validator/ViolationMapper/ViolationMapperTest.php +++ b/src/Symfony/Component/Form/Tests/Extension/Validator/ViolationMapper/ViolationMapperTest.php @@ -22,10 +22,12 @@ use Symfony\Component\Form\Form; use Symfony\Component\Form\FormConfigBuilder; use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormRenderer; use Symfony\Component\Form\Tests\Extension\Validator\ViolationMapper\Fixtures\Issue; use Symfony\Component\PropertyAccess\PropertyPath; use Symfony\Component\Validator\ConstraintViolation; use Symfony\Component\Validator\ConstraintViolationInterface; +use Symfony\Contracts\Translation\TranslatorInterface; /** * @author Bernhard Schussek @@ -1590,4 +1592,150 @@ class ViolationMapperTest extends TestCase $this->assertEquals([$this->getFormError($violation2, $grandChild2)], iterator_to_array($grandChild2->getErrors()), $grandChild2->getName().' should have an error, but has none'); $this->assertEquals([$this->getFormError($violation3, $grandChild3)], iterator_to_array($grandChild3->getErrors()), $grandChild3->getName().' should have an error, but has none'); } + + public function testMessageWithLabel1() + { + $renderer = $this->getMockBuilder(FormRenderer::class) + ->setMethods(null) + ->disableOriginalConstructor() + ->getMock() + ; + $translator = $this->getMockBuilder(TranslatorInterface::class)->getMock(); + $translator->expects($this->any())->method('trans')->willReturnMap([ + ['Name', [], null, null, 'Custom Name'], + ]); + $this->mapper = new ViolationMapper($renderer, $translator); + + $parent = $this->getForm('parent'); + $child = $this->getForm('name', 'name'); + $parent->add($child); + + $parent->submit([]); + + $violation = new ConstraintViolation('Message {{ label }}', null, [], null, 'data.name', null); + $this->mapper->mapViolation($violation, $parent); + + $this->assertCount(1, $child->getErrors(), $child->getName().' should have an error, but has none'); + + $errors = iterator_to_array($child->getErrors()); + if (isset($errors[0])) { + /** @var FormError $error */ + $error = $errors[0]; + $this->assertSame('Message Custom Name', $error->getMessage()); + } + } + + public function testMessageWithLabel2() + { + $translator = $this->getMockBuilder(TranslatorInterface::class)->getMock(); + $translator->expects($this->any())->method('trans')->willReturnMap([ + ['options_label', [], null, null, 'Translated Label'], + ]); + $this->mapper = new ViolationMapper(null, $translator); + + $parent = $this->getForm('parent'); + + $config = new FormConfigBuilder('name', null, $this->dispatcher, [ + 'error_mapping' => [], + 'label' => 'options_label', + ]); + $config->setMapped(true); + $config->setInheritData(false); + $config->setPropertyPath('name'); + $config->setCompound(true); + $config->setDataMapper(new PropertyPathMapper()); + + $child = new Form($config); + $parent->add($child); + + $parent->submit([]); + + $violation = new ConstraintViolation('Message {{ label }}', null, [], null, 'data.name', null); + $this->mapper->mapViolation($violation, $parent); + + $this->assertCount(1, $child->getErrors(), $child->getName().' should have an error, but has none'); + + $errors = iterator_to_array($child->getErrors()); + if (isset($errors[0])) { + /** @var FormError $error */ + $error = $errors[0]; + $this->assertSame('Message Translated Label', $error->getMessage()); + } + } + + public function testMessageWithLabelFormat1() + { + $translator = $this->getMockBuilder(TranslatorInterface::class)->getMock(); + $translator->expects($this->any())->method('trans')->willReturnMap([ + ['form.custom', [], null, null, 'Translated 1st Custom Label'], + ]); + $this->mapper = new ViolationMapper(null, $translator); + + $parent = $this->getForm('parent'); + + $config = new FormConfigBuilder('custom', null, $this->dispatcher, [ + 'error_mapping' => [], + 'label_format' => 'form.%name%', + ]); + $config->setMapped(true); + $config->setInheritData(false); + $config->setPropertyPath('custom'); + $config->setCompound(true); + $config->setDataMapper(new PropertyPathMapper()); + + $child = new Form($config); + $parent->add($child); + + $parent->submit([]); + + $violation = new ConstraintViolation('Message {{ label }}', null, [], null, 'data.custom', null); + $this->mapper->mapViolation($violation, $parent); + + $this->assertCount(1, $child->getErrors(), $child->getName().' should have an error, but has none'); + + $errors = iterator_to_array($child->getErrors()); + if (isset($errors[0])) { + /** @var FormError $error */ + $error = $errors[0]; + $this->assertSame('Message Translated 1st Custom Label', $error->getMessage()); + } + } + + public function testMessageWithLabelFormat2() + { + $translator = $this->getMockBuilder(TranslatorInterface::class)->getMock(); + $translator->expects($this->any())->method('trans')->willReturnMap([ + ['form_custom-id', [], null, null, 'Translated 2nd Custom Label'], + ]); + $this->mapper = new ViolationMapper(null, $translator); + + $parent = $this->getForm('parent'); + + $config = new FormConfigBuilder('custom-id', null, $this->dispatcher, [ + 'error_mapping' => [], + 'label_format' => 'form_%id%', + ]); + $config->setMapped(true); + $config->setInheritData(false); + $config->setPropertyPath('custom-id'); + $config->setCompound(true); + $config->setDataMapper(new PropertyPathMapper()); + + $child = new Form($config); + $parent->add($child); + + $parent->submit([]); + + $violation = new ConstraintViolation('Message {{ label }}', null, [], null, 'data.custom-id', null); + $this->mapper->mapViolation($violation, $parent); + + $this->assertCount(1, $child->getErrors(), $child->getName().' should have an error, but has none'); + + $errors = iterator_to_array($child->getErrors()); + if (isset($errors[0])) { + /** @var FormError $error */ + $error = $errors[0]; + $this->assertSame('Message Translated 2nd Custom Label', $error->getMessage()); + } + } }