Completed
Pull Request — master (#6204)
by
unknown
20:19 queued 06:45
created

SonataAdminExtension::getName()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 0
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the Sonata Project package.
7
 *
8
 * (c) Thomas Rabaix <[email protected]>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13
14
namespace Sonata\AdminBundle\Twig\Extension;
15
16
use Doctrine\Common\Util\ClassUtils;
17
use Psr\Log\LoggerInterface;
18
use Sonata\AdminBundle\Admin\AdminInterface;
19
use Sonata\AdminBundle\Admin\FieldDescriptionInterface;
20
use Sonata\AdminBundle\Admin\Pool;
21
use Sonata\AdminBundle\Exception\NoValueException;
22
use Sonata\AdminBundle\Templating\TemplateRegistryInterface;
23
use Symfony\Component\DependencyInjection\ContainerInterface;
24
use Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException;
25
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
26
use Symfony\Component\Security\Acl\Voter\FieldVote;
27
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
28
use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException;
29
use Symfony\Contracts\Translation\TranslatorInterface;
30
use Twig\Environment;
31
use Twig\Error\LoaderError;
32
use Twig\Extension\AbstractExtension;
33
use Twig\TemplateWrapper;
34
use Twig\TwigFilter;
35
use Twig\TwigFunction;
36
37
/**
38
 * @final since sonata-project/admin-bundle 3.52
39
 *
40
 * @author Thomas Rabaix <[email protected]>
41
 */
42
final class SonataAdminExtension extends AbstractExtension
43
{
44
    // @todo: there are more locales which are not supported by moment and they need to be translated/normalized/canonicalized here
45
    public const MOMENT_UNSUPPORTED_LOCALES = [
46
        'de' => ['de', 'de-at'],
47
        'es' => ['es', 'es-do'],
48
        'nl' => ['nl', 'nl-be'],
49
        'fr' => ['fr', 'fr-ca', 'fr-ch'],
50
    ];
51
52
    /**
53
     * @var TranslatorInterface|null
54
     */
55
    protected $translator;
56
    /**
57
     * @var Pool
58
     */
59
    private $pool;
60
61
    /**
62
     * @var LoggerInterface
63
     */
64
    private $logger;
65
66
    /**
67
     * @var string[]
68
     */
69
    private $xEditableTypeMapping = [];
70
71
    /**
72
     * @var ContainerInterface
73
     */
74
    private $templateRegistries;
75
76
    /**
77
     * @var AuthorizationCheckerInterface
78
     */
79
    private $securityChecker;
80
81
    public function __construct(
82
        Pool $pool,
83
        ?LoggerInterface $logger = null,
84
        TranslatorInterface $translator,
85
        ?ContainerInterface $templateRegistries = null,
86
        ?AuthorizationCheckerInterface $securityChecker = null
87
    ) {
88
        $this->pool = $pool;
89
        $this->logger = $logger;
90
        $this->translator = $translator;
91
        $this->templateRegistries = $templateRegistries;
92
        $this->securityChecker = $securityChecker;
93
    }
94
95
    public function getFilters()
96
    {
97
        return [
98
            new TwigFilter(
99
                'render_list_element',
100
                [$this, 'renderListElement'],
101
                [
102
                    'is_safe' => ['html'],
103
                    'needs_environment' => true,
104
                ]
105
            ),
106
            new TwigFilter(
107
                'render_view_element',
108
                [$this, 'renderViewElement'],
109
                [
110
                    'is_safe' => ['html'],
111
                    'needs_environment' => true,
112
                ]
113
            ),
114
            new TwigFilter(
115
                'render_view_element_compare',
116
                [$this, 'renderViewElementCompare'],
117
                [
118
                    'is_safe' => ['html'],
119
                    'needs_environment' => true,
120
                ]
121
            ),
122
            new TwigFilter(
123
                'render_relation_element',
124
                [$this, 'renderRelationElement']
125
            ),
126
            new TwigFilter(
127
                'sonata_urlsafeid',
128
                [$this, 'getUrlSafeIdentifier']
129
            ),
130
            new TwigFilter(
131
                'sonata_xeditable_type',
132
                [$this, 'getXEditableType']
133
            ),
134
            new TwigFilter(
135
                'sonata_xeditable_choices',
136
                [$this, 'getXEditableChoices']
137
            ),
138
        ];
139
    }
140
141
    public function getFunctions()
142
    {
143
        return [
144
            new TwigFunction('canonicalize_locale_for_moment', [$this, 'getCanonicalizedLocaleForMoment'], ['needs_context' => true]),
145
            new TwigFunction('canonicalize_locale_for_select2', [$this, 'getCanonicalizedLocaleForSelect2'], ['needs_context' => true]),
146
            new TwigFunction('is_granted_affirmative', [$this, 'isGrantedAffirmative']),
147
        ];
148
    }
149
150
    public function getName()
151
    {
152
        return 'sonata_admin';
153
    }
154
155
    /**
156
     * render a list element from the FieldDescription.
157
     *
158
     * @param object $object
159
     * @param array  $params
160
     *
161
     * @return string
162
     */
163
    public function renderListElement(
164
        Environment $environment,
165
        $object,
166
        FieldDescriptionInterface $fieldDescription,
167
        $params = []
168
    ) {
169
        $template = $this->getTemplate(
170
            $fieldDescription,
171
            $this->getTemplateRegistry($fieldDescription->getAdmin()->getCode())->getTemplate('base_list_field'),
172
            $environment
173
        );
174
175
        return $this->render($fieldDescription, $template, array_merge($params, [
176
            'admin' => $fieldDescription->getAdmin(),
177
            'object' => $object,
178
            'value' => $this->getValueFromFieldDescription($object, $fieldDescription),
179
            'field_description' => $fieldDescription,
180
        ]), $environment);
181
    }
182
183
    /**
184
     * render a view element.
185
     *
186
     * @param object $object
187
     *
188
     * @return string
189
     */
190
    public function renderViewElement(
191
        Environment $environment,
192
        FieldDescriptionInterface $fieldDescription,
193
        $object
194
    ) {
195
        $template = $this->getTemplate(
196
            $fieldDescription,
197
            '@SonataAdmin/CRUD/base_show_field.html.twig',
198
            $environment
199
        );
200
201
        $value = $fieldDescription->getValue($object);
202
203
        return $this->render($fieldDescription, $template, [
204
            'field_description' => $fieldDescription,
205
            'object' => $object,
206
            'value' => $value,
207
            'admin' => $fieldDescription->getAdmin(),
208
        ], $environment);
209
    }
210
211
    /**
212
     * render a compared view element.
213
     *
214
     * @param mixed $baseObject
215
     * @param mixed $compareObject
216
     *
217
     * @return string
218
     */
219
    public function renderViewElementCompare(
220
        Environment $environment,
221
        FieldDescriptionInterface $fieldDescription,
222
        $baseObject,
223
        $compareObject
224
    ) {
225
        $template = $this->getTemplate(
226
            $fieldDescription,
227
            '@SonataAdmin/CRUD/base_show_field.html.twig',
228
            $environment
229
        );
230
231
        $baseValue = $fieldDescription->getValue($baseObject);
232
        $compareValue = $fieldDescription->getValue($compareObject);
233
234
        $baseValueOutput = $template->render([
235
            'admin' => $fieldDescription->getAdmin(),
236
            'field_description' => $fieldDescription,
237
            'value' => $baseValue,
238
            'object' => $baseObject,
239
        ]);
240
241
        $compareValueOutput = $template->render([
242
            'field_description' => $fieldDescription,
243
            'admin' => $fieldDescription->getAdmin(),
244
            'value' => $compareValue,
245
            'object' => $compareObject,
246
        ]);
247
248
        // Compare the rendered output of both objects by using the (possibly) overridden field block
249
        $isDiff = $baseValueOutput !== $compareValueOutput;
250
251
        return $this->render($fieldDescription, $template, [
252
            'field_description' => $fieldDescription,
253
            'value' => $baseValue,
254
            'value_compare' => $compareValue,
255
            'is_diff' => $isDiff,
256
            'admin' => $fieldDescription->getAdmin(),
257
            'object' => $baseObject,
258
        ], $environment);
259
    }
260
261
    /**
262
     * @param mixed $element
263
     *
264
     * @throws \RuntimeException
265
     *
266
     * @return mixed
267
     */
268
    public function renderRelationElement($element, FieldDescriptionInterface $fieldDescription)
269
    {
270
        if (!\is_object($element)) {
271
            return $element;
272
        }
273
274
        $propertyPath = $fieldDescription->getOption('associated_property');
275
276
        if (null === $propertyPath) {
277
            // For BC kept associated_tostring option behavior
278
            $method = $fieldDescription->getOption('associated_tostring');
279
280
            if ($method) {
281
                @trigger_error(
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
282
                    'Option "associated_tostring" is deprecated since version 2.3 and will be removed in 4.0. Use "associated_property" instead.',
283
                    E_USER_DEPRECATED
284
                );
285
            } else {
286
                $method = '__toString';
287
            }
288
289
            if (!method_exists($element, $method)) {
290
                throw new \RuntimeException(sprintf(
291
                    'You must define an `associated_property` option or create a `%s::__toString` method'
292
                    .' to the field option %s from service %s is ',
293
                    \get_class($element),
294
                    $fieldDescription->getName(),
295
                    $fieldDescription->getAdmin()->getCode()
296
                ));
297
            }
298
299
            return $element->$method();
300
        }
301
302
        if (\is_callable($propertyPath)) {
303
            return $propertyPath($element);
304
        }
305
306
        return $this->pool->getPropertyAccessor()->getValue($element, $propertyPath);
307
    }
308
309
    /**
310
     * Get the identifiers as a string that is safe to use in a url.
311
     *
312
     * @param object $model
313
     *
314
     * @return string string representation of the id that is safe to use in a url
315
     */
316
    public function getUrlSafeIdentifier($model, ?AdminInterface $admin = null)
317
    {
318
        if (null === $admin) {
319
            $class = ClassUtils::getClass($model);
320
            if (!$this->pool->hasAdminByClass($class)) {
321
                throw new \InvalidArgumentException('You must pass an admin.');
322
            }
323
324
            $admin = $this->pool->getAdminByClass($class);
325
        }
326
327
        return $admin->getUrlSafeIdentifier($model);
328
    }
329
330
    /**
331
     * @param string[] $xEditableTypeMapping
332
     */
333
    public function setXEditableTypeMapping($xEditableTypeMapping): void
334
    {
335
        $this->xEditableTypeMapping = $xEditableTypeMapping;
336
    }
337
338
    /**
339
     * @return string|bool
340
     */
341
    public function getXEditableType($type)
342
    {
343
        return isset($this->xEditableTypeMapping[$type]) ? $this->xEditableTypeMapping[$type] : false;
344
    }
345
346
    /**
347
     * Return xEditable choices based on the field description choices options & catalogue options.
348
     * With the following choice options:
349
     *     ['Status1' => 'Alias1', 'Status2' => 'Alias2']
350
     * The method will return:
351
     *     [['value' => 'Status1', 'text' => 'Alias1'], ['value' => 'Status2', 'text' => 'Alias2']].
352
     *
353
     * @return array
354
     */
355
    public function getXEditableChoices(FieldDescriptionInterface $fieldDescription)
356
    {
357
        $choices = $fieldDescription->getOption('choices', []);
358
        $catalogue = $fieldDescription->getOption('catalogue');
359
        $xEditableChoices = [];
360
        if (!empty($choices)) {
361
            reset($choices);
362
            $first = current($choices);
363
            // the choices are already in the right format
364
            if (\is_array($first) && \array_key_exists('value', $first) && \array_key_exists('text', $first)) {
365
                $xEditableChoices = $choices;
366
            } else {
367
                foreach ($choices as $value => $text) {
368
                    if ($catalogue) {
369
                        if (null !== $this->translator) {
370
                            $text = $this->translator->trans($text, [], $catalogue);
371
                        // NEXT_MAJOR: Remove this check
372
                        } elseif (method_exists($fieldDescription->getAdmin(), 'trans')) {
373
                            $text = $fieldDescription->getAdmin()->trans($text, [], $catalogue);
0 ignored issues
show
Deprecated Code introduced by
The method Sonata\AdminBundle\Admin\AdminInterface::trans() has been deprecated with message: since sonata-project/admin-bundle 3.9, to be removed in 4.0

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
374
                        }
375
                    }
376
377
                    $xEditableChoices[] = [
378
                        'value' => $value,
379
                        'text' => $text,
380
                    ];
381
                }
382
            }
383
        }
384
385
        if (false === $fieldDescription->getOption('required', true)
386
            && false === $fieldDescription->getOption('multiple', false)
387
        ) {
388
            $xEditableChoices = array_merge([[
389
                'value' => '',
390
                'text' => '',
391
            ]], $xEditableChoices);
392
        }
393
394
        return $xEditableChoices;
395
    }
396
397
    /*
398
     * Returns a canonicalized locale for "moment" NPM library,
399
     * or `null` if the locale's language is "en", which doesn't require localization.
400
     *
401
     * @return string|null
402
     */
403
    public function getCanonicalizedLocaleForMoment(array $context)
404
    {
405
        $locale = strtolower(str_replace('_', '-', $context['app']->getRequest()->getLocale()));
406
407
        // "en" language doesn't require localization.
408
        if (('en' === $lang = substr($locale, 0, 2)) && !\in_array($locale, ['en-au', 'en-ca', 'en-gb', 'en-ie', 'en-nz'], true)) {
409
            return null;
410
        }
411
412
        foreach (self::MOMENT_UNSUPPORTED_LOCALES as $language => $locales) {
413
            if ($language === $lang && !\in_array($locale, $locales, true)) {
414
                $locale = $language;
415
            }
416
        }
417
418
        return $locale;
419
    }
420
421
    /**
422
     * Returns a canonicalized locale for "select2" NPM library,
423
     * or `null` if the locale's language is "en", which doesn't require localization.
424
     *
425
     * @return string|null
426
     */
427
    public function getCanonicalizedLocaleForSelect2(array $context)
428
    {
429
        $locale = str_replace('_', '-', $context['app']->getRequest()->getLocale());
430
431
        // "en" language doesn't require localization.
432
        if ('en' === $lang = substr($locale, 0, 2)) {
433
            return null;
434
        }
435
436
        switch ($locale) {
437
            case 'pt':
438
                $locale = 'pt-PT';
439
                break;
440
            case 'ug':
441
                $locale = 'ug-CN';
442
                break;
443
            case 'zh':
444
                $locale = 'zh-CN';
445
                break;
446
            default:
447
                if (!\in_array($locale, ['pt-BR', 'pt-PT', 'ug-CN', 'zh-CN', 'zh-TW'], true)) {
448
                    $locale = $lang;
449
                }
450
        }
451
452
        return $locale;
453
    }
454
455
    /**
456
     * @param string|array $role
457
     * @param object|null  $object
458
     * @param string|null  $field
459
     *
460
     * @return bool
461
     */
462
    public function isGrantedAffirmative($role, $object = null, $field = null)
463
    {
464
        if (null === $this->securityChecker) {
465
            return false;
466
        }
467
468
        if (null !== $field) {
469
            $object = new FieldVote($object, $field);
470
        }
471
472
        if (!\is_array($role)) {
473
            $role = [$role];
474
        }
475
476
        foreach ($role as $oneRole) {
477
            try {
478
                if ($this->securityChecker->isGranted($oneRole, $object)) {
479
                    return true;
480
                }
481
            } catch (AuthenticationCredentialsNotFoundException $e) {
482
                // empty on purpose
483
            }
484
        }
485
486
        return false;
487
    }
488
489
    /**
490
     * return the value related to FieldDescription, if the associated object does no
491
     * exists => a temporary one is created.
492
     *
493
     * @param object $object
494
     *
495
     * @throws \RuntimeException
496
     *
497
     * @return mixed
498
     */
499
    private function getValueFromFieldDescription(
500
        $object,
501
        FieldDescriptionInterface $fieldDescription,
502
        array $params = []
503
    ) {
504
        if (isset($params['loop']) && $object instanceof \ArrayAccess) {
505
            throw new \RuntimeException('remove the loop requirement');
506
        }
507
508
        $value = null;
509
510
        try {
511
            $value = $fieldDescription->getValue($object);
512
        } catch (NoValueException $e) {
513
            if ($fieldDescription->getAssociationAdmin()) {
514
                $value = $fieldDescription->getAssociationAdmin()->getNewInstance();
515
            } else {
516
                throw $e;
517
            }
518
        }
519
520
        return $value;
521
    }
522
523
    /**
524
     * Get template.
525
     *
526
     * @param string $defaultTemplate
527
     *
528
     * @return TemplateWrapper
529
     */
530
    private function getTemplate(
531
        FieldDescriptionInterface $fieldDescription,
532
        $defaultTemplate,
533
        Environment $environment
534
    ) {
535
        $templateName = $fieldDescription->getTemplate() ?: $defaultTemplate;
536
537
        try {
538
            $template = $environment->load($templateName);
539
        } catch (LoaderError $e) {
540
            @trigger_error(sprintf(
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
541
                'Relying on default template loading on field template loading exception is deprecated since 3.1'
542
                .' and will be removed in 4.0. A %s exception will be thrown instead',
543
                LoaderError::class
544
            ), E_USER_DEPRECATED);
545
            $template = $environment->load($defaultTemplate);
546
547
            if (null !== $this->logger) {
548
                $this->logger->warning(sprintf(
549
                    'An error occured trying to load the template "%s" for the field "%s",'
550
                    .' the default template "%s" was used instead.',
551
                    $templateName,
552
                    $fieldDescription->getFieldName(),
553
                    $defaultTemplate
554
                ), ['exception' => $e]);
555
            }
556
        }
557
558
        return $template;
559
    }
560
561
    private function render(
562
        FieldDescriptionInterface $fieldDescription,
563
        TemplateWrapper $template,
564
        array $parameters,
565
        Environment $environment
566
    ): ?string {
567
        $content = $template->render($parameters);
568
569
        if ($environment->isDebug()) {
570
            $commentTemplate = <<<'EOT'
571
572
<!-- START
573
    fieldName: %s
574
    template: %s
575
    compiled template: %s
576
    -->
577
    %s
578
<!-- END - fieldName: %s -->
579
EOT;
580
581
            return sprintf(
582
                $commentTemplate,
583
                $fieldDescription->getFieldName(),
584
                $fieldDescription->getTemplate(),
585
                $template->getSourceContext()->getName(),
586
                $content,
587
                $fieldDescription->getFieldName()
588
            );
589
        }
590
591
        return $content;
592
    }
593
594
    /**
595
     * @throws ServiceCircularReferenceException
596
     * @throws ServiceNotFoundException
597
     */
598
    private function getTemplateRegistry(string $adminCode): TemplateRegistryInterface
599
    {
600
        $serviceId = $adminCode.'.template_registry';
601
        $templateRegistry = $this->templateRegistries->get($serviceId);
602
603
        if ($templateRegistry instanceof TemplateRegistryInterface) {
604
            return $templateRegistry;
605
        }
606
607
        throw new ServiceNotFoundException($serviceId);
608
    }
609
}
610