From b8b6ab60df876e156641eeee81d4c492529aafdb 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 Co-Authored-By: Javier Spagnoletti change "entity" word to "model" in documentations change deprecated error message add datetime in editable date form types --- docs/reference/action_list.rst | 109 +++++++++++ src/Action/SetObjectFieldValueAction.php | 59 +++--- src/Controller/HelperController.php | 29 ++- .../BooleanToStringTransformer.php | 45 +++++ src/Form/DataTransformerResolver.php | 87 +++++++++ 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 | 145 ++++++++++++++- tests/Controller/HelperControllerTest.php | 17 +- .../BooleanToStringTransformerTest.php | 79 ++++++++ tests/Form/DataTransformerResolverTest.php | 170 ++++++++++++++++++ 15 files changed, 729 insertions(+), 36 deletions(-) create mode 100644 src/Form/DataTransformer/BooleanToStringTransformer.php create mode 100644 src/Form/DataTransformerResolver.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 db547eeebd6..b50a3371c25 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): Permission + { + // 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 ($value === null || $value === '') { + return null; + } + + if ($value instanceof ModerationStatus) { + return $value; + } + + try { + return ModerationStatus::byValue($value); + } catch (\Exception $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 37cae1f92c8..5c0d0d5082e 100644 --- a/src/Action/SetObjectFieldValueAction.php +++ b/src/Action/SetObjectFieldValueAction.php @@ -14,7 +14,9 @@ namespace Sonata\AdminBundle\Action; use Sonata\AdminBundle\Admin\Pool; +use Sonata\AdminBundle\Form\DataTransformerResolver; 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 +42,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 DataTransformerResolver)) { + @trigger_error(sprintf( + 'Passing other type than %s in argument 4 to %s() is deprecated since sonta-project/admin-bundle 3.x and will throw %s exception in 4.0.', + DataTransformerResolver::class, + __METHOD__, + \TypeError::class + ), E_USER_DEPRECATED); + $resolver = new DataTransformerResolver(); + } + $this->pool = $pool; $this->twig = $twig; $this->validator = $validator; + $this->resolver = $resolver; } /** @@ -113,34 +137,25 @@ public function __invoke(Request $request): JsonResponse $propertyPath = new PropertyPath($field); } - // Handle date type has setter expect a DateTime object - if ('' !== $value && 'date' === $fieldDescription->getType()) { - $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..9082b738236 100644 --- a/src/Controller/HelperController.php +++ b/src/Controller/HelperController.php @@ -20,6 +20,7 @@ use Sonata\AdminBundle\Action\SetObjectFieldValueAction; use Sonata\AdminBundle\Admin\AdminHelper; use Sonata\AdminBundle\Admin\Pool; +use Sonata\AdminBundle\Form\DataTransformerResolver; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -64,22 +65,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 DataTransformerResolver)) { + @trigger_error(sprintf( + 'Passing other type than %s in argument 4 to %s() is deprecated since sonta-project/admin-bundle 3.x and will throw %s exception in 4.0.', + DataTransformerResolver::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 +142,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..bc2d4ebf8bd --- /dev/null +++ b/src/Form/DataTransformerResolver.php @@ -0,0 +1,87 @@ + + * + * 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 Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToStringTransformer; + +/** + * @author Peter Gribanov + */ +final class DataTransformerResolver +{ + /** + * @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 and datetime types have setter expecting a DateTime object + if (\in_array($fieldDescription->getType(), ['date', 'datetime'], true)) { + return new DateTimeToStringTransformer(); + } + + // Handle boolean type transforming the value into a boolean + if ('boolean' === $fieldType) { + return new BooleanToStringTransformer('1'); + } + + // Handle entity choice association type, transforming the value into entity + if ('choice' === $fieldType) { + $className = $fieldDescription->getOption('class'); + + if (null !== $className && $className === $fieldDescription->getTargetEntity()) { + return new ModelToIdTransformer($modelManager, $className); + } + } + + return null; + } +} 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 c22f4746774..2df7ec92440 100644 --- a/src/Resources/config/form_types.xml +++ b/src/Resources/config/form_types.xml @@ -67,5 +67,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 7a37862b997..8466c0a48e6 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,11 +134,13 @@ 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->action)($request); + $this->assertTrue($object->getEnabled()); $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); } @@ -139,7 +157,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); @@ -155,7 +172,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, @@ -170,12 +186,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()); } @@ -205,6 +223,7 @@ public function testSetObjectFieldValueActionWithViolations(): void ])); $fieldDescription->getOption('editable')->willReturn(true); $fieldDescription->getType()->willReturn('boolean'); + $fieldDescription->getOption('data_transformer')->willReturn(null); $response = ($this->action)($request); @@ -250,14 +269,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(false); $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']); + + $data_transformer = new CallbackTransformer(static function ($value) { + return (string) (int) $value; + }, static function ($value) { + 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(false); + $fieldDescription->getValue(Argument::cetera())->willReturn('some value'); + $fieldDescription->getOption('data_transformer')->willReturn($data_transformer); + + $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(false); + $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 ec4b1b4e751..b0adfb9bb63 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..b49e3fa6f00 --- /dev/null +++ b/tests/Form/DataTransformerResolverTest.php @@ -0,0 +1,170 @@ + + * + * 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->resolver->resolve($this->fieldDescription->reveal(), $this->modelManager->reveal())); + } + + 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) { + return (string) (int) $value; + }, static function ($value) { + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + }); + $this->fieldDescription->getOption('data_transformer')->willReturn($customDataTransformer); + $this->fieldDescription->getType()->willReturn($fieldType); + + $dataTransformer = $this->resolver->resolve($this->fieldDescription->reveal(), $this->modelManager->reveal()); + + $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->resolver->resolve($this->fieldDescription->reveal(), $this->modelManager->reveal()); + + $this->assertInstanceOf(DateTimeToStringTransformer::class, $dataTransformer); + } + + public function testResolveBooleanDataTransformer(): void + { + $this->fieldDescription->getOption('data_transformer')->willReturn(null); + $this->fieldDescription->getType()->willReturn('boolean'); + + $dataTransformer = $this->resolver->resolve($this->fieldDescription->reveal(), $this->modelManager->reveal()); + + $this->assertInstanceOf(BooleanToStringTransformer::class, $dataTransformer); + $this->assertSame('1', $dataTransformer->transform(true)); + } + + 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->resolver->resolve($this->fieldDescription->reveal(), $this->modelManager->reveal())); + } + + 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->resolver->resolve($this->fieldDescription->reveal(), $this->modelManager->reveal())); + } + + 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->resolver->resolve($this->fieldDescription->reveal(), $this->modelManager->reveal()); + + $this->assertInstanceOf(ModelToIdTransformer::class, $dataTransformer); + $this->assertSame($object, $dataTransformer->reverseTransform($newId)); + } + + /** + * @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 = new DataTransformerResolver([ + $fieldType => $customDataTransformer, // override predefined transformer + ]); + + $dataTransformer = $this->resolver->resolve($this->fieldDescription->reveal(), $this->modelManager->reveal()); + + $this->assertInstanceOf(DataTransformerInterface::class, $dataTransformer); + $this->assertSame($customDataTransformer, $dataTransformer); + } +}