Completed
Pull Request — master (#5229)
by Grégoire
30:41
created

SonataAdminExtension   F

Complexity

Total Complexity 66

Size/Duplication

Total Lines 585
Duplicated Lines 0 %

Coupling/Cohesion

Components 5
Dependencies 17

Importance

Changes 0
Metric Value
wmc 66
lcom 5
cbo 17
dl 0
loc 585
rs 3.12
c 0
b 0
f 0

19 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 20 2
A getFilters() 0 45 1
A getFunctions() 0 8 1
A getName() 0 4 1
A renderListElement() 0 21 1
A renderViewElement() 0 24 2
A renderViewElementCompare() 0 47 3
B renderRelationElement() 0 41 6
A getUrlsafeIdentifier() 0 8 2
A setXEditableTypeMapping() 0 4 1
A getXEditableType() 0 4 2
B getXEditableChoices() 0 41 11
B getCanonicalizedLocaleForMoment() 0 21 7
B getCanonicalizedLocaleForSelect2() 0 27 6
B isGrantedAffirmative() 0 26 7
A getValueFromFieldDescription() 0 21 5
A getTemplate() 0 31 4
A render() 0 32 2
A getTemplateRegistry() 0 11 2

How to fix   Complexity   

Complex Class

Complex classes like SonataAdminExtension often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use SonataAdminExtension, and based on these observations, apply Extract Interface, too.

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\Component\Translation\TranslatorInterface;
30
use Twig\Environment;
31
use Twig\Extension\AbstractExtension;
32
use Twig\Template;
33
use Twig\TemplateWrapper;
34
use Twig\TwigFilter;
35
use Twig\TwigFunction;
36
37
/**
38
 * @author Thomas Rabaix <[email protected]>
39
 */
40
final class SonataAdminExtension extends AbstractExtension
41
{
42
    /**
43
     * @var TranslatorInterface|null
44
     */
45
    protected $translator;
46
    /**
47
     * @var Pool
48
     */
49
    private $pool;
50
51
    /**
52
     * @var LoggerInterface
53
     */
54
    private $logger;
55
56
    /**
57
     * @var string[]
58
     */
59
    private $xEditableTypeMapping = [];
60
61
    /**
62
     * @var ContainerInterface
63
     */
64
    private $templateRegistries;
65
66
    /**
67
     * @var AuthorizationCheckerInterface
68
     */
69
    private $securityChecker;
70
71
    public function __construct(
72
        Pool $pool,
73
        LoggerInterface $logger = null,
74
        TranslatorInterface $translator = null,
75
        ContainerInterface $templateRegistries = null,
76
        AuthorizationCheckerInterface $securityChecker = null
77
    ) {
78
        // NEXT_MAJOR: make the translator parameter required
79
        if (null === $translator) {
80
            @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...
81
                'The $translator parameter will be required fields with the 4.0 release.',
82
                E_USER_DEPRECATED
83
            );
84
        }
85
        $this->pool = $pool;
86
        $this->logger = $logger;
87
        $this->translator = $translator;
88
        $this->templateRegistries = $templateRegistries;
89
        $this->securityChecker = $securityChecker;
90
    }
91
92
    public function getFilters()
93
    {
94
        return [
95
            new TwigFilter(
96
                'render_list_element',
97
                [$this, 'renderListElement'],
98
                [
99
                    'is_safe' => ['html'],
100
                    'needs_environment' => true,
101
                ]
102
            ),
103
            new TwigFilter(
104
                'render_view_element',
105
                [$this, 'renderViewElement'],
106
                [
107
                    'is_safe' => ['html'],
108
                    'needs_environment' => true,
109
                ]
110
            ),
111
            new TwigFilter(
112
                'render_view_element_compare',
113
                [$this, 'renderViewElementCompare'],
114
                [
115
                    'is_safe' => ['html'],
116
                    'needs_environment' => true,
117
                ]
118
            ),
119
            new TwigFilter(
120
                'render_relation_element',
121
                [$this, 'renderRelationElement']
122
            ),
123
            new TwigFilter(
124
                'sonata_urlsafeid',
125
                [$this, 'getUrlsafeIdentifier']
126
            ),
127
            new TwigFilter(
128
                'sonata_xeditable_type',
129
                [$this, 'getXEditableType']
130
            ),
131
            new TwigFilter(
132
                'sonata_xeditable_choices',
133
                [$this, 'getXEditableChoices']
134
            ),
135
        ];
136
    }
137
138
    public function getFunctions()
139
    {
140
        return [
141
            new TwigFunction('canonicalize_locale_for_moment', [$this, 'getCanonicalizedLocaleForMoment'], ['needs_context' => true]),
142
            new TwigFunction('canonicalize_locale_for_select2', [$this, 'getCanonicalizedLocaleForSelect2'], ['needs_context' => true]),
143
            new TwigFunction('is_granted_affirmative', [$this, 'isGrantedAffirmative']),
144
        ];
145
    }
146
147
    public function getName()
148
    {
149
        return 'sonata_admin';
150
    }
151
152
    /**
153
     * render a list element from the FieldDescription.
154
     *
155
     * @param mixed $object
156
     * @param array $params
157
     *
158
     * @return string
159
     */
160
    public function renderListElement(
161
        Environment $environment,
162
        $object,
163
        FieldDescriptionInterface $fieldDescription,
164
        $params = []
165
    ) {
166
        $template = $this->getTemplate(
167
            $fieldDescription,
168
            // NEXT_MAJOR: Remove this line and use commented line below instead
169
            $fieldDescription->getAdmin()->getTemplate('base_list_field'),
0 ignored issues
show
Deprecated Code introduced by
The method Sonata\AdminBundle\Admin...nterface::getTemplate() has been deprecated with message: since 3.35. To be removed in 4.0. Use TemplateRegistry services instead

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...
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 mixed $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 \call_user_func([$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 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 null|string
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
        if ('es' === $lang && !\in_array($locale, ['es', 'es-do'], true)) {
418
            // `moment: ^2.8` only ships "es" and "es-do" locales for "es" language
419
            $locale = 'es';
420
        } elseif ('nl' === $lang && !\in_array($locale, ['nl', 'nl-be'], true)) {
421
            // `moment: ^2.8` only ships "nl" and "nl-be" locales for "nl" language
422
            $locale = 'nl';
423
        }
424
425
        // @todo: there are more locales which are not supported by moment and they need to be translated/normalized/canonicalized here
426
427
        return $locale;
428
    }
429
430
    /**
431
     * Returns a canonicalized locale for "select2" NPM library,
432
     * or `null` if the locale's language is "en", which doesn't require localization.
433
     *
434
     * @return null|string
435
     */
436
    public function getCanonicalizedLocaleForSelect2(array $context)
437
    {
438
        $locale = str_replace('_', '-', $context['app']->getRequest()->getLocale());
439
440
        // "en" language doesn't require localization.
441
        if ('en' === $lang = substr($locale, 0, 2)) {
442
            return null;
443
        }
444
445
        switch ($locale) {
446
            case 'pt':
447
                $locale = 'pt-PT';
448
                break;
449
            case 'ug':
450
                $locale = 'ug-CN';
451
                break;
452
            case 'zh':
453
                $locale = 'zh-CN';
454
                break;
455
            default:
456
                if (!\in_array($locale, ['pt-BR', 'pt-PT', 'ug-CN', 'zh-CN', 'zh-TW'], true)) {
457
                    $locale = $lang;
458
                }
459
        }
460
461
        return $locale;
462
    }
463
464
    /**
465
     * @param string|array $role
466
     * @param object|null  $object
467
     * @param string|null  $field
468
     *
469
     * @return bool
470
     */
471
    public function isGrantedAffirmative($role, $object = null, $field = null)
472
    {
473
        if (null === $this->securityChecker) {
474
            return false;
475
        }
476
477
        if (null !== $field) {
478
            $object = new FieldVote($object, $field);
479
        }
480
481
        if (!\is_array($role)) {
482
            $role = [$role];
483
        }
484
485
        foreach ($role as $oneRole) {
486
            try {
487
                if ($this->securityChecker->isGranted($oneRole, $object)) {
488
                    return true;
489
                }
490
            } catch (AuthenticationCredentialsNotFoundException $e) {
491
                // empty on purpose
492
            }
493
        }
494
495
        return false;
496
    }
497
498
    /**
499
     * return the value related to FieldDescription, if the associated object does no
500
     * exists => a temporary one is created.
501
     *
502
     * @param object $object
503
     *
504
     * @throws \RuntimeException
505
     *
506
     * @return mixed
507
     */
508
    private function getValueFromFieldDescription(
509
        $object,
510
        FieldDescriptionInterface $fieldDescription,
511
        array $params = []
512
    ) {
513
        if (isset($params['loop']) && $object instanceof \ArrayAccess) {
514
            throw new \RuntimeException('remove the loop requirement');
515
        }
516
517
        $value = null;
518
519
        try {
520
            $value = $fieldDescription->getValue($object);
521
        } catch (NoValueException $e) {
522
            if ($fieldDescription->getAssociationAdmin()) {
523
                $value = $fieldDescription->getAssociationAdmin()->getNewInstance();
524
            }
525
        }
526
527
        return $value;
528
    }
529
530
    /**
531
     * Get template.
532
     *
533
     * @param string $defaultTemplate
534
     *
535
     * @return TemplateWrapper
536
     */
537
    private function getTemplate(
538
        FieldDescriptionInterface $fieldDescription,
539
        $defaultTemplate,
540
        Environment $environment
541
    ) {
542
        $templateName = $fieldDescription->getTemplate() ?: $defaultTemplate;
543
544
        try {
545
            $template = $environment->load($templateName);
546
        } 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...
547
            @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...
548
                'Relying on default template loading on field template loading exception '.
549
                'is deprecated since 3.1 and will be removed in 4.0. '.
550
                'A \Twig_Error_Loader exception will be thrown instead',
551
                E_USER_DEPRECATED
552
            );
553
            $template = $environment->load($defaultTemplate);
554
555
            if (null !== $this->logger) {
556
                $this->logger->warning(sprintf(
557
                    'An error occured trying to load the template "%s" for the field "%s", '.
558
                    'the default template "%s" was used instead.',
559
                    $templateName,
560
                    $fieldDescription->getFieldName(),
561
                    $defaultTemplate
562
                ), ['exception' => $e]);
563
            }
564
        }
565
566
        return $template;
567
    }
568
569
    /**
570
     * @return string
571
     */
572
    private function render(
573
        FieldDescriptionInterface $fieldDescription,
574
        TemplateWrapper $template,
575
        array $parameters,
576
        Environment $environment
577
    ) {
578
        $content = $template->render($parameters);
579
580
        if ($environment->isDebug()) {
581
            $commentTemplate = <<<'EOT'
582
583
<!-- START
584
    fieldName: %s
585
    template: %s
586
    compiled template: %s
587
    -->
588
    %s
589
<!-- END - fieldName: %s -->
590
EOT;
591
592
            return sprintf(
593
                $commentTemplate,
594
                $fieldDescription->getFieldName(),
595
                $fieldDescription->getTemplate(),
596
                $template->getSourceContext()->getName(),
597
                $content,
598
                $fieldDescription->getFieldName()
599
            );
600
        }
601
602
        return $content;
603
    }
604
605
    /**
606
     * @param string $adminCode
607
     *
608
     * @throws ServiceCircularReferenceException
609
     * @throws ServiceNotFoundException
610
     *
611
     * @return TemplateRegistryInterface
612
     */
613
    private function getTemplateRegistry($adminCode)
614
    {
615
        $serviceId = $adminCode.'.template_registry';
616
        $templateRegistry = $this->templateRegistries->get($serviceId);
617
618
        if ($templateRegistry instanceof TemplateRegistryInterface) {
619
            return $templateRegistry;
620
        }
621
622
        throw new ServiceNotFoundException($serviceId);
623
    }
624
}
625