Skip to content

Commit

Permalink
Data Transformers in list fields editable fix #5693 (#5937)
Browse files Browse the repository at this point in the history
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/Form/DataTransformer/BooleanToStringTransformerTest.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

merge changes from PR #6144

merge changes from PR #6284

compare date with time in DataTransformerResolverTest
  • Loading branch information
peter-gribanov committed Sep 13, 2020
1 parent 98677e1 commit c4d0026
Show file tree
Hide file tree
Showing 16 changed files with 849 additions and 50 deletions.
109 changes: 109 additions & 0 deletions docs/reference/action_list.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----------------------------------------------

Expand Down
70 changes: 35 additions & 35 deletions src/Action/SetObjectFieldValueAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +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;
Expand All @@ -42,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)) {
Expand All @@ -52,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;
}

/**
Expand Down Expand Up @@ -120,43 +144,25 @@ public function __invoke(Request $request): JsonResponse
$propertyPath = new PropertyPath($field);
}

// Handle date type has setter expect a DateTime object
if ('' !== $value && TemplateRegistry::TYPE_DATE === $fieldDescription->getType()) {
$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 && TemplateRegistry::TYPE_BOOLEAN === $fieldDescription->getType()) {
$value = filter_var($value, FILTER_VALIDATE_BOOLEAN);
}

// Handle entity choice association type, transforming the value into entity
if ('' !== $value
&& TemplateRegistry::TYPE_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);

Expand All @@ -181,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();
}
}
26 changes: 23 additions & 3 deletions src/Controller/HelperController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)) {
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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);
}
Expand Down
45 changes: 45 additions & 0 deletions src/Form/DataTransformer/BooleanToStringTransformer.php
Original file line number Diff line number Diff line change
@@ -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);
}
}

0 comments on commit c4d0026

Please sign in to comment.