From 7d4c0ffc56ba8faaeeba30e31b463bfc1a62c753 Mon Sep 17 00:00:00 2001 From: Peter Gribanov Date: Thu, 5 Mar 2020 13:22:56 +0300 Subject: [PATCH] Data Transformers in list fields editable fix #5693 allow use data_transformer in SetObjectFieldValueAction create BooleanToStringTransformer for allows to use non-strings update SetObjectFieldValueActionTest use yoda conditions fix errors in HelperControllerTest test BooleanToStringTransformer allow override transformers for 'date', 'boolean' and 'choice' field types mark BooleanToStringTransformer and BooleanToStringTransformer classes as final add example of using the data_transformer option in docs add full docs about Symfony Data Transformers optimize resolve Data Transformer fix docs create DataTransformerResolver service add type hint for BooleanToStringTransformer::$trueValue allow add a custom global transformers field type should be a string correct default value for $globalCustomTransformers correct test DataTransformerResolverTest::testAddCustomGlobalTransformer() add BC support usage of DataTransformerResolver Update tests/Action/SetObjectFieldValueActionTest.php Update tests/Action/SetObjectFieldValueActionTest.php Update tests/Form/DataTransformer/BooleanToStringTransformerTest.php Update tests/Form/DataTransformer/BooleanToStringTransformerTest.php Update tests/Form/DataTransformerResolverTest.php Update tests/Form/DataTransformerResolverTest.php Update src/Action/SetObjectFieldValueAction.php change "entity" word to "model" in documentations change deprecated error message add datetime in editable date form types correct test transform datetime and date form types test DateTime object in assertSame() fix typo restore getTemplate() return value in SetObjectFieldValueActionTest use Yoda conditions lazy-load predefined data transformers add DataTransformerResolverInterface use constants for determinate a field type test laze-load data transformers test usage DataTransformerResolver::addCustomGlobalTransformer() create simple function in DataTransformerResolverTest --- docs/reference/action_list.rst | 109 +++++++++ src/Action/SetObjectFieldValueAction.php | 60 +++-- src/Controller/HelperController.php | 30 ++- .../BooleanToStringTransformer.php | 45 ++++ src/Form/DataTransformerResolver.php | 99 ++++++++ src/Form/DataTransformerResolverInterface.php | 31 +++ src/Resources/config/actions.xml | 1 + src/Resources/config/core.xml | 1 + src/Resources/config/form_types.xml | 1 + tests/Action/Bar.php | 10 +- tests/Action/Baz.php | 2 +- tests/Action/Foo.php | 10 +- .../Action/SetObjectFieldValueActionTest.php | 151 +++++++++++- tests/Controller/HelperControllerTest.php | 17 +- .../BooleanToStringTransformerTest.php | 79 +++++++ tests/Form/DataTransformerResolverTest.php | 222 ++++++++++++++++++ 16 files changed, 831 insertions(+), 37 deletions(-) create mode 100644 src/Form/DataTransformer/BooleanToStringTransformer.php create mode 100644 src/Form/DataTransformerResolver.php create mode 100644 src/Form/DataTransformerResolverInterface.php create mode 100644 tests/Form/DataTransformer/BooleanToStringTransformerTest.php create mode 100644 tests/Form/DataTransformerResolverTest.php diff --git a/docs/reference/action_list.rst b/docs/reference/action_list.rst index cec5a2e174e..13ce0659b08 100644 --- a/docs/reference/action_list.rst +++ b/docs/reference/action_list.rst @@ -226,6 +226,115 @@ to more field types, see `SonataDoctrineORMAdminBundle Documentation`_. values so use the ``inverse`` option if you really cannot find a good enough antonym for the name you have. +Symfony Data Transformers +^^^^^^^^^^^^^^^^^^^^^^^^^ + +If the model field has a limited list of values (enumeration), it is convenient to use a value object to control +the available values. For example, consider the value object of moderation status with the following values: +``awaiting``, ``approved``, ``rejected``:: + + final class ModerationStatus + { + public const AWAITING = 'awaiting'; + public const APPROVED = 'approved'; + public const REJECTED = 'rejected'; + + private static $instances = []; + + private string $value; + + private function __construct(string $value) + { + if (!array_key_exists($value, self::choices())) { + throw new \DomainException(sprintf('The value "%s" is not a valid moderation status.', $value)); + } + + $this->value = $value; + } + + public static function byValue(string $value): ModerationStatus + { + // limitation of count object instances + if (!isset(self::$instances[$value])) { + self::$instances[$value] = new static($value); + } + + return self::$instances[$value]; + } + + public function getValue(): string + { + return $this->value; + } + + public static function choices(): array + { + return [ + self::AWAITING => 'moderation_status.awaiting', + self::APPROVED => 'moderation_status.approved', + self::REJECTED => 'moderation_status.rejected', + ]; + } + + public function __toString(): string + { + return self::choices()[$this->value]; + } + } + +To use this Value Object in the _`Symfony Form`: https://symfony.com/doc/current/forms.html component, we need a +_`Data Transformer`: https://symfony.com/doc/current/form/data_transformers.html :: + + use Symfony\Component\Form\DataTransformerInterface; + use Symfony\Component\Form\Exception\TransformationFailedException; + + final class ModerationStatusDataTransformer implements DataTransformerInterface + { + public function transform($value): ?string + { + $status = $this->reverseTransform($value); + + return $status instanceof ModerationStatus ? $status->value() : null; + } + + public function reverseTransform($value): ?ModerationStatus + { + if (null === $value || '' === $value) { + return null; + } + + if ($value instanceof ModerationStatus) { + return $value; + } + + try { + return ModerationStatus::byValue($value); + } catch (\Throwable $e) { + throw new TransformationFailedException($e->getMessage(), $e->getCode(), $e); + } + } + } + +For quick moderation of objects, it is convenient to do this on the page for viewing all objects. But if we just +indicate the field as editable, then when editing we get in the object a string with the value itself (``awaiting``, +``approved``, ``rejected``), and not the Value Object (``ModerationStatus``). To solve this problem, you must specify +the Data Transformer in the ``data_transformer`` field so that it correctly converts the input data into the data +expected by your object:: + + // ... + + protected function configureListFields(ListMapper $listMapper) + { + $listMapper + ->add('moderation_status', 'choice', [ + 'editable' => true, + 'choices' => ModerationStatus::choices(), + 'data_transformer' => new ModerationStatusDataTransformer(), + ]) + ; + } + + Customizing the query used to generate the list ----------------------------------------------- diff --git a/src/Action/SetObjectFieldValueAction.php b/src/Action/SetObjectFieldValueAction.php index 2eeab3cb3a3..44daf57d79d 100644 --- a/src/Action/SetObjectFieldValueAction.php +++ b/src/Action/SetObjectFieldValueAction.php @@ -14,7 +14,10 @@ namespace Sonata\AdminBundle\Action; use Sonata\AdminBundle\Admin\Pool; +use Sonata\AdminBundle\Form\DataTransformerResolver; +use Sonata\AdminBundle\Form\DataTransformerResolverInterface; use Sonata\AdminBundle\Twig\Extension\SonataAdminExtension; +use Symfony\Component\Form\DataTransformerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -40,18 +43,40 @@ final class SetObjectFieldValueAction */ private $validator; - public function __construct(Environment $twig, Pool $pool, $validator) + /** + * @var DataTransformerResolver + */ + private $resolver; + + /** + * @param ValidatorInterface $validator + * @param DataTransformerResolver|null $resolver + */ + public function __construct(Environment $twig, Pool $pool, $validator, $resolver = null) { // NEXT_MAJOR: Move ValidatorInterface check to method signature if (!($validator instanceof ValidatorInterface)) { throw new \InvalidArgumentException( - 'Argument 3 is an instance of '.\get_class($validator).', expecting an instance of' - .' \Symfony\Component\Validator\Validator\ValidatorInterface' + 'Argument 3 is an instance of '.\get_class($validator).', expecting an instance of ' + .ValidatorInterface::class ); } + + // NEXT_MAJOR: Move DataTransformerResolver check to method signature + if (!$resolver instanceof DataTransformerResolverInterface) { + @trigger_error(sprintf( + 'Passing other type than %s in argument 4 to %s() is deprecated since sonata-project/admin-bundle 3.x and will throw %s exception in 4.0.', + DataTransformerResolverInterface::class, + __METHOD__, + \TypeError::class + ), E_USER_DEPRECATED); + $resolver = new DataTransformerResolver(); + } + $this->pool = $pool; $this->twig = $twig; $this->validator = $validator; + $this->resolver = $resolver; } /** @@ -113,34 +138,25 @@ public function __invoke(Request $request): JsonResponse $propertyPath = new PropertyPath($field); } - // Handle date and datetime types have setter expecting a DateTime object - if ('' !== $value && \in_array($fieldDescription->getType(), ['date', 'datetime'], true)) { - $value = new \DateTime($value); - } - - // Handle boolean type transforming the value into a boolean - if ('' !== $value && 'boolean' === $fieldDescription->getType()) { - $value = filter_var($value, FILTER_VALIDATE_BOOLEAN); - } + if ('' === $value) { + $this->pool->getPropertyAccessor()->setValue($object, $propertyPath, null); + } else { + $dataTransformer = $this->resolver->resolve($fieldDescription, $admin->getModelManager()); - // Handle entity choice association type, transforming the value into entity - if ('' !== $value - && 'choice' === $fieldDescription->getType() - && null !== $fieldDescription->getOption('class') - && $fieldDescription->getOption('class') === $fieldDescription->getTargetEntity() - ) { - $value = $admin->getModelManager()->find($fieldDescription->getOption('class'), $value); + if ($dataTransformer instanceof DataTransformerInterface) { + $value = $dataTransformer->reverseTransform($value); + } - if (!$value) { + if (!$value && 'choice' === $fieldDescription->getType()) { return new JsonResponse(sprintf( 'Edit failed, object with id: %s not found in association: %s.', $originalValue, $field ), Response::HTTP_NOT_FOUND); } - } - $this->pool->getPropertyAccessor()->setValue($object, $propertyPath, '' !== $value ? $value : null); + $this->pool->getPropertyAccessor()->setValue($object, $propertyPath, $value); + } $violations = $this->validator->validate($object); diff --git a/src/Controller/HelperController.php b/src/Controller/HelperController.php index 0ff2695c6d5..d1d14e18282 100644 --- a/src/Controller/HelperController.php +++ b/src/Controller/HelperController.php @@ -20,6 +20,8 @@ use Sonata\AdminBundle\Action\SetObjectFieldValueAction; use Sonata\AdminBundle\Admin\AdminHelper; use Sonata\AdminBundle\Admin\Pool; +use Sonata\AdminBundle\Form\DataTransformerResolver; +use Sonata\AdminBundle\Form\DataTransformerResolverInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -64,22 +66,40 @@ class HelperController protected $validator; /** - * @param ValidatorInterface $validator + * @var DataTransformerResolver */ - public function __construct(Environment $twig, Pool $pool, AdminHelper $helper, $validator) + private $resolver; + + /** + * @param ValidatorInterface $validator + * @param DataTransformerResolver|null $resolver + */ + public function __construct(Environment $twig, Pool $pool, AdminHelper $helper, $validator, $resolver = null) { // NEXT_MAJOR: Move ValidatorInterface check to method signature if (!($validator instanceof ValidatorInterface)) { throw new \InvalidArgumentException( - 'Argument 4 is an instance of '.\get_class($validator).', expecting an instance of' - .' \Symfony\Component\Validator\Validator\ValidatorInterface' + 'Argument 4 is an instance of '.\get_class($validator).', expecting an instance of ' + .ValidatorInterface::class ); } + // NEXT_MAJOR: Move DataTransformerResolver check to method signature + if (!$resolver instanceof DataTransformerResolverInterface) { + @trigger_error(sprintf( + 'Passing other type than %s in argument 4 to %s() is deprecated since sonata-project/admin-bundle 3.x and will throw %s exception in 4.0.', + DataTransformerResolverInterface::class, + __METHOD__, + \TypeError::class + ), E_USER_DEPRECATED); + $resolver = new DataTransformerResolver(); + } + $this->twig = $twig; $this->pool = $pool; $this->helper = $helper; $this->validator = $validator; + $this->resolver = $resolver; } /** @@ -123,7 +143,7 @@ public function getShortObjectDescriptionAction(Request $request) */ public function setObjectFieldValueAction(Request $request) { - $action = new SetObjectFieldValueAction($this->twig, $this->pool, $this->validator); + $action = new SetObjectFieldValueAction($this->twig, $this->pool, $this->validator, $this->resolver); return $action($request); } diff --git a/src/Form/DataTransformer/BooleanToStringTransformer.php b/src/Form/DataTransformer/BooleanToStringTransformer.php new file mode 100644 index 00000000000..8307f9d38f5 --- /dev/null +++ b/src/Form/DataTransformer/BooleanToStringTransformer.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\AdminBundle\Form\DataTransformer; + +use Symfony\Component\Form\DataTransformerInterface; + +/** + * This is analog of Symfony\Component\Form\Extension\Core\DataTransformer\BooleanToStringTransformer + * which allows you to use non-strings in reverseTransform() method. + * + * @author Peter Gribanov + */ +final class BooleanToStringTransformer implements DataTransformerInterface +{ + /** + * @var string + */ + private $trueValue; + + public function __construct(string $trueValue) + { + $this->trueValue = $trueValue; + } + + public function transform($value): ?string + { + return $value ? $this->trueValue : null; + } + + public function reverseTransform($value): bool + { + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + } +} diff --git a/src/Form/DataTransformerResolver.php b/src/Form/DataTransformerResolver.php new file mode 100644 index 00000000000..0855077eaa8 --- /dev/null +++ b/src/Form/DataTransformerResolver.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\AdminBundle\Form; + +use Sonata\AdminBundle\Admin\FieldDescriptionInterface; +use Sonata\AdminBundle\Form\DataTransformer\BooleanToStringTransformer; +use Sonata\AdminBundle\Form\DataTransformer\ModelToIdTransformer; +use Sonata\AdminBundle\Model\ModelManagerInterface; +use Sonata\AdminBundle\Templating\TemplateRegistry; +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToStringTransformer; + +/** + * @author Peter Gribanov + */ +final class DataTransformerResolver implements DataTransformerResolverInterface +{ + /** + * @var array + */ + private $globalCustomTransformers = []; + + /** + * @param array $customGlobalTransformers + */ + public function __construct(array $customGlobalTransformers = []) + { + foreach ($customGlobalTransformers as $fieldType => $dataTransformer) { + $this->addCustomGlobalTransformer($fieldType, $dataTransformer); + } + } + + public function addCustomGlobalTransformer(string $fieldType, DataTransformerInterface $dataTransformer): void + { + $this->globalCustomTransformers[$fieldType] = $dataTransformer; + } + + public function resolve( + FieldDescriptionInterface $fieldDescription, + ModelManagerInterface $modelManager + ): ?DataTransformerInterface { + $dataTransformer = $fieldDescription->getOption('data_transformer'); + + // allow override predefined transformers for 'date', 'boolean' and 'choice' field types + if ($dataTransformer instanceof DataTransformerInterface) { + return $dataTransformer; + } + + $fieldType = (string) $fieldDescription->getType(); + + // allow override predefined transformers on a global level + if (\array_key_exists($fieldType, $this->globalCustomTransformers)) { + return $this->globalCustomTransformers[$fieldType]; + } + + // Handle date type has setter expect a DateTime object + if (TemplateRegistry::TYPE_DATE === $fieldType) { + $this->globalCustomTransformers[$fieldType] = new DateTimeToStringTransformer(null, null, 'Y-m-d'); + + return $this->globalCustomTransformers[$fieldType]; + } + + // Handle datetime type has setter expect a DateTime object + if (TemplateRegistry::TYPE_DATETIME === $fieldType) { + $this->globalCustomTransformers[$fieldType] = new DateTimeToStringTransformer(null, null, 'Y-m-d H:i:s'); + + return $this->globalCustomTransformers[$fieldType]; + } + + // Handle boolean type transforming the value into a boolean + if (TemplateRegistry::TYPE_BOOLEAN === $fieldType) { + $this->globalCustomTransformers[$fieldType] = new BooleanToStringTransformer('1'); + + return $this->globalCustomTransformers[$fieldType]; + } + + // Handle entity choice association type, transforming the value into entity + if (TemplateRegistry::TYPE_CHOICE === $fieldType) { + $className = $fieldDescription->getOption('class'); + + if (null !== $className && $className === $fieldDescription->getTargetEntity()) { + return new ModelToIdTransformer($modelManager, $className); + } + } + + return null; + } +} diff --git a/src/Form/DataTransformerResolverInterface.php b/src/Form/DataTransformerResolverInterface.php new file mode 100644 index 00000000000..f2d763ebb5a --- /dev/null +++ b/src/Form/DataTransformerResolverInterface.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\AdminBundle\Form; + +use Sonata\AdminBundle\Admin\FieldDescriptionInterface; +use Sonata\AdminBundle\Model\ModelManagerInterface; +use Symfony\Component\Form\DataTransformerInterface; + +/** + * @author Peter Gribanov + */ +interface DataTransformerResolverInterface +{ + public function addCustomGlobalTransformer(string $fieldType, DataTransformerInterface $dataTransformer): void; + + public function resolve( + FieldDescriptionInterface $fieldDescription, + ModelManagerInterface $modelManager + ): ?DataTransformerInterface; +} diff --git a/src/Resources/config/actions.xml b/src/Resources/config/actions.xml index 1e3c0e49393..9d54c3a4605 100644 --- a/src/Resources/config/actions.xml +++ b/src/Resources/config/actions.xml @@ -34,6 +34,7 @@ + diff --git a/src/Resources/config/core.xml b/src/Resources/config/core.xml index dc6c29ca381..981f01d1546 100644 --- a/src/Resources/config/core.xml +++ b/src/Resources/config/core.xml @@ -62,6 +62,7 @@ + diff --git a/src/Resources/config/form_types.xml b/src/Resources/config/form_types.xml index fd0e97ee032..9dc4dac7fc1 100644 --- a/src/Resources/config/form_types.xml +++ b/src/Resources/config/form_types.xml @@ -68,5 +68,6 @@ + diff --git a/tests/Action/Bar.php b/tests/Action/Bar.php index 89ad2123db4..9ab4a79d8bf 100644 --- a/tests/Action/Bar.php +++ b/tests/Action/Bar.php @@ -15,7 +15,15 @@ class Bar { - public function setEnabled($value): void + private $enabled; + + public function getEnabled(): bool + { + return $this->enabled; + } + + public function setEnabled(bool $enabled): void { + $this->enabled = $enabled; } } diff --git a/tests/Action/Baz.php b/tests/Action/Baz.php index 126fa794208..9f2ac0f9e02 100644 --- a/tests/Action/Baz.php +++ b/tests/Action/Baz.php @@ -22,7 +22,7 @@ public function setBar(Bar $bar): void $this->bar = $bar; } - public function getBar() + public function getBar(): Bar { return $this->bar; } diff --git a/tests/Action/Foo.php b/tests/Action/Foo.php index 8a5126e4cdf..44b96ba6cc3 100644 --- a/tests/Action/Foo.php +++ b/tests/Action/Foo.php @@ -15,7 +15,15 @@ class Foo { - public function setEnabled($value): void + private $enabled; + + public function getEnabled(): bool + { + return $this->enabled; + } + + public function setEnabled(bool $enabled): void { + $this->enabled = $enabled; } } diff --git a/tests/Action/SetObjectFieldValueActionTest.php b/tests/Action/SetObjectFieldValueActionTest.php index e8ca3af1f50..b36e068dbfb 100644 --- a/tests/Action/SetObjectFieldValueActionTest.php +++ b/tests/Action/SetObjectFieldValueActionTest.php @@ -20,10 +20,12 @@ use Sonata\AdminBundle\Admin\AbstractAdmin; use Sonata\AdminBundle\Admin\FieldDescriptionInterface; use Sonata\AdminBundle\Admin\Pool; +use Sonata\AdminBundle\Form\DataTransformerResolver; use Sonata\AdminBundle\Model\ModelManagerInterface; use Sonata\AdminBundle\Templating\TemplateRegistryInterface; use Sonata\AdminBundle\Twig\Extension\SonataAdminExtension; use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\Form\CallbackTransformer; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\PropertyAccess\PropertyAccessor; @@ -61,6 +63,16 @@ final class SetObjectFieldValueActionTest extends TestCase */ private $validator; + /** + * @var ModelManagerInterface + */ + private $modelManager; + + /** + * @var DataTransformerResolver + */ + private $resolver; + protected function setUp(): void { $this->twig = new Environment(new ArrayLoader([ @@ -72,11 +84,15 @@ protected function setUp(): void $this->pool->getInstance(Argument::any())->willReturn($this->admin->reveal()); $this->admin->setRequest(Argument::type(Request::class))->shouldBeCalled(); $this->validator = $this->prophesize(ValidatorInterface::class); + $this->modelManager = $this->prophesize(ModelManagerInterface::class); + $this->resolver = new DataTransformerResolver(); $this->action = new SetObjectFieldValueAction( $this->twig, $this->pool->reveal(), - $this->validator->reveal() + $this->validator->reveal(), + $this->resolver ); + $this->admin->getModelManager()->willReturn($this->modelManager->reveal()); } public function testSetObjectFieldValueAction(): void @@ -118,6 +134,7 @@ public function testSetObjectFieldValueAction(): void $fieldDescription->getType()->willReturn('boolean'); $fieldDescription->getTemplate()->willReturn('field_template'); $fieldDescription->getValue(Argument::cetera())->willReturn('some value'); + $fieldDescription->getOption('data_transformer')->willReturn(null); $this->validator->validate($object)->willReturn(new ConstraintViolationList([])); @@ -165,11 +182,14 @@ public function testSetObjectFieldValueActionWithDate(): void $fieldDescription->getType()->willReturn('date'); $fieldDescription->getTemplate()->willReturn('field_template'); $fieldDescription->getValue(Argument::cetera())->willReturn('some value'); + $fieldDescription->getOption('data_transformer')->willReturn(null); $this->validator->validate($object)->willReturn(new ConstraintViolationList([])); $response = ($this->action)($request); + $this->assertInstanceOf(\DateTime::class, $object->getDateProp()); + $this->assertSame('2020-12-12', $object->getDateProp()->format('Y-m-d')); $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); } @@ -212,11 +232,14 @@ public function testSetObjectFieldValueActionWithDateTime(): void $fieldDescription->getType()->willReturn('datetime'); $fieldDescription->getTemplate()->willReturn('field_template'); $fieldDescription->getValue(Argument::cetera())->willReturn('some value'); + $fieldDescription->getOption('data_transformer')->willReturn(null); $this->validator->validate($object)->willReturn(new ConstraintViolationList([])); $response = ($this->action)($request); + $this->assertInstanceOf(\DateTime::class, $object->getDatetimeProp()); + $this->assertSame('2020-12-12 23:11:23', $object->getDatetimeProp()->format('Y-m-d H:i:s')); $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); } @@ -233,7 +256,6 @@ public function testSetObjectFieldValueActionOnARelationField(): void ], [], [], [], [], ['REQUEST_METHOD' => Request::METHOD_POST, 'HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest']); $fieldDescription = $this->prophesize(FieldDescriptionInterface::class); - $modelManager = $this->prophesize(ModelManagerInterface::class); $translator = $this->prophesize(TranslatorInterface::class); $propertyAccessor = new PropertyAccessor(); $templateRegistry = $this->prophesize(TemplateRegistryInterface::class); @@ -249,7 +271,6 @@ public function testSetObjectFieldValueActionOnARelationField(): void // NEXT_MAJOR: Remove this line $this->admin->getTemplate('base_list_field')->willReturn('admin_template'); $templateRegistry->getTemplate('base_list_field')->willReturn('admin_template'); - $this->admin->getModelManager()->willReturn($modelManager->reveal()); $this->twig->addExtension(new SonataAdminExtension( $this->pool->reveal(), null, @@ -264,12 +285,14 @@ public function testSetObjectFieldValueActionOnARelationField(): void $fieldDescription->getAdmin()->willReturn($this->admin->reveal()); $fieldDescription->getTemplate()->willReturn('field_template'); $fieldDescription->getValue(Argument::cetera())->willReturn('some value'); - $modelManager->find(\get_class($associationObject), 1)->willReturn($associationObject); + $fieldDescription->getOption('data_transformer')->willReturn(null); + $this->modelManager->find(\get_class($associationObject), 1)->willReturn($associationObject); $this->validator->validate($object)->willReturn(new ConstraintViolationList([])); $response = ($this->action)($request); + $this->assertSame($associationObject, $object->getBar()); $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); } @@ -299,6 +322,7 @@ public function testSetObjectFieldValueActionWithViolations(): void ])); $fieldDescription->getOption('editable')->willReturn(true); $fieldDescription->getType()->willReturn('boolean'); + $fieldDescription->getOption('data_transformer')->willReturn(null); $response = ($this->action)($request); @@ -319,7 +343,6 @@ public function testSetObjectFieldEditableMultipleValue(): void $fieldDescription = $this->prophesize(FieldDescriptionInterface::class); $pool = $this->prophesize(Pool::class); - $template = $this->prophesize(Template::class); $translator = $this->prophesize(TranslatorInterface::class); $propertyAccessor = new PropertyAccessor(); $templateRegistry = $this->prophesize(TemplateRegistryInterface::class); @@ -344,14 +367,130 @@ public function testSetObjectFieldEditableMultipleValue(): void $fieldDescription->getOption('editable')->willReturn(true); $fieldDescription->getOption('multiple')->willReturn(true); $fieldDescription->getAdmin()->willReturn($this->admin->reveal()); - $fieldDescription->getType()->willReturn('boolean'); + $fieldDescription->getType()->willReturn(null); $fieldDescription->getTemplate()->willReturn('field_template'); $fieldDescription->getValue(Argument::cetera())->willReturn(['some value']); + $fieldDescription->getOption('data_transformer')->willReturn(null); + + $this->validator->validate($object)->willReturn(new ConstraintViolationList([])); + + $response = ($this->action)($request); + + $this->assertSame([1, 2], $object->status); + $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); + } + + public function testSetObjectFieldTransformed(): void + { + $object = new Foo(); + $request = new Request([ + 'code' => 'sonata.post.admin', + 'objectId' => 42, + 'field' => 'enabled', + 'value' => 'yes', + 'context' => 'list', + ], [], [], [], [], ['REQUEST_METHOD' => Request::METHOD_POST, 'HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest']); + + $dataTransformer = new CallbackTransformer(static function ($value): string { + return (string) (int) $value; + }, static function ($value): bool { + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + }); + + $fieldDescription = $this->prophesize(FieldDescriptionInterface::class); + $pool = $this->prophesize(Pool::class); + $translator = $this->prophesize(TranslatorInterface::class); + $propertyAccessor = new PropertyAccessor(); + $templateRegistry = $this->prophesize(TemplateRegistryInterface::class); + $container = $this->prophesize(ContainerInterface::class); + + $this->admin->getObject(42)->willReturn($object); + $this->admin->getCode()->willReturn('sonata.post.admin'); + $this->admin->hasAccess('edit', $object)->willReturn(true); + $this->admin->getListFieldDescription('enabled')->willReturn($fieldDescription->reveal()); + $this->admin->update($object)->shouldBeCalled(); + // NEXT_MAJOR: Remove this line + $this->admin->getTemplate('base_list_field')->willReturn('admin_template'); + $templateRegistry->getTemplate('base_list_field')->willReturn('admin_template'); + $container->get('sonata.post.admin.template_registry')->willReturn($templateRegistry->reveal()); + $this->pool->getPropertyAccessor()->willReturn($propertyAccessor); + $this->twig->addExtension(new SonataAdminExtension( + $pool->reveal(), + null, + $translator->reveal(), + $container->reveal() + )); + $fieldDescription->getOption('editable')->willReturn(true); + $fieldDescription->getAdmin()->willReturn($this->admin->reveal()); + $fieldDescription->getType()->willReturn(null); + $fieldDescription->getTemplate()->willReturn('field_template'); + $fieldDescription->getValue(Argument::cetera())->willReturn('some value'); + $fieldDescription->getOption('data_transformer')->willReturn($dataTransformer); + + $this->validator->validate($object)->willReturn(new ConstraintViolationList([])); + + $response = ($this->action)($request); + + $this->assertTrue($object->getEnabled()); + $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); + } + + public function testSetObjectFieldOverrideTransformer(): void + { + $object = new Foo(); + $request = new Request([ + 'code' => 'sonata.post.admin', + 'objectId' => 42, + 'field' => 'enabled', + 'value' => 'yes', + 'context' => 'list', + ], [], [], [], [], ['REQUEST_METHOD' => Request::METHOD_POST, 'HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest']); + + $isOverridden = false; + $dataTransformer = new CallbackTransformer(static function ($value): string { + return (string) (int) $value; + }, static function ($value) use (&$isOverridden): bool { + $isOverridden = true; + + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + }); + + $fieldDescription = $this->prophesize(FieldDescriptionInterface::class); + $pool = $this->prophesize(Pool::class); + $translator = $this->prophesize(TranslatorInterface::class); + $propertyAccessor = new PropertyAccessor(); + $templateRegistry = $this->prophesize(TemplateRegistryInterface::class); + $container = $this->prophesize(ContainerInterface::class); + + $this->admin->getObject(42)->willReturn($object); + $this->admin->getCode()->willReturn('sonata.post.admin'); + $this->admin->hasAccess('edit', $object)->willReturn(true); + $this->admin->getListFieldDescription('enabled')->willReturn($fieldDescription->reveal()); + $this->admin->update($object)->shouldBeCalled(); + // NEXT_MAJOR: Remove this line + $this->admin->getTemplate('base_list_field')->willReturn('admin_template'); + $templateRegistry->getTemplate('base_list_field')->willReturn('admin_template'); + $container->get('sonata.post.admin.template_registry')->willReturn($templateRegistry->reveal()); + $this->pool->getPropertyAccessor()->willReturn($propertyAccessor); + $this->twig->addExtension(new SonataAdminExtension( + $pool->reveal(), + null, + $translator->reveal(), + $container->reveal() + )); + $fieldDescription->getOption('editable')->willReturn(true); + $fieldDescription->getAdmin()->willReturn($this->admin->reveal()); + $fieldDescription->getType()->willReturn('boolean'); + $fieldDescription->getTemplate()->willReturn('field_template'); + $fieldDescription->getValue(Argument::cetera())->willReturn('some value'); + $fieldDescription->getOption('data_transformer')->willReturn($dataTransformer); $this->validator->validate($object)->willReturn(new ConstraintViolationList([])); $response = ($this->action)($request); + $this->assertTrue($object->getEnabled()); + $this->assertTrue($isOverridden); $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); } } diff --git a/tests/Controller/HelperControllerTest.php b/tests/Controller/HelperControllerTest.php index 13f5b57df97..5ec5a75202c 100644 --- a/tests/Controller/HelperControllerTest.php +++ b/tests/Controller/HelperControllerTest.php @@ -23,6 +23,7 @@ use Sonata\AdminBundle\Controller\HelperController; use Sonata\AdminBundle\Datagrid\DatagridInterface; use Sonata\AdminBundle\Datagrid\Pager; +use Sonata\AdminBundle\Form\DataTransformerResolver; use Sonata\AdminBundle\Model\ModelManagerInterface; use Sonata\AdminBundle\Object\MetadataInterface; use Sonata\AdminBundle\Templating\TemplateRegistryInterface; @@ -83,6 +84,11 @@ class HelperControllerTest extends TestCase */ private $controller; + /** + * @var DataTransformerResolver + */ + private $resolver; + /** * {@inheritdoc} */ @@ -93,6 +99,7 @@ protected function setUp(): void $this->helper = $this->prophesize(AdminHelper::class); $this->validator = $this->prophesize(ValidatorInterface::class); $this->admin = $this->prophesize(AbstractAdmin::class); + $this->resolver = new DataTransformerResolver(); $this->pool->getInstance(Argument::any())->willReturn($this->admin->reveal()); $this->admin->setRequest(Argument::type(Request::class))->shouldBeCalled(); @@ -101,7 +108,8 @@ protected function setUp(): void $this->twig->reveal(), $this->pool->reveal(), $this->helper->reveal(), - $this->validator->reveal() + $this->validator->reveal(), + $this->resolver ); } @@ -200,6 +208,7 @@ public function testSetObjectFieldValueAction(): void $propertyAccessor = new PropertyAccessor(); $templateRegistry = $this->prophesize(TemplateRegistryInterface::class); $container = $this->prophesize(ContainerInterface::class); + $modelManager = $this->prophesize(ModelManagerInterface::class); $this->admin->getObject(42)->willReturn($object); $this->admin->getCode()->willReturn('sonata.post.admin'); @@ -208,6 +217,7 @@ public function testSetObjectFieldValueAction(): void $this->admin->update($object)->shouldBeCalled(); // NEXT_MAJOR: Remove this line $this->admin->getTemplate('base_list_field')->willReturn('admin_template'); + $this->admin->getModelManager()->willReturn($modelManager->reveal()); $templateRegistry->getTemplate('base_list_field')->willReturn('admin_template'); $container->get('sonata.post.admin.template_registry')->willReturn($templateRegistry->reveal()); $this->pool->getPropertyAccessor()->willReturn($propertyAccessor); @@ -221,6 +231,7 @@ public function testSetObjectFieldValueAction(): void $fieldDescription->getType()->willReturn('boolean'); $fieldDescription->getTemplate()->willReturn(false); $fieldDescription->getValue(Argument::cetera())->willReturn('some value'); + $fieldDescription->getOption('data_transformer')->willReturn(null); $this->validator->validate($object)->willReturn(new ConstraintViolationList([])); $response = $this->controller->setObjectFieldValueAction($request); @@ -274,6 +285,7 @@ public function testSetObjectFieldValueActionOnARelationField(): void $fieldDescription->getAdmin()->willReturn($this->admin->reveal()); $fieldDescription->getTemplate()->willReturn('field_template'); $fieldDescription->getValue(Argument::cetera())->willReturn('some value'); + $fieldDescription->getOption('data_transformer')->willReturn(null); $modelManager->find(\get_class($associationObject), 1)->willReturn($associationObject); $response = $this->controller->setObjectFieldValueAction($request); @@ -373,17 +385,20 @@ public function testSetObjectFieldValueActionWithViolations(): void $fieldDescription = $this->prophesize(FieldDescriptionInterface::class); $propertyAccessor = new PropertyAccessor(); + $modelManager = $this->prophesize(ModelManagerInterface::class); $this->pool->getPropertyAccessor()->willReturn($propertyAccessor); $this->admin->getObject(42)->willReturn($object); $this->admin->hasAccess('edit', $object)->willReturn(true); $this->admin->getListFieldDescription('bar.enabled')->willReturn($fieldDescription->reveal()); + $this->admin->getModelManager()->willReturn($modelManager->reveal()); $this->validator->validate($bar)->willReturn(new ConstraintViolationList([ new ConstraintViolation('error1', null, [], null, 'enabled', null), new ConstraintViolation('error2', null, [], null, 'enabled', null), ])); $fieldDescription->getOption('editable')->willReturn(true); $fieldDescription->getType()->willReturn('boolean'); + $fieldDescription->getOption('data_transformer')->willReturn(null); $response = $this->controller->setObjectFieldValueAction($request); diff --git a/tests/Form/DataTransformer/BooleanToStringTransformerTest.php b/tests/Form/DataTransformer/BooleanToStringTransformerTest.php new file mode 100644 index 00000000000..b806730f1ba --- /dev/null +++ b/tests/Form/DataTransformer/BooleanToStringTransformerTest.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\AdminBundle\Tests\Form\DataTransformer; + +use PHPUnit\Framework\TestCase; +use Sonata\AdminBundle\Form\DataTransformer\BooleanToStringTransformer; + +/** + * @author Peter Gribanov + */ +final class BooleanToStringTransformerTest extends TestCase +{ + public function provideTransform(): array + { + return [ + [null, null, '1'], + [false, null, '1'], + [true, '1', '1'], + [true, 'true', 'true'], + [true, 'yes', 'yes'], + [true, 'on', 'on'], + ]; + } + + /** + * @dataProvider provideTransform + */ + public function testTransform($value, ?string $expected, string $trueValue): void + { + $transformer = new BooleanToStringTransformer($trueValue); + + $this->assertSame($expected, $transformer->transform($value)); + } + + public function provideReverseTransform(): array + { + return [ + [null, false], + [true, true], + [1, true], + ['1', true], + ['true', true], + ['yes', true], + ['on', true], + [false, false], + [0, false], + ['0', false], + ['false', false], + ['no', false], + ['off', false], + ['', false], + // invalid values + ['foo', false], + [new \DateTime(), false], + [PHP_INT_MAX, false], + ]; + } + + /** + * @dataProvider provideReverseTransform + */ + public function testReverseTransform($value, bool $expected): void + { + $transformer = new BooleanToStringTransformer('1'); + + $this->assertSame($expected, $transformer->reverseTransform($value)); + } +} diff --git a/tests/Form/DataTransformerResolverTest.php b/tests/Form/DataTransformerResolverTest.php new file mode 100644 index 00000000000..c6f27bc0628 --- /dev/null +++ b/tests/Form/DataTransformerResolverTest.php @@ -0,0 +1,222 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\AdminBundle\Tests\Form; + +use PHPUnit\Framework\TestCase; +use Sonata\AdminBundle\Admin\FieldDescriptionInterface; +use Sonata\AdminBundle\Form\DataTransformer\BooleanToStringTransformer; +use Sonata\AdminBundle\Form\DataTransformer\ModelToIdTransformer; +use Sonata\AdminBundle\Form\DataTransformerResolver; +use Sonata\AdminBundle\Model\ModelManagerInterface; +use Symfony\Component\Form\CallbackTransformer; +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToStringTransformer; + +/** + * @author Peter Gribanov + */ +final class DataTransformerResolverTest extends TestCase +{ + /** + * @var DataTransformerResolver + */ + private $resolver; + + /** + * @var FieldDescriptionInterface + */ + private $fieldDescription; + + /** + * @var FieldDescriptionInterface + */ + private $modelManager; + + protected function setUp(): void + { + $this->fieldDescription = $this->prophesize(FieldDescriptionInterface::class); + $this->modelManager = $this->prophesize(ModelManagerInterface::class); + $this->resolver = new DataTransformerResolver(); + } + + public function testFailedResolve(): void + { + $this->assertNull($this->resolve()); + } + + public function provideFieldTypes(): array + { + return [ + ['foo'], + // override predefined transformers + ['date'], + ['boolean'], + ['choice'], + ]; + } + + /** + * @dataProvider provideFieldTypes + */ + public function testResolveCustomDataTransformer(string $fieldType): void + { + $customDataTransformer = new CallbackTransformer(static function ($value): string { + return (string) (int) $value; + }, static function ($value): bool { + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + }); + $this->fieldDescription->getOption('data_transformer')->willReturn($customDataTransformer); + $this->fieldDescription->getType()->willReturn($fieldType); + + $dataTransformer = $this->resolve(); + + $this->assertInstanceOf(DataTransformerInterface::class, $dataTransformer); + $this->assertSame($customDataTransformer, $dataTransformer); + } + + public function testResolveDateDataTransformer(): void + { + $this->fieldDescription->getOption('data_transformer')->willReturn(null); + $this->fieldDescription->getType()->willReturn('date'); + + $dataTransformer = $this->resolve(); + + $this->assertInstanceOf(DateTimeToStringTransformer::class, $dataTransformer); + + // test laze-load + $secondDataTransformer = $this->resolve(); + + $this->assertSame($dataTransformer, $secondDataTransformer); + } + + public function testResolveDateDatatimeTransformer(): void + { + $this->fieldDescription->getOption('data_transformer')->willReturn(null); + $this->fieldDescription->getType()->willReturn('datetime'); + + $dataTransformer = $this->resolve(); + + $this->assertInstanceOf(DateTimeToStringTransformer::class, $dataTransformer); + + // test laze-load + $secondDataTransformer = $this->resolve(); + + $this->assertSame($dataTransformer, $secondDataTransformer); + } + + public function testResolveBooleanDataTransformer(): void + { + $this->fieldDescription->getOption('data_transformer')->willReturn(null); + $this->fieldDescription->getType()->willReturn('boolean'); + + $dataTransformer = $this->resolve(); + + $this->assertInstanceOf(BooleanToStringTransformer::class, $dataTransformer); + $this->assertSame('1', $dataTransformer->transform(true)); + + // test laze-load + $secondDataTransformer = $this->resolve(); + + $this->assertSame($dataTransformer, $secondDataTransformer); + } + + public function testResolveChoiceWithoutClassName(): void + { + $this->fieldDescription->getOption('data_transformer')->willReturn(null); + $this->fieldDescription->getType()->willReturn('choice'); + $this->fieldDescription->getOption('class')->willReturn(null); + + $this->assertNull($this->resolve()); + } + + public function testResolveChoiceBadClassName(): void + { + $this->fieldDescription->getOption('data_transformer')->willReturn(null); + $this->fieldDescription->getType()->willReturn('choice'); + $this->fieldDescription->getOption('class')->willReturn(\stdClass::class); + $this->fieldDescription->getTargetEntity()->willReturn(\DateTime::class); + + $this->assertNull($this->resolve()); + } + + public function testResolveChoice(): void + { + $newId = 1; + $className = \stdClass::class; + $object = new \stdClass(); + + $this->fieldDescription->getOption('data_transformer')->willReturn(null); + $this->fieldDescription->getType()->willReturn('choice'); + $this->fieldDescription->getOption('class')->willReturn($className); + $this->fieldDescription->getTargetEntity()->willReturn($className); + + $this->modelManager->find($className, $newId)->willReturn($object); + + $dataTransformer = $this->resolve(); + + $this->assertInstanceOf(ModelToIdTransformer::class, $dataTransformer); + $this->assertSame($object, $dataTransformer->reverseTransform($newId)); + } + + /** + * @dataProvider provideFieldTypes + */ + public function testCustomGlobalTransformers(string $fieldType): void + { + $customDataTransformer = new CallbackTransformer(static function ($value): string { + return (string) (int) $value; + }, static function ($value): bool { + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + }); + + $this->fieldDescription->getOption('data_transformer')->willReturn(null); + $this->fieldDescription->getType()->willReturn($fieldType); + + $this->resolver = new DataTransformerResolver([ + $fieldType => $customDataTransformer, // override predefined transformer + ]); + + $dataTransformer = $this->resolve(); + + $this->assertInstanceOf(DataTransformerInterface::class, $dataTransformer); + $this->assertSame($customDataTransformer, $dataTransformer); + } + + /** + * @dataProvider provideFieldTypes + */ + public function testAddCustomGlobalTransformer(string $fieldType): void + { + $customDataTransformer = new CallbackTransformer(static function ($value): string { + return (string) (int) $value; + }, static function ($value): bool { + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + }); + + $this->fieldDescription->getOption('data_transformer')->willReturn(null); + $this->fieldDescription->getType()->willReturn($fieldType); + + $this->resolver->addCustomGlobalTransformer($fieldType, $customDataTransformer); + + $dataTransformer = $this->resolve(); + + $this->assertInstanceOf(DataTransformerInterface::class, $dataTransformer); + $this->assertSame($customDataTransformer, $dataTransformer); + } + + protected function resolve(): ?DataTransformerInterface + { + return $this->resolver->resolve($this->fieldDescription->reveal(), $this->modelManager->reveal()); + } +}