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 ` | +--------------------------------------+---------------------+-----------------------------------------------------------------------+ +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 @@ + + * + * 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..3aeaaafad74 --- /dev/null +++ b/src/Form/DataTransformerResolver.php @@ -0,0 +1,126 @@ + + * + * 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 + */ +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, + $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 @@ + + * + * 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 719c3369a1a..7f40c580a14 100644 --- a/src/Resources/config/core.xml +++ b/src/Resources/config/core.xml @@ -69,6 +69,7 @@ + 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 @@ + + 1 + + + + boolean + + + 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 @@ + + * + * 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..61e3cc7bcb4 --- /dev/null +++ b/tests/Form/DataTransformerResolverTest.php @@ -0,0 +1,252 @@ + + * + * 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 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()); + } +}