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