<?php
namespace EasyCorp\Bundle\EasyAdminBundle\Twig;
use Doctrine\ORM\Mapping\ClassMetadata;
use EasyCorp\Bundle\EasyAdminBundle\Configuration\ConfigManager;
use EasyCorp\Bundle\EasyAdminBundle\Router\EasyAdminRouter;
use EasyCorp\Bundle\EasyAdminBundle\Security\AuthorizationChecker;
use Symfony\Component\HttpKernel\Kernel;
use Symfony\Component\Intl\Countries;
use Symfony\Component\Intl\Exception\MissingResourceException;
use Symfony\Component\Intl\Intl;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\Security\Http\Logout\LogoutUrlGenerator;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
/**
* Defines the filters and functions used to render the bundle's templates.
*
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
*/
class EasyAdminTwigExtension extends AbstractExtension
{
private $configManager;
private $propertyAccessor;
private $easyAdminRouter;
private $debug;
private $logoutUrlGenerator;
/** @var TranslatorInterface|null */
private $translator;
private $authorizationChecker;
public function __construct(ConfigManager $configManager, PropertyAccessorInterface $propertyAccessor, EasyAdminRouter $easyAdminRouter, bool $debug, ?LogoutUrlGenerator $logoutUrlGenerator, $translator, AuthorizationChecker $authorizationChecker)
{
$this->configManager = $configManager;
$this->propertyAccessor = $propertyAccessor;
$this->easyAdminRouter = $easyAdminRouter;
$this->debug = $debug;
$this->logoutUrlGenerator = $logoutUrlGenerator;
$this->translator = $translator;
$this->authorizationChecker = $authorizationChecker;
}
/**
* {@inheritdoc}
*/
public function getFunctions()
{
return [
new TwigFunction('easyadmin_render_field_for_*_view', [$this, 'renderEntityField'], ['is_safe' => ['html'], 'needs_environment' => true]),
new TwigFunction('easyadmin_config', [$this, 'getBackendConfiguration']),
new TwigFunction('easyadmin_entity', [$this, 'getEntityConfiguration']),
new TwigFunction('easyadmin_path', [$this, 'getEntityPath']),
new TwigFunction('easyadmin_action_is_enabled', [$this, 'isActionEnabled']),
new TwigFunction('easyadmin_action_is_enabled_for_*_view', [$this, 'isActionEnabled']),
new TwigFunction('easyadmin_get_action', [$this, 'getActionConfiguration']),
new TwigFunction('easyadmin_get_action_for_*_view', [$this, 'getActionConfiguration']),
new TwigFunction('easyadmin_get_actions_for_*_item', [$this, 'getActionsForItem']),
new TwigFunction('easyadmin_logout_path', [$this, 'getLogoutPath']),
new TwigFunction('easyadmin_read_property', [$this, 'readProperty']),
new TwigFunction('easyadmin_is_granted', [$this, 'isGranted']),
];
}
/**
* {@inheritdoc}
*/
public function getFilters()
{
$filters = [
new TwigFilter('easyadmin_truncate', [$this, 'truncateText'], ['needs_environment' => true]),
new TwigFilter('easyadmin_urldecode', 'urldecode'),
new TwigFilter('easyadmin_form_hidden_params', [$this, 'getFormHiddenParams']),
new TwigFilter('easyadmin_filesize', [$this, 'fileSize']),
];
if (Kernel::VERSION_ID >= 40200) {
$filters[] = new TwigFilter('transchoice', [$this, 'transchoice']);
}
return $filters;
}
public function fileSize(int $bytes): string
{
$size = ['B', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'];
$factor = (int) floor(log($bytes) / log(1024));
return (int) ($bytes / (1024 ** $factor)).@$size[$factor];
}
/**
* Returns the entire backend configuration or the value corresponding to
* the provided key. The dots of the key are automatically transformed into
* nested keys. Example: 'assets.css' => $config['assets']['css'].
*
* @param string|null $key
*
* @return mixed
*/
public function getBackendConfiguration($key = null)
{
return $this->configManager->getBackendConfig($key);
}
/**
* Returns the entire configuration of the given entity.
*
* @param string $entityName
*
* @return array|null
*/
public function getEntityConfiguration($entityName)
{
return null !== $this->getBackendConfiguration('entities.'.$entityName)
? $this->configManager->getEntityConfig($entityName)
: null;
}
/**
* @param object|string $entity
* @param string $action
* @param array $parameters
*
* @return string
*/
public function getEntityPath($entity, $action, array $parameters = [])
{
return $this->easyAdminRouter->generate($entity, $action, $parameters);
}
/**
* Renders the value stored in a property/field of the given entity. This
* function contains a lot of code protections to avoid errors when the
* property doesn't exist or its value is not accessible. This ensures that
* the function never generates a warning or error message when calling it.
*
* @param Environment $twig
* @param string $view The view in which the item is being rendered
* @param string $entityName The name of the entity associated with the item
* @param object $item The item which is being rendered
* @param array $fieldMetadata The metadata of the actual field being rendered
*
* @return string
*
* @throws \Exception
*/
public function renderEntityField(Environment $twig, $view, $entityName, $item, array $fieldMetadata)
{
$entityConfiguration = $this->configManager->getEntityConfig($entityName);
$hasCustomTemplate = 0 !== strpos($fieldMetadata['template'], '@EasyAdmin/');
$templateParameters = [];
try {
$templateParameters = $this->getTemplateParameters($entityName, $view, $fieldMetadata, $item);
// if the field defines a custom template, render it (no matter if the value is null or inaccessible)
if ($hasCustomTemplate) {
return $twig->render($fieldMetadata['template'], $templateParameters);
}
if (false === $templateParameters['is_accessible']) {
return $twig->render($entityConfiguration['templates']['label_inaccessible'], $templateParameters);
}
if (null === $templateParameters['value']) {
return $twig->render($entityConfiguration['templates']['label_null'], $templateParameters);
}
if (empty($templateParameters['value']) && \in_array($fieldMetadata['dataType'], ['image', 'file', 'array', 'simple_array'])) {
return $twig->render($templateParameters['entity_config']['templates']['label_empty'], $templateParameters);
}
return $twig->render($fieldMetadata['template'], $templateParameters);
} catch (\Exception $e) {
if ($this->debug) {
throw $e;
}
return $twig->render($entityConfiguration['templates']['label_undefined'], $templateParameters);
}
}
private function getTemplateParameters($entityName, $view, array $fieldMetadata, $item)
{
$fieldName = $fieldMetadata['property'];
$fieldType = $fieldMetadata['dataType'];
$parameters = [
'backend_config' => $this->getBackendConfiguration(),
'entity_config' => $this->configManager->getEntityConfig($entityName),
'field_options' => $fieldMetadata,
'item' => $item,
'view' => $view,
];
if ($this->propertyAccessor->isReadable($item, $fieldName)) {
$parameters['value'] = $this->propertyAccessor->getValue($item, $fieldName);
$parameters['is_accessible'] = true;
} else {
$parameters['value'] = null;
$parameters['is_accessible'] = false;
}
if ('image' === $fieldType) {
$parameters = $this->addImageFieldParameters($parameters);
}
if ('file' === $fieldType) {
$parameters = $this->addFileFieldParameters($parameters);
}
if ('association' === $fieldType) {
$parameters = $this->addAssociationFieldParameters($parameters);
}
if ('country' === $fieldType) {
$parameters['value'] = null !== $parameters['value'] ? strtoupper($parameters['value']) : null;
$parameters['country_name'] = $this->getCountryName($parameters['value']);
}
if ('avatar' === $fieldType) {
$parameters['image_height'] = $fieldMetadata['height'];
if ($fieldMetadata['is_image_url'] ?? false) {
$parameters['image_url'] = $parameters['value'];
} else {
$parameters['image_url'] = null === $parameters['value'] ? null : sprintf('https://www.gravatar.com/avatar/%s?s=%d&d=mp', md5($parameters['value']), $parameters['image_height']);
}
}
// when a virtual field doesn't define it's type, consider it a string
if (true === $fieldMetadata['virtual'] && null === $parameters['field_options']['dataType']) {
$parameters['value'] = (string) $parameters['value'];
}
return $parameters;
}
private function addImageFieldParameters(array $templateParameters)
{
// add the base path only to images that are not absolute URLs (http or https) or protocol-relative URLs (//)
if (null !== $templateParameters['value'] && 0 === preg_match('/^(http[s]?|\/\/)/i', $templateParameters['value'])) {
$templateParameters['value'] = isset($templateParameters['field_options']['base_path'])
? rtrim($templateParameters['field_options']['base_path'], '/').'/'.ltrim($templateParameters['value'], '/')
: '/'.ltrim($templateParameters['value'], '/');
}
$templateParameters['uuid'] = md5($templateParameters['value']);
return $templateParameters;
}
private function addFileFieldParameters(array $templateParameters)
{
// add the base path only to files that are not absolute URLs (http or https) or protocol-relative URLs (//)
if (null !== $templateParameters['value'] && 0 === preg_match('/^(http[s]?|\/\/)/i', $templateParameters['value'])) {
$templateParameters['value'] = isset($templateParameters['field_options']['base_path'])
? rtrim($templateParameters['field_options']['base_path'], '/').'/'.ltrim($templateParameters['value'], '/')
: '/'.ltrim($templateParameters['value'], '/');
}
$templateParameters['filename'] = $templateParameters['field_options']['filename'] ?? basename($templateParameters['value']);
return $templateParameters;
}
private function addAssociationFieldParameters(array $templateParameters)
{
$targetEntityConfig = $this->configManager->getEntityConfigByClass($templateParameters['field_options']['targetEntity']);
// the associated entity is not managed by EasyAdmin
if (null === $targetEntityConfig) {
return $templateParameters;
}
$isShowActionAllowed = !\in_array('show', $targetEntityConfig['disabled_actions']);
if ($templateParameters['field_options']['associationType'] & ClassMetadata::TO_ONE) {
if ($this->propertyAccessor->isReadable($templateParameters['value'], $targetEntityConfig['primary_key_field_name'])) {
$primaryKeyValue = $this->propertyAccessor->getValue($templateParameters['value'], $targetEntityConfig['primary_key_field_name']);
} else {
$primaryKeyValue = null;
}
// get the string representation of the associated *-to-one entity
if (method_exists($templateParameters['value'], '__toString')) {
$templateParameters['value'] = (string) $templateParameters['value'];
} elseif (null !== $primaryKeyValue) {
$templateParameters['value'] = sprintf('%s #%s', $targetEntityConfig['name'], $primaryKeyValue);
} else {
$templateParameters['value'] = null;
}
// if the associated entity is managed by EasyAdmin, and the "show"
// action is enabled for the associated entity, display a link to it
if (null !== $targetEntityConfig && null !== $primaryKeyValue && $isShowActionAllowed) {
$templateParameters['link_parameters'] = [
'action' => 'show',
'entity' => $targetEntityConfig['name'],
// casting to string is needed because entities can use objects as primary keys
'id' => (string) $primaryKeyValue,
];
}
}
if ($templateParameters['field_options']['associationType'] & ClassMetadata::TO_MANY) {
// if the associated entity is managed by EasyAdmin, and the "show"
// action is enabled for the associated entity, display a link to it
if (null !== $targetEntityConfig && $isShowActionAllowed) {
$templateParameters['link_parameters'] = [
'action' => 'show',
'entity' => $targetEntityConfig['name'],
'primary_key_name' => $targetEntityConfig['primary_key_field_name'],
];
}
}
return $templateParameters;
}
/**
* Checks whether the given 'action' is enabled for the given 'entity'.
*
* @param string $view
* @param string $action
* @param string $entityName
*
* @return bool
*/
public function isActionEnabled($view, $action, $entityName)
{
return $this->configManager->isActionEnabled($entityName, $view, $action);
}
/**
* Returns the full action configuration for the given 'entity' and 'view'.
*
* @param string $view
* @param string $action
* @param string $entityName
*
* @return array
*/
public function getActionConfiguration($view, $action, $entityName)
{
return $this->configManager->getActionConfig($entityName, $view, $action);
}
/**
* Returns the actions configured for each item displayed in the given view.
* This method is needed because some actions are displayed globally for the
* entire view (e.g. 'new' action in 'list' view).
*
* @param string $view
* @param string $entityName
*
* @return array
*/
public function getActionsForItem($view, $entityName)
{
try {
$entityConfig = $this->configManager->getEntityConfig($entityName);
} catch (\Exception $e) {
return [];
}
$disabledActions = $entityConfig['disabled_actions'];
$viewActions = $entityConfig[$view]['actions'];
$actionsExcludedForItems = [
'list' => ['new', 'search'],
'edit' => [],
'new' => [],
'show' => [],
];
$excludedActions = $actionsExcludedForItems[$view];
return array_filter($viewActions, function ($action) use ($excludedActions, $disabledActions) {
return !\in_array($action['name'], $excludedActions) && !\in_array($action['name'], $disabledActions);
});
}
/*
* Copied from the official Text Twig extension.
*
* code: https://github.com/twigphp/Twig-extensions/blob/master/lib/Twig/Extensions/Extension/Text.php
* author: Henrik Bjornskov <hb@peytz.dk>
* copyright holder: (c) 2009 Fabien Potencier
*
* @return string
*/
public function truncateText(Environment $env, $value, $length = 64, $preserve = false, $separator = '...')
{
try {
$value = (string) $value;
} catch (\Exception $e) {
$value = '';
}
if (mb_strlen($value, $env->getCharset()) > $length) {
if ($preserve) {
// If breakpoint is on the last word, return the value without separator.
if (false === ($breakpoint = mb_strpos($value, ' ', $length, $env->getCharset()))) {
return $value;
}
$length = $breakpoint;
}
return rtrim(mb_substr($value, 0, $length, $env->getCharset())).$separator;
}
return $value;
}
public function getFormHiddenParams(array $params, string $prefix): iterable
{
foreach ($params as $key => $value) {
$key = $prefix.'['.$key.']';
if (\is_array($value)) {
yield from $this->getFormHiddenParams($value, $key);
} else {
yield $key => $value;
}
}
}
/**
* Remove this filter when the Symfony's requirement is equal or greater than 4.2
* and use the built-in trans filter instead with a %count% parameter.
*/
public function transchoice($message, $count, array $arguments = [], $domain = null, $locale = null)
{
if (null === $this->translator) {
return strtr($message, $arguments);
}
return $this->translator->trans($message, array_merge(['%count%' => $count], $arguments), $domain, $locale);
}
/**
* This reimplementation of Symfony's logout_path() helper is needed because
* when no arguments are passed to the getLogoutPath(), it's common to get
* exceptions and there is no way to recover from them in a Twig template.
*/
public function getLogoutPath()
{
if (null === $this->logoutUrlGenerator) {
return;
}
try {
return $this->logoutUrlGenerator->getLogoutPath();
} catch (\Exception $e) {
return;
}
}
public function readProperty($objectOrArray, ?string $propertyPath)
{
if (null === $propertyPath) {
return null;
}
if ('__toString' === $propertyPath) {
try {
return (string) $objectOrArray;
} catch (\Exception $e) {
return null;
}
}
try {
return $this->propertyAccessor->getValue($objectOrArray, $propertyPath);
} catch (\Exception $e) {
return null;
}
}
public function isGranted($permissions, $subject = null): bool
{
return $this->authorizationChecker->isGranted($permissions, $subject);
}
private function getCountryName(?string $countryCode): ?string
{
if (null === $countryCode) {
return null;
}
// Compatibility with Symfony versions before 4.3
if (!class_exists(Countries::class)) {
return Intl::getRegionBundle()->getCountryName($countryCode) ?? null;
}
try {
return Countries::getName($countryCode);
} catch (MissingResourceException $e) {
return null;
}
}
}