vendor/sonata-project/admin-bundle/src/Twig/Extension/SonataAdminExtension.php line 209

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. /*
  4.  * This file is part of the Sonata Project package.
  5.  *
  6.  * (c) Thomas Rabaix <thomas.rabaix@sonata-project.org>
  7.  *
  8.  * For the full copyright and license information, please view the LICENSE
  9.  * file that was distributed with this source code.
  10.  */
  11. namespace Sonata\AdminBundle\Twig\Extension;
  12. use Doctrine\Common\Util\ClassUtils;
  13. use Psr\Log\LoggerInterface;
  14. use Sonata\AdminBundle\Admin\AdminInterface;
  15. use Sonata\AdminBundle\Admin\FieldDescriptionInterface;
  16. use Sonata\AdminBundle\Admin\Pool;
  17. use Sonata\AdminBundle\Exception\NoValueException;
  18. use Sonata\AdminBundle\Templating\TemplateRegistryInterface;
  19. use Symfony\Component\DependencyInjection\ContainerInterface;
  20. use Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException;
  21. use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
  22. use Symfony\Component\Security\Acl\Voter\FieldVote;
  23. use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
  24. use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException;
  25. use Symfony\Component\Translation\TranslatorInterface as LegacyTranslationInterface;
  26. use Symfony\Contracts\Translation\TranslatorInterface;
  27. use Twig\Environment;
  28. use Twig\Error\LoaderError;
  29. use Twig\Extension\AbstractExtension;
  30. use Twig\Template;
  31. use Twig\TemplateWrapper;
  32. use Twig\TwigFilter;
  33. use Twig\TwigFunction;
  34. /**
  35.  * @final since sonata-project/admin-bundle 3.52
  36.  *
  37.  * @author Thomas Rabaix <thomas.rabaix@sonata-project.org>
  38.  */
  39. class SonataAdminExtension extends AbstractExtension
  40. {
  41.     // @todo: there are more locales which are not supported by moment and they need to be translated/normalized/canonicalized here
  42.     public const MOMENT_UNSUPPORTED_LOCALES = [
  43.         'de' => ['de''de-at'],
  44.         'es' => ['es''es-do'],
  45.         'nl' => ['nl''nl-be'],
  46.         'fr' => ['fr''fr-ca''fr-ch'],
  47.     ];
  48.     /**
  49.      * @var Pool
  50.      */
  51.     protected $pool;
  52.     /**
  53.      * @var LoggerInterface
  54.      */
  55.     protected $logger;
  56.     /**
  57.      * @var TranslatorInterface|null
  58.      */
  59.     protected $translator;
  60.     /**
  61.      * @var string[]
  62.      */
  63.     private $xEditableTypeMapping = [];
  64.     /**
  65.      * @var ContainerInterface
  66.      */
  67.     private $templateRegistries;
  68.     /**
  69.      * @var AuthorizationCheckerInterface
  70.      */
  71.     private $securityChecker;
  72.     public function __construct(
  73.         Pool $pool,
  74.         ?LoggerInterface $logger null,
  75.         $translator null,
  76.         ?ContainerInterface $templateRegistries null,
  77.         ?AuthorizationCheckerInterface $securityChecker null
  78.     ) {
  79.         // NEXT_MAJOR: make the translator parameter required, move TranslatorInterface check to method signature
  80.         // and remove this block
  81.         if (null === $translator) {
  82.             @trigger_error(
  83.                 'The $translator parameter will be required fields with the 4.0 release.',
  84.                 E_USER_DEPRECATED
  85.             );
  86.         } else {
  87.             if (!$translator instanceof TranslatorInterface) {
  88.                 @trigger_error(sprintf(
  89.                     'The $translator parameter should be an instance of "%s" and will be mandatory in 4.0.',
  90.                     TranslatorInterface::class
  91.                 ), E_USER_DEPRECATED);
  92.             }
  93.             if (!$translator instanceof TranslatorInterface && !$translator instanceof LegacyTranslationInterface) {
  94.                 throw new \TypeError(sprintf(
  95.                     'Argument 2 must be an instance of "%s" or preferably "%s", "%s given"',
  96.                     TranslatorInterface::class,
  97.                     LegacyTranslationInterface::class,
  98.                     \get_class($translator)
  99.                 ));
  100.             }
  101.         }
  102.         $this->pool $pool;
  103.         $this->logger $logger;
  104.         $this->translator $translator;
  105.         $this->templateRegistries $templateRegistries;
  106.         $this->securityChecker $securityChecker;
  107.     }
  108.     public function getFilters()
  109.     {
  110.         return [
  111.             new TwigFilter(
  112.                 'render_list_element',
  113.                 [$this'renderListElement'],
  114.                 [
  115.                     'is_safe' => ['html'],
  116.                     'needs_environment' => true,
  117.                 ]
  118.             ),
  119.             new TwigFilter(
  120.                 'render_view_element',
  121.                 [$this'renderViewElement'],
  122.                 [
  123.                     'is_safe' => ['html'],
  124.                     'needs_environment' => true,
  125.                 ]
  126.             ),
  127.             new TwigFilter(
  128.                 'render_view_element_compare',
  129.                 [$this'renderViewElementCompare'],
  130.                 [
  131.                     'is_safe' => ['html'],
  132.                     'needs_environment' => true,
  133.                 ]
  134.             ),
  135.             new TwigFilter(
  136.                 'render_relation_element',
  137.                 [$this'renderRelationElement']
  138.             ),
  139.             new TwigFilter(
  140.                 'sonata_urlsafeid',
  141.                 [$this'getUrlSafeIdentifier']
  142.             ),
  143.             new TwigFilter(
  144.                 'sonata_xeditable_type',
  145.                 [$this'getXEditableType']
  146.             ),
  147.             new TwigFilter(
  148.                 'sonata_xeditable_choices',
  149.                 [$this'getXEditableChoices']
  150.             ),
  151.         ];
  152.     }
  153.     public function getFunctions()
  154.     {
  155.         return [
  156.             new TwigFunction('canonicalize_locale_for_moment', [$this'getCanonicalizedLocaleForMoment'], ['needs_context' => true]),
  157.             new TwigFunction('canonicalize_locale_for_select2', [$this'getCanonicalizedLocaleForSelect2'], ['needs_context' => true]),
  158.             new TwigFunction('is_granted_affirmative', [$this'isGrantedAffirmative']),
  159.         ];
  160.     }
  161.     public function getName()
  162.     {
  163.         return 'sonata_admin';
  164.     }
  165.     /**
  166.      * render a list element from the FieldDescription.
  167.      *
  168.      * @param object $object
  169.      * @param array  $params
  170.      *
  171.      * @return string
  172.      */
  173.     public function renderListElement(
  174.         Environment $environment,
  175.         $object,
  176.         FieldDescriptionInterface $fieldDescription,
  177.         $params = []
  178.     ) {
  179.         $template $this->getTemplate(
  180.             $fieldDescription,
  181.             // NEXT_MAJOR: Remove this line and use commented line below instead
  182.             $fieldDescription->getAdmin()->getTemplate('base_list_field'),
  183.             //$this->getTemplateRegistry($fieldDescription->getAdmin()->getCode())->getTemplate('base_list_field'),
  184.             $environment
  185.         );
  186.         return $this->render($fieldDescription$templatearray_merge($params, [
  187.             'admin' => $fieldDescription->getAdmin(),
  188.             'object' => $object,
  189.             'value' => $this->getValueFromFieldDescription($object$fieldDescription),
  190.             'field_description' => $fieldDescription,
  191.         ]), $environment);
  192.     }
  193.     /**
  194.      * @deprecated since sonata-project/admin-bundle 3.33, to be removed in 4.0. Use render instead
  195.      *
  196.      * @return string
  197.      */
  198.     public function output(
  199.         FieldDescriptionInterface $fieldDescription,
  200.         Template $template,
  201.         array $parameters,
  202.         Environment $environment
  203.     ) {
  204.         return $this->render(
  205.             $fieldDescription,
  206.             new TemplateWrapper($environment$template),
  207.             $parameters,
  208.             $environment
  209.         );
  210.     }
  211.     /**
  212.      * return the value related to FieldDescription, if the associated object does no
  213.      * exists => a temporary one is created.
  214.      *
  215.      * @param object $object
  216.      *
  217.      * @throws \RuntimeException
  218.      *
  219.      * @return mixed
  220.      */
  221.     public function getValueFromFieldDescription(
  222.         $object,
  223.         FieldDescriptionInterface $fieldDescription,
  224.         array $params = []
  225.     ) {
  226.         if (isset($params['loop']) && $object instanceof \ArrayAccess) {
  227.             throw new \RuntimeException('remove the loop requirement');
  228.         }
  229.         $value null;
  230.         try {
  231.             $value $fieldDescription->getValue($object);
  232.         } catch (NoValueException $e) {
  233.             if ($fieldDescription->getAssociationAdmin()) {
  234.                 $value $fieldDescription->getAssociationAdmin()->getNewInstance();
  235.             }
  236.         }
  237.         return $value;
  238.     }
  239.     /**
  240.      * render a view element.
  241.      *
  242.      * @param object $object
  243.      *
  244.      * @return string
  245.      */
  246.     public function renderViewElement(
  247.         Environment $environment,
  248.         FieldDescriptionInterface $fieldDescription,
  249.         $object
  250.     ) {
  251.         $template $this->getTemplate(
  252.             $fieldDescription,
  253.             '@SonataAdmin/CRUD/base_show_field.html.twig',
  254.             $environment
  255.         );
  256.         try {
  257.             $value $fieldDescription->getValue($object);
  258.         } catch (NoValueException $e) {
  259.             $value null;
  260.         }
  261.         return $this->render($fieldDescription$template, [
  262.             'field_description' => $fieldDescription,
  263.             'object' => $object,
  264.             'value' => $value,
  265.             'admin' => $fieldDescription->getAdmin(),
  266.         ], $environment);
  267.     }
  268.     /**
  269.      * render a compared view element.
  270.      *
  271.      * @param mixed $baseObject
  272.      * @param mixed $compareObject
  273.      *
  274.      * @return string
  275.      */
  276.     public function renderViewElementCompare(
  277.         Environment $environment,
  278.         FieldDescriptionInterface $fieldDescription,
  279.         $baseObject,
  280.         $compareObject
  281.     ) {
  282.         $template $this->getTemplate(
  283.             $fieldDescription,
  284.             '@SonataAdmin/CRUD/base_show_field.html.twig',
  285.             $environment
  286.         );
  287.         try {
  288.             $baseValue $fieldDescription->getValue($baseObject);
  289.         } catch (NoValueException $e) {
  290.             $baseValue null;
  291.         }
  292.         try {
  293.             $compareValue $fieldDescription->getValue($compareObject);
  294.         } catch (NoValueException $e) {
  295.             $compareValue null;
  296.         }
  297.         $baseValueOutput $template->render([
  298.             'admin' => $fieldDescription->getAdmin(),
  299.             'field_description' => $fieldDescription,
  300.             'value' => $baseValue,
  301.         ]);
  302.         $compareValueOutput $template->render([
  303.             'field_description' => $fieldDescription,
  304.             'admin' => $fieldDescription->getAdmin(),
  305.             'value' => $compareValue,
  306.         ]);
  307.         // Compare the rendered output of both objects by using the (possibly) overridden field block
  308.         $isDiff $baseValueOutput !== $compareValueOutput;
  309.         return $this->render($fieldDescription$template, [
  310.             'field_description' => $fieldDescription,
  311.             'value' => $baseValue,
  312.             'value_compare' => $compareValue,
  313.             'is_diff' => $isDiff,
  314.             'admin' => $fieldDescription->getAdmin(),
  315.         ], $environment);
  316.     }
  317.     /**
  318.      * @param mixed $element
  319.      *
  320.      * @throws \RuntimeException
  321.      *
  322.      * @return mixed
  323.      */
  324.     public function renderRelationElement($elementFieldDescriptionInterface $fieldDescription)
  325.     {
  326.         if (!\is_object($element)) {
  327.             return $element;
  328.         }
  329.         $propertyPath $fieldDescription->getOption('associated_property');
  330.         if (null === $propertyPath) {
  331.             // For BC kept associated_tostring option behavior
  332.             $method $fieldDescription->getOption('associated_tostring');
  333.             if ($method) {
  334.                 @trigger_error(
  335.                     'Option "associated_tostring" is deprecated since version 2.3 and will be removed in 4.0. '
  336.                     .'Use "associated_property" instead.',
  337.                     E_USER_DEPRECATED
  338.                 );
  339.             } else {
  340.                 $method '__toString';
  341.             }
  342.             if (!method_exists($element$method)) {
  343.                 throw new \RuntimeException(sprintf(
  344.                     'You must define an `associated_property` option or '.
  345.                     'create a `%s::__toString` method to the field option %s from service %s is ',
  346.                     \get_class($element),
  347.                     $fieldDescription->getName(),
  348.                     $fieldDescription->getAdmin()->getCode()
  349.                 ));
  350.             }
  351.             return $element->{$method}();
  352.         }
  353.         if (\is_callable($propertyPath)) {
  354.             return $propertyPath($element);
  355.         }
  356.         return $this->pool->getPropertyAccessor()->getValue($element$propertyPath);
  357.     }
  358.     /**
  359.      * Get the identifiers as a string that is safe to use in a url.
  360.      *
  361.      * @param object $model
  362.      *
  363.      * @return string string representation of the id that is safe to use in a url
  364.      */
  365.     public function getUrlSafeIdentifier($model, ?AdminInterface $admin null)
  366.     {
  367.         if (null === $admin) {
  368.             $admin $this->pool->getAdminByClass(ClassUtils::getClass($model));
  369.         }
  370.         return $admin->getUrlSafeIdentifier($model);
  371.     }
  372.     /**
  373.      * @param string[] $xEditableTypeMapping
  374.      */
  375.     public function setXEditableTypeMapping($xEditableTypeMapping)
  376.     {
  377.         $this->xEditableTypeMapping $xEditableTypeMapping;
  378.     }
  379.     /**
  380.      * @return string|bool
  381.      */
  382.     public function getXEditableType($type)
  383.     {
  384.         return isset($this->xEditableTypeMapping[$type]) ? $this->xEditableTypeMapping[$type] : false;
  385.     }
  386.     /**
  387.      * Return xEditable choices based on the field description choices options & catalogue options.
  388.      * With the following choice options:
  389.      *     ['Status1' => 'Alias1', 'Status2' => 'Alias2']
  390.      * The method will return:
  391.      *     [['value' => 'Status1', 'text' => 'Alias1'], ['value' => 'Status2', 'text' => 'Alias2']].
  392.      *
  393.      * @return array
  394.      */
  395.     public function getXEditableChoices(FieldDescriptionInterface $fieldDescription)
  396.     {
  397.         $choices $fieldDescription->getOption('choices', []);
  398.         $catalogue $fieldDescription->getOption('catalogue');
  399.         $xEditableChoices = [];
  400.         if (!empty($choices)) {
  401.             reset($choices);
  402.             $first current($choices);
  403.             // the choices are already in the right format
  404.             if (\is_array($first) && \array_key_exists('value'$first) && \array_key_exists('text'$first)) {
  405.                 $xEditableChoices $choices;
  406.             } else {
  407.                 foreach ($choices as $value => $text) {
  408.                     if ($catalogue) {
  409.                         if (null !== $this->translator) {
  410.                             $text $this->translator->trans($text, [], $catalogue);
  411.                         // NEXT_MAJOR: Remove this check
  412.                         } elseif (method_exists($fieldDescription->getAdmin(), 'trans')) {
  413.                             $text $fieldDescription->getAdmin()->trans($text, [], $catalogue);
  414.                         }
  415.                     }
  416.                     $xEditableChoices[] = [
  417.                         'value' => $value,
  418.                         'text' => $text,
  419.                     ];
  420.                 }
  421.             }
  422.         }
  423.         if (false === $fieldDescription->getOption('required'true)
  424.             && false === $fieldDescription->getOption('multiple'false)
  425.         ) {
  426.             $xEditableChoices array_merge([[
  427.                 'value' => '',
  428.                 'text' => '',
  429.             ]], $xEditableChoices);
  430.         }
  431.         return $xEditableChoices;
  432.     }
  433.     /**
  434.      * Returns a canonicalized locale for "moment" NPM library,
  435.      * or `null` if the locale's language is "en", which doesn't require localization.
  436.      *
  437.      * @return string|null
  438.      */
  439.     final public function getCanonicalizedLocaleForMoment(array $context)
  440.     {
  441.         $locale strtolower(str_replace('_''-'$context['app']->getRequest()->getLocale()));
  442.         // "en" language doesn't require localization.
  443.         if (('en' === $lang substr($locale02)) && !\in_array($locale, ['en-au''en-ca''en-gb''en-ie''en-nz'], true)) {
  444.             return null;
  445.         }
  446.         foreach (self::MOMENT_UNSUPPORTED_LOCALES as $language => $locales) {
  447.             if ($language === $lang && !\in_array($locale$localestrue)) {
  448.                 $locale $language;
  449.             }
  450.         }
  451.         return $locale;
  452.     }
  453.     /**
  454.      * Returns a canonicalized locale for "select2" NPM library,
  455.      * or `null` if the locale's language is "en", which doesn't require localization.
  456.      *
  457.      * @return string|null
  458.      */
  459.     final public function getCanonicalizedLocaleForSelect2(array $context)
  460.     {
  461.         $locale str_replace('_''-'$context['app']->getRequest()->getLocale());
  462.         // "en" language doesn't require localization.
  463.         if ('en' === $lang substr($locale02)) {
  464.             return null;
  465.         }
  466.         switch ($locale) {
  467.             case 'pt':
  468.                 $locale 'pt-PT';
  469.                 break;
  470.             case 'ug':
  471.                 $locale 'ug-CN';
  472.                 break;
  473.             case 'zh':
  474.                 $locale 'zh-CN';
  475.                 break;
  476.             default:
  477.                 if (!\in_array($locale, ['pt-BR''pt-PT''ug-CN''zh-CN''zh-TW'], true)) {
  478.                     $locale $lang;
  479.                 }
  480.         }
  481.         return $locale;
  482.     }
  483.     /**
  484.      * @param string|array $role
  485.      * @param object|null  $object
  486.      * @param string|null  $field
  487.      *
  488.      * @return bool
  489.      */
  490.     public function isGrantedAffirmative($role$object null$field null)
  491.     {
  492.         if (null === $this->securityChecker) {
  493.             return false;
  494.         }
  495.         if (null !== $field) {
  496.             $object = new FieldVote($object$field);
  497.         }
  498.         if (!\is_array($role)) {
  499.             $role = [$role];
  500.         }
  501.         foreach ($role as $oneRole) {
  502.             try {
  503.                 if ($this->securityChecker->isGranted($oneRole$object)) {
  504.                     return true;
  505.                 }
  506.             } catch (AuthenticationCredentialsNotFoundException $e) {
  507.                 // empty on purpose
  508.             }
  509.         }
  510.         return false;
  511.     }
  512.     /**
  513.      * Get template.
  514.      *
  515.      * @param string $defaultTemplate
  516.      *
  517.      * @return TemplateWrapper
  518.      */
  519.     protected function getTemplate(
  520.         FieldDescriptionInterface $fieldDescription,
  521.         $defaultTemplate,
  522.         Environment $environment
  523.     ) {
  524.         $templateName $fieldDescription->getTemplate() ?: $defaultTemplate;
  525.         try {
  526.             $template $environment->load($templateName);
  527.         } catch (LoaderError $e) {
  528.             @trigger_error(
  529.                 sprintf(
  530.                     'Relying on default template loading on field template loading exception '.
  531.                     'is deprecated since 3.1 and will be removed in 4.0. '.
  532.                     'A %s exception will be thrown instead',
  533.                     LoaderError::class
  534.                 ),
  535.                 E_USER_DEPRECATED
  536.             );
  537.             $template $environment->load($defaultTemplate);
  538.             if (null !== $this->logger) {
  539.                 $this->logger->warning(sprintf(
  540.                     'An error occured trying to load the template "%s" for the field "%s", '.
  541.                     'the default template "%s" was used instead.',
  542.                     $templateName,
  543.                     $fieldDescription->getFieldName(),
  544.                     $defaultTemplate
  545.                 ), ['exception' => $e]);
  546.             }
  547.         }
  548.         return $template;
  549.     }
  550.     private function render(
  551.         FieldDescriptionInterface $fieldDescription,
  552.         TemplateWrapper $template,
  553.         array $parameters,
  554.         Environment $environment
  555.     ): ?string {
  556.         $content $template->render($parameters);
  557.         if ($environment->isDebug()) {
  558.             $commentTemplate = <<<'EOT'
  559. <!-- START
  560.     fieldName: %s
  561.     template: %s
  562.     compiled template: %s
  563.     -->
  564.     %s
  565. <!-- END - fieldName: %s -->
  566. EOT;
  567.             return sprintf(
  568.                 $commentTemplate,
  569.                 $fieldDescription->getFieldName(),
  570.                 $fieldDescription->getTemplate(),
  571.                 $template->getSourceContext()->getName(),
  572.                 $content,
  573.                 $fieldDescription->getFieldName()
  574.             );
  575.         }
  576.         return $content;
  577.     }
  578.     /**
  579.      * @throws ServiceCircularReferenceException
  580.      * @throws ServiceNotFoundException
  581.      */
  582.     private function getTemplateRegistry(string $adminCode): TemplateRegistryInterface
  583.     {
  584.         $serviceId $adminCode.'.template_registry';
  585.         $templateRegistry $this->templateRegistries->get($serviceId);
  586.         if ($templateRegistry instanceof TemplateRegistryInterface) {
  587.             return $templateRegistry;
  588.         }
  589.         throw new ServiceNotFoundException($serviceId);
  590.     }
  591. }