From 222750264c9b55592b8ab6aee3884eff07f18912 Mon Sep 17 00:00:00 2001 From: Peter Gribanov <wcode404@gmail.com> 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 Process deprecation of FieldDescriptionInterface::getTargetEntity() Use FieldDescriptionInterface::getTargetModel if exists #6208 change usage getTargetEntity() -> getTargetModel() in DataTransformerResolverTest merge changes from PR #6167 register BooleanToStringTransformer as a service --- docs/reference/action_list.rst | 109 ++++++++ src/Action/SetObjectFieldValueAction.php | 71 ++--- src/Controller/HelperController.php | 26 +- .../BooleanToStringTransformer.php | 45 ++++ src/Form/DataTransformerResolver.php | 126 +++++++++ src/Form/DataTransformerResolverInterface.php | 31 +++ src/Resources/config/actions.xml | 1 + src/Resources/config/core.xml | 1 + src/Resources/config/form_types.xml | 9 + tests/Action/Bar.php | 10 +- tests/Action/Baz.php | 2 +- tests/Action/Foo.php | 10 +- .../Action/SetObjectFieldValueActionTest.php | 153 ++++++++++- tests/Controller/HelperControllerTest.php | 17 +- .../BooleanToStringTransformerTest.php | 79 ++++++ tests/Form/DataTransformerResolverTest.php | 252 ++++++++++++++++++ 16 files changed, 893 insertions(+), 49 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 8a9d84d5882..7b9ddd723d1 100644 --- a/docs/reference/action_list.rst +++ b/docs/reference/action_list.rst @@ -159,6 +159,115 @@ Available types and associated options | ``TemplateRegistry::TYPE_*`` | | See :doc:`Field Types <field_types>` | +--------------------------------------+---------------------+-----------------------------------------------------------------------+ +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 e1b5ec4d45f..310fb0acdd0 100644 --- a/src/Action/SetObjectFieldValueAction.php +++ b/src/Action/SetObjectFieldValueAction.php @@ -13,9 +13,12 @@ namespace Sonata\AdminBundle\Action; -use Sonata\AdminBundle\Admin\FieldDescriptionInterface; use Sonata\AdminBundle\Admin\Pool; +use Sonata\AdminBundle\Form\DataTransformerResolver; +use Sonata\AdminBundle\Form\DataTransformerResolverInterface; +use Sonata\AdminBundle\Templating\TemplateRegistry; 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; @@ -41,7 +44,16 @@ 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)) { @@ -51,9 +63,22 @@ public function __construct(Environment $twig, Pool $pool, $validator) 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; } /** @@ -119,43 +144,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)) { - $inputTimezone = new \DateTimeZone(date_default_timezone_get()); - $outputTimezone = $fieldDescription->getOption('timezone'); + if ('' === $value) { + $this->pool->getPropertyAccessor()->setValue($object, $propertyPath, null); + } else { + $dataTransformer = $this->resolver->resolve($fieldDescription, $admin->getModelManager()); - if ($outputTimezone && !$outputTimezone instanceof \DateTimeZone) { - $outputTimezone = new \DateTimeZone($outputTimezone); + if ($dataTransformer instanceof DataTransformerInterface) { + $value = $dataTransformer->reverseTransform($value); } - $value = new \DateTime($value, $outputTimezone ?: $inputTimezone); - $value->setTimezone($inputTimezone); - } - - // Handle boolean type transforming the value into a boolean - if ('' !== $value && 'boolean' === $fieldDescription->getType()) { - $value = filter_var($value, FILTER_VALIDATE_BOOLEAN); - } - - // Handle entity choice association type, transforming the value into entity - if ('' !== $value - && 'choice' === $fieldDescription->getType() - && null !== $fieldDescription->getOption('class') - // NEXT_MAJOR: Replace this call with "$fieldDescription->getOption('class') === $fieldDescription->getTargetModel()". - && $this->hasFieldDescriptionAssociationWithClass($fieldDescription, $fieldDescription->getOption('class')) - ) { - $value = $admin->getModelManager()->find($fieldDescription->getOption('class'), $value); - - if (!$value) { + if (!$value && TemplateRegistry::TYPE_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); @@ -180,10 +187,4 @@ public function __invoke(Request $request): JsonResponse return new JsonResponse($content, Response::HTTP_OK); } - - private function hasFieldDescriptionAssociationWithClass(FieldDescriptionInterface $fieldDescription, string $class): bool - { - return (method_exists($fieldDescription, 'getTargetModel') && $class === $fieldDescription->getTargetModel()) - || $class === $fieldDescription->getTargetEntity(); - } } diff --git a/src/Controller/HelperController.php b/src/Controller/HelperController.php index ef18b130a7a..3ca1df524d5 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,9 +66,15 @@ 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)) { @@ -77,10 +85,22 @@ public function __construct(Environment $twig, Pool $pool, AdminHelper $helper, )); } + // 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; } /** @@ -124,7 +144,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 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the Sonata Project package. + * + * (c) Thomas Rabaix <thomas.rabaix@sonata-project.org> + * + * 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 <info@peter-gribanov.ru> + */ +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..3aeaaafad74 --- /dev/null +++ b/src/Form/DataTransformerResolver.php @@ -0,0 +1,126 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the Sonata Project package. + * + * (c) Thomas Rabaix <thomas.rabaix@sonata-project.org> + * + * 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\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 <info@peter-gribanov.ru> + */ +final class DataTransformerResolver implements DataTransformerResolverInterface +{ + /** + * @var array<string, DataTransformerInterface> + */ + private $globalCustomTransformers = []; + + /** + * @param array<string, DataTransformerInterface> $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, + $this->getOutputTimezone($fieldDescription), + '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, + $this->getOutputTimezone($fieldDescription), + 'Y-m-d H:i:s' + ); + + 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 && + // NEXT_MAJOR: Replace this call with "$className === $fieldDescription->getTargetModel()". + $this->hasFieldDescriptionAssociationWithClass($fieldDescription, $className) + ) { + return new ModelToIdTransformer($modelManager, $className); + } + } + + return null; + } + + private function getOutputTimezone(FieldDescriptionInterface $fieldDescription): ?string + { + $outputTimezone = $fieldDescription->getOption('timezone'); + + if (!$outputTimezone) { + return null; + } + + if ($outputTimezone instanceof \DateTimeZone) { + return $outputTimezone->getName(); + } + + return $outputTimezone; + } + + private function hasFieldDescriptionAssociationWithClass( + FieldDescriptionInterface $fieldDescription, + string $class + ): bool { + return ( + method_exists($fieldDescription, 'getTargetModel') && $class === $fieldDescription->getTargetModel() + ) || $class === $fieldDescription->getTargetEntity(); + } +} 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 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the Sonata Project package. + * + * (c) Thomas Rabaix <thomas.rabaix@sonata-project.org> + * + * 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 <info@peter-gribanov.ru> + */ +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 @@ <argument type="service" id="twig"/> <argument type="service" id="sonata.admin.pool"/> <argument type="service" id="validator"/> + <argument type="service" id="sonata.admin.form.data_transformer_resolver"/> </service> <service id="sonata.admin.action.retrieve_autocomplete_items" class="Sonata\AdminBundle\Action\RetrieveAutocompleteItemsAction" public="true"> <argument type="service" id="sonata.admin.pool"/> diff --git a/src/Resources/config/core.xml b/src/Resources/config/core.xml index 719c3369a1a..7f40c580a14 100644 --- a/src/Resources/config/core.xml +++ b/src/Resources/config/core.xml @@ -69,6 +69,7 @@ <argument type="service" id="sonata.admin.pool"/> <argument type="service" id="sonata.admin.helper"/> <argument type="service" id="validator"/> + <argument type="service" id="sonata.admin.form.data_transformer_resolver"/> </service> <!-- audit manager --> <service id="sonata.admin.audit.manager" class="Sonata\AdminBundle\Model\AuditManager" public="true"> diff --git a/src/Resources/config/form_types.xml b/src/Resources/config/form_types.xml index fd0e97ee032..1fcc8aa22b6 100644 --- a/src/Resources/config/form_types.xml +++ b/src/Resources/config/form_types.xml @@ -68,5 +68,14 @@ <tag name="form.type" alias="sonata_type_filter_datetime_range"/> <argument type="service" id="translator"/> </service> + <service id="sonata.admin.form.data_transformer.boolean_to_string" class="Sonata\AdminBundle\Form\DataTransformer\BooleanToStringTransformer" public="false"> + <argument>1</argument> + </service> + <service id="sonata.admin.form.data_transformer_resolver" class="Sonata\AdminBundle\Form\DataTransformerResolver" public="false"> + <call method="addCustomGlobalTransformer"> + <argument>boolean</argument> + <argument type="service" id="sonata.admin.form.data_transformer.boolean_to_string"/> + </call> + </service> </services> </container> 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 8ac23867994..28d3f263f8a 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; @@ -47,7 +49,7 @@ final class SetObjectFieldValueActionTest extends TestCase private $twig; /** - * @var GetShortObjectDescriptionAction + * @var SetObjectFieldValueAction */ private $action; @@ -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([])); @@ -184,11 +201,14 @@ public function testSetObjectFieldValueActionWithDate($timezone, \DateTimeZone $ $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()); $defaultTimezone = new \DateTimeZone(date_default_timezone_get()); @@ -243,11 +263,14 @@ public function testSetObjectFieldValueActionWithDateTime($timezone, \DateTimeZo $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()); $defaultTimezone = new \DateTimeZone(date_default_timezone_get()); @@ -272,7 +295,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); @@ -288,7 +310,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, @@ -303,12 +324,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()); } @@ -338,6 +361,7 @@ public function testSetObjectFieldValueActionWithViolations(): void ])); $fieldDescription->getOption('editable')->willReturn(true); $fieldDescription->getType()->willReturn('boolean'); + $fieldDescription->getOption('data_transformer')->willReturn(null); $response = ($this->action)($request); @@ -358,7 +382,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); @@ -383,14 +406,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 d00c502188a..fec54965ae1 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 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the Sonata Project package. + * + * (c) Thomas Rabaix <thomas.rabaix@sonata-project.org> + * + * 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 <info@peter-gribanov.ru> + */ +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..61e3cc7bcb4 --- /dev/null +++ b/tests/Form/DataTransformerResolverTest.php @@ -0,0 +1,252 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the Sonata Project package. + * + * (c) Thomas Rabaix <thomas.rabaix@sonata-project.org> + * + * 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 <info@peter-gribanov.ru> + */ +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 getTimeZones(): iterable + { + $default = new \DateTimeZone(date_default_timezone_get()); + $custom = new \DateTimeZone('Europe/Rome'); + + return [ + 'empty timezone' => [null, $default], + 'disabled timezone' => [false, $default], + 'default timezone by name' => [$default->getName(), $default], + 'default timezone by object' => [$default, $default], + 'custom timezone by name' => [$custom->getName(), $custom], + 'custom timezone by object' => [$custom, $custom], + ]; + } + + /** + * @dataProvider getTimeZones + */ + public function testResolveDateDataTransformer($timezone, \DateTimeZone $expectedTimezone): void + { + $this->fieldDescription->getOption('data_transformer')->willReturn(null); + $this->fieldDescription->getOption('timezone')->willReturn($timezone); + $this->fieldDescription->getType()->willReturn('date'); + + $dataTransformer = $this->resolve(); + + $this->assertInstanceOf(DateTimeToStringTransformer::class, $dataTransformer); + + $value = '2020-12-12'; + $defaultTimezone = new \DateTimeZone(date_default_timezone_get()); + $expectedDate = new \DateTime($value, $expectedTimezone); + $expectedDate->setTimezone($defaultTimezone); + + $resultDate = $dataTransformer->reverseTransform($value); + + $this->assertInstanceOf(\DateTime::class, $resultDate); + $this->assertSame($expectedDate->format('Y-m-d'), $resultDate->format('Y-m-d')); + $this->assertSame($defaultTimezone->getName(), $resultDate->getTimezone()->getName()); + + // test laze-load + $secondDataTransformer = $this->resolve(); + + $this->assertSame($dataTransformer, $secondDataTransformer); + } + + /** + * @dataProvider getTimeZones + */ + public function testResolveDateDatatimeTransformer($timezone, \DateTimeZone $expectedTimezone): void + { + $this->fieldDescription->getOption('data_transformer')->willReturn(null); + $this->fieldDescription->getOption('timezone')->willReturn($timezone); + $this->fieldDescription->getType()->willReturn('datetime'); + + $dataTransformer = $this->resolve(); + + $this->assertInstanceOf(DateTimeToStringTransformer::class, $dataTransformer); + + $value = '2020-12-12 23:11:23'; + $defaultTimezone = new \DateTimeZone(date_default_timezone_get()); + $expectedDate = new \DateTime($value, $expectedTimezone); + $expectedDate->setTimezone($defaultTimezone); + + $resultDate = $dataTransformer->reverseTransform($value); + + $this->assertInstanceOf(\DateTime::class, $resultDate); + $this->assertSame($expectedDate->format('Y-m-d'), $resultDate->format('Y-m-d')); + $this->assertSame($defaultTimezone->getName(), $resultDate->getTimezone()->getName()); + + // 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->getTargetModel()->willReturn(\DateTime::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->getTargetModel()->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()); + } +}