diff --git a/UPGRADE-3.x.md b/UPGRADE-3.x.md index 2c45675705..38db790d5d 100644 --- a/UPGRADE-3.x.md +++ b/UPGRADE-3.x.md @@ -1,6 +1,32 @@ UPGRADE 3.x =========== +UPGRADE FROM 3.xx to 3.xx +========================= + +### Sonata\AdminBundle\Twig\Extension\SonataAdminExtension + +- Deprecated `SonataAdminExtension::MOMENT_UNSUPPORTED_LOCALES` constant. +- Deprecated `SonataAdminExtension::setXEditableTypeMapping()` method. +- Deprecated `SonataAdminExtension::getXEditableType()` method. +- Deprecated `SonataAdminExtension::getXEditableChoices()` method. +- Deprecated `SonataAdminExtension::getCanonicalizedLocaleForMoment()` method in favor of + `CanonicalizerExtension::getCanonicalizedLocaleForMoment()`. +- Deprecated `SonataAdminExtension::getCanonicalizedLocaleForSelect2()` method in favor of + `CanonicalizerExtension::getCanonicalizedLocaleForSelect2()`. +- Deprecated `SonataAdminExtension::isGrantedAffirmative()` method in favor of + `SecurityExtension::isGrantedAffirmative()`. +- Deprecated `SonataAdminExtension::renderListElement()` method in favor of + `RenderElementExtension::renderListElement()`. +- Deprecated `SonataAdminExtension::renderViewElement()` method in favor of + `RenderElementExtension::renderViewElement()`. +- Deprecated `SonataAdminExtension::renderViewElementCompare()` method in favor of + `RenderElementExtension::renderViewElementCompare()`. +- Deprecated `SonataAdminExtension::renderRelationElement()` method in favor of + `RenderElementExtension::renderRelationElement()`. +- Deprecated `SonataAdminExtension::getTemplate()` method. +- Deprecated `SonataAdminExtension::getTemplateRegistry()` method. + UPGRADE FROM 3.85 to 3.86 ========================= diff --git a/src/Action/SetObjectFieldValueAction.php b/src/Action/SetObjectFieldValueAction.php index 39fa070f62..12db30ac35 100644 --- a/src/Action/SetObjectFieldValueAction.php +++ b/src/Action/SetObjectFieldValueAction.php @@ -206,10 +206,12 @@ public function __invoke(Request $request): JsonResponse // render the widget // todo : fix this, the twig environment variable is not set inside the extension ... + // NEXT_MAJOR: Modify lines below to use RenderElementExtension instead of SonataAdminExtension $extension = $this->twig->getExtension(SonataAdminExtension::class); \assert($extension instanceof SonataAdminExtension); - $content = $extension->renderListElement($this->twig, $rootObject, $fieldDescription); + // NEXT_MAJOR: Remove the last two arguments + $content = $extension->renderListElement($this->twig, $rootObject, $fieldDescription, [], 'sonata_deprecation_mute'); return new JsonResponse($content, Response::HTTP_OK); } diff --git a/src/Resources/config/twig.php b/src/Resources/config/twig.php index cf51aaf84a..a4d29b2890 100644 --- a/src/Resources/config/twig.php +++ b/src/Resources/config/twig.php @@ -11,10 +11,14 @@ * file that was distributed with this source code. */ +use Sonata\AdminBundle\Twig\Extension\CanonicalizeExtension; use Sonata\AdminBundle\Twig\Extension\GroupExtension; use Sonata\AdminBundle\Twig\Extension\PaginationExtension; +use Sonata\AdminBundle\Twig\Extension\RenderElementExtension; +use Sonata\AdminBundle\Twig\Extension\SecurityExtension; use Sonata\AdminBundle\Twig\Extension\SonataAdminExtension; use Sonata\AdminBundle\Twig\Extension\TemplateRegistryExtension; +use Sonata\AdminBundle\Twig\Extension\XEditableExtension; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\DependencyInjection\Loader\Configurator\ReferenceConfigurator; @@ -49,14 +53,19 @@ ->tag('twig.extension') ->args([ new ReferenceConfigurator('sonata.admin.pool'), + // NEXT_MAJOR: Remove next line. (new ReferenceConfigurator('logger'))->nullOnInvalid(), + // NEXT_MAJOR: Remove next line. new ReferenceConfigurator('translator'), new ReferenceConfigurator('service_container'), new ReferenceConfigurator('property_accessor'), + // NEXT_MAJOR: Remove next line. new ReferenceConfigurator('security.authorization_checker'), ]) + // NEXT_MAJOR: Remove next call. ->call('setXEditableTypeMapping', [ '%sonata.admin.twig.extension.x_editable_type_mapping%', + 'sonata_deprecation_mute', ]) ->set('sonata.templates.twig.extension', TemplateRegistryExtension::class) @@ -75,5 +84,32 @@ // NEXT_MAJOR: Remove this service. ->set('sonata.pagination.twig.extension', PaginationExtension::class) ->tag('twig.extension') + + ->set('sonata.security.twig.extension', SecurityExtension::class) + ->tag('twig.extension') + ->args([ + new ReferenceConfigurator('security.authorization_checker'), + ]) + + ->set('sonata.canonicalize.twig.extension', CanonicalizeExtension::class) + ->tag('twig.extension') + ->args([ + new ReferenceConfigurator('request_stack'), + ]) + + ->set('sonata.xeditable.twig.extension', XEditableExtension::class) + ->tag('twig.extension') + ->args([ + new ReferenceConfigurator('translator'), + '%sonata.admin.twig.extension.x_editable_type_mapping%', + ]) + + ->set('sonata.render_element.twig.extension', RenderElementExtension::class) + ->tag('twig.extension') + ->args([ + new ReferenceConfigurator('property_accessor'), + new ReferenceConfigurator('service_container'), + (new ReferenceConfigurator('logger'))->nullOnInvalid(), + ]) ; }; diff --git a/src/Twig/Extension/CanonicalizeExtension.php b/src/Twig/Extension/CanonicalizeExtension.php new file mode 100644 index 0000000000..76644f2574 --- /dev/null +++ b/src/Twig/Extension/CanonicalizeExtension.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\AdminBundle\Twig\Extension; + +use Symfony\Component\HttpFoundation\RequestStack; +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +final class CanonicalizeExtension extends AbstractExtension +{ + // @todo: there are more locales which are not supported by moment and they need to be translated/normalized/canonicalized here + private const MOMENT_UNSUPPORTED_LOCALES = [ + 'de' => ['de', 'de-at'], + 'es' => ['es', 'es-do'], + 'nl' => ['nl', 'nl-be'], + 'fr' => ['fr', 'fr-ca', 'fr-ch'], + ]; + + /** + * @var RequestStack + */ + private $requestStack; + + /** + * @internal This class should only be used through Twig + */ + public function __construct(RequestStack $requestStack) + { + $this->requestStack = $requestStack; + } + + /** + * @return TwigFunction[] + */ + public function getFunctions(): array + { + return [ + //NEXT_MAJOR: Uncomment lines below + //new TwigFunction('canonicalize_locale_for_moment', [$this, 'getCanonicalizedLocaleForMoment']), + //new TwigFunction('canonicalize_locale_for_select2', [$this, 'getCanonicalizedLocaleForSelect2']), + ]; + } + + /* + * Returns a canonicalized locale for "moment" NPM library, + * or `null` if the locale's language is "en", which doesn't require localization. + */ + public function getCanonicalizedLocaleForMoment(): ?string + { + $locale = strtolower(str_replace('_', '-', $this->requestStack->getCurrentRequest()->getLocale())); + + // "en" language doesn't require localization. + if (('en' === $lang = substr($locale, 0, 2)) && !\in_array($locale, ['en-au', 'en-ca', 'en-gb', 'en-ie', 'en-nz'], true)) { + return null; + } + + foreach (self::MOMENT_UNSUPPORTED_LOCALES as $language => $locales) { + if ($language === $lang && !\in_array($locale, $locales, true)) { + $locale = $language; + } + } + + return $locale; + } + + /** + * Returns a canonicalized locale for "select2" NPM library, + * or `null` if the locale's language is "en", which doesn't require localization. + */ + public function getCanonicalizedLocaleForSelect2(): ?string + { + $locale = str_replace('_', '-', $this->requestStack->getCurrentRequest()->getLocale()); + + // "en" language doesn't require localization. + if ('en' === $lang = substr($locale, 0, 2)) { + return null; + } + + switch ($locale) { + case 'pt': + $locale = 'pt-PT'; + break; + case 'ug': + $locale = 'ug-CN'; + break; + case 'zh': + $locale = 'zh-CN'; + break; + default: + if (!\in_array($locale, ['pt-BR', 'pt-PT', 'ug-CN', 'zh-CN', 'zh-TW'], true)) { + $locale = $lang; + } + } + + return $locale; + } +} diff --git a/src/Twig/Extension/RenderElementExtension.php b/src/Twig/Extension/RenderElementExtension.php new file mode 100644 index 0000000000..436975436c --- /dev/null +++ b/src/Twig/Extension/RenderElementExtension.php @@ -0,0 +1,441 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\AdminBundle\Twig\Extension; + +use Psr\Log\LoggerInterface; +use Sonata\AdminBundle\Admin\FieldDescriptionInterface; +use Sonata\AdminBundle\Exception\NoValueException; +use Sonata\AdminBundle\Templating\TemplateRegistryInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException; +use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Twig\Environment; +use Twig\Error\LoaderError; +use Twig\Extension\AbstractExtension; +use Twig\TemplateWrapper; +use Twig\TwigFilter; + +final class RenderElementExtension extends AbstractExtension +{ + /** + * @var LoggerInterface|null + */ + private $logger; + + /** + * @var ContainerInterface|null + */ + private $templateRegistries; + + /** + * @var PropertyAccessorInterface + */ + private $propertyAccessor; + + /** + * NEXT_MAJOR: Make $templateRegistries mandatory. + * + * @internal This class should only be used through Twig + */ + public function __construct( + PropertyAccessorInterface $propertyAccessor, + ?ContainerInterface $templateRegistries = null, + ?LoggerInterface $logger = null + ) { + $this->propertyAccessor = $propertyAccessor; + $this->templateRegistries = $templateRegistries; + $this->logger = $logger; + } + + /** + * @return TwigFilter[] + */ + public function getFilters(): array + { + return [ + //NEXT_MAJOR: Uncomment lines below + /* + new TwigFilter( + 'render_list_element', + [$this, 'renderListElement'], + [ + 'is_safe' => ['html'], + 'needs_environment' => true, + ] + ), + new TwigFilter( + 'render_view_element', + [$this, 'renderViewElement'], + [ + 'is_safe' => ['html'], + 'needs_environment' => true, + ] + ), + new TwigFilter( + 'render_view_element_compare', + [$this, 'renderViewElementCompare'], + [ + 'is_safe' => ['html'], + 'needs_environment' => true, + ] + ), + new TwigFilter( + 'render_relation_element', + [$this, 'renderRelationElement'] + ), + */ + ]; + } + + /** + * render a list element from the FieldDescription. + * + * @param object|array $listElement + * @param array $params + * + * @return string + */ + public function renderListElement( + Environment $environment, + $listElement, + FieldDescriptionInterface $fieldDescription, + $params = [] + ) { + $template = $this->getTemplate( + $fieldDescription, + // NEXT_MAJOR: Remove this line and use commented line below instead + $fieldDescription->getAdmin()->getTemplate('base_list_field'), + //$this->getTemplateRegistry($fieldDescription->getAdmin()->getCode())->getTemplate('base_list_field'), + $environment + ); + + [$object, $value] = $this->getObjectAndValueFromListElement($listElement, $fieldDescription); + + return $this->render($fieldDescription, $template, array_merge($params, [ + 'admin' => $fieldDescription->getAdmin(), + 'object' => $object, + 'value' => $value, + 'field_description' => $fieldDescription, + ]), $environment); + } + + /** + * render a view element. + * + * @param object $object + * + * @return string + */ + public function renderViewElement( + Environment $environment, + FieldDescriptionInterface $fieldDescription, + $object + ) { + $template = $this->getTemplate( + $fieldDescription, + '@SonataAdmin/CRUD/base_show_field.html.twig', + $environment + ); + + try { + $value = $fieldDescription->getValue($object); + } catch (NoValueException $e) { + // NEXT_MAJOR: Remove the try catch in order to throw the NoValueException. + @trigger_error( + sprintf( + 'Accessing a non existing value for the field "%s" is deprecated' + .' since sonata-project/admin-bundle 3.67 and will throw an exception in 4.0.', + $fieldDescription->getName(), + ), + E_USER_DEPRECATED + ); + + $value = null; + } + + return $this->render($fieldDescription, $template, [ + 'field_description' => $fieldDescription, + 'object' => $object, + 'value' => $value, + 'admin' => $fieldDescription->getAdmin(), + ], $environment); + } + + /** + * render a compared view element. + * + * @param mixed $baseObject + * @param mixed $compareObject + * + * @return string + */ + public function renderViewElementCompare( + Environment $environment, + FieldDescriptionInterface $fieldDescription, + $baseObject, + $compareObject + ) { + $template = $this->getTemplate( + $fieldDescription, + '@SonataAdmin/CRUD/base_show_field.html.twig', + $environment + ); + + try { + $baseValue = $fieldDescription->getValue($baseObject); + } catch (NoValueException $e) { + // NEXT_MAJOR: Remove the try catch in order to throw the NoValueException. + @trigger_error( + sprintf( + 'Accessing a non existing value for the field "%s" is deprecated' + .' since sonata-project/admin-bundle 3.67 and will throw an exception in 4.0.', + $fieldDescription->getName(), + ), + E_USER_DEPRECATED + ); + + $baseValue = null; + } + + try { + $compareValue = $fieldDescription->getValue($compareObject); + } catch (NoValueException $e) { + // NEXT_MAJOR: Remove the try catch in order to throw the NoValueException. + @trigger_error( + sprintf( + 'Accessing a non existing value for the field "%s" is deprecated' + .' since sonata-project/admin-bundle 3.67 and will throw an exception in 4.0.', + $fieldDescription->getName(), + ), + E_USER_DEPRECATED + ); + + $compareValue = null; + } + + $baseValueOutput = $template->render([ + 'admin' => $fieldDescription->getAdmin(), + 'field_description' => $fieldDescription, + 'value' => $baseValue, + 'object' => $baseObject, + ]); + + $compareValueOutput = $template->render([ + 'field_description' => $fieldDescription, + 'admin' => $fieldDescription->getAdmin(), + 'value' => $compareValue, + 'object' => $compareObject, + ]); + + // Compare the rendered output of both objects by using the (possibly) overridden field block + $isDiff = $baseValueOutput !== $compareValueOutput; + + return $this->render($fieldDescription, $template, [ + 'field_description' => $fieldDescription, + 'value' => $baseValue, + 'value_compare' => $compareValue, + 'is_diff' => $isDiff, + 'admin' => $fieldDescription->getAdmin(), + 'object' => $baseObject, + 'object_compare' => $compareObject, + ], $environment); + } + + /** + * @param mixed $element + * + * @throws \RuntimeException + * + * @return mixed + */ + public function renderRelationElement($element, FieldDescriptionInterface $fieldDescription) + { + if (!\is_object($element)) { + return $element; + } + + $propertyPath = $fieldDescription->getOption('associated_property'); + + if (null === $propertyPath) { + // For BC kept associated_tostring option behavior + // NEXT_MAJOR Remove next line. + $method = $fieldDescription->getOption('associated_tostring'); + // NEXT_MAJOR: Remove the "if" part and leave the "else" part + if ($method) { + @trigger_error( + 'Option "associated_tostring" is deprecated since version 2.3 and will be removed in 4.0. Use "associated_property" instead.', + E_USER_DEPRECATED + ); + } else { + $method = '__toString'; + } + + if (!method_exists($element, $method)) { + throw new \RuntimeException(sprintf( + 'You must define an `associated_property` option or create a `%s::__toString` method' + .' to the field option %s from service %s is ', + \get_class($element), + $fieldDescription->getName(), + $fieldDescription->getAdmin()->getCode() + )); + } + + return $element->{$method}(); + } + + if (\is_callable($propertyPath)) { + return $propertyPath($element); + } + + return $this->propertyAccessor->getValue($element, $propertyPath); + } + + /** + * NEXT_MAJOR: Make this method private. + * + * @internal This method will be private in the next major version + * + * @param string $defaultTemplate + * + * @return TemplateWrapper + */ + public function getTemplate( + FieldDescriptionInterface $fieldDescription, + $defaultTemplate, + Environment $environment + ) { + $templateName = $fieldDescription->getTemplate() ?: $defaultTemplate; + + try { + $template = $environment->load($templateName); + } catch (LoaderError $e) { + @trigger_error(sprintf( + 'Relying on default template loading on field template loading exception is deprecated since 3.1' + .' and will be removed in 4.0. A %s exception will be thrown instead', + LoaderError::class + ), E_USER_DEPRECATED); + $template = $environment->load($defaultTemplate); + + if (null !== $this->logger) { + $this->logger->warning(sprintf( + 'An error occured trying to load the template "%s" for the field "%s",' + .' the default template "%s" was used instead.', + $templateName, + $fieldDescription->getFieldName(), + $defaultTemplate + ), ['exception' => $e]); + } + } + + return $template; + } + + /** + * Extracts the object and requested value from the $listElement. + * + * @param object|array $listElement + * + * @throws \TypeError when $listElement is not an object or an array with an object on offset 0 + * + * @return array An array containing object and value + */ + private function getObjectAndValueFromListElement( + $listElement, + FieldDescriptionInterface $fieldDescription + ): array { + if (\is_object($listElement)) { + $object = $listElement; + } elseif (\is_array($listElement)) { + if (!isset($listElement[0]) || !\is_object($listElement[0])) { + throw new \TypeError(sprintf('If argument 1 passed to %s() is an array it must contain an object at offset 0.', __METHOD__)); + } + + $object = $listElement[0]; + } else { + throw new \TypeError(sprintf('Argument 1 passed to %s() must be an object or an array, %s given.', __METHOD__, \gettype($listElement))); + } + + if (\is_array($listElement) && \array_key_exists($fieldDescription->getName(), $listElement)) { + $value = $listElement[$fieldDescription->getName()]; + } else { + try { + $value = $fieldDescription->getValue($object); + } catch (NoValueException $e) { + // NEXT_MAJOR: throw the NoValueException. + @trigger_error( + sprintf( + 'Accessing a non existing value for the field "%s" is deprecated' + .' since sonata-project/admin-bundle 3.67 and will throw an exception in 4.0.', + $fieldDescription->getName(), + ), + E_USER_DEPRECATED + ); + + $value = null; + } + } + + return [$object, $value]; + } + + private function render( + FieldDescriptionInterface $fieldDescription, + TemplateWrapper $template, + array $parameters, + Environment $environment + ): string { + $content = $template->render($parameters); + + if ($environment->isDebug()) { + $commentTemplate = <<<'EOT' + + + %s + +EOT; + + return sprintf( + $commentTemplate, + $fieldDescription->getFieldName(), + $fieldDescription->getTemplate(), + $template->getSourceContext()->getName(), + $content, + $fieldDescription->getFieldName() + ); + } + + return $content; + } + + /** + * @throws ServiceCircularReferenceException + * @throws ServiceNotFoundException + */ + private function getTemplateRegistry(string $adminCode): TemplateRegistryInterface + { + $serviceId = $adminCode.'.template_registry'; + $templateRegistry = $this->templateRegistries->get($serviceId); + + if ($templateRegistry instanceof TemplateRegistryInterface) { + return $templateRegistry; + } + + throw new ServiceNotFoundException($serviceId); + } +} diff --git a/src/Twig/Extension/SecurityExtension.php b/src/Twig/Extension/SecurityExtension.php new file mode 100644 index 0000000000..085fe68d50 --- /dev/null +++ b/src/Twig/Extension/SecurityExtension.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\Twig\Extension; + +use Symfony\Component\Security\Acl\Voter\FieldVote; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException; +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +final class SecurityExtension extends AbstractExtension +{ + /** + * @var AuthorizationCheckerInterface|null + */ + private $securityChecker; + + /** + * @internal This class should only be used through Twig + */ + public function __construct( + ?AuthorizationCheckerInterface $securityChecker = null + ) { + $this->securityChecker = $securityChecker; + } + + /** + * @return TwigFunction[] + */ + public function getFunctions(): array + { + return [ + //NEXT_MAJOR: Uncomment line below + //new TwigFunction('is_granted_affirmative', [$this, 'isGrantedAffirmative']), + ]; + } + + /** + * @param string|array $role + */ + public function isGrantedAffirmative($role, ?object $object = null, ?string $field = null): bool + { + if (null === $this->securityChecker) { + return false; + } + + if (null !== $field) { + $object = new FieldVote($object, $field); + } + + if (!\is_array($role)) { + $role = [$role]; + } + + foreach ($role as $oneRole) { + try { + if ($this->securityChecker->isGranted($oneRole, $object)) { + return true; + } + } catch (AuthenticationCredentialsNotFoundException $e) { + // empty on purpose + } + } + + return false; + } +} diff --git a/src/Twig/Extension/SonataAdminExtension.php b/src/Twig/Extension/SonataAdminExtension.php index e10866c456..41e65b98ec 100644 --- a/src/Twig/Extension/SonataAdminExtension.php +++ b/src/Twig/Extension/SonataAdminExtension.php @@ -19,18 +19,13 @@ use Sonata\AdminBundle\Admin\FieldDescriptionInterface; use Sonata\AdminBundle\Admin\Pool; use Sonata\AdminBundle\Exception\NoValueException; -use Sonata\AdminBundle\Templating\TemplateRegistryInterface; use Symfony\Component\DependencyInjection\ContainerInterface; -use Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException; -use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\Security\Acl\Voter\FieldVote; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; -use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException; use Symfony\Component\Translation\TranslatorInterface as LegacyTranslationInterface; use Symfony\Contracts\Translation\TranslatorInterface; use Twig\Environment; -use Twig\Error\LoaderError; use Twig\Extension\AbstractExtension; use Twig\Template; use Twig\TemplateWrapper; @@ -44,6 +39,11 @@ */ class SonataAdminExtension extends AbstractExtension { + /** + * NEXT_MAJOR: Remove this constant. + * + * @deprecated since sonata-project/admin-bundle 3.x and will be removed in 4.0 + */ // @todo: there are more locales which are not supported by moment and they need to be translated/normalized/canonicalized here public const MOMENT_UNSUPPORTED_LOCALES = [ 'de' => ['de', 'de-at'], @@ -58,6 +58,8 @@ class SonataAdminExtension extends AbstractExtension protected $pool; /** + * NEXT_MAJOR: Remove this property. + * * @var LoggerInterface */ protected $logger; @@ -68,17 +70,27 @@ class SonataAdminExtension extends AbstractExtension protected $translator; /** + * NEXT_MAJOR: Remove this property. + * * @var string[] + * + * @deprecated since sonata-project/admin-bundle 3.x and will be removed in 4.0 */ private $xEditableTypeMapping = []; /** + * NEXT_MAJOR: Remove this property. + * * @var ContainerInterface */ private $templateRegistries; /** + * NEXT_MAJOR: Remove this property. + * * @var AuthorizationCheckerInterface + * + * @deprecated since sonata-project/admin-bundle 3.x and will be removed in 4.0 */ private $securityChecker; @@ -88,22 +100,52 @@ class SonataAdminExtension extends AbstractExtension private $propertyAccessor; /** - * NEXT_MAJOR: Make $propertyAccessor mandatory. + * NEXT_MAJOR: Remove this property. + * + * @var XEditableExtension|null + */ + private $xEditableExtension; + + /** + * NEXT_MAJOR: Remove this property. + * + * @var SecurityExtension|null + */ + private $securityExtension; + + /** + * NEXT_MAJOR: Remove this property. + * + * @var CanonicalizeExtension|null + */ + private $canonicalizeExtension; + + /** + * NEXT_MAJOR: Remove this property. + * + * @var RenderElementExtension|null + */ + private $renderElementExtension; + + /** + * NEXT_MAJOR: Remove @internal tag, $translator & $securityChecker parameters and make propertyAccessor mandatory. + * + * @internal */ public function __construct( Pool $pool, - ?LoggerInterface $logger = null, + ?LoggerInterface $logger = null, //NEXT_MAJOR: Remove this parameter $translator = null, ?ContainerInterface $templateRegistries = null, $propertyAccessorOrSecurityChecker = null, - ?AuthorizationCheckerInterface $securityChecker = null + ?AuthorizationCheckerInterface $securityChecker = null //NEXT_MAJOR: Remove this parameter ) { // NEXT_MAJOR: make the translator parameter required, move TranslatorInterface check to method signature // and remove this block if (null === $translator) { @trigger_error( - 'The $translator parameter will be required fields with the 4.0 release.', + 'The $translator parameter will be required field with the 4.0 release.', E_USER_DEPRECATED ); } else { @@ -162,7 +204,7 @@ public function __construct( $this->propertyAccessor = $pool->getPropertyAccessor(); $this->securityChecker = $securityChecker; } else { - $this->securityChecker = $securityChecker; + $this->securityChecker = $securityChecker; //NEXT_MAJOR: Remove this property $this->propertyAccessor = $propertyAccessorOrSecurityChecker; } @@ -178,58 +220,124 @@ public function __construct( public function getFilters() { return [ + //NEXT_MAJOR remove this filter new TwigFilter( 'render_list_element', - [$this, 'renderListElement'], + function ( + Environment $environment, + $listElement, + FieldDescriptionInterface $fieldDescription, + $params = [] + ) { + return $this->renderListElement( + $environment, + $listElement, + $fieldDescription, + $params, + 'sonata_deprecation_mute' + ); + }, [ 'is_safe' => ['html'], 'needs_environment' => true, ] ), + //NEXT_MAJOR remove this filter new TwigFilter( 'render_view_element', - [$this, 'renderViewElement'], + function ( + Environment $environment, + FieldDescriptionInterface $fieldDescription, + $object + ) { + return $this->renderViewElement( + $environment, + $fieldDescription, + $object, + 'sonata_deprecation_mute' + ); + }, [ 'is_safe' => ['html'], 'needs_environment' => true, ] ), + //NEXT_MAJOR remove this filter new TwigFilter( 'render_view_element_compare', - [$this, 'renderViewElementCompare'], + function ( + Environment $environment, + FieldDescriptionInterface $fieldDescription, + $baseObject, + $compareObject + ) { + return $this->renderViewElementCompare( + $environment, + $fieldDescription, + $baseObject, + $compareObject, + 'sonata_deprecation_mute' + ); + }, [ 'is_safe' => ['html'], 'needs_environment' => true, ] ), + //NEXT_MAJOR remove this filter new TwigFilter( 'render_relation_element', - [$this, 'renderRelationElement'] + function ( + $element, + FieldDescriptionInterface $fieldDescription + ) { + return $this->renderRelationElement( + $element, + $fieldDescription, + 'sonata_deprecation_mute' + ); + } ), new TwigFilter( 'sonata_urlsafeid', [$this, 'getUrlSafeIdentifier'] ), + //NEXT_MAJOR remove this filter new TwigFilter( 'sonata_xeditable_type', - [$this, 'getXEditableType'] + function ($type) { + return $this->getXEditableType($type, 'sonata_deprecation_mute'); + } ), + //NEXT_MAJOR remove this filter new TwigFilter( 'sonata_xeditable_choices', - [$this, 'getXEditableChoices'] + function (FieldDescriptionInterface $fieldDescription) { + return $this->getXEditableChoices($fieldDescription, 'sonata_deprecation_mute'); + } ), ]; } /** + * NEXT_MAJOR: Remove this method. + * * @return TwigFunction[] + * + * @deprecated since sonata-project/admin-bundle 3.x and will be removed in 4.0 */ public function getFunctions() { return [ - new TwigFunction('canonicalize_locale_for_moment', [$this, 'getCanonicalizedLocaleForMoment'], ['needs_context' => true]), - new TwigFunction('canonicalize_locale_for_select2', [$this, 'getCanonicalizedLocaleForSelect2'], ['needs_context' => true]), - new TwigFunction('is_granted_affirmative', [$this, 'isGrantedAffirmative']), + new TwigFunction('canonicalize_locale_for_moment', function (array $context) { + return $this->getCanonicalizedLocaleForMoment($context, 'sonata_deprecation_mute'); + }, ['needs_context' => true]), + new TwigFunction('canonicalize_locale_for_select2', function (array $context) { + return $this->getCanonicalizedLocaleForSelect2($context, 'sonata_deprecation_mute'); + }, ['needs_context' => true]), + new TwigFunction('is_granted_affirmative', function ($role, $object = null, $field = null) { + return $this->isGrantedAffirmative($role, $object, $field, 'sonata_deprecation_mute'); + }), ]; } @@ -239,12 +347,15 @@ public function getName() } /** + * NEXT_MAJOR: Remove this method * render a list element from the FieldDescription. * * @param object|array $listElement * @param array $params * * @return string + * + * @deprecated since sonata-project/admin-bundle 3.x and will be removed in 4.0 */ public function renderListElement( Environment $environment, @@ -252,22 +363,18 @@ public function renderListElement( FieldDescriptionInterface $fieldDescription, $params = [] ) { - $template = $this->getTemplate( - $fieldDescription, - // NEXT_MAJOR: Remove this line and use commented line below instead - $fieldDescription->getAdmin()->getTemplate('base_list_field'), - //$this->getTemplateRegistry($fieldDescription->getAdmin()->getCode())->getTemplate('base_list_field'), - $environment - ); - - [$object, $value] = $this->getObjectAndValueFromListElement($listElement, $fieldDescription); - - return $this->render($fieldDescription, $template, array_merge($params, [ - 'admin' => $fieldDescription->getAdmin(), - 'object' => $object, - 'value' => $value, - 'field_description' => $fieldDescription, - ]), $environment); + if ('sonata_deprecation_mute' !== (\func_get_args()[4] ?? null)) { + @trigger_error(sprintf( + 'The %s method is deprecated in favor of RenderElementExtension::renderListElement since version 3.x and will be removed in 4.0.', + __METHOD__ + ), E_USER_DEPRECATED); + } + + if (null === $this->renderElementExtension) { + $this->renderElementExtension = new RenderElementExtension($this->propertyAccessor, $this->templateRegistries, $this->logger); + } + + return $this->renderElementExtension->renderListElement($environment, $listElement, $fieldDescription, $params); } /** @@ -281,12 +388,36 @@ public function output( array $parameters, Environment $environment ) { - return $this->render( - $fieldDescription, - new TemplateWrapper($environment, $template), - $parameters, - $environment - ); + @trigger_error(sprintf( + 'The %s method is deprecated since version 3.33 and will be removed in 4.0.', + __METHOD__ + ), E_USER_DEPRECATED); + + $content = $template->render($parameters); + + if ($environment->isDebug()) { + $commentTemplate = <<<'EOT' + + + %s + +EOT; + + return sprintf( + $commentTemplate, + $fieldDescription->getFieldName(), + $fieldDescription->getTemplate(), + $template->getSourceContext()->getName(), + $content, + $fieldDescription->getFieldName() + ); + } + + return $content; } /** @@ -342,54 +473,44 @@ public function getValueFromFieldDescription( } /** + * NEXT_MAJOR: Remove this method * render a view element. * * @param object $object * * @return string + * + * @deprecated since sonata-project/admin-bundle 3.x and will be removed in 4.0 */ public function renderViewElement( Environment $environment, FieldDescriptionInterface $fieldDescription, $object ) { - $template = $this->getTemplate( - $fieldDescription, - '@SonataAdmin/CRUD/base_show_field.html.twig', - $environment - ); - - try { - $value = $fieldDescription->getValue($object); - } catch (NoValueException $e) { - // NEXT_MAJOR: Remove the try catch in order to throw the NoValueException. - @trigger_error( - sprintf( - 'Accessing a non existing value for the field "%s" is deprecated' - .' since sonata-project/admin-bundle 3.67 and will throw an exception in 4.0.', - $fieldDescription->getName(), - ), - E_USER_DEPRECATED - ); + if ('sonata_deprecation_mute' !== (\func_get_args()[3] ?? null)) { + @trigger_error(sprintf( + 'The %s method is deprecated in favor of RenderElementExtension::renderViewElement since version 3.x and will be removed in 4.0.', + __METHOD__ + ), E_USER_DEPRECATED); + } - $value = null; + if (null === $this->renderElementExtension) { + $this->renderElementExtension = new RenderElementExtension($this->propertyAccessor, $this->templateRegistries, $this->logger); } - return $this->render($fieldDescription, $template, [ - 'field_description' => $fieldDescription, - 'object' => $object, - 'value' => $value, - 'admin' => $fieldDescription->getAdmin(), - ], $environment); + return $this->renderElementExtension->renderViewElement($environment, $fieldDescription, $object); } /** + * NEXT_MAJOR: Remove this method * render a compared view element. * * @param mixed $baseObject * @param mixed $compareObject * * @return string + * + * @deprecated since sonata-project/admin-bundle 3.x and will be removed in 4.0 */ public function renderViewElementCompare( Environment $environment, @@ -397,118 +518,44 @@ public function renderViewElementCompare( $baseObject, $compareObject ) { - $template = $this->getTemplate( - $fieldDescription, - '@SonataAdmin/CRUD/base_show_field.html.twig', - $environment - ); - - try { - $baseValue = $fieldDescription->getValue($baseObject); - } catch (NoValueException $e) { - // NEXT_MAJOR: Remove the try catch in order to throw the NoValueException. - @trigger_error( - sprintf( - 'Accessing a non existing value for the field "%s" is deprecated' - .' since sonata-project/admin-bundle 3.67 and will throw an exception in 4.0.', - $fieldDescription->getName(), - ), - E_USER_DEPRECATED - ); - - $baseValue = null; + if ('sonata_deprecation_mute' !== (\func_get_args()[4] ?? null)) { + @trigger_error(sprintf( + 'The %s method is deprecated in favor of RenderElementExtension::renderViewElementCompare since version 3.x and will be removed in 4.0.', + __METHOD__ + ), E_USER_DEPRECATED); } - - try { - $compareValue = $fieldDescription->getValue($compareObject); - } catch (NoValueException $e) { - // NEXT_MAJOR: Remove the try catch in order to throw the NoValueException. - @trigger_error( - sprintf( - 'Accessing a non existing value for the field "%s" is deprecated' - .' since sonata-project/admin-bundle 3.67 and will throw an exception in 4.0.', - $fieldDescription->getName(), - ), - E_USER_DEPRECATED - ); - - $compareValue = null; + if (null === $this->renderElementExtension) { + $this->renderElementExtension = new RenderElementExtension($this->propertyAccessor, $this->templateRegistries, $this->logger); } - $baseValueOutput = $template->render([ - 'admin' => $fieldDescription->getAdmin(), - 'field_description' => $fieldDescription, - 'value' => $baseValue, - 'object' => $baseObject, - ]); - - $compareValueOutput = $template->render([ - 'field_description' => $fieldDescription, - 'admin' => $fieldDescription->getAdmin(), - 'value' => $compareValue, - 'object' => $compareObject, - ]); - - // Compare the rendered output of both objects by using the (possibly) overridden field block - $isDiff = $baseValueOutput !== $compareValueOutput; - - return $this->render($fieldDescription, $template, [ - 'field_description' => $fieldDescription, - 'value' => $baseValue, - 'value_compare' => $compareValue, - 'is_diff' => $isDiff, - 'admin' => $fieldDescription->getAdmin(), - 'object' => $baseObject, - 'object_compare' => $compareObject, - ], $environment); + return $this->renderElementExtension->renderViewElementCompare($environment, $fieldDescription, $baseObject, $compareObject); } /** + * NEXT_MAJOR: Remove this method. + * * @param mixed $element * * @throws \RuntimeException * * @return mixed + * + * @deprecated since sonata-project/admin-bundle 3.x and will be removed in 4.0 */ public function renderRelationElement($element, FieldDescriptionInterface $fieldDescription) { - if (!\is_object($element)) { - return $element; - } - - $propertyPath = $fieldDescription->getOption('associated_property'); - - if (null === $propertyPath) { - // For BC kept associated_tostring option behavior - $method = $fieldDescription->getOption('associated_tostring'); - - if ($method) { - @trigger_error( - 'Option "associated_tostring" is deprecated since version 2.3 and will be removed in 4.0. Use "associated_property" instead.', - E_USER_DEPRECATED - ); - } else { - $method = '__toString'; - } - - if (!method_exists($element, $method)) { - throw new \RuntimeException(sprintf( - 'You must define an `associated_property` option or create a `%s::__toString` method' - .' to the field option %s from service %s is ', - \get_class($element), - $fieldDescription->getName(), - $fieldDescription->getAdmin()->getCode() - )); - } - - return $element->{$method}(); + if ('sonata_deprecation_mute' !== (\func_get_args()[2] ?? null)) { + @trigger_error(sprintf( + 'The %s method is deprecated in favor of RenderElementExtension::renderRelationElement since version 3.x and will be removed in 4.0.', + __METHOD__ + ), E_USER_DEPRECATED); } - if (\is_callable($propertyPath)) { - return $propertyPath($element); + if (null === $this->renderElementExtension) { + $this->renderElementExtension = new RenderElementExtension($this->propertyAccessor, $this->templateRegistries, $this->logger); } - return $this->propertyAccessor->getValue($element, $propertyPath); + return $this->renderElementExtension->renderRelationElement($element, $fieldDescription); } /** @@ -533,22 +580,46 @@ public function getUrlSafeIdentifier($model, ?AdminInterface $admin = null) } /** + * NEXT_MAJOR: Remove this method. + * * @param string[] $xEditableTypeMapping + * + * @deprecated since sonata-project/admin-bundle 3.x and will be removed in 4.0 */ public function setXEditableTypeMapping($xEditableTypeMapping) { + if ('sonata_deprecation_mute' !== (\func_get_args()[1] ?? null)) { + @trigger_error(sprintf( + 'The %s method is deprecated in favor of XEditableExtension::setXEditableTypeMapping since version 3.x and will be removed in 4.0.', + __METHOD__ + ), E_USER_DEPRECATED); + } + $this->xEditableTypeMapping = $xEditableTypeMapping; } /** + * NEXT_MAJOR: Remove this method. + * * @return string|bool + * + * @deprecated since sonata-project/admin-bundle 3.x and will be removed in 4.0 */ public function getXEditableType($type) { + if ('sonata_deprecation_mute' !== (\func_get_args()[1] ?? null)) { + @trigger_error(sprintf( + 'The %s method is deprecated in favor of XEditableExtension::getXEditableType since version 3.x and will be removed in 4.0.', + __METHOD__ + ), E_USER_DEPRECATED); + } + return $this->xEditableTypeMapping[$type] ?? false; } /** + * NEXT_MAJOR: Remove this method. + * * Return xEditable choices based on the field description choices options & catalogue options. * With the following choice options: * ['Status1' => 'Alias1', 'Status2' => 'Alias2'] @@ -556,273 +627,131 @@ public function getXEditableType($type) * [['value' => 'Status1', 'text' => 'Alias1'], ['value' => 'Status2', 'text' => 'Alias2']]. * * @return array + * + * @deprecated since sonata-project/admin-bundle 3.x and will be removed in 4.0 */ public function getXEditableChoices(FieldDescriptionInterface $fieldDescription) { - $choices = $fieldDescription->getOption('choices', []); - $catalogue = $fieldDescription->getOption('catalogue'); - $xEditableChoices = []; - if (!empty($choices)) { - reset($choices); - $first = current($choices); - // the choices are already in the right format - if (\is_array($first) && \array_key_exists('value', $first) && \array_key_exists('text', $first)) { - $xEditableChoices = $choices; - } else { - foreach ($choices as $value => $text) { - if ($catalogue) { - if (null !== $this->translator) { - $text = $this->translator->trans($text, [], $catalogue); - // NEXT_MAJOR: Remove this check - } elseif (method_exists($fieldDescription->getAdmin(), 'trans')) { - $text = $fieldDescription->getAdmin()->trans($text, [], $catalogue); - } - } - - $xEditableChoices[] = [ - 'value' => $value, - 'text' => $text, - ]; - } - } + if ('sonata_deprecation_mute' !== (\func_get_args()[1] ?? null)) { + @trigger_error(sprintf( + 'The %s method is deprecated in favor of XEditableExtension::getXEditableChoices since version 3.x and will be removed in 4.0.', + __METHOD__ + ), E_USER_DEPRECATED); } - if (false === $fieldDescription->getOption('required', true) - && false === $fieldDescription->getOption('multiple', false) - ) { - $xEditableChoices = array_merge([[ - 'value' => '', - 'text' => '', - ]], $xEditableChoices); + if (null === $this->xEditableExtension) { + $this->xEditableExtension = new XEditableExtension($this->translator, $this->xEditableTypeMapping); } - return $xEditableChoices; + return $this->xEditableExtension->getXEditableChoices($fieldDescription); } /** + * NEXT_MAJOR: Remove this method. + * * Returns a canonicalized locale for "moment" NPM library, * or `null` if the locale's language is "en", which doesn't require localization. * * @return string|null + * + * @deprecated since sonata-project/admin-bundle 3.x and will be removed in 4.0 */ final public function getCanonicalizedLocaleForMoment(array $context) { - $locale = strtolower(str_replace('_', '-', $context['app']->getRequest()->getLocale())); - - // "en" language doesn't require localization. - if (('en' === $lang = substr($locale, 0, 2)) && !\in_array($locale, ['en-au', 'en-ca', 'en-gb', 'en-ie', 'en-nz'], true)) { - return null; + if ('sonata_deprecation_mute' !== (\func_get_args()[1] ?? null)) { + @trigger_error(sprintf( + 'The %s method is deprecated in favor of CanonicalizeExtension::getCanonicalizedLocaleForMoment since version 3.x and will be removed in 4.0.', + __METHOD__ + ), E_USER_DEPRECATED); } - foreach (self::MOMENT_UNSUPPORTED_LOCALES as $language => $locales) { - if ($language === $lang && !\in_array($locale, $locales, true)) { - $locale = $language; - } + if (null === $this->canonicalizeExtension) { + $requestStack = new RequestStack(); + $requestStack->push($context['app']->getRequest()); + $this->canonicalizeExtension = new CanonicalizeExtension($requestStack); } - return $locale; + return $this->canonicalizeExtension->getCanonicalizedLocaleForMoment(); } /** + * NEXT_MAJOR: Remove this method. + * * Returns a canonicalized locale for "select2" NPM library, * or `null` if the locale's language is "en", which doesn't require localization. * * @return string|null + * + * @deprecated since sonata-project/admin-bundle 3.x and will be removed in 4.0 */ final public function getCanonicalizedLocaleForSelect2(array $context) { - $locale = str_replace('_', '-', $context['app']->getRequest()->getLocale()); - - // "en" language doesn't require localization. - if ('en' === $lang = substr($locale, 0, 2)) { - return null; + if ('sonata_deprecation_mute' !== (\func_get_args()[1] ?? null)) { + @trigger_error(sprintf( + 'The %s method is deprecated in favor of CanonicalizeExtension::getCanonicalizedLocaleForSelect2 since version 3.x and will be removed in 4.0.', + __METHOD__ + ), E_USER_DEPRECATED); } - switch ($locale) { - case 'pt': - $locale = 'pt-PT'; - break; - case 'ug': - $locale = 'ug-CN'; - break; - case 'zh': - $locale = 'zh-CN'; - break; - default: - if (!\in_array($locale, ['pt-BR', 'pt-PT', 'ug-CN', 'zh-CN', 'zh-TW'], true)) { - $locale = $lang; - } + if (null === $this->canonicalizeExtension) { + $requestStack = new RequestStack(); + $requestStack->push($context['app']->getRequest()); + $this->canonicalizeExtension = new CanonicalizeExtension($requestStack); } - return $locale; + return $this->canonicalizeExtension->getCanonicalizedLocaleForSelect2(); } /** + * NEXT_MAJOR: Remove this method. + * * @param string|array $role * @param object|null $object * @param string|null $field * * @return bool + * + * @deprecated since sonata-project/admin-bundle 3.x and will be removed in 4.0 */ public function isGrantedAffirmative($role, $object = null, $field = null) { - if (null === $this->securityChecker) { - return false; - } - - if (null !== $field) { - $object = new FieldVote($object, $field); - } - - if (!\is_array($role)) { - $role = [$role]; + if ('sonata_deprecation_mute' !== (\func_get_args()[3] ?? null)) { + @trigger_error(sprintf( + 'The %s method is deprecated in favor of SecurityExtension::isGrantedAffirmative since version 3.x and will be removed in 4.0.', + __METHOD__ + ), E_USER_DEPRECATED); } - foreach ($role as $oneRole) { - try { - if ($this->securityChecker->isGranted($oneRole, $object)) { - return true; - } - } catch (AuthenticationCredentialsNotFoundException $e) { - // empty on purpose - } + if (null === $this->securityExtension) { + $this->securityExtension = new SecurityExtension($this->securityChecker); } - return false; + return $this->securityExtension->isGrantedAffirmative($role, $object, $field); } /** + * NEXT_MAJOR: Remove this method. + * * Get template. * * @param string $defaultTemplate * * @return TemplateWrapper + * + * @deprecated since sonata-project/admin-bundle 3.x and will be removed in 4.0 */ protected function getTemplate( FieldDescriptionInterface $fieldDescription, $defaultTemplate, Environment $environment ) { - $templateName = $fieldDescription->getTemplate() ?: $defaultTemplate; - - try { - $template = $environment->load($templateName); - } catch (LoaderError $e) { + if ('sonata_deprecation_mute' !== (\func_get_args()[3] ?? null)) { @trigger_error(sprintf( - 'Relying on default template loading on field template loading exception is deprecated since 3.1' - .' and will be removed in 4.0. A %s exception will be thrown instead', - LoaderError::class + 'The %s method is deprecated in favor of RenderElementExtension::getTemplate since version 3.x and will be removed in 4.0.', + __METHOD__ ), E_USER_DEPRECATED); - $template = $environment->load($defaultTemplate); - - if (null !== $this->logger) { - $this->logger->warning(sprintf( - 'An error occured trying to load the template "%s" for the field "%s",' - .' the default template "%s" was used instead.', - $templateName, - $fieldDescription->getFieldName(), - $defaultTemplate - ), ['exception' => $e]); - } - } - - return $template; - } - - private function render( - FieldDescriptionInterface $fieldDescription, - TemplateWrapper $template, - array $parameters, - Environment $environment - ): string { - $content = $template->render($parameters); - - if ($environment->isDebug()) { - $commentTemplate = <<<'EOT' - - - %s - -EOT; - - return sprintf( - $commentTemplate, - $fieldDescription->getFieldName(), - $fieldDescription->getTemplate(), - $template->getSourceContext()->getName(), - $content, - $fieldDescription->getFieldName() - ); - } - - return $content; - } - - /** - * @throws ServiceCircularReferenceException - * @throws ServiceNotFoundException - */ - private function getTemplateRegistry(string $adminCode): TemplateRegistryInterface - { - $serviceId = $adminCode.'.template_registry'; - $templateRegistry = $this->templateRegistries->get($serviceId); - - if ($templateRegistry instanceof TemplateRegistryInterface) { - return $templateRegistry; - } - - throw new ServiceNotFoundException($serviceId); - } - - /** - * Extracts the object and requested value from the $listElement. - * - * @param object|array $listElement - * - * @throws \TypeError when $listElement is not an object or an array with an object on offset 0 - * - * @return array An array containing object and value - */ - private function getObjectAndValueFromListElement( - $listElement, - FieldDescriptionInterface $fieldDescription - ): array { - if (\is_object($listElement)) { - $object = $listElement; - } elseif (\is_array($listElement)) { - if (!isset($listElement[0]) || !\is_object($listElement[0])) { - throw new \TypeError(sprintf('If argument 1 passed to %s() is an array it must contain an object at offset 0.', __METHOD__)); - } - - $object = $listElement[0]; - } else { - throw new \TypeError(sprintf('Argument 1 passed to %s() must be an object or an array, %s given.', __METHOD__, \gettype($listElement))); - } - - if (\is_array($listElement) && \array_key_exists($fieldDescription->getName(), $listElement)) { - $value = $listElement[$fieldDescription->getName()]; - } else { - try { - $value = $fieldDescription->getValue($object); - } catch (NoValueException $e) { - // NEXT_MAJOR: throw the NoValueException. - @trigger_error( - sprintf( - 'Accessing a non existing value for the field "%s" is deprecated' - .' since sonata-project/admin-bundle 3.67 and will throw an exception in 4.0.', - $fieldDescription->getName(), - ), - E_USER_DEPRECATED - ); - - $value = null; - } } - return [$object, $value]; + return $this->renderElementExtension->getTemplate($fieldDescription, $defaultTemplate, $$environment); } } diff --git a/src/Twig/Extension/XEditableExtension.php b/src/Twig/Extension/XEditableExtension.php new file mode 100644 index 0000000000..9650d3a282 --- /dev/null +++ b/src/Twig/Extension/XEditableExtension.php @@ -0,0 +1,118 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\AdminBundle\Twig\Extension; + +use Sonata\AdminBundle\Admin\FieldDescriptionInterface; +use Symfony\Contracts\Translation\TranslatorInterface; +use Twig\Extension\AbstractExtension; +use Twig\TwigFilter; + +final class XEditableExtension extends AbstractExtension +{ + /** + * @var string[] + */ + private $xEditableTypeMapping = []; + + /** + * @var TranslatorInterface + */ + private $translator; + + /** + * @param string[] $xEditableTypeMapping + * + * @internal This class should only be used through Twig + */ + public function __construct( + TranslatorInterface $translator, + array $xEditableTypeMapping + ) { + $this->translator = $translator; + $this->xEditableTypeMapping = $xEditableTypeMapping; + } + + /** + * @return TwigFilter[] + */ + public function getFilters(): array + { + return [ + //NEXT_MAJOR: Uncomment lines below + /* + new TwigFilter( + 'sonata_xeditable_type', + [$this, 'getXEditableType'] + ), + new TwigFilter( + 'sonata_xeditable_choices', + [$this, 'getXEditableChoices'] + ), + */ + ]; + } + + /** + * @return string|bool + */ + public function getXEditableType(string $type) + { + return $this->xEditableTypeMapping[$type] ?? false; + } + + /** + * Return xEditable choices based on the field description choices options & catalogue options. + * With the following choice options: + * ['Status1' => 'Alias1', 'Status2' => 'Alias2'] + * The method will return: + * [['value' => 'Status1', 'text' => 'Alias1'], ['value' => 'Status2', 'text' => 'Alias2']]. + */ + public function getXEditableChoices(FieldDescriptionInterface $fieldDescription): array + { + $choices = $fieldDescription->getOption('choices', []); + $catalogue = $fieldDescription->getOption('catalogue'); + $xEditableChoices = []; + if (!empty($choices)) { + reset($choices); + $first = current($choices); + // the choices are already in the right format + if (\is_array($first) && \array_key_exists('value', $first) && \array_key_exists('text', $first)) { + $xEditableChoices = $choices; + } else { + foreach ($choices as $value => $text) { + if ($catalogue) { + $text = $this->translator->trans($text, [], $catalogue); + } + + $xEditableChoices[] = [ + 'value' => $value, + 'text' => $text, + ]; + } + } + } + + if ( + false === $fieldDescription->getOption('required', true) + && false === $fieldDescription->getOption('multiple', false) + ) { + $xEditableChoices = array_merge([[ + 'value' => '', + 'text' => '', + ]], $xEditableChoices); + } + + return $xEditableChoices; + } +} diff --git a/tests/Controller/HelperControllerTest.php b/tests/Controller/HelperControllerTest.php index 48f7339005..2aa6c3574b 100644 --- a/tests/Controller/HelperControllerTest.php +++ b/tests/Controller/HelperControllerTest.php @@ -224,7 +224,7 @@ public function testSetObjectFieldValueAction(): void $container->set('sonata.post.admin.template_registry', $templateRegistry); $this->pool->method('getPropertyAccessor')->willReturn($propertyAccessor); $this->twig->method('getExtension')->with(SonataAdminExtension::class)->willReturn( - new SonataAdminExtension($pool, null, $translator, $container) + new SonataAdminExtension($pool, null, $translator, $container, $propertyAccessor) ); $this->twig->method('load')->with('admin_template')->willReturn(new TemplateWrapper($this->twig, $template)); $this->twig->method('isDebug')->willReturn(false); @@ -280,7 +280,7 @@ public function testSetObjectFieldValueActionOnARelationField(): void $this->admin->method('getModelManager')->willReturn($modelManager); $this->validator->method('validate')->with($object)->willReturn(new ConstraintViolationList([])); $this->twig->method('getExtension')->with(SonataAdminExtension::class)->willReturn( - new SonataAdminExtension($this->pool, null, $translator, $container) + new SonataAdminExtension($this->pool, null, $translator, $container, $propertyAccessor) ); $this->twig->method('load')->with('field_template')->willReturn(new TemplateWrapper($this->twig, $template)); $this->twig->method('isDebug')->willReturn(false); diff --git a/tests/Twig/Extension/CanonicalizeExtensionTest.php b/tests/Twig/Extension/CanonicalizeExtensionTest.php new file mode 100644 index 0000000000..ad5a6e5334 --- /dev/null +++ b/tests/Twig/Extension/CanonicalizeExtensionTest.php @@ -0,0 +1,247 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\AdminBundle\Tests\Twig\Extension; + +use PHPUnit\Framework\TestCase; +use Sonata\AdminBundle\Twig\Extension\CanonicalizeExtension; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * @author Andrej Hudec + */ +final class CanonicalizeExtensionTest extends TestCase +{ + /** + * @var RequestStack + */ + private $requestStack; + + /** + * @var CanonicalizeExtension + */ + private $twigExtension; + + protected function setUp(): void + { + $this->requestStack = new RequestStack(); + $this->requestStack->push(new Request()); + $this->twigExtension = new CanonicalizeExtension($this->requestStack); + } + + /** + * @dataProvider momentLocalesProvider + */ + public function testCanonicalizedLocaleForMoment(?string $expected, string $original): void + { + $this->changeLocale($original); + $this->assertSame($expected, $this->twigExtension->getCanonicalizedLocaleForMoment()); + } + + /** + * @dataProvider select2LocalesProvider + */ + public function testCanonicalizedLocaleForSelect2(?string $expected, string $original): void + { + $this->changeLocale($original); + $this->assertSame($expected, $this->twigExtension->getCanonicalizedLocaleForSelect2()); + } + + /** + * @return array + */ + public function momentLocalesProvider(): array + { + return [ + ['af', 'af'], + ['ar-dz', 'ar-dz'], + ['ar', 'ar'], + ['ar-ly', 'ar-ly'], + ['ar-ma', 'ar-ma'], + ['ar-sa', 'ar-sa'], + ['ar-tn', 'ar-tn'], + ['az', 'az'], + ['be', 'be'], + ['bg', 'bg'], + ['bn', 'bn'], + ['bo', 'bo'], + ['br', 'br'], + ['bs', 'bs'], + ['ca', 'ca'], + ['cs', 'cs'], + ['cv', 'cv'], + ['cy', 'cy'], + ['da', 'da'], + ['de-at', 'de-at'], + ['de', 'de'], + ['de', 'de-de'], + ['dv', 'dv'], + ['el', 'el'], + [null, 'en'], + [null, 'en-us'], + ['en-au', 'en-au'], + ['en-ca', 'en-ca'], + ['en-gb', 'en-gb'], + ['en-ie', 'en-ie'], + ['en-nz', 'en-nz'], + ['eo', 'eo'], + ['es-do', 'es-do'], + ['es', 'es-ar'], + ['es', 'es-mx'], + ['es', 'es'], + ['et', 'et'], + ['eu', 'eu'], + ['fa', 'fa'], + ['fi', 'fi'], + ['fo', 'fo'], + ['fr-ca', 'fr-ca'], + ['fr-ch', 'fr-ch'], + ['fr', 'fr-fr'], + ['fr', 'fr'], + ['fy', 'fy'], + ['gd', 'gd'], + ['gl', 'gl'], + ['he', 'he'], + ['hi', 'hi'], + ['hr', 'hr'], + ['hu', 'hu'], + ['hy-am', 'hy-am'], + ['id', 'id'], + ['is', 'is'], + ['it', 'it'], + ['ja', 'ja'], + ['jv', 'jv'], + ['ka', 'ka'], + ['kk', 'kk'], + ['km', 'km'], + ['ko', 'ko'], + ['ky', 'ky'], + ['lb', 'lb'], + ['lo', 'lo'], + ['lt', 'lt'], + ['lv', 'lv'], + ['me', 'me'], + ['mi', 'mi'], + ['mk', 'mk'], + ['ml', 'ml'], + ['mr', 'mr'], + ['ms', 'ms'], + ['ms-my', 'ms-my'], + ['my', 'my'], + ['nb', 'nb'], + ['ne', 'ne'], + ['nl-be', 'nl-be'], + ['nl', 'nl'], + ['nl', 'nl-nl'], + ['nn', 'nn'], + ['pa-in', 'pa-in'], + ['pl', 'pl'], + ['pt-br', 'pt-br'], + ['pt', 'pt'], + ['ro', 'ro'], + ['ru', 'ru'], + ['se', 'se'], + ['si', 'si'], + ['sk', 'sk'], + ['sl', 'sl'], + ['sq', 'sq'], + ['sr-cyrl', 'sr-cyrl'], + ['sr', 'sr'], + ['ss', 'ss'], + ['sv', 'sv'], + ['sw', 'sw'], + ['ta', 'ta'], + ['te', 'te'], + ['tet', 'tet'], + ['th', 'th'], + ['tlh', 'tlh'], + ['tl-ph', 'tl-ph'], + ['tr', 'tr'], + ['tzl', 'tzl'], + ['tzm', 'tzm'], + ['tzm-latn', 'tzm-latn'], + ['uk', 'uk'], + ['uz', 'uz'], + ['vi', 'vi'], + ['x-pseudo', 'x-pseudo'], + ['yo', 'yo'], + ['zh-cn', 'zh-cn'], + ['zh-hk', 'zh-hk'], + ['zh-tw', 'zh-tw'], + ]; + } + + /** + * @return array + */ + public function select2LocalesProvider() + { + return [ + ['ar', 'ar'], + ['az', 'az'], + ['bg', 'bg'], + ['ca', 'ca'], + ['cs', 'cs'], + ['da', 'da'], + ['de', 'de'], + ['el', 'el'], + [null, 'en'], + ['es', 'es'], + ['et', 'et'], + ['eu', 'eu'], + ['fa', 'fa'], + ['fi', 'fi'], + ['fr', 'fr'], + ['gl', 'gl'], + ['he', 'he'], + ['hr', 'hr'], + ['hu', 'hu'], + ['id', 'id'], + ['is', 'is'], + ['it', 'it'], + ['ja', 'ja'], + ['ka', 'ka'], + ['ko', 'ko'], + ['lt', 'lt'], + ['lv', 'lv'], + ['mk', 'mk'], + ['ms', 'ms'], + ['nb', 'nb'], + ['nl', 'nl'], + ['pl', 'pl'], + ['pt-PT', 'pt'], + ['pt-BR', 'pt-BR'], + ['pt-PT', 'pt-PT'], + ['ro', 'ro'], + ['rs', 'rs'], + ['ru', 'ru'], + ['sk', 'sk'], + ['sv', 'sv'], + ['th', 'th'], + ['tr', 'tr'], + ['ug-CN', 'ug'], + ['ug-CN', 'ug-CN'], + ['uk', 'uk'], + ['vi', 'vi'], + ['zh-CN', 'zh'], + ['zh-CN', 'zh-CN'], + ['zh-TW', 'zh-TW'], + ]; + } + + private function changeLocale(string $locale): void + { + $this->requestStack->getCurrentRequest()->setLocale($locale); + } +} diff --git a/tests/Twig/Extension/RenderElementExtensionTest.php b/tests/Twig/Extension/RenderElementExtensionTest.php new file mode 100644 index 0000000000..02acad731c --- /dev/null +++ b/tests/Twig/Extension/RenderElementExtensionTest.php @@ -0,0 +1,2590 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\AdminBundle\Tests\Twig\Extension; + +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Sonata\AdminBundle\Admin\AbstractAdmin; +use Sonata\AdminBundle\Admin\AdminInterface; +use Sonata\AdminBundle\Admin\FieldDescriptionInterface; +use Sonata\AdminBundle\Admin\Pool; +use Sonata\AdminBundle\Exception\NoValueException; +use Sonata\AdminBundle\Templating\TemplateRegistryInterface; +use Sonata\AdminBundle\Tests\Fixtures\Entity\FooToString; +use Sonata\AdminBundle\Tests\Fixtures\StubFilesystemLoader; +use Sonata\AdminBundle\Twig\Extension\RenderElementExtension; +use Sonata\AdminBundle\Twig\Extension\SonataAdminExtension; +use Sonata\AdminBundle\Twig\Extension\XEditableExtension; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; +use Symfony\Bridge\Twig\Extension\RoutingExtension; +use Symfony\Bridge\Twig\Extension\TranslationExtension; +use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\Container; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessor; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Routing\Generator\UrlGenerator; +use Symfony\Component\Routing\Loader\XmlFileLoader; +use Symfony\Component\Routing\RequestContext; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Translation\Loader\XliffFileLoader; +use Symfony\Component\Translation\Translator; +use Symfony\Contracts\Translation\TranslatorInterface; +use Twig\Environment; +use Twig\Error\LoaderError; +use Twig\Extra\String\StringExtension; + +/** + * @author Andrej Hudec + */ +final class RenderElementExtensionTest extends TestCase +{ + use ExpectDeprecationTrait; + private const X_EDITABLE_TYPE_MAPPING = [ + 'choice' => 'select', + 'boolean' => 'select', + 'text' => 'text', + 'textarea' => 'textarea', + 'html' => 'textarea', + 'email' => 'email', + 'string' => 'text', + 'smallint' => 'text', + 'bigint' => 'text', + 'integer' => 'number', + 'decimal' => 'number', + 'currency' => 'number', + 'percent' => 'number', + 'url' => 'url', + ]; + + /** + * @var RenderElementExtension + */ + private $twigExtension; + + /** + * @var Environment + */ + private $environment; + + /** + * @var AdminInterface + */ + private $admin; + + /** + * @var AdminInterface + */ + private $adminBar; + + /** + * @var FieldDescriptionInterface + */ + private $fieldDescription; + + /** + * @var \stdClass + */ + private $object; + + /** + * @var Pool + */ + private $pool; + + /** + * @var LoggerInterface + */ + private $logger; + + /** + * @var TranslatorInterface + */ + private $translator; + + /** + * @var Container + */ + private $container; + + /** + * @var TemplateRegistryInterface + */ + private $templateRegistry; + + /** + * @var PropertyAccessor + */ + private $propertyAccessor; + + protected function setUp(): void + { + date_default_timezone_set('Europe/London'); + + $container = new Container(); + + $this->pool = new Pool( + $container, + ['sonata_admin_foo_service'], + [], + ['fooClass' => ['sonata_admin_foo_service']] + ); + + $this->logger = $this->getMockForAbstractClass(LoggerInterface::class); + + // translation extension + $translator = new Translator('en'); + $translator->addLoader('xlf', new XliffFileLoader()); + $translator->addResource( + 'xlf', + sprintf('%s/../../../src/Resources/translations/SonataAdminBundle.en.xliff', __DIR__), + 'en', + 'SonataAdminBundle' + ); + + $this->translator = $translator; + + $this->templateRegistry = $this->createStub(TemplateRegistryInterface::class); + $this->container = new Container(); + $this->container->set('sonata_admin_foo_service.template_registry', $this->templateRegistry); + $propertyAccessor = PropertyAccess::createPropertyAccessor(); + + $request = $this->createMock(Request::class); + $request->method('get')->with('_sonata_admin')->willReturn('sonata_admin_foo_service'); + + $loader = new StubFilesystemLoader([ + __DIR__.'/../../../src/Resources/views/CRUD', + __DIR__.'/../../Fixtures/Resources/views/CRUD', + ]); + $loader->addPath(__DIR__.'/../../../src/Resources/views/', 'SonataAdmin'); + $loader->addPath(__DIR__.'/../../Fixtures/Resources/views/', 'App'); + + $this->environment = new Environment($loader, [ + 'strict_variables' => true, + 'cache' => false, + 'autoescape' => 'html', + 'optimizations' => 0, + ]); + + $this->twigExtension = new RenderElementExtension( + $propertyAccessor, + $this->container, + $this->logger, + ); + + $this->registerRequiredTwigExtensions($propertyAccessor); + + // initialize object + $this->object = new \stdClass(); + + // initialize admin + $this->admin = $this->createMock(AbstractAdmin::class); + + $this->admin + ->method('getCode') + ->willReturn('sonata_admin_foo_service'); + + $this->admin + ->method('id') + ->with($this->equalTo($this->object)) + ->willReturn(12345); + + $this->admin + ->method('getNormalizedIdentifier') + ->with($this->equalTo($this->object)) + ->willReturn(12345); + + $this->adminBar = $this->createMock(AbstractAdmin::class); + $this->adminBar + ->method('hasAccess') + ->willReturn(true); + $this->adminBar + ->method('getNormalizedIdentifier') + ->with($this->equalTo($this->object)) + ->willReturn(12345); + + $container->set('sonata_admin_foo_service', $this->admin); + $container->set('sonata_admin_bar_service', $this->adminBar); + + // initialize field description + $this->fieldDescription = $this->getMockForAbstractClass(FieldDescriptionInterface::class); + + $this->fieldDescription + ->method('getName') + ->willReturn('fd_name'); + + $this->fieldDescription + ->method('getAdmin') + ->willReturn($this->admin); + + $this->fieldDescription + ->method('getLabel') + ->willReturn('Data'); + } + + /** + * NEXT_MAJOR: Remove legacy group. + * + * @group legacy + * @dataProvider getRenderListElementTests + */ + public function testRenderListElement(string $expected, string $type, $value, array $options): void + { + $this->admin + ->method('getPersistentParameters') + ->willReturn(['context' => 'foo']); + + $this->admin + ->method('hasAccess') + ->willReturn(true); + + // NEXT_MAJOR: Remove this line + $this->admin + ->method('getTemplate') + ->with('base_list_field') + ->willReturn('@SonataAdmin/CRUD/base_list_field.html.twig'); + + $this->templateRegistry->method('getTemplate')->with('base_list_field') + ->willReturn('@SonataAdmin/CRUD/base_list_field.html.twig'); + + $this->fieldDescription + ->method('getValue') + ->willReturn($value); + + $this->fieldDescription + ->method('getType') + ->willReturn($type); + + $this->fieldDescription + ->method('getOptions') + ->willReturn($options); + + $this->fieldDescription + ->method('getOption') + ->willReturnCallback(static function ($name, $default = null) use ($options) { + return $options[$name] ?? $default; + }); + + $this->fieldDescription + ->method('getTemplate') + ->willReturnCallback(static function () use ($type): ?string { + switch ($type) { + case 'string': + return '@SonataAdmin/CRUD/list_string.html.twig'; + case 'boolean': + return '@SonataAdmin/CRUD/list_boolean.html.twig'; + case 'datetime': + return '@SonataAdmin/CRUD/list_datetime.html.twig'; + case 'date': + return '@SonataAdmin/CRUD/list_date.html.twig'; + case 'time': + return '@SonataAdmin/CRUD/list_time.html.twig'; + case 'currency': + return '@SonataAdmin/CRUD/list_currency.html.twig'; + case 'percent': + return '@SonataAdmin/CRUD/list_percent.html.twig'; + case 'email': + return '@SonataAdmin/CRUD/list_email.html.twig'; + case 'choice': + return '@SonataAdmin/CRUD/list_choice.html.twig'; + case 'array': + return '@SonataAdmin/CRUD/list_array.html.twig'; + case 'trans': + return '@SonataAdmin/CRUD/list_trans.html.twig'; + case 'url': + return '@SonataAdmin/CRUD/list_url.html.twig'; + case 'html': + return '@SonataAdmin/CRUD/list_html.html.twig'; + case 'nonexistent': + // template doesn`t exist + return '@SonataAdmin/CRUD/list_nonexistent_template.html.twig'; + default: + return null; + } + }); + + // NEXT_MAJOR: Remove next line. + $this->expectDeprecation('The Sonata\AdminBundle\Admin\AbstractAdmin::getTemplate method is deprecated (since sonata-project/admin-bundle 3.34, will be dropped in 4.0. Use TemplateRegistry services instead).'); + + $this->assertSame( + $this->removeExtraWhitespace($expected), + $this->removeExtraWhitespace($this->twigExtension->renderListElement( + $this->environment, + $this->object, + $this->fieldDescription, + )) + ); + } + + /** + * NEXT_MAJOR: Remove legacy group. + * + * @group legacy + */ + public function testRenderListElementWithAdditionalValuesInArray(): void + { + // NEXT_MAJOR: Remove this line + $this->admin + ->method('getTemplate') + ->with('base_list_field') + ->willReturn('@SonataAdmin/CRUD/base_list_field.html.twig'); + + $this->templateRegistry->method('getTemplate')->with('base_list_field') + ->willReturn('@SonataAdmin/CRUD/base_list_field.html.twig'); + + $this->fieldDescription + ->method('getTemplate') + ->willReturn('@SonataAdmin/CRUD/list_string.html.twig'); + + // NEXT_MAJOR: Remove next line. + $this->expectDeprecation('The Sonata\AdminBundle\Admin\AbstractAdmin::getTemplate method is deprecated (since sonata-project/admin-bundle 3.34, will be dropped in 4.0. Use TemplateRegistry services instead).'); + + $this->assertSame( + $this->removeExtraWhitespace(' Extra value '), + $this->removeExtraWhitespace($this->twigExtension->renderListElement( + $this->environment, + [$this->object, 'fd_name' => 'Extra value'], + $this->fieldDescription + )) + ); + } + + /** + * NEXT_MAJOR: Remove legacy group. + * + * @group legacy + */ + public function testRenderListElementWithNoValueException(): void + { + // NEXT_MAJOR: Remove this line + $this->admin + ->method('getTemplate') + ->with('base_list_field') + ->willReturn('@SonataAdmin/CRUD/base_list_field.html.twig'); + + $this->templateRegistry->method('getTemplate')->with('base_list_field') + ->willReturn('@SonataAdmin/CRUD/base_list_field.html.twig'); + + $this->fieldDescription + ->method('getValue') + ->willReturnCallback(static function (): void { + throw new NoValueException(); + }); + + // NEXT_MAJOR: Remove next line. + $this->expectDeprecation('Accessing a non existing value for the field "fd_name" is deprecated since sonata-project/admin-bundle 3.67 and will throw an exception in 4.0.'); + + $this->assertSame( + $this->removeExtraWhitespace(' '), + $this->removeExtraWhitespace($this->twigExtension->renderListElement( + $this->environment, + $this->object, + $this->fieldDescription + )) + ); + } + + /** + * NEXT_MAJOR: Remove this method. + * + * @dataProvider getDeprecatedRenderListElementTests + * @group legacy + */ + public function testDeprecatedRenderListElement(string $expected, ?string $value, array $options): void + { + $this->admin + ->method('hasAccess') + ->willReturn(true); + + // NEXT_MAJOR: Remove this line + $this->admin + ->method('getTemplate') + ->with('base_list_field') + ->willReturn('@SonataAdmin/CRUD/base_list_field.html.twig'); + + $this->templateRegistry->method('getTemplate')->with('base_list_field') + ->willReturn('@SonataAdmin/CRUD/base_list_field.html.twig'); + + $this->fieldDescription + ->method('getValue') + ->willReturn($value); + + $this->fieldDescription + ->method('getType') + ->willReturn('nonexistent'); + + $this->fieldDescription + ->method('getOptions') + ->willReturn($options); + + $this->fieldDescription + ->method('getOption') + ->willReturnCallback(static function ($name, $default = null) use ($options) { + return $options[$name] ?? $default; + }); + + $this->fieldDescription + ->method('getTemplate') + ->willReturn('@SonataAdmin/CRUD/list_nonexistent_template.html.twig'); + + $this->assertSame( + $this->removeExtraWhitespace($expected), + $this->removeExtraWhitespace($this->twigExtension->renderListElement( + $this->environment, + $this->object, + $this->fieldDescription + )) + ); + } + + /** + * @group legacy + */ + public function testRenderListElementNonExistentTemplate(): void + { + // NEXT_MAJOR: Remove this line + $this->admin->method('getTemplate') + ->with('base_list_field') + ->willReturn('@SonataAdmin/CRUD/base_list_field.html.twig'); + + $this->templateRegistry->method('getTemplate')->with('base_list_field') + ->willReturn('@SonataAdmin/CRUD/base_list_field.html.twig'); + + $this->fieldDescription->expects($this->once()) + ->method('getValue') + ->willReturn('Foo'); + + $this->fieldDescription->expects($this->once()) + ->method('getFieldName') + ->willReturn('Foo_name'); + + $this->fieldDescription->expects($this->exactly(2)) + ->method('getType') + ->willReturn('nonexistent'); + + $this->fieldDescription->expects($this->once()) + ->method('getTemplate') + ->willReturn('@SonataAdmin/CRUD/list_nonexistent_template.html.twig'); + + $this->logger->expects($this->once()) + ->method('warning') + ->with(($this->stringStartsWith($this->removeExtraWhitespace( + 'An error occured trying to load the template + "@SonataAdmin/CRUD/list_nonexistent_template.html.twig" + for the field "Foo_name", the default template + "@SonataAdmin/CRUD/base_list_field.html.twig" was used + instead.' + )))); + + $this->twigExtension->renderListElement($this->environment, $this->object, $this->fieldDescription); + } + + /** + * @group legacy + */ + public function testRenderListElementErrorLoadingTemplate(): void + { + $this->expectException(LoaderError::class); + $this->expectExceptionMessage('Unable to find template "@SonataAdmin/CRUD/base_list_nonexistent_field.html.twig"'); + + // NEXT_MAJOR: Remove this line + $this->admin->method('getTemplate') + ->with('base_list_field') + ->willReturn('@SonataAdmin/CRUD/base_list_nonexistent_field.html.twig'); + + $this->templateRegistry->method('getTemplate')->with('base_list_field') + ->willReturn('@SonataAdmin/CRUD/base_list_nonexistent_field.html.twig'); + + $this->fieldDescription->expects($this->once()) + ->method('getTemplate') + ->willReturn('@SonataAdmin/CRUD/list_nonexistent_template.html.twig'); + + $this->twigExtension->renderListElement($this->environment, $this->object, $this->fieldDescription); + + $this->templateRegistry->getTemplate('base_list_field')->shouldHaveBeenCalled(); + } + + /** + * @group legacy + * @expectedDeprecation The Sonata\AdminBundle\Admin\AbstractAdmin::getTemplate method is deprecated (since sonata-project/admin-bundle 3.34, will be dropped in 4.0. Use TemplateRegistry services instead). + */ + public function testRenderWithDebug(): void + { + $this->fieldDescription + ->method('getTemplate') + ->willReturn('@SonataAdmin/CRUD/base_list_field.html.twig'); + + $this->fieldDescription + ->method('getFieldName') + ->willReturn('fd_name'); + + $this->fieldDescription + ->method('getValue') + ->willReturn('foo'); + + $parameters = [ + 'admin' => $this->admin, + 'value' => 'foo', + 'field_description' => $this->fieldDescription, + 'object' => $this->object, + ]; + + $this->environment->enableDebug(); + + $this->assertSame( + $this->removeExtraWhitespace( + <<<'EOT' + + foo + +EOT + ), + $this->removeExtraWhitespace( + $this->twigExtension->renderListElement($this->environment, $this->object, $this->fieldDescription, $parameters) + ) + ); + } + + /** + * @dataProvider getRenderViewElementTests + */ + public function testRenderViewElement(string $expected, string $type, $value, array $options): void + { + $this->admin + ->method('getTemplate') + ->willReturn('@SonataAdmin/CRUD/base_show_field.html.twig'); + + $this->fieldDescription + ->method('getValue') + ->willReturn($value); + + $this->fieldDescription + ->method('getType') + ->willReturn($type); + + $this->fieldDescription + ->method('getOptions') + ->willReturn($options); + + $this->fieldDescription + ->method('getTemplate') + ->willReturnCallback(static function () use ($type): ?string { + switch ($type) { + case 'boolean': + return '@SonataAdmin/CRUD/show_boolean.html.twig'; + case 'datetime': + return '@SonataAdmin/CRUD/show_datetime.html.twig'; + case 'date': + return '@SonataAdmin/CRUD/show_date.html.twig'; + case 'time': + return '@SonataAdmin/CRUD/show_time.html.twig'; + case 'currency': + return '@SonataAdmin/CRUD/show_currency.html.twig'; + case 'percent': + return '@SonataAdmin/CRUD/show_percent.html.twig'; + case 'email': + return '@SonataAdmin/CRUD/show_email.html.twig'; + case 'choice': + return '@SonataAdmin/CRUD/show_choice.html.twig'; + case 'array': + return '@SonataAdmin/CRUD/show_array.html.twig'; + case 'trans': + return '@SonataAdmin/CRUD/show_trans.html.twig'; + case 'url': + return '@SonataAdmin/CRUD/show_url.html.twig'; + case 'html': + return '@SonataAdmin/CRUD/show_html.html.twig'; + default: + return null; + } + }); + + $this->assertSame( + $this->removeExtraWhitespace($expected), + $this->removeExtraWhitespace( + $this->twigExtension->renderViewElement( + $this->environment, + $this->fieldDescription, + $this->object + ) + ) + ); + } + + /** + * @group legacy + * @assertDeprecation Accessing a non existing value is deprecated since sonata-project/admin-bundle 3.67 and will throw an exception in 4.0. + * + * @dataProvider getRenderViewElementWithNoValueTests + */ + public function testRenderViewElementWithNoValue(string $expected, string $type, array $options): void + { + $this->admin + ->method('getTemplate') + ->willReturn('@SonataAdmin/CRUD/base_show_field.html.twig'); + + $this->fieldDescription + ->method('getValue') + ->willThrowException(new NoValueException()); + + $this->fieldDescription + ->method('getType') + ->willReturn($type); + + $this->fieldDescription + ->method('getOptions') + ->willReturn($options); + + $this->fieldDescription + ->method('getTemplate') + ->willReturnCallback(static function () use ($type): ?string { + switch ($type) { + case 'boolean': + return '@SonataAdmin/CRUD/show_boolean.html.twig'; + case 'datetime': + return '@SonataAdmin/CRUD/show_datetime.html.twig'; + case 'date': + return '@SonataAdmin/CRUD/show_date.html.twig'; + case 'time': + return '@SonataAdmin/CRUD/show_time.html.twig'; + case 'currency': + return '@SonataAdmin/CRUD/show_currency.html.twig'; + case 'percent': + return '@SonataAdmin/CRUD/show_percent.html.twig'; + case 'email': + return '@SonataAdmin/CRUD/show_email.html.twig'; + case 'choice': + return '@SonataAdmin/CRUD/show_choice.html.twig'; + case 'array': + return '@SonataAdmin/CRUD/show_array.html.twig'; + case 'trans': + return '@SonataAdmin/CRUD/show_trans.html.twig'; + case 'url': + return '@SonataAdmin/CRUD/show_url.html.twig'; + case 'html': + return '@SonataAdmin/CRUD/show_html.html.twig'; + default: + return null; + } + }); + + $this->assertSame( + $this->removeExtraWhitespace($expected), + $this->removeExtraWhitespace( + $this->twigExtension->renderViewElement( + $this->environment, + $this->fieldDescription, + $this->object + ) + ) + ); + } + + public function getRenderViewElementWithNoValueTests(): iterable + { + return [ + // NoValueException + ['Data ', 'string', ['safe' => false]], + ['Data ', 'text', ['safe' => false]], + ['Data ', 'textarea', ['safe' => false]], + ['Data  ', 'datetime', []], + [ + 'Data  ', + 'datetime', + ['format' => 'd.m.Y H:i:s'], + ], + ['Data  ', 'date', []], + ['Data  ', 'date', ['format' => 'd.m.Y']], + ['Data  ', 'time', []], + ['Data ', 'number', ['safe' => false]], + ['Data ', 'integer', ['safe' => false]], + ['Data  ', 'percent', []], + ['Data  ', 'currency', ['currency' => 'EUR']], + ['Data  ', 'currency', ['currency' => 'GBP']], + ['Data
    ', 'array', ['safe' => false]], + [ + 'Data no', + 'boolean', + [], + ], + [ + 'Data ', + 'trans', + ['safe' => false, 'catalogue' => 'SonataAdminBundle'], + ], + [ + 'Data ', + 'choice', + ['safe' => false, 'choices' => []], + ], + [ + 'Data ', + 'choice', + ['safe' => false, 'choices' => [], 'multiple' => true], + ], + ['Data  ', 'url', []], + [ + 'Data  ', + 'url', + ['url' => 'http://example.com'], + ], + [ + 'Data  ', + 'url', + ['route' => ['name' => 'sonata_admin_foo']], + ], + ]; + } + + /** + * @dataProvider getRenderViewElementCompareTests + */ + public function testRenderViewElementCompare(string $expected, string $type, $value, array $options, ?string $objectName = null): void + { + $this->admin + ->method('getTemplate') + ->willReturn('@SonataAdmin/CRUD/base_show_compare.html.twig'); + + $this->fieldDescription + ->method('getValue') + ->willReturn($value); + + $this->fieldDescription + ->method('getType') + ->willReturn($type); + + $this->fieldDescription + ->method('getOptions') + ->willReturn($options); + + $this->fieldDescription + ->method('getTemplate') + ->willReturnCallback(static function () use ($type, $options): ?string { + if (isset($options['template'])) { + return $options['template']; + } + + switch ($type) { + case 'boolean': + return '@SonataAdmin/CRUD/show_boolean.html.twig'; + case 'datetime': + return '@SonataAdmin/CRUD/show_datetime.html.twig'; + case 'date': + return '@SonataAdmin/CRUD/show_date.html.twig'; + case 'time': + return '@SonataAdmin/CRUD/show_time.html.twig'; + case 'currency': + return '@SonataAdmin/CRUD/show_currency.html.twig'; + case 'percent': + return '@SonataAdmin/CRUD/show_percent.html.twig'; + case 'email': + return '@SonataAdmin/CRUD/show_email.html.twig'; + case 'choice': + return '@SonataAdmin/CRUD/show_choice.html.twig'; + case 'array': + return '@SonataAdmin/CRUD/show_array.html.twig'; + case 'trans': + return '@SonataAdmin/CRUD/show_trans.html.twig'; + case 'url': + return '@SonataAdmin/CRUD/show_url.html.twig'; + case 'html': + return '@SonataAdmin/CRUD/show_html.html.twig'; + default: + return null; + } + }); + + $this->object->name = 'SonataAdmin'; + + $comparedObject = clone $this->object; + + if (null !== $objectName) { + $comparedObject->name = $objectName; + } + + $this->assertSame( + $this->removeExtraWhitespace($expected), + $this->removeExtraWhitespace( + $this->twigExtension->renderViewElementCompare( + $this->environment, + $this->fieldDescription, + $this->object, + $comparedObject + ) + ) + ); + } + + public function testRenderRelationElementNoObject(): void + { + $this->assertSame('foo', $this->twigExtension->renderRelationElement('foo', $this->fieldDescription)); + } + + public function testRenderRelationElementToString(): void + { + $this->fieldDescription->expects($this->exactly(2)) + ->method('getOption') + ->willReturnCallback(static function ($value, $default = null) { + if ('associated_property' === $value) { + return $default; + } + }); + + $element = new FooToString(); + $this->assertSame('salut', $this->twigExtension->renderRelationElement($element, $this->fieldDescription)); + } + + /** + * NEXT_MAJOR: Remove this test. + * + * @group legacy + */ + public function testDeprecatedRelationElementToString(): void + { + $this->fieldDescription->expects($this->exactly(2)) + ->method('getOption') + ->willReturnCallback(static function ($value, $default = null) { + if ('associated_tostring' === $value) { + return '__toString'; + } + }); + + $element = new FooToString(); + $this->assertSame( + 'salut', + $this->twigExtension->renderRelationElement($element, $this->fieldDescription) + ); + } + + public function testRenderRelationElementCustomToString(): void + { + $this->fieldDescription->expects($this->once()) + ->method('getOption') + ->willReturnCallback(static function ($value, $default = null) { + if ('associated_property' === $value) { + return 'customToString'; + } + }); + + $element = new class() { + public function customToString(): string + { + return 'fooBar'; + } + }; + + $this->assertSame('fooBar', $this->twigExtension->renderRelationElement($element, $this->fieldDescription)); + } + + /** + * NEXT_MAJOR: Remove legacy group. + * + * @group legacy + */ + public function testRenderRelationElementMethodNotExist(): void + { + // NEXT_MAJOR: change $this->exactly(2) for $this->once() + $this->fieldDescription->expects($this->exactly(2)) + ->method('getOption') + + ->willReturnCallback(static function ($value, $default = null) { + if ('associated_property' === $value) { + return null; + } + // NEXT_MAJOR: Remove next block. + if ('associated_tostring' === $value) { + return null; + } + }); + + $element = new \stdClass(); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('You must define an `associated_property` option or create a `stdClass::__toString'); + + $this->twigExtension->renderRelationElement($element, $this->fieldDescription); + } + + /** + * NEXT_MAJOR: Remove this method. + * + * @group legacy + */ + public function testRenderRelationElementMethodWithDeprecatedAssociatedToString(): void + { + $this->fieldDescription->expects($this->exactly(2)) + ->method('getOption') + ->willReturnCallback(static function ($value, $default = null) { + if ('associated_property' === $value) { + return null; + } + + if ('associated_tostring' === $value) { + return 'customToString'; + } + }); + + $element = new class() { + public function customToString(): string + { + return 'fooBar'; + } + }; + + $this->assertSame('fooBar', $this->twigExtension->renderRelationElement($element, $this->fieldDescription)); + } + + public function testRenderRelationElementWithPropertyPath(): void + { + $this->fieldDescription->expects($this->once()) + ->method('getOption') + + ->willReturnCallback(static function ($value, $default = null) { + if ('associated_property' === $value) { + return 'foo'; + } + }); + + $element = new \stdClass(); + $element->foo = 'bar'; + + $this->assertSame('bar', $this->twigExtension->renderRelationElement($element, $this->fieldDescription)); + } + + public function testRenderRelationElementWithClosure(): void + { + $this->fieldDescription->expects($this->once()) + ->method('getOption') + + ->willReturnCallback(static function ($value, $default = null) { + if ('associated_property' === $value) { + return static function ($element): string { + return sprintf('closure %s', $element->foo); + }; + } + }); + + $element = new \stdClass(); + $element->foo = 'bar'; + + $this->assertSame( + 'closure bar', + $this->twigExtension->renderRelationElement($element, $this->fieldDescription) + ); + } + + public function getRenderListElementTests(): array + { + return [ + [ + ' Example ', + 'string', + 'Example', + [], + ], + [ + ' ', + 'string', + null, + [], + ], + [ + ' Example ', + 'text', + 'Example', + [], + ], + [ + ' ', + 'text', + null, + [], + ], + [ + ' Example ', + 'textarea', + 'Example', + [], + ], + [ + ' ', + 'textarea', + null, + [], + ], + 'datetime field' => [ + ' + + ', + 'datetime', + new \DateTime('2013-12-24 10:11:12', new \DateTimeZone('Europe/London')), + [], + ], + [ + ' + + ', + 'datetime', + new \DateTime('2013-12-24 10:11:12', new \DateTimeZone('UTC')), + ['timezone' => 'Asia/Hong_Kong'], + ], + [ + '   ', + 'datetime', + null, + [], + ], + [ + ' + + ', + 'datetime', + new \DateTime('2013-12-24 10:11:12', new \DateTimeZone('Europe/London')), + ['format' => 'd.m.Y H:i:s'], + ], + [ + '   ', + 'datetime', + null, + ['format' => 'd.m.Y H:i:s'], + ], + [ + ' + + ', + 'datetime', + new \DateTime('2013-12-24 10:11:12', new \DateTimeZone('UTC')), + ['format' => 'd.m.Y H:i:s', 'timezone' => 'Asia/Hong_Kong'], + ], + [ + '   ', + 'datetime', + null, + ['format' => 'd.m.Y H:i:s', 'timezone' => 'Asia/Hong_Kong'], + ], + [ + ' + + ', + 'date', + new \DateTime('2013-12-24 10:11:12', new \DateTimeZone('Europe/London')), + [], + ], + [ + '   ', + 'date', + null, + [], + ], + [ + ' + + ', + 'date', + new \DateTime('2013-12-24 10:11:12', new \DateTimeZone('Europe/London')), + ['format' => 'd.m.Y'], + ], + [ + '   ', + 'date', + null, + ['format' => 'd.m.Y'], + ], + [ + ' + + ', + 'time', + new \DateTime('2013-12-24 10:11:12', new \DateTimeZone('Europe/London')), + [], + ], + [ + ' + + ', + 'time', + new \DateTime('2013-12-24 10:11:12', new \DateTimeZone('UTC')), + ['timezone' => 'Asia/Hong_Kong'], + ], + [ + '   ', + 'time', + null, + [], + ], + [ + ' 10.746135 ', + 'number', 10.746135, + [], + ], + [ + ' ', + 'number', + null, + [], + ], + [ + ' 5678 ', + 'integer', + 5678, + [], + ], + [ + ' ', + 'integer', + null, + [], + ], + [ + ' 1074.6135 % ', + 'percent', + 10.746135, + [], + ], + [ + ' 0 % ', + 'percent', + 0, + [], + ], + [ + '   ', + 'percent', + null, + [], + ], + [ + ' EUR 10.746135 ', + 'currency', + 10.746135, + ['currency' => 'EUR'], + ], + [ + ' EUR 0 ', + 'currency', + 0, + ['currency' => 'EUR'], + ], + [ + ' GBP 51.23456 ', + 'currency', + 51.23456, + ['currency' => 'GBP'], + ], + [ + '   ', + 'currency', + null, + ['currency' => 'GBP'], + ], + [ + '   ', + 'email', + null, + [], + ], + [ + ' admin@admin.com ', + 'email', + 'admin@admin.com', + [], + ], + [ + ' + admin@admin.com ', + 'email', + 'admin@admin.com', + ['as_string' => false], + ], + [ + ' admin@admin.com ', + 'email', + 'admin@admin.com', + ['as_string' => true], + ], + [ + ' + admin@admin.com ', + 'email', + 'admin@admin.com', + ['subject' => 'Main Theme', 'body' => 'Message Body'], + ], + [ + ' + admin@admin.com ', + 'email', + 'admin@admin.com', + ['subject' => 'Main Theme'], + ], + [ + ' + admin@admin.com ', + 'email', + 'admin@admin.com', + ['body' => 'Message Body'], + ], + [ + ' admin@admin.com ', + 'email', + 'admin@admin.com', + ['as_string' => true, 'subject' => 'Main Theme', 'body' => 'Message Body'], + ], + [ + ' admin@admin.com ', + 'email', + 'admin@admin.com', + ['as_string' => true, 'body' => 'Message Body'], + ], + [ + ' admin@admin.com ', + 'email', + 'admin@admin.com', + ['as_string' => true, 'subject' => 'Main Theme'], + ], + [ + ' + [1 => First, 2 => Second] + ', + 'array', + [1 => 'First', 2 => 'Second'], + [], + ], + [ + ' [] ', + 'array', + null, + [], + ], + [ + ' + yes + ', + 'boolean', + true, + ['editable' => false], + ], + [ + ' + no + ', + 'boolean', + false, + ['editable' => false], + ], + [ + ' + no + ', + 'boolean', + null, + ['editable' => false], + ], + [ + <<<'EOT' + + + yes + + +EOT + , + 'boolean', + true, + ['editable' => true], + ], + [ + <<<'EOT' + + + no + +EOT + , + 'boolean', + false, + ['editable' => true], + ], + [ + <<<'EOT' + + + no + +EOT + , + 'boolean', + null, + ['editable' => true], + ], + [ + ' Delete ', + 'trans', + 'action_delete', + ['catalogue' => 'SonataAdminBundle'], + ], + [ + ' ', + 'trans', + null, + ['catalogue' => 'SonataAdminBundle'], + ], + [ + ' Delete ', + 'trans', + 'action_delete', + ['format' => '%s', 'catalogue' => 'SonataAdminBundle'], + ], + [ + ' + action.action_delete + ', + 'trans', + 'action_delete', + ['format' => 'action.%s'], + ], + [ + ' + action.action_delete + ', + 'trans', + 'action_delete', + ['format' => 'action.%s', 'catalogue' => 'SonataAdminBundle'], + ], + [ + ' Status1 ', + 'choice', + 'Status1', + [], + ], + [ + ' Status1 ', + 'choice', + ['Status1'], + ['choices' => [], 'multiple' => true], + ], + [ + ' Alias1 ', + 'choice', + 'Status1', + ['choices' => ['Status1' => 'Alias1', 'Status2' => 'Alias2', 'Status3' => 'Alias3']], + ], + [ + ' ', + 'choice', + null, + ['choices' => ['Status1' => 'Alias1', 'Status2' => 'Alias2', 'Status3' => 'Alias3']], + ], + [ + ' + NoValidKeyInChoices + ', + 'choice', + 'NoValidKeyInChoices', + ['choices' => ['Status1' => 'Alias1', 'Status2' => 'Alias2', 'Status3' => 'Alias3']], + ], + [ + ' Delete ', + 'choice', + 'Foo', + ['catalogue' => 'SonataAdminBundle', 'choices' => [ + 'Foo' => 'action_delete', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ]], + ], + [ + ' Alias1, Alias3 ', + 'choice', + ['Status1', 'Status3'], + ['choices' => [ + 'Status1' => 'Alias1', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ], 'multiple' => true], ], + [ + ' Alias1 | Alias3 ', + 'choice', + ['Status1', 'Status3'], + ['choices' => [ + 'Status1' => 'Alias1', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ], 'multiple' => true, 'delimiter' => ' | '], ], + [ + ' ', + 'choice', + null, + ['choices' => [ + 'Status1' => 'Alias1', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ], 'multiple' => true], + ], + [ + ' + NoValidKeyInChoices + ', + 'choice', + ['NoValidKeyInChoices'], + ['choices' => [ + 'Status1' => 'Alias1', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ], 'multiple' => true], + ], + [ + ' + NoValidKeyInChoices, Alias2 + ', + 'choice', + ['NoValidKeyInChoices', 'Status2'], + ['choices' => [ + 'Status1' => 'Alias1', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ], 'multiple' => true], + ], + [ + ' Delete, Alias3 ', + 'choice', + ['Foo', 'Status3'], + ['catalogue' => 'SonataAdminBundle', 'choices' => [ + 'Foo' => 'action_delete', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ], 'multiple' => true], + ], + [ + ' + <b>Alias1</b>, <b>Alias3</b> + ', + 'choice', + ['Status1', 'Status3'], + ['choices' => [ + 'Status1' => 'Alias1', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ], 'multiple' => true], ], + [ + <<<'EOT' + + + Status1 + + +EOT + , + 'choice', + 'Status1', + ['editable' => true], + ], + [ + <<<'EOT' + + + Alias1 + +EOT + , + 'choice', + 'Status1', + [ + 'editable' => true, + 'choices' => [ + 'Status1' => 'Alias1', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ], + ], + ], + [ + <<<'EOT' + + + + + +EOT + , + 'choice', + null, + [ + 'editable' => true, + 'choices' => [ + 'Status1' => 'Alias1', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ], + ], + ], + [ + <<<'EOT' + + + NoValidKeyInChoices + + +EOT + , + 'choice', + 'NoValidKeyInChoices', + [ + 'editable' => true, + 'choices' => [ + 'Status1' => 'Alias1', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ], + ], + ], + [ + <<<'EOT' + + + Delete + + +EOT + , + 'choice', + 'Foo', + [ + 'editable' => true, + 'catalogue' => 'SonataAdminBundle', + 'choices' => [ + 'Foo' => 'action_delete', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ], + ], + ], + [ + '   ', + 'url', + null, + [], + ], + [ + '   ', + 'url', + null, + ['url' => 'http://example.com'], + ], + [ + '   ', + 'url', + null, + ['route' => ['name' => 'sonata_admin_foo']], + ], + [ + ' + http://example.com + ', + 'url', + 'http://example.com', + [], + ], + [ + ' + https://example.com + ', + 'url', + 'https://example.com', + [], + ], + [ + ' + https://example.com + ', + 'url', + 'https://example.com', + ['attributes' => ['target' => '_blank']], + ], + [ + ' + https://example.com + ', + 'url', + 'https://example.com', + ['attributes' => ['target' => '_blank', 'class' => 'fooLink']], + ], + [ + ' + example.com + ', + 'url', + 'http://example.com', + ['hide_protocol' => true], + ], + [ + ' + example.com + ', + 'url', + 'https://example.com', + ['hide_protocol' => true], + ], + [ + ' + http://example.com + ', + 'url', + 'http://example.com', + ['hide_protocol' => false], + ], + [ + ' + https://example.com + ', + 'url', + 'https://example.com', + ['hide_protocol' => false], + ], + [ + ' + Foo + ', + 'url', + 'Foo', + ['url' => 'http://example.com'], + ], + [ + ' + <b>Foo</b> + ', + 'url', + 'Foo', + ['url' => 'http://example.com'], + ], + [ + ' + Foo + ', + 'url', + 'Foo', + ['route' => ['name' => 'sonata_admin_foo']], + ], + [ + ' + Foo + ', + 'url', + 'Foo', + ['route' => ['name' => 'sonata_admin_foo', 'absolute' => true]], + ], + [ + ' + foo/bar?a=b&c=123456789 + ', + 'url', + 'http://foo/bar?a=b&c=123456789', + ['route' => ['name' => 'sonata_admin_foo'], + 'hide_protocol' => true, ], + ], + [ + ' + foo/bar?a=b&c=123456789 + ', + 'url', + 'http://foo/bar?a=b&c=123456789', + [ + 'route' => ['name' => 'sonata_admin_foo', 'absolute' => true], + 'hide_protocol' => true, + ], + ], + [ + ' + Foo + ', + 'url', + 'Foo', + [ + 'route' => ['name' => 'sonata_admin_foo_param', + 'parameters' => ['param1' => 'abcd', 'param2' => 'efgh', 'param3' => 'ijkl'], ], + ], + ], + [ + ' + Foo + ', + 'url', + 'Foo', + [ + 'route' => ['name' => 'sonata_admin_foo_param', + 'absolute' => true, + 'parameters' => ['param1' => 'abcd', 'param2' => 'efgh', 'param3' => 'ijkl'], ], + ], + ], + [ + ' + Foo + ', + 'url', + 'Foo', + [ + 'route' => ['name' => 'sonata_admin_foo_object', + 'parameters' => ['param1' => 'abcd', 'param2' => 'efgh', 'param3' => 'ijkl'], + 'identifier_parameter_name' => 'barId', ], + ], + ], + [ + ' + Foo + ', + 'url', + 'Foo', + [ + 'route' => ['name' => 'sonata_admin_foo_object', + 'absolute' => true, + 'parameters' => ['param1' => 'abcd', 'param2' => 'efgh', 'param3' => 'ijkl'], + 'identifier_parameter_name' => 'barId', ], + ], + ], + [ + ' +

    Creating a Template for the Field and form

    + ', + 'html', + '

    Creating a Template for the Field and form

    ', + [], + ], + [ + ' + Creating a Template for the Field and form + ', + 'html', + '

    Creating a Template for the Field and form

    ', + ['strip' => true], + ], + [ + ' + Creating a Template for the... + ', + 'html', + '

    Creating a Template for the Field and form

    ', + ['truncate' => true], + ], + [ + ' Creatin... ', + 'html', + '

    Creating a Template for the Field and form

    ', + ['truncate' => ['length' => 10]], + ], + [ + ' + Creating a Template for the Field... + ', + 'html', + '

    Creating a Template for the Field and form

    ', + ['truncate' => ['cut' => false]], + ], + [ + ' + Creating a Template for t etc. + ', + 'html', + '

    Creating a Template for the Field and form

    ', + ['truncate' => ['ellipsis' => ' etc.']], + ], + [ + ' + Creating a Template[...] + ', + 'html', + '

    Creating a Template for the Field and form

    ', + [ + 'truncate' => [ + 'length' => 20, + 'cut' => false, + 'ellipsis' => '[...]', + ], + ], + ], + + [ + <<<'EOT' + +
    A very long string
    + +EOT + , + 'text', + 'A very long string', + [ + 'collapse' => true, + ], + ], + [ + <<<'EOT' + +
    A very long string
    + +EOT + , + 'text', + 'A very long string', + [ + 'collapse' => [ + 'height' => 10, + 'more' => 'More', + 'less' => 'Less', + ], + ], + ], + [ + <<<'EOT' + + + Delete, Alias2 + + +EOT + , + 'choice', + [ + 'Status1', + 'Status2', + ], + [ + 'editable' => true, + 'multiple' => true, + 'catalogue' => 'SonataAdminBundle', + 'choices' => [ + 'Status1' => 'action_delete', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ], + ], + ], + ]; + } + + // NEXT_MAJOR: Remove this method. + public function getDeprecatedRenderListElementTests(): array + { + return [ + [ + ' Example ', + 'Example', + [], + ], + [ + ' ', + null, + [], + ], + ]; + } + + public function getRenderViewElementTests(): array + { + return [ + ['Data Example', 'string', 'Example', ['safe' => false]], + ['Data Example', 'text', 'Example', ['safe' => false]], + ['Data Example', 'textarea', 'Example', ['safe' => false]], + [ + 'Data ', + 'datetime', + new \DateTime('2013-12-24 10:11:12', new \DateTimeZone('Europe/London')), [], + ], + [ + 'Data ', + 'datetime', + new \DateTime('2013-12-24 10:11:12', new \DateTimeZone('Europe/London')), + ['format' => 'd.m.Y H:i:s'], + ], + [ + 'Data ', + 'datetime', + new \DateTime('2013-12-24 10:11:12', new \DateTimeZone('UTC')), + ['timezone' => 'Asia/Hong_Kong'], + ], + [ + 'Data ', + 'date', + new \DateTime('2013-12-24 10:11:12', new \DateTimeZone('Europe/London')), + [], + ], + [ + 'Data ', + 'date', + new \DateTime('2013-12-24 10:11:12', new \DateTimeZone('Europe/London')), + ['format' => 'd.m.Y'], + ], + [ + 'Data ', + 'time', + new \DateTime('2013-12-24 10:11:12', new \DateTimeZone('Europe/London')), + [], + ], + [ + 'Data ', + 'time', + new \DateTime('2013-12-24 10:11:12', new \DateTimeZone('UTC')), + ['timezone' => 'Asia/Hong_Kong'], + ], + ['Data 10.746135', 'number', 10.746135, ['safe' => false]], + ['Data 5678', 'integer', 5678, ['safe' => false]], + ['Data 1074.6135 %', 'percent', 10.746135, []], + ['Data 0 %', 'percent', 0, []], + ['Data EUR 10.746135', 'currency', 10.746135, ['currency' => 'EUR']], + ['Data GBP 51.23456', 'currency', 51.23456, ['currency' => 'GBP']], + ['Data EUR 0', 'currency', 0, ['currency' => 'EUR']], + [ + 'Data
    • 1 => First
    • 2 => Second
    ', + 'array', + [1 => 'First', 2 => 'Second'], + ['safe' => false], + ], + [ + 'Data [1 => First, 2 => Second] ', + 'array', + [1 => 'First', 2 => 'Second'], + ['safe' => false, 'inline' => true], + ], + [ + 'Data yes', + 'boolean', + true, + [], + ], + [ + 'Data yes', + 'boolean', + true, + ['inverse' => true], + ], + ['Data no', 'boolean', false, []], + [ + 'Data no', + 'boolean', + false, + ['inverse' => true], + ], + [ + 'Data Delete ', + 'trans', + 'action_delete', + ['safe' => false, 'catalogue' => 'SonataAdminBundle'], + ], + [ + 'Data Delete ', + 'trans', + 'delete', + ['safe' => false, 'catalogue' => 'SonataAdminBundle', 'format' => 'action_%s'], + ], + ['Data Status1', 'choice', 'Status1', ['safe' => false]], + [ + 'Data Alias1', + 'choice', + 'Status1', + ['safe' => false, 'choices' => [ + 'Status1' => 'Alias1', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ]], + ], + [ + 'Data NoValidKeyInChoices', + 'choice', + 'NoValidKeyInChoices', + ['safe' => false, 'choices' => [ + 'Status1' => 'Alias1', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ]], + ], + [ + 'Data Delete', + 'choice', + 'Foo', + ['safe' => false, 'catalogue' => 'SonataAdminBundle', 'choices' => [ + 'Foo' => 'action_delete', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ]], + ], + [ + 'Data NoValidKeyInChoices', + 'choice', + ['NoValidKeyInChoices'], + ['safe' => false, 'choices' => [ + 'Status1' => 'Alias1', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ], 'multiple' => true], + ], + [ + 'Data NoValidKeyInChoices, Alias2', + 'choice', + ['NoValidKeyInChoices', 'Status2'], + ['safe' => false, 'choices' => [ + 'Status1' => 'Alias1', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ], 'multiple' => true], + ], + [ + 'Data Alias1, Alias3', + 'choice', + ['Status1', 'Status3'], + ['safe' => false, 'choices' => [ + 'Status1' => 'Alias1', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ], 'multiple' => true], + ], + [ + 'Data Alias1 | Alias3', + 'choice', + ['Status1', 'Status3'], ['safe' => false, 'choices' => [ + 'Status1' => 'Alias1', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ], 'multiple' => true, 'delimiter' => ' | '], + ], + [ + 'Data Delete, Alias3', + 'choice', + ['Foo', 'Status3'], + ['safe' => false, 'catalogue' => 'SonataAdminBundle', 'choices' => [ + 'Foo' => 'action_delete', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ], 'multiple' => true], + ], + [ + 'Data Alias1, Alias3', + 'choice', + ['Status1', 'Status3'], + ['safe' => true, 'choices' => [ + 'Status1' => 'Alias1', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ], 'multiple' => true], + ], + [ + 'Data <b>Alias1</b>, <b>Alias3</b>', + 'choice', + ['Status1', 'Status3'], + ['safe' => false, 'choices' => [ + 'Status1' => 'Alias1', + 'Status2' => 'Alias2', + 'Status3' => 'Alias3', + ], 'multiple' => true], + ], + [ + 'Data http://example.com', + 'url', + 'http://example.com', + ['safe' => false], + ], + [ + 'Data http://example.com', + 'url', + 'http://example.com', + ['safe' => false, 'attributes' => ['target' => '_blank']], + ], + [ + 'Data http://example.com', + 'url', + 'http://example.com', + ['safe' => false, 'attributes' => ['target' => '_blank', 'class' => 'fooLink']], + ], + [ + 'Data https://example.com', + 'url', + 'https://example.com', + ['safe' => false], + ], + [ + 'Data example.com', + 'url', + 'http://example.com', + ['safe' => false, 'hide_protocol' => true], + ], + [ + 'Data example.com', + 'url', + 'https://example.com', + ['safe' => false, 'hide_protocol' => true], + ], + [ + 'Data http://example.com', + 'url', + 'http://example.com', + ['safe' => false, 'hide_protocol' => false], + ], + [ + 'Data https://example.com', + 'url', + 'https://example.com', + ['safe' => false, + 'hide_protocol' => false, ], + ], + [ + 'Data Foo', + 'url', + 'Foo', + ['safe' => false, 'url' => 'http://example.com'], + ], + [ + 'Data <b>Foo</b>', + 'url', + 'Foo', + ['safe' => false, 'url' => 'http://example.com'], + ], + [ + 'Data Foo', + 'url', + 'Foo', + ['safe' => true, 'url' => 'http://example.com'], + ], + [ + 'Data Foo', + 'url', + 'Foo', + ['safe' => false, 'route' => ['name' => 'sonata_admin_foo']], + ], + [ + 'Data Foo', + 'url', + 'Foo', + ['safe' => false, 'route' => [ + 'name' => 'sonata_admin_foo', + 'absolute' => true, + ]], + ], + [ + 'Data foo/bar?a=b&c=123456789', + 'url', + 'http://foo/bar?a=b&c=123456789', + [ + 'safe' => false, + 'route' => ['name' => 'sonata_admin_foo'], + 'hide_protocol' => true, + ], + ], + [ + 'Data foo/bar?a=b&c=123456789', + 'url', + 'http://foo/bar?a=b&c=123456789', + ['safe' => false, 'route' => [ + 'name' => 'sonata_admin_foo', + 'absolute' => true, + ], 'hide_protocol' => true], + ], + [ + 'Data Foo', + 'url', + 'Foo', + ['safe' => false, 'route' => [ + 'name' => 'sonata_admin_foo_param', + 'parameters' => ['param1' => 'abcd', 'param2' => 'efgh', 'param3' => 'ijkl'], + ]], + ], + [ + 'Data Foo', + 'url', + 'Foo', + ['safe' => false, 'route' => [ + 'name' => 'sonata_admin_foo_param', + 'absolute' => true, + 'parameters' => [ + 'param1' => 'abcd', + 'param2' => 'efgh', + 'param3' => 'ijkl', + ], + ]], + ], + [ + 'Data Foo', + 'url', + 'Foo', + ['safe' => false, 'route' => [ + 'name' => 'sonata_admin_foo_object', + 'parameters' => [ + 'param1' => 'abcd', + 'param2' => 'efgh', + 'param3' => 'ijkl', + ], + 'identifier_parameter_name' => 'barId', + ]], + ], + [ + 'Data Foo', + 'url', + 'Foo', + ['safe' => false, 'route' => [ + 'name' => 'sonata_admin_foo_object', + 'absolute' => true, + 'parameters' => [ + 'param1' => 'abcd', + 'param2' => 'efgh', + 'param3' => 'ijkl', + ], + 'identifier_parameter_name' => 'barId', + ]], + ], + [ + 'Data  ', + 'email', + null, + [], + ], + [ + 'Data admin@admin.com', + 'email', + 'admin@admin.com', + [], + ], + [ + 'Data admin@admin.com', + 'email', + 'admin@admin.com', + ['subject' => 'Main Theme', 'body' => 'Message Body'], + ], + [ + 'Data admin@admin.com', + 'email', + 'admin@admin.com', + ['subject' => 'Main Theme'], + ], + [ + 'Data admin@admin.com', + 'email', + 'admin@admin.com', + ['body' => 'Message Body'], + ], + [ + 'Data admin@admin.com', + 'email', + 'admin@admin.com', + ['as_string' => true, 'subject' => 'Main Theme', 'body' => 'Message Body'], + ], + [ + 'Data admin@admin.com', + 'email', + 'admin@admin.com', + ['as_string' => true, 'subject' => 'Main Theme'], + ], + [ + 'Data admin@admin.com', + 'email', + 'admin@admin.com', + ['as_string' => true, 'body' => 'Message Body'], + ], + [ + 'Data admin@admin.com', + 'email', + 'admin@admin.com', + ['as_string' => false], + ], + [ + 'Data admin@admin.com', + 'email', + 'admin@admin.com', + ['as_string' => true], + ], + [ + 'Data

    Creating a Template for the Field and form

    ', + 'html', + '

    Creating a Template for the Field and form

    ', + [], + ], + [ + 'Data Creating a Template for the Field and form ', + 'html', + '

    Creating a Template for the Field and form

    ', + ['strip' => true], + ], + [ + 'Data Creating a Template for the... ', + 'html', + '

    Creating a Template for the Field and form

    ', + ['truncate' => true], + ], + [ + 'Data Creatin... ', + 'html', + '

    Creating a Template for the Field and form

    ', + ['truncate' => ['length' => 10]], + ], + [ + 'Data Creating a Template for the Field... ', + 'html', + '

    Creating a Template for the Field and form

    ', + ['truncate' => ['cut' => false]], + ], + [ + 'Data Creating a Template for t etc. ', + 'html', + '

    Creating a Template for the Field and form

    ', + ['truncate' => ['ellipsis' => ' etc.']], + ], + [ + 'Data Creating a Template[...] ', + 'html', + '

    Creating a Template for the Field and form

    ', + [ + 'truncate' => [ + 'length' => 20, + 'cut' => false, + 'ellipsis' => '[...]', + ], + ], + ], + [ + <<<'EOT' +Data
    + A very long string +
    +EOT + , + 'text', + ' A very long string ', + [ + 'collapse' => true, + 'safe' => false, + ], + ], + [ + <<<'EOT' +Data
    + A very long string +
    +EOT + , + 'text', + ' A very long string ', + [ + 'collapse' => [ + 'height' => 10, + 'more' => 'More', + 'less' => 'Less', + ], + 'safe' => false, + ], + ], + ]; + } + + public function getRenderViewElementCompareTests(): iterable + { + return [ + ['Data ExampleExample', 'string', 'Example', ['safe' => false]], + ['Data ExampleExample', 'text', 'Example', ['safe' => false]], + ['Data ExampleExample', 'textarea', 'Example', ['safe' => false]], + ['Data SonataAdmin
    ExampleSonataAdmin
    Example', 'virtual_field', 'Example', ['template' => 'custom_show_field.html.twig', 'safe' => false, 'SonataAdmin']], + ['Data SonataAdmin
    Examplesonata-project/admin-bundle
    Example', 'virtual_field', 'Example', ['template' => 'custom_show_field.html.twig', 'safe' => false], 'sonata-project/admin-bundle'], + [ + 'Data ' + .'', + 'datetime', + new \DateTime('2020-05-27 10:11:12', new \DateTimeZone('Europe/London')), [], + ], + [ + 'Data ' + .'', + 'datetime', + new \DateTime('2020-05-27 10:11:12', new \DateTimeZone('Europe/London')), + ['format' => 'd.m.Y H:i:s'], + ], + [ + 'Data ' + .'', + 'datetime', + new \DateTime('2020-05-27 10:11:12', new \DateTimeZone('UTC')), + ['timezone' => 'Asia/Hong_Kong'], + ], + [ + 'Data ' + .'', + 'date', + new \DateTime('2020-05-27 10:11:12', new \DateTimeZone('Europe/London')), + [], + ], + ]; + } + + /** + * This method generates url part for Twig layout. + */ + private function buildTwigLikeUrl(array $url): string + { + return htmlspecialchars(http_build_query($url, '', '&', PHP_QUERY_RFC3986)); + } + + private function removeExtraWhitespace(string $string): string + { + return trim(preg_replace( + '/\s+/', + ' ', + $string + )); + } + + private function registerRequiredTwigExtensions(PropertyAccessorInterface $propertyAccessor): void + { + //NEXT_MAJOR: Remove next line. + $this->registerSonataAdminExtension($propertyAccessor); + + $this->environment->addExtension($this->twigExtension); + $this->environment->addExtension(new XEditableExtension($this->translator, self::X_EDITABLE_TYPE_MAPPING)); + $this->environment->addExtension(new TranslationExtension($this->translator)); + $this->environment->addExtension(new FakeTemplateRegistryExtension()); + $this->environment->addExtension(new StringExtension()); + + $this->registerRoutingExtension(); + } + + /** + * NEXT_MAJOR: Remove this method. + */ + private function registerSonataAdminExtension(PropertyAccessor $propertyAccessor): void + { + $securityChecker = $this->createStub(AuthorizationCheckerInterface::class); + + $sonataAdminExtension = new SonataAdminExtension( + $this->pool, + $this->logger, + $this->translator, + $this->container, + $propertyAccessor, + $securityChecker + ); + $sonataAdminExtension->setXEditableTypeMapping(self::X_EDITABLE_TYPE_MAPPING, 'sonata_deprecation_mute'); + $this->environment->addExtension($sonataAdminExtension); + } + + private function registerRoutingExtension(): void + { + $xmlFileLoader = new XmlFileLoader(new FileLocator([ + sprintf('%s/../../../src/Resources/config/routing', __DIR__), + ])); + $routeCollection = $xmlFileLoader->load('sonata_admin.xml'); + + $xmlFileLoader = new XmlFileLoader(new FileLocator([ + sprintf('%s/../../Fixtures/Resources/config/routing', __DIR__), + ])); + + $testRouteCollection = $xmlFileLoader->load('routing.xml'); + + $routeCollection->addCollection($testRouteCollection); + $requestContext = new RequestContext(); + $urlGenerator = new UrlGenerator($routeCollection, $requestContext); + $this->environment->addExtension(new RoutingExtension($urlGenerator)); + } +} diff --git a/tests/Twig/Extension/SecurityExtensionTest.php b/tests/Twig/Extension/SecurityExtensionTest.php new file mode 100644 index 0000000000..f983aecdbe --- /dev/null +++ b/tests/Twig/Extension/SecurityExtensionTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\AdminBundle\Tests\Twig\Extension; + +use PHPUnit\Framework\TestCase; +use Sonata\AdminBundle\Twig\Extension\SecurityExtension; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; + +/** + * @author Andrej Hudec + */ +final class SecurityExtensionTest extends TestCase +{ + public function testIsGrantedAffirmative(): void + { + $securityChecker = $this->createStub(AuthorizationCheckerInterface::class); + $twigExtension = new SecurityExtension($securityChecker); + + $securityChecker + ->method('isGranted') + ->withConsecutive( + ['foo', null], + ['bar', null], + ['foo', null], + ['bar', null] + ) + ->willReturnMap([ + ['foo', null, false], + ['bar', null, true], + ]); + + $this->assertTrue($twigExtension->isGrantedAffirmative(['foo', 'bar'])); + $this->assertFalse($twigExtension->isGrantedAffirmative('foo')); + $this->assertTrue($twigExtension->isGrantedAffirmative('bar')); + } +} diff --git a/tests/Twig/Extension/SonataAdminExtensionTest.php b/tests/Twig/Extension/SonataAdminExtensionTest.php index b3ba6cabc4..df086f7e66 100644 --- a/tests/Twig/Extension/SonataAdminExtensionTest.php +++ b/tests/Twig/Extension/SonataAdminExtensionTest.php @@ -177,7 +177,8 @@ protected function setUp(): void $propertyAccessor, $this->securityChecker ); - $this->twigExtension->setXEditableTypeMapping($this->xEditableTypeMapping); + + $this->twigExtension->setXEditableTypeMapping($this->xEditableTypeMapping, 'sonata_deprecation_mute'); $request = $this->createMock(Request::class); $request->method('get')->with('_sonata_admin')->willReturn('sonata_admin_foo_service'); @@ -338,12 +339,17 @@ public function testConstructWithLegacyTranslator(): void } /** + * NEXT_MAJOR: Remove this method. + * * @group legacy - * @expectedDeprecation The Sonata\AdminBundle\Admin\AbstractAdmin::getTemplate method is deprecated (since sonata-project/admin-bundle 3.34, will be dropped in 4.0. Use TemplateRegistry services instead). * @dataProvider getRenderListElementTests */ public function testRenderListElement(string $expected, string $type, $value, array $options): void { + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::renderListElement method is deprecated in favor of RenderElementExtension::renderListElement since version 3.x and will be removed in 4.0.'); + + $this->expectDeprecation('The Sonata\AdminBundle\Admin\AbstractAdmin::getTemplate method is deprecated (since sonata-project/admin-bundle 3.34, will be dropped in 4.0. Use TemplateRegistry services instead).'); + $this->admin ->method('getPersistentParameters') ->willReturn(['context' => 'foo']); @@ -428,13 +434,16 @@ public function testRenderListElement(string $expected, string $type, $value, ar } /** - * NEXT_MAJOR: Remove @expectedDeprecation. + * NEXT_MAJOR: Remove this method. * * @group legacy - * @expectedDeprecation The Sonata\AdminBundle\Admin\AbstractAdmin::getTemplate method is deprecated (since sonata-project/admin-bundle 3.34, will be dropped in 4.0. Use TemplateRegistry services instead). */ public function testRenderListElementWithAdditionalValuesInArray(): void { + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::renderListElement method is deprecated in favor of RenderElementExtension::renderListElement since version 3.x and will be removed in 4.0.'); + + $this->expectDeprecation('The Sonata\AdminBundle\Admin\AbstractAdmin::getTemplate method is deprecated (since sonata-project/admin-bundle 3.34, will be dropped in 4.0. Use TemplateRegistry services instead).'); + // NEXT_MAJOR: Remove this line $this->admin ->method('getTemplate') @@ -459,13 +468,16 @@ public function testRenderListElementWithAdditionalValuesInArray(): void } /** - * NEXT_MAJOR: Remove @expectedDeprecation. + * NEXT_MAJOR: Remove this method. * * @group legacy - * @expectedDeprecation Accessing a non existing value for the field "fd_name" is deprecated since sonata-project/admin-bundle 3.67 and will throw an exception in 4.0. */ public function testRenderListElementWithNoValueException(): void { + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::renderListElement method is deprecated in favor of RenderElementExtension::renderListElement since version 3.x and will be removed in 4.0.'); + + $this->expectDeprecation('Accessing a non existing value for the field "fd_name" is deprecated since sonata-project/admin-bundle 3.67 and will throw an exception in 4.0.'); + // NEXT_MAJOR: Remove this line $this->admin ->method('getTemplate') @@ -492,6 +504,8 @@ public function testRenderListElementWithNoValueException(): void } /** + * NEXT_MAJOR: Remove this method. + * * @dataProvider getDeprecatedRenderListElementTests * @group legacy */ @@ -532,6 +546,8 @@ public function testDeprecatedRenderListElement(string $expected, ?string $value ->method('getTemplate') ->willReturn('@SonataAdmin/CRUD/list_nonexistent_template.html.twig'); + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::renderListElement method is deprecated in favor of RenderElementExtension::renderListElement since version 3.x and will be removed in 4.0.'); + $this->assertSame( $this->removeExtraWhitespace($expected), $this->removeExtraWhitespace($this->twigExtension->renderListElement( @@ -542,6 +558,9 @@ public function testDeprecatedRenderListElement(string $expected, ?string $value ); } + /** + * NEXT_MAJOR: Remove this method. + */ public function getDeprecatedRenderListElementTests() { return [ @@ -558,6 +577,9 @@ public function getDeprecatedRenderListElementTests() ]; } + /** + * NEXT_MAJOR: Remove this method. + */ public function getRenderListElementTests() { return [ @@ -1534,6 +1556,8 @@ class="x-editable" } /** + * NEXT_MAJOR: Remove this method. + * * @group legacy */ public function testRenderListElementNonExistentTemplate(): void @@ -1572,11 +1596,15 @@ public function testRenderListElementNonExistentTemplate(): void instead.' )))); + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::renderListElement method is deprecated in favor of RenderElementExtension::renderListElement since version 3.x and will be removed in 4.0.'); + $this->twigExtension->renderListElement($this->environment, $this->object, $this->fieldDescription); } /** - * @group legacy + * NEXT_MAJOR: Remove this method. + * + * @group legacy */ public function testRenderListElementErrorLoadingTemplate(): void { @@ -1595,12 +1623,17 @@ public function testRenderListElementErrorLoadingTemplate(): void ->method('getTemplate') ->willReturn('@SonataAdmin/CRUD/list_nonexistent_template.html.twig'); + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::renderListElement method is deprecated in favor of RenderElementExtension::renderListElement since version 3.x and will be removed in 4.0.'); + $this->twigExtension->renderListElement($this->environment, $this->object, $this->fieldDescription); $this->templateRegistry->getTemplate('base_list_field')->shouldHaveBeenCalled(); } /** + * NEXT_MAJOR: Remove this method. + * + * @group legacy * @dataProvider getRenderViewElementTests */ public function testRenderViewElement(string $expected, string $type, $value, array $options): void @@ -1654,6 +1687,8 @@ public function testRenderViewElement(string $expected, string $type, $value, ar } }); + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::renderViewElement method is deprecated in favor of RenderElementExtension::renderViewElement since version 3.x and will be removed in 4.0.'); + $this->assertSame( $this->removeExtraWhitespace($expected), $this->removeExtraWhitespace( @@ -1666,6 +1701,9 @@ public function testRenderViewElement(string $expected, string $type, $value, ar ); } + /** + * NEXT_MAJOR: Remove this method. + */ public function getRenderViewElementTests() { return [ @@ -2168,6 +2206,8 @@ class="sonata-readmore" } /** + * NEXT_MAJOR: Remove this method. + * * @group legacy * @assertDeprecation Accessing a non existing value for the field "fd_name" is deprecated since sonata-project/admin-bundle 3.67 and will throw an exception in 4.0. * @@ -2224,6 +2264,8 @@ public function testRenderViewElementWithNoValue(string $expected, string $type, } }); + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::renderViewElement method is deprecated in favor of RenderElementExtension::renderViewElement since version 3.x and will be removed in 4.0.'); + $this->assertSame( $this->removeExtraWhitespace($expected), $this->removeExtraWhitespace( @@ -2236,6 +2278,9 @@ public function testRenderViewElementWithNoValue(string $expected, string $type, ); } + /** + * NEXT_MAJOR: Remove this method. + */ public function getRenderViewElementWithNoValueTests(): iterable { return [ @@ -2298,13 +2343,15 @@ public function getRenderViewElementWithNoValueTests(): iterable * @group legacy * * @dataProvider getDeprecatedTextExtensionItems - * - * @expectedDeprecation The "truncate.preserve" option is deprecated since sonata-project/admin-bundle 3.65, to be removed in 4.0. Use "truncate.cut" instead. ("@SonataAdmin/CRUD/show_html.html.twig" at line %d). - * - * @expectedDeprecation The "truncate.separator" option is deprecated since sonata-project/admin-bundle 3.65, to be removed in 4.0. Use "truncate.ellipsis" instead. ("@SonataAdmin/CRUD/show_html.html.twig" at line %d). */ public function testDeprecatedTextExtension(string $expected, string $type, $value, array $options): void { + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::renderViewElement method is deprecated in favor of RenderElementExtension::renderViewElement since version 3.x and will be removed in 4.0.'); + + $this->expectDeprecation('The "truncate.preserve" option is deprecated since sonata-project/admin-bundle 3.65, to be removed in 4.0. Use "truncate.cut" instead. ("@SonataAdmin/CRUD/show_html.html.twig" at line %d).'); + + $this->expectDeprecation('The "truncate.separator" option is deprecated since sonata-project/admin-bundle 3.65, to be removed in 4.0. Use "truncate.ellipsis" instead. ("@SonataAdmin/CRUD/show_html.html.twig" at line %d).'); + $loader = new StubFilesystemLoader([ sprintf('%s/../../../src/Resources/views/CRUD', __DIR__), ]); @@ -2465,6 +2512,8 @@ public function testGetValueFromFieldDescriptionWithNoValueExceptionNewAdminInst } /** + * NEXT_MAJOR: Remove this method. + * * @group legacy */ public function testOutput(): void @@ -2499,6 +2548,9 @@ public function testOutput(): void ); $this->environment->enableDebug(); + + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::output method is deprecated since version 3.33 and will be removed in 4.0.'); + $this->assertSame( $this->removeExtraWhitespace( <<<'EOT' @@ -2518,11 +2570,16 @@ public function testOutput(): void } /** + * NEXT_MAJOR: Remove this method. + * * @group legacy - * @expectedDeprecation The Sonata\AdminBundle\Admin\AbstractAdmin::getTemplate method is deprecated (since sonata-project/admin-bundle 3.34, will be dropped in 4.0. Use TemplateRegistry services instead). */ public function testRenderWithDebug(): void { + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::renderListElement method is deprecated in favor of RenderElementExtension::renderListElement since version 3.x and will be removed in 4.0.'); + + $this->expectDeprecation('The Sonata\AdminBundle\Admin\AbstractAdmin::getTemplate method is deprecated (since sonata-project/admin-bundle 3.34, will be dropped in 4.0. Use TemplateRegistry services instead).'); + $this->fieldDescription ->method('getTemplate') ->willReturn('@SonataAdmin/CRUD/base_list_field.html.twig'); @@ -2562,11 +2619,23 @@ public function testRenderWithDebug(): void ); } + /** + * NEXT_MAJOR: Remove this method. + * + * @group legacy + */ public function testRenderRelationElementNoObject(): void { + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::renderRelationElement method is deprecated in favor of RenderElementExtension::renderRelationElement since version 3.x and will be removed in 4.0.'); + $this->assertSame('foo', $this->twigExtension->renderRelationElement('foo', $this->fieldDescription)); } + /** + * NEXT_MAJOR: Remove this method. + * + * @group legacy + */ public function testRenderRelationElementToString(): void { $this->fieldDescription->expects($this->exactly(2)) @@ -2578,10 +2647,15 @@ public function testRenderRelationElementToString(): void }); $element = new FooToString(); + + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::renderRelationElement method is deprecated in favor of RenderElementExtension::renderRelationElement since version 3.x and will be removed in 4.0.'); + $this->assertSame('salut', $this->twigExtension->renderRelationElement($element, $this->fieldDescription)); } /** + * NEXT_MAJOR: Remove this method. + * * @group legacy */ public function testDeprecatedRelationElementToString(): void @@ -2595,6 +2669,9 @@ public function testDeprecatedRelationElementToString(): void }); $element = new FooToString(); + + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::renderRelationElement method is deprecated in favor of RenderElementExtension::renderRelationElement since version 3.x and will be removed in 4.0.'); + $this->assertSame( 'salut', $this->twigExtension->renderRelationElement($element, $this->fieldDescription) @@ -2602,6 +2679,8 @@ public function testDeprecatedRelationElementToString(): void } /** + * NEXT_MAJOR: Remove this method. + * * @group legacy */ public function testRenderRelationElementCustomToString(): void @@ -2625,10 +2704,14 @@ public function testRenderRelationElementCustomToString(): void ->method('customToString') ->willReturn('fooBar'); + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::renderRelationElement method is deprecated in favor of RenderElementExtension::renderRelationElement since version 3.x and will be removed in 4.0.'); + $this->assertSame('fooBar', $this->twigExtension->renderRelationElement($element, $this->fieldDescription)); } /** + * NEXT_MAJOR: Remove this method. + * * @group legacy */ public function testRenderRelationElementMethodNotExist(): void @@ -2646,9 +2729,16 @@ public function testRenderRelationElementMethodNotExist(): void $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('You must define an `associated_property` option or create a `stdClass::__toString'); + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::renderRelationElement method is deprecated in favor of RenderElementExtension::renderRelationElement since version 3.x and will be removed in 4.0.'); + $this->twigExtension->renderRelationElement($element, $this->fieldDescription); } + /** + * NEXT_MAJOR: Remove this method. + * + * @group legacy + */ public function testRenderRelationElementWithPropertyPath(): void { $this->fieldDescription->expects($this->once()) @@ -2663,9 +2753,16 @@ public function testRenderRelationElementWithPropertyPath(): void $element = new \stdClass(); $element->foo = 'bar'; + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::renderRelationElement method is deprecated in favor of RenderElementExtension::renderRelationElement since version 3.x and will be removed in 4.0.'); + $this->assertSame('bar', $this->twigExtension->renderRelationElement($element, $this->fieldDescription)); } + /** + * NEXT_MAJOR: Remove this method. + * + * @group legacy + */ public function testRenderRelationElementWithClosure(): void { $this->fieldDescription->expects($this->once()) @@ -2682,6 +2779,8 @@ public function testRenderRelationElementWithClosure(): void $element = new \stdClass(); $element->foo = 'bar'; + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::renderRelationElement method is deprecated in favor of RenderElementExtension::renderRelationElement since version 3.x and will be removed in 4.0.'); + $this->assertSame( 'closure bar', $this->twigExtension->renderRelationElement($element, $this->fieldDescription) @@ -2786,6 +2885,9 @@ public function testGetUrlsafeIdentifier_GivenAdmin_Bar(): void $this->assertSame(1234567, $twigExtension->getUrlSafeIdentifier($model, $this->adminBar)); } + /** + * NEXT_MAJOR: Remove this method. + */ public function xEditableChoicesProvider() { return [ @@ -2832,7 +2934,11 @@ public function xEditableChoicesProvider() } /** + * NEXT_MAJOR: Remove this method. + * * @dataProvider xEditablechoicesProvider + * + * @group legacy */ public function testGetXEditableChoicesIsIdempotent(array $options, array $expectedChoices): void { @@ -2851,6 +2957,8 @@ public function testGetXEditableChoicesIsIdempotent(array $options, array $expec $options['multiple'] ?? null )); + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::getXEditableChoices method is deprecated in favor of XEditableExtension::getXEditableChoices since version 3.x and will be removed in 4.0.'); + $this->assertSame($expectedChoices, $this->twigExtension->getXEditableChoices($fieldDescription)); } @@ -2910,13 +3018,22 @@ public function select2LocalesProvider() } /** + * NEXT_MAJOR: Remove this method. + * * @dataProvider select2LocalesProvider + * + * @group legacy */ public function testCanonicalizedLocaleForSelect2(?string $expected, string $original): void { + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::getCanonicalizedLocaleForSelect2 method is deprecated in favor of CanonicalizeExtension::getCanonicalizedLocaleForSelect2 since version 3.x and will be removed in 4.0.'); + $this->assertSame($expected, $this->twigExtension->getCanonicalizedLocaleForSelect2($this->mockExtensionContext($original))); } + /** + * NEXT_MAJOR: Remove this method. + */ public function momentLocalesProvider(): array { return [ @@ -3039,15 +3156,28 @@ public function momentLocalesProvider(): array } /** + * NEXT_MAJOR: Remove this method. + * * @dataProvider momentLocalesProvider + * + * @group legacy */ public function testCanonicalizedLocaleForMoment(?string $expected, string $original): void { + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::getCanonicalizedLocaleForMoment method is deprecated in favor of CanonicalizeExtension::getCanonicalizedLocaleForMoment since version 3.x and will be removed in 4.0.'); + $this->assertSame($expected, $this->twigExtension->getCanonicalizedLocaleForMoment($this->mockExtensionContext($original))); } + /** + * NEXT_MAJOR: Remove this method. + * + * @group legacy + */ public function testIsGrantedAffirmative(): void { + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::isGrantedAffirmative method is deprecated in favor of SecurityExtension::isGrantedAffirmative since version 3.x and will be removed in 4.0.'); + $this->securityChecker ->method('isGranted') ->withConsecutive( @@ -3067,6 +3197,9 @@ public function testIsGrantedAffirmative(): void } /** + * NEXT_MAJOR: Remove this method. + * + * @group legacy * @dataProvider getRenderViewElementCompareTests */ public function testRenderViewElementCompare(string $expected, string $type, $value, array $options, ?string $objectName = null): void @@ -3132,6 +3265,8 @@ public function testRenderViewElementCompare(string $expected, string $type, $va $comparedObject->name = $objectName; } + $this->expectDeprecation('The Sonata\AdminBundle\Twig\Extension\SonataAdminExtension::renderViewElementCompare method is deprecated in favor of RenderElementExtension::renderViewElementCompare since version 3.x and will be removed in 4.0.'); + $this->assertSame( $this->removeExtraWhitespace($expected), $this->removeExtraWhitespace( @@ -3145,6 +3280,9 @@ public function testRenderViewElementCompare(string $expected, string $type, $va ); } + /** + * NEXT_MAJOR: Remove this method. + */ public function getRenderViewElementCompareTests(): iterable { return [ diff --git a/tests/Twig/Extension/XEditableExtensionTest.php b/tests/Twig/Extension/XEditableExtensionTest.php new file mode 100644 index 0000000000..62a6abeb92 --- /dev/null +++ b/tests/Twig/Extension/XEditableExtensionTest.php @@ -0,0 +1,118 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\AdminBundle\Tests\Twig\Extension; + +use PHPUnit\Framework\TestCase; +use Sonata\AdminBundle\Admin\FieldDescriptionInterface; +use Sonata\AdminBundle\Twig\Extension\XEditableExtension; +use Symfony\Component\Translation\Translator; + +/** + * @author Andrej Hudec + */ +final class XEditableExtensionTest extends TestCase +{ + /** + * @dataProvider xEditablechoicesProvider + */ + public function testGetXEditableChoicesIsIdempotent(array $options, array $expectedChoices): void + { + $xEditableTypeMapping = [ + 'choice' => 'select', + 'boolean' => 'select', + 'text' => 'text', + 'textarea' => 'textarea', + 'html' => 'textarea', + 'email' => 'email', + 'string' => 'text', + 'smallint' => 'text', + 'bigint' => 'text', + 'integer' => 'number', + 'decimal' => 'number', + 'currency' => 'number', + 'percent' => 'number', + 'url' => 'url', + ]; + + $twigExtension = new XEditableExtension(new Translator('en'), $xEditableTypeMapping); + + $fieldDescription = $this->getMockForAbstractClass(FieldDescriptionInterface::class); + $fieldDescription + ->method('getOption') + ->withConsecutive( + ['choices', []], + ['catalogue'], + ['required'], + ['multiple'] + ) + ->will($this->onConsecutiveCalls( + $options['choices'], + 'MyCatalogue', + $options['multiple'] ?? null + )); + + $this->assertSame($expectedChoices, $twigExtension->getXEditableChoices($fieldDescription)); + } + + /** + * @phpstan-return array, + * array + * }> + */ + public function xEditableChoicesProvider() + { + return [ + 'needs processing' => [ + ['choices' => ['Status1' => 'Alias1', 'Status2' => 'Alias2']], + [ + ['value' => 'Status1', 'text' => 'Alias1'], + ['value' => 'Status2', 'text' => 'Alias2'], + ], + ], + 'already processed' => [ + ['choices' => [ + ['value' => 'Status1', 'text' => 'Alias1'], + ['value' => 'Status2', 'text' => 'Alias2'], + ]], + [ + ['value' => 'Status1', 'text' => 'Alias1'], + ['value' => 'Status2', 'text' => 'Alias2'], + ], + ], + 'not required' => [ + [ + 'required' => false, + 'choices' => ['' => '', 'Status1' => 'Alias1', 'Status2' => 'Alias2'], + ], + [ + ['value' => '', 'text' => ''], + ['value' => 'Status1', 'text' => 'Alias1'], + ['value' => 'Status2', 'text' => 'Alias2'], + ], + ], + 'not required multiple' => [ + [ + 'required' => false, + 'multiple' => true, + 'choices' => ['Status1' => 'Alias1', 'Status2' => 'Alias2'], + ], + [ + ['value' => 'Status1', 'text' => 'Alias1'], + ['value' => 'Status2', 'text' => 'Alias2'], + ], + ], + ]; + } +}