diff --git a/src/Action/SetObjectFieldValueAction.php b/src/Action/SetObjectFieldValueAction.php index 5337b9da43..e9dc54117c 100644 --- a/src/Action/SetObjectFieldValueAction.php +++ b/src/Action/SetObjectFieldValueAction.php @@ -137,7 +137,7 @@ public function __invoke(Request $request): JsonResponse $value = $dataTransformer->reverseTransform($value); } - if (null === $value && FieldDescriptionInterface::TYPE_CHOICE === $fieldDescription->getType()) { + if (null === $value && \in_array($fieldDescription->getType(), [FieldDescriptionInterface::TYPE_CHOICE, FieldDescriptionInterface::TYPE_ENUM], true)) { return new JsonResponse(\sprintf( 'Edit failed, object with id "%s" not found in association "%s".', $objectId, diff --git a/src/Form/DataTransformer/BackedEnumTransformer.php b/src/Form/DataTransformer/BackedEnumTransformer.php new file mode 100644 index 0000000000..f954bdc9a5 --- /dev/null +++ b/src/Form/DataTransformer/BackedEnumTransformer.php @@ -0,0 +1,73 @@ + + * + * 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; +use Symfony\Component\Form\Exception\TransformationFailedException; +use Symfony\Component\Form\Exception\UnexpectedTypeException; + +/** + * @phpstan-template T of \BackedEnum + * @phpstan-implements DataTransformerInterface + */ +final class BackedEnumTransformer implements DataTransformerInterface +{ + /** + * @phpstan-param class-string $className + */ + public function __construct( + private string $className, + ) { + } + + /** + * @param int|string|null $value + * + * @phpstan-return T|null + */ + public function reverseTransform($value): ?\BackedEnum + { + if (null === $value || '' === $value) { + return null; + } + + if (!\is_int($value) && !\is_string($value)) { + throw new TransformationFailedException(\sprintf('Could not transform value: expecting an int or string, got "%s".', get_debug_type($value))); + } + + try { + return $this->className::from($value); + } catch (\ValueError|\TypeError) { + throw new TransformationFailedException(\sprintf('Could not transform value "%s".', $value)); + } + } + + /** + * @param \BackedEnum|null $value + * + * @phpstan-param T|null $value + */ + public function transform($value): string|int|null + { + if (null === $value) { + return null; + } + + if (!$value instanceof \BackedEnum) { + throw new UnexpectedTypeException($value, \BackedEnum::class); + } + + return $value->value; + } +} diff --git a/src/Form/DataTransformerResolver.php b/src/Form/DataTransformerResolver.php index d5ef693547..42ce66ea07 100644 --- a/src/Form/DataTransformerResolver.php +++ b/src/Form/DataTransformerResolver.php @@ -14,6 +14,7 @@ namespace Sonata\AdminBundle\Form; use Sonata\AdminBundle\FieldDescription\FieldDescriptionInterface; +use Sonata\AdminBundle\Form\DataTransformer\BackedEnumTransformer; use Sonata\AdminBundle\Form\DataTransformer\ModelToIdTransformer; use Sonata\AdminBundle\Model\ModelManagerInterface; use Symfony\Component\Form\DataTransformerInterface; @@ -75,6 +76,18 @@ public function resolve( return $this->globalCustomTransformers[$fieldType]; } + if (FieldDescriptionInterface::TYPE_ENUM === $fieldType) { + $className = $fieldDescription->getOption('class'); + + if ( + null !== $className + && \is_string($className) + && is_a($className, \BackedEnum::class, true) + ) { + return new BackedEnumTransformer($className); + } + } + // Handle entity choice association type, transforming the value into entity if (FieldDescriptionInterface::TYPE_CHOICE === $fieldType) { $targetModel = $fieldDescription->getTargetModel(); diff --git a/src/Resources/views/CRUD/base_list_field.html.twig b/src/Resources/views/CRUD/base_list_field.html.twig index 66a443b4ae..41643f2f45 100644 --- a/src/Resources/views/CRUD/base_list_field.html.twig +++ b/src/Resources/views/CRUD/base_list_field.html.twig @@ -61,6 +61,8 @@ file that was distributed with this source code. {% set data_value = value|date('Y-m-d', options.timezone|default(null)) %} {% elseif field_description.type == constant('Sonata\\AdminBundle\\FieldDescription\\FieldDescriptionInterface::TYPE_BOOLEAN') and value is empty %} {% set data_value = 0 %} + {% elseif field_description.type == constant('Sonata\\AdminBundle\\FieldDescription\\FieldDescriptionInterface::TYPE_ENUM') and value is not empty %} + {% set data_value = value.value %} {% elseif value is iterable %} {% set data_value = value|json_encode %} {% else %} diff --git a/src/Resources/views/CRUD/list_enum.html.twig b/src/Resources/views/CRUD/list_enum.html.twig index 55ea346149..a46f28f2cc 100644 --- a/src/Resources/views/CRUD/list_enum.html.twig +++ b/src/Resources/views/CRUD/list_enum.html.twig @@ -11,6 +11,21 @@ file that was distributed with this source code. {% extends get_admin_template('base_list_field', admin.code) %} +{% set is_editable = + field_description.option('editable', false) and + admin.hasAccess('edit', object) +%} +{% set x_editable_type = field_description.type|sonata_xeditable_type %} + +{% block field_span_attributes %} + {% if is_editable and x_editable_type %} + {% apply spaceless %} + {{ parent() }} + data-source="{{ field_description|sonata_xeditable_choices|json_encode }}" + {% endapply %} + {% endif %} +{% endblock %} + {% block field %} {%- include '@SonataAdmin/CRUD/display_enum.html.twig' with { value: value, diff --git a/src/Twig/XEditableRuntime.php b/src/Twig/XEditableRuntime.php index 1cf35e7d06..9ed13aab66 100644 --- a/src/Twig/XEditableRuntime.php +++ b/src/Twig/XEditableRuntime.php @@ -20,6 +20,7 @@ final class XEditableRuntime implements RuntimeExtensionInterface { public const FIELD_DESCRIPTION_MAPPING = [ + FieldDescriptionInterface::TYPE_ENUM => 'select', FieldDescriptionInterface::TYPE_CHOICE => 'select', FieldDescriptionInterface::TYPE_BOOLEAN => 'select', FieldDescriptionInterface::TYPE_TEXTAREA => 'textarea', @@ -83,6 +84,11 @@ public function getXEditableChoices(FieldDescriptionInterface $fieldDescription) break; } + if ($text instanceof \BackedEnum) { + $value = $text->value; + $text = $text->name; + } + if (\is_string($catalogue)) { $text = $this->translator->trans($text, [], $catalogue); } diff --git a/tests/Form/DataTransformer/BackedEnumTransformerTest.php b/tests/Form/DataTransformer/BackedEnumTransformerTest.php new file mode 100644 index 0000000000..efb71b84a6 --- /dev/null +++ b/tests/Form/DataTransformer/BackedEnumTransformerTest.php @@ -0,0 +1,78 @@ + + * + * 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\BackedEnumTransformer; +use Sonata\AdminBundle\Tests\Fixtures\Enum\Suit; +use Symfony\Component\Form\Exception\TransformationFailedException; +use Symfony\Component\Form\Exception\UnexpectedTypeException; + +/** + * @requires PHP 8.1 + */ +final class BackedEnumTransformerTest extends TestCase +{ + public function testReverseTransform(): void + { + $transformer = new BackedEnumTransformer(Suit::class); + + static::assertNull($transformer->reverseTransform(null)); + static::assertNull($transformer->reverseTransform('')); + static::assertSame(Suit::Hearts, $transformer->reverseTransform(Suit::Hearts->value)); + } + + public function testReverseTransformNotValidValue(): void + { + $this->expectException(TransformationFailedException::class); + $this->expectExceptionMessage('Could not transform value "not_valid_value".'); + + $transformer = new BackedEnumTransformer(Suit::class); + $transformer->reverseTransform('not_valid_value'); + } + + /** + * @psalm-suppress InvalidArgument + */ + public function testReverseTransformNotScalar(): void + { + $this->expectException(TransformationFailedException::class); + $this->expectExceptionMessage('Could not transform value: expecting an int or string, got "stdClass".'); + + $transformer = new BackedEnumTransformer(Suit::class); + // @phpstan-ignore-next-line + $transformer->reverseTransform(new \stdClass()); + } + + public function testTransform(): void + { + $transformer = new BackedEnumTransformer(Suit::class); + + static::assertNull($transformer->transform(null)); + static::assertSame(Suit::Clubs->value, $transformer->transform(Suit::Clubs)); + } + + /** + * @psalm-suppress InvalidArgument + */ + public function testTransformUnexpectedType(): void + { + $this->expectException(UnexpectedTypeException::class); + $this->expectExceptionMessage('Expected argument of type "BackedEnum", "stdClass" given'); + + $transformer = new BackedEnumTransformer(Suit::class); + // @phpstan-ignore-next-line + $transformer->transform(new \stdClass()); + } +} diff --git a/tests/Twig/XEditableRuntimeTest.php b/tests/Twig/XEditableRuntimeTest.php index 21562bf729..e1665c2a60 100644 --- a/tests/Twig/XEditableRuntimeTest.php +++ b/tests/Twig/XEditableRuntimeTest.php @@ -15,6 +15,7 @@ use PHPUnit\Framework\TestCase; use Sonata\AdminBundle\FieldDescription\FieldDescriptionInterface; +use Sonata\AdminBundle\Tests\Fixtures\Enum\Suit; use Sonata\AdminBundle\Twig\XEditableRuntime; use Symfony\Component\Translation\Translator; @@ -91,5 +92,20 @@ public function provideGetXEditableChoicesIsIdempotentCases(): iterable ['value' => 'Status2', 'text' => 'Alias2'], ], ]; + + // TODO: Remove the "if" check when dropping support of PHP < 8.1 and add the case to the list + if (\PHP_VERSION_ID >= 80100) { + yield 'enum cases' => [ + [ + 'required' => false, + 'multiple' => false, + 'choices' => [Suit::Hearts, Suit::Clubs], + ], + [ + ['value' => 'H', 'text' => 'Hearts'], + ['value' => 'C', 'text' => 'Clubs'], + ], + ]; + } } }