Completed
Pull Request — master (#6060)
by Grégoire
02:49
created

SonataAdminExtension::getUrlSafeIdentifier()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

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

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

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