Completed
Push — master ( 57b7f2...4f95ff )
by Grégoire
28:13 queued 01:46
created

SonataAdminExtension::renderRelationElement()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 30

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 30
rs 9.1288
c 0
b 0
f 0
cc 5
nc 5
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 Sonata\AdminBundle\Admin\AdminInterface;
18
use Sonata\AdminBundle\Admin\FieldDescriptionInterface;
19
use Sonata\AdminBundle\Admin\Pool;
20
use Sonata\AdminBundle\Exception\NoValueException;
21
use Sonata\AdminBundle\Templating\TemplateRegistryInterface;
22
use Symfony\Component\DependencyInjection\ContainerInterface;
23
use Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException;
24
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
25
use Symfony\Component\Security\Acl\Voter\FieldVote;
26
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
27
use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException;
28
use Symfony\Contracts\Translation\TranslatorInterface;
29
use Twig\Environment;
30
use Twig\Extension\AbstractExtension;
31
use Twig\TemplateWrapper;
32
use Twig\TwigFilter;
33
use Twig\TwigFunction;
34
35
/**
36
 * @author Thomas Rabaix <[email protected]>
37
 */
38
final class SonataAdminExtension extends AbstractExtension
39
{
40
    // @todo: there are more locales which are not supported by moment and they need to be translated/normalized/canonicalized here
41
    public const MOMENT_UNSUPPORTED_LOCALES = [
42
        'de' => ['de', 'de-at'],
43
        'es' => ['es', 'es-do'],
44
        'nl' => ['nl', 'nl-be'],
45
        'fr' => ['fr', 'fr-ca', 'fr-ch'],
46
    ];
47
48
    /**
49
     * @var TranslatorInterface
50
     */
51
    private $translator;
52
53
    /**
54
     * @var Pool
55
     */
56
    private $pool;
57
58
    /**
59
     * @var string[]
60
     */
61
    private $xEditableTypeMapping = [];
62
63
    /**
64
     * @var ContainerInterface
65
     */
66
    private $templateRegistries;
67
68
    /**
69
     * @var AuthorizationCheckerInterface
70
     */
71
    private $securityChecker;
72
73
    public function __construct(
74
        Pool $pool,
75
        TranslatorInterface $translator,
76
        ?ContainerInterface $templateRegistries = null,
77
        ?AuthorizationCheckerInterface $securityChecker = null
78
    ) {
79
        $this->pool = $pool;
80
        $this->translator = $translator;
81
        $this->templateRegistries = $templateRegistries;
82
        $this->securityChecker = $securityChecker;
83
    }
84
85
    public function getFilters()
86
    {
87
        return [
88
            new TwigFilter(
89
                'render_list_element',
90
                [$this, 'renderListElement'],
91
                [
92
                    'is_safe' => ['html'],
93
                    'needs_environment' => true,
94
                ]
95
            ),
96
            new TwigFilter(
97
                'render_view_element',
98
                [$this, 'renderViewElement'],
99
                [
100
                    'is_safe' => ['html'],
101
                    'needs_environment' => true,
102
                ]
103
            ),
104
            new TwigFilter(
105
                'render_view_element_compare',
106
                [$this, 'renderViewElementCompare'],
107
                [
108
                    'is_safe' => ['html'],
109
                    'needs_environment' => true,
110
                ]
111
            ),
112
            new TwigFilter(
113
                'render_relation_element',
114
                [$this, 'renderRelationElement']
115
            ),
116
            new TwigFilter(
117
                'sonata_urlsafeid',
118
                [$this, 'getUrlSafeIdentifier']
119
            ),
120
            new TwigFilter(
121
                'sonata_xeditable_type',
122
                [$this, 'getXEditableType']
123
            ),
124
            new TwigFilter(
125
                'sonata_xeditable_choices',
126
                [$this, 'getXEditableChoices']
127
            ),
128
        ];
129
    }
130
131
    public function getFunctions()
132
    {
133
        return [
134
            new TwigFunction('canonicalize_locale_for_moment', [$this, 'getCanonicalizedLocaleForMoment'], ['needs_context' => true]),
135
            new TwigFunction('canonicalize_locale_for_select2', [$this, 'getCanonicalizedLocaleForSelect2'], ['needs_context' => true]),
136
            new TwigFunction('is_granted_affirmative', [$this, 'isGrantedAffirmative']),
137
        ];
138
    }
139
140
    public function getName()
141
    {
142
        return 'sonata_admin';
143
    }
144
145
    /**
146
     * render a list element from the FieldDescription.
147
     *
148
     * @param object|array $listElement
149
     * @param array        $params
150
     *
151
     * @return string
152
     */
153
    public function renderListElement(
154
        Environment $environment,
155
        $listElement,
156
        FieldDescriptionInterface $fieldDescription,
157
        $params = []
158
    ) {
159
        $template = $this->getTemplate(
160
            $fieldDescription,
161
            $this->getTemplateRegistry($fieldDescription->getAdmin()->getCode())->getTemplate('base_list_field'),
162
            $environment
163
        );
164
165
        [$object, $value] = $this->getObjectAndValueFromListElement($listElement, $fieldDescription);
0 ignored issues
show
Bug introduced by
The variable $object does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
Bug introduced by
The variable $value does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
166
167
        return $this->render($fieldDescription, $template, array_merge($params, [
168
            'admin' => $fieldDescription->getAdmin(),
169
            'object' => $object,
170
            'value' => $value,
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
    /**
563
     * Extracts the object and requested value from the $listElement.
564
     *
565
     * @param object|array $listElement
566
     *
567
     * @throws \TypeError when $listElement is not an object or an array with an object on offset 0
568
     *
569
     * @return array An array containing object and value
570
     */
571
    private function getObjectAndValueFromListElement(
572
        $listElement,
573
        FieldDescriptionInterface $fieldDescription
574
    ): array {
575
        if (\is_object($listElement)) {
576
            $object = $listElement;
577
        } elseif (\is_array($listElement)) {
578
            if (!isset($listElement[0]) || !\is_object($listElement[0])) {
579
                throw new \TypeError(sprintf('If argument 1 passed to %s() is an array it must contain an object at offset 0.', __METHOD__));
0 ignored issues
show
Unused Code introduced by
The call to TypeError::__construct() has too many arguments starting with sprintf('If argument 1 p...offset 0.', __METHOD__).

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
580
            }
581
582
            $object = $listElement[0];
583
        } else {
584
            throw new \TypeError(sprintf('Argument 1 passed to %s() must be an object or an array, %s given.', __METHOD__, \gettype($listElement)));
0 ignored issues
show
Unused Code introduced by
The call to TypeError::__construct() has too many arguments starting with sprintf('Argument 1 pass...\gettype($listElement)).

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
585
        }
586
587
        if (\is_array($listElement) && \array_key_exists($fieldDescription->getName(), $listElement)) {
588
            $value = $listElement[$fieldDescription->getName()];
589
        } else {
590
            $value = $fieldDescription->getValue($object);
591
        }
592
593
        return [$object, $value];
594
    }
595
}
596