Completed
Pull Request — master (#6093)
by Mathieu
35:29
created

SonataAdminExtension::renderViewElementCompare()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 41

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
282
                    'Option "associated_tostring" is deprecated since version 2.3 and will be removed in 4.0. '
283
                    .'Use "associated_property" instead.',
284
                    E_USER_DEPRECATED
285
                );
286
            } else {
287
                $method = '__toString';
288
            }
289
290
            if (!method_exists($element, $method)) {
291
                throw new \RuntimeException(sprintf(
292
                    'You must define an `associated_property` option or '.
293
                    'create a `%s::__toString` method to the field option %s from service %s is ',
294
                    \get_class($element),
295
                    $fieldDescription->getName(),
296
                    $fieldDescription->getAdmin()->getCode()
297
                ));
298
            }
299
300
            return $element->$method();
301
        }
302
303
        if (\is_callable($propertyPath)) {
304
            return $propertyPath($element);
305
        }
306
307
        return $this->pool->getPropertyAccessor()->getValue($element, $propertyPath);
308
    }
309
310
    /**
311
     * Get the identifiers as a string that is safe to use in a url.
312
     *
313
     * @param object $model
314
     *
315
     * @return string string representation of the id that is safe to use in a url
316
     */
317
    public function getUrlSafeIdentifier($model, ?AdminInterface $admin = null)
318
    {
319
        if (null === $admin) {
320
            $class = ClassUtils::getClass($model);
321
            if (!$this->pool->hasAdminByClass($class)) {
322
                throw new \InvalidArgumentException('You must pass an admin.');
323
            }
324
325
            $admin = $this->pool->getAdminByClass($class);
326
        }
327
328
        return $admin->getUrlSafeIdentifier($model);
329
    }
330
331
    /**
332
     * @param string[] $xEditableTypeMapping
333
     */
334
    public function setXEditableTypeMapping($xEditableTypeMapping): void
335
    {
336
        $this->xEditableTypeMapping = $xEditableTypeMapping;
337
    }
338
339
    /**
340
     * @return string|bool
341
     */
342
    public function getXEditableType($type)
343
    {
344
        return isset($this->xEditableTypeMapping[$type]) ? $this->xEditableTypeMapping[$type] : false;
345
    }
346
347
    /**
348
     * Return xEditable choices based on the field description choices options & catalogue options.
349
     * With the following choice options:
350
     *     ['Status1' => 'Alias1', 'Status2' => 'Alias2']
351
     * The method will return:
352
     *     [['value' => 'Status1', 'text' => 'Alias1'], ['value' => 'Status2', 'text' => 'Alias2']].
353
     *
354
     * @return array
355
     */
356
    public function getXEditableChoices(FieldDescriptionInterface $fieldDescription)
357
    {
358
        $choices = $fieldDescription->getOption('choices', []);
359
        $catalogue = $fieldDescription->getOption('catalogue');
360
        $xEditableChoices = [];
361
        if (!empty($choices)) {
362
            reset($choices);
363
            $first = current($choices);
364
            // the choices are already in the right format
365
            if (\is_array($first) && \array_key_exists('value', $first) && \array_key_exists('text', $first)) {
366
                $xEditableChoices = $choices;
367
            } else {
368
                foreach ($choices as $value => $text) {
369
                    if ($catalogue) {
370
                        if (null !== $this->translator) {
371
                            $text = $this->translator->trans($text, [], $catalogue);
372
                        // NEXT_MAJOR: Remove this check
373
                        } elseif (method_exists($fieldDescription->getAdmin(), 'trans')) {
374
                            $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...
375
                        }
376
                    }
377
378
                    $xEditableChoices[] = [
379
                        'value' => $value,
380
                        'text' => $text,
381
                    ];
382
                }
383
            }
384
        }
385
386
        if (false === $fieldDescription->getOption('required', true)
387
            && false === $fieldDescription->getOption('multiple', false)
388
        ) {
389
            $xEditableChoices = array_merge([[
390
                'value' => '',
391
                'text' => '',
392
            ]], $xEditableChoices);
393
        }
394
395
        return $xEditableChoices;
396
    }
397
398
    /*
399
     * Returns a canonicalized locale for "moment" NPM library,
400
     * or `null` if the locale's language is "en", which doesn't require localization.
401
     *
402
     * @return string|null
403
     */
404
    public function getCanonicalizedLocaleForMoment(array $context)
405
    {
406
        $locale = strtolower(str_replace('_', '-', $context['app']->getRequest()->getLocale()));
407
408
        // "en" language doesn't require localization.
409
        if (('en' === $lang = substr($locale, 0, 2)) && !\in_array($locale, ['en-au', 'en-ca', 'en-gb', 'en-ie', 'en-nz'], true)) {
410
            return null;
411
        }
412
413
        foreach (self::MOMENT_UNSUPPORTED_LOCALES as $language => $locales) {
414
            if ($language === $lang && !\in_array($locale, $locales, true)) {
415
                $locale = $language;
416
            }
417
        }
418
419
        return $locale;
420
    }
421
422
    /**
423
     * Returns a canonicalized locale for "select2" NPM library,
424
     * or `null` if the locale's language is "en", which doesn't require localization.
425
     *
426
     * @return string|null
427
     */
428
    public function getCanonicalizedLocaleForSelect2(array $context)
429
    {
430
        $locale = str_replace('_', '-', $context['app']->getRequest()->getLocale());
431
432
        // "en" language doesn't require localization.
433
        if ('en' === $lang = substr($locale, 0, 2)) {
434
            return null;
435
        }
436
437
        switch ($locale) {
438
            case 'pt':
439
                $locale = 'pt-PT';
440
                break;
441
            case 'ug':
442
                $locale = 'ug-CN';
443
                break;
444
            case 'zh':
445
                $locale = 'zh-CN';
446
                break;
447
            default:
448
                if (!\in_array($locale, ['pt-BR', 'pt-PT', 'ug-CN', 'zh-CN', 'zh-TW'], true)) {
449
                    $locale = $lang;
450
                }
451
        }
452
453
        return $locale;
454
    }
455
456
    /**
457
     * @param string|array $role
458
     * @param object|null  $object
459
     * @param string|null  $field
460
     *
461
     * @return bool
462
     */
463
    public function isGrantedAffirmative($role, $object = null, $field = null)
464
    {
465
        if (null === $this->securityChecker) {
466
            return false;
467
        }
468
469
        if (null !== $field) {
470
            $object = new FieldVote($object, $field);
471
        }
472
473
        if (!\is_array($role)) {
474
            $role = [$role];
475
        }
476
477
        foreach ($role as $oneRole) {
478
            try {
479
                if ($this->securityChecker->isGranted($oneRole, $object)) {
480
                    return true;
481
                }
482
            } catch (AuthenticationCredentialsNotFoundException $e) {
483
                // empty on purpose
484
            }
485
        }
486
487
        return false;
488
    }
489
490
    /**
491
     * return the value related to FieldDescription, if the associated object does no
492
     * exists => a temporary one is created.
493
     *
494
     * @param object $object
495
     *
496
     * @throws \RuntimeException
497
     *
498
     * @return mixed
499
     */
500
    private function getValueFromFieldDescription(
501
        $object,
502
        FieldDescriptionInterface $fieldDescription,
503
        array $params = []
504
    ) {
505
        if (isset($params['loop']) && $object instanceof \ArrayAccess) {
506
            throw new \RuntimeException('remove the loop requirement');
507
        }
508
509
        $value = null;
510
511
        try {
512
            $value = $fieldDescription->getValue($object);
513
        } catch (NoValueException $e) {
514
            if ($fieldDescription->getAssociationAdmin()) {
515
                $value = $fieldDescription->getAssociationAdmin()->getNewInstance();
516
            } else {
517
                throw $e;
518
            }
519
        }
520
521
        return $value;
522
    }
523
524
    /**
525
     * Get template.
526
     *
527
     * @param string $defaultTemplate
528
     *
529
     * @return TemplateWrapper
530
     */
531
    private function getTemplate(
532
        FieldDescriptionInterface $fieldDescription,
533
        $defaultTemplate,
534
        Environment $environment
535
    ) {
536
        $templateName = $fieldDescription->getTemplate() ?: $defaultTemplate;
537
538
        try {
539
            $template = $environment->load($templateName);
540
        } catch (LoaderError $e) {
541
            @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...
542
                sprintf(
543
                    'Relying on default template loading on field template loading exception '.
544
                    'is deprecated since 3.1 and will be removed in 4.0. '.
545
                    'A %s exception will be thrown instead',
546
                    LoaderError::class
547
                ),
548
                E_USER_DEPRECATED
549
            );
550
            $template = $environment->load($defaultTemplate);
551
552
            if (null !== $this->logger) {
553
                $this->logger->warning(sprintf(
554
                    'An error occured trying to load the template "%s" for the field "%s", '.
555
                    'the default template "%s" was used instead.',
556
                    $templateName,
557
                    $fieldDescription->getFieldName(),
558
                    $defaultTemplate
559
                ), ['exception' => $e]);
560
            }
561
        }
562
563
        return $template;
564
    }
565
566
    private function render(
567
        FieldDescriptionInterface $fieldDescription,
568
        TemplateWrapper $template,
569
        array $parameters,
570
        Environment $environment
571
    ): ?string {
572
        $content = $template->render($parameters);
573
574
        if ($environment->isDebug()) {
575
            $commentTemplate = <<<'EOT'
576
577
<!-- START
578
    fieldName: %s
579
    template: %s
580
    compiled template: %s
581
    -->
582
    %s
583
<!-- END - fieldName: %s -->
584
EOT;
585
586
            return sprintf(
587
                $commentTemplate,
588
                $fieldDescription->getFieldName(),
589
                $fieldDescription->getTemplate(),
590
                $template->getSourceContext()->getName(),
591
                $content,
592
                $fieldDescription->getFieldName()
593
            );
594
        }
595
596
        return $content;
597
    }
598
599
    /**
600
     * @throws ServiceCircularReferenceException
601
     * @throws ServiceNotFoundException
602
     */
603
    private function getTemplateRegistry(string $adminCode): TemplateRegistryInterface
604
    {
605
        $serviceId = $adminCode.'.template_registry';
606
        $templateRegistry = $this->templateRegistries->get($serviceId);
607
608
        if ($templateRegistry instanceof TemplateRegistryInterface) {
609
            return $templateRegistry;
610
        }
611
612
        throw new ServiceNotFoundException($serviceId);
613
    }
614
}
615