Completed
Pull Request — master (#6210)
by Jordi Sala
02:52
created

SonataAdminExtension   C

Complexity

Total Complexity 57

Size/Duplication

Total Lines 522
Duplicated Lines 0 %

Coupling/Cohesion

Components 5
Dependencies 17

Importance

Changes 0
Metric Value
wmc 57
lcom 5
cbo 17
dl 0
loc 522
rs 5.04
c 0
b 0
f 0

19 Methods

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