Completed
Push — 3.x ( 8fe5ca...73cd2e )
by Grégoire
05:30
created

getCanonicalizedLocaleForMoment()   A

Complexity

Conditions 6
Paths 4

Size

Total Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 17
rs 9.0777
c 0
b 0
f 0
cc 6
nc 4
nop 1
1
<?php
2
3
/*
4
 * This file is part of the Sonata Project package.
5
 *
6
 * (c) Thomas Rabaix <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace Sonata\AdminBundle\Twig\Extension;
13
14
use Doctrine\Common\Util\ClassUtils;
15
use Psr\Log\LoggerInterface;
16
use Sonata\AdminBundle\Admin\AdminInterface;
17
use Sonata\AdminBundle\Admin\FieldDescriptionInterface;
18
use Sonata\AdminBundle\Admin\Pool;
19
use Sonata\AdminBundle\Exception\NoValueException;
20
use Sonata\AdminBundle\Templating\TemplateRegistryInterface;
21
use Symfony\Component\DependencyInjection\ContainerInterface;
22
use Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException;
23
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
24
use Symfony\Component\Security\Acl\Voter\FieldVote;
25
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
26
use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException;
27
use Symfony\Component\Translation\TranslatorInterface;
28
use Twig\Environment;
29
use Twig\Error\LoaderError;
30
use Twig\Extension\AbstractExtension;
31
use Twig\Template;
32
use Twig\TemplateWrapper;
33
use Twig\TwigFilter;
34
use Twig\TwigFunction;
35
36
/**
37
 * @author Thomas Rabaix <[email protected]>
38
 */
39
class SonataAdminExtension extends AbstractExtension
40
{
41
    // @todo: there are more locales which are not supported by moment and they need to be translated/normalized/canonicalized here
42
    const MOMENT_UNSUPPORTED_LOCALES = [
43
        'es' => ['es', 'es-do'],
44
        'nl' => ['nl', 'nl-be'],
45
        'fr' => ['fr', 'fr-ca', 'fr-ch'],
46
    ];
47
48
    /**
49
     * @var Pool
50
     */
51
    protected $pool;
52
53
    /**
54
     * @var LoggerInterface
55
     */
56
    protected $logger;
57
58
    /**
59
     * @var TranslatorInterface|null
60
     */
61
    protected $translator;
62
63
    /**
64
     * @var string[]
65
     */
66
    private $xEditableTypeMapping = [];
67
68
    /**
69
     * @var ContainerInterface
70
     */
71
    private $templateRegistries;
72
73
    /**
74
     * @var AuthorizationCheckerInterface
75
     */
76
    private $securityChecker;
77
78
    public function __construct(
79
        Pool $pool,
80
        LoggerInterface $logger = null,
81
        TranslatorInterface $translator = null,
82
        ContainerInterface $templateRegistries = null,
83
        AuthorizationCheckerInterface $securityChecker = null
84
    ) {
85
        // NEXT_MAJOR: make the translator parameter required
86
        if (null === $translator) {
87
            @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...
88
                'The $translator parameter will be required fields with the 4.0 release.',
89
                E_USER_DEPRECATED
90
            );
91
        }
92
        $this->pool = $pool;
93
        $this->logger = $logger;
94
        $this->translator = $translator;
95
        $this->templateRegistries = $templateRegistries;
96
        $this->securityChecker = $securityChecker;
97
    }
98
99
    public function getFilters()
100
    {
101
        return [
102
            new TwigFilter(
103
                'render_list_element',
104
                [$this, 'renderListElement'],
105
                [
106
                    'is_safe' => ['html'],
107
                    'needs_environment' => true,
108
                ]
109
            ),
110
            new TwigFilter(
111
                'render_view_element',
112
                [$this, 'renderViewElement'],
113
                [
114
                    'is_safe' => ['html'],
115
                    'needs_environment' => true,
116
                ]
117
            ),
118
            new TwigFilter(
119
                'render_view_element_compare',
120
                [$this, 'renderViewElementCompare'],
121
                [
122
                    'is_safe' => ['html'],
123
                    'needs_environment' => true,
124
                ]
125
            ),
126
            new TwigFilter(
127
                'render_relation_element',
128
                [$this, 'renderRelationElement']
129
            ),
130
            new TwigFilter(
131
                'sonata_urlsafeid',
132
                [$this, 'getUrlsafeIdentifier']
133
            ),
134
            new TwigFilter(
135
                'sonata_xeditable_type',
136
                [$this, 'getXEditableType']
137
            ),
138
            new TwigFilter(
139
                'sonata_xeditable_choices',
140
                [$this, 'getXEditableChoices']
141
            ),
142
        ];
143
    }
144
145
    public function getFunctions()
146
    {
147
        return [
148
            new TwigFunction('canonicalize_locale_for_moment', [$this, 'getCanonicalizedLocaleForMoment'], ['needs_context' => true]),
149
            new TwigFunction('canonicalize_locale_for_select2', [$this, 'getCanonicalizedLocaleForSelect2'], ['needs_context' => true]),
150
            new TwigFunction('is_granted_affirmative', [$this, 'isGrantedAffirmative']),
151
        ];
152
    }
153
154
    public function getName()
155
    {
156
        return 'sonata_admin';
157
    }
158
159
    /**
160
     * render a list element from the FieldDescription.
161
     *
162
     * @param mixed $object
163
     * @param array $params
164
     *
165
     * @return string
166
     */
167
    public function renderListElement(
168
        Environment $environment,
169
        $object,
170
        FieldDescriptionInterface $fieldDescription,
171
        $params = []
172
    ) {
173
        $template = $this->getTemplate(
174
            $fieldDescription,
175
            // NEXT_MAJOR: Remove this line and use commented line below instead
176
            $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...
177
            //$this->getTemplateRegistry($fieldDescription->getAdmin()->getCode())->getTemplate('base_list_field'),
178
            $environment
179
        );
180
181
        return $this->render($fieldDescription, $template, array_merge($params, [
182
            'admin' => $fieldDescription->getAdmin(),
183
            'object' => $object,
184
            'value' => $this->getValueFromFieldDescription($object, $fieldDescription),
185
            'field_description' => $fieldDescription,
186
        ]), $environment);
187
    }
188
189
    /**
190
     * @deprecated since 3.33, to be removed in 4.0. Use render instead
191
     *
192
     * @return string
193
     */
194
    public function output(
195
        FieldDescriptionInterface $fieldDescription,
196
        Template $template,
197
        array $parameters,
198
        Environment $environment
199
    ) {
200
        return $this->render(
201
            $fieldDescription,
202
            new TemplateWrapper($environment, $template),
203
            $parameters,
204
            $environment
205
        );
206
    }
207
208
    /**
209
     * return the value related to FieldDescription, if the associated object does no
210
     * exists => a temporary one is created.
211
     *
212
     * @param object $object
213
     *
214
     * @throws \RuntimeException
215
     *
216
     * @return mixed
217
     */
218
    public function getValueFromFieldDescription(
219
        $object,
220
        FieldDescriptionInterface $fieldDescription,
221
        array $params = []
222
    ) {
223
        if (isset($params['loop']) && $object instanceof \ArrayAccess) {
224
            throw new \RuntimeException('remove the loop requirement');
225
        }
226
227
        $value = null;
228
229
        try {
230
            $value = $fieldDescription->getValue($object);
231
        } catch (NoValueException $e) {
232
            if ($fieldDescription->getAssociationAdmin()) {
233
                $value = $fieldDescription->getAssociationAdmin()->getNewInstance();
234
            }
235
        }
236
237
        return $value;
238
    }
239
240
    /**
241
     * render a view element.
242
     *
243
     * @param mixed $object
244
     *
245
     * @return string
246
     */
247
    public function renderViewElement(
248
        Environment $environment,
249
        FieldDescriptionInterface $fieldDescription,
250
        $object
251
    ) {
252
        $template = $this->getTemplate(
253
            $fieldDescription,
254
            '@SonataAdmin/CRUD/base_show_field.html.twig',
255
            $environment
256
        );
257
258
        try {
259
            $value = $fieldDescription->getValue($object);
260
        } catch (NoValueException $e) {
261
            $value = null;
262
        }
263
264
        return $this->render($fieldDescription, $template, [
265
            'field_description' => $fieldDescription,
266
            'object' => $object,
267
            'value' => $value,
268
            'admin' => $fieldDescription->getAdmin(),
269
        ], $environment);
270
    }
271
272
    /**
273
     * render a compared view element.
274
     *
275
     * @param mixed $baseObject
276
     * @param mixed $compareObject
277
     *
278
     * @return string
279
     */
280
    public function renderViewElementCompare(
281
        Environment $environment,
282
        FieldDescriptionInterface $fieldDescription,
283
        $baseObject,
284
        $compareObject
285
    ) {
286
        $template = $this->getTemplate(
287
            $fieldDescription,
288
            '@SonataAdmin/CRUD/base_show_field.html.twig',
289
            $environment
290
        );
291
292
        try {
293
            $baseValue = $fieldDescription->getValue($baseObject);
294
        } catch (NoValueException $e) {
295
            $baseValue = null;
296
        }
297
298
        try {
299
            $compareValue = $fieldDescription->getValue($compareObject);
300
        } catch (NoValueException $e) {
301
            $compareValue = null;
302
        }
303
304
        $baseValueOutput = $template->render([
305
            'admin' => $fieldDescription->getAdmin(),
306
            'field_description' => $fieldDescription,
307
            'value' => $baseValue,
308
        ]);
309
310
        $compareValueOutput = $template->render([
311
            'field_description' => $fieldDescription,
312
            'admin' => $fieldDescription->getAdmin(),
313
            'value' => $compareValue,
314
        ]);
315
316
        // Compare the rendered output of both objects by using the (possibly) overridden field block
317
        $isDiff = $baseValueOutput !== $compareValueOutput;
318
319
        return $this->render($fieldDescription, $template, [
320
            'field_description' => $fieldDescription,
321
            'value' => $baseValue,
322
            'value_compare' => $compareValue,
323
            'is_diff' => $isDiff,
324
            'admin' => $fieldDescription->getAdmin(),
325
        ], $environment);
326
    }
327
328
    /**
329
     * @param mixed $element
330
     *
331
     * @throws \RuntimeException
332
     *
333
     * @return mixed
334
     */
335
    public function renderRelationElement($element, FieldDescriptionInterface $fieldDescription)
336
    {
337
        if (!\is_object($element)) {
338
            return $element;
339
        }
340
341
        $propertyPath = $fieldDescription->getOption('associated_property');
342
343
        if (null === $propertyPath) {
344
            // For BC kept associated_tostring option behavior
345
            $method = $fieldDescription->getOption('associated_tostring');
346
347
            if ($method) {
348
                @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...
349
                    'Option "associated_tostring" is deprecated since version 2.3 and will be removed in 4.0. '
350
                    .'Use "associated_property" instead.',
351
                    E_USER_DEPRECATED
352
                );
353
            } else {
354
                $method = '__toString';
355
            }
356
357
            if (!method_exists($element, $method)) {
358
                throw new \RuntimeException(sprintf(
359
                    'You must define an `associated_property` option or '.
360
                    'create a `%s::__toString` method to the field option %s from service %s is ',
361
                    \get_class($element),
362
                    $fieldDescription->getName(),
363
                    $fieldDescription->getAdmin()->getCode()
364
                ));
365
            }
366
367
            return \call_user_func([$element, $method]);
368
        }
369
370
        if (\is_callable($propertyPath)) {
371
            return $propertyPath($element);
372
        }
373
374
        return $this->pool->getPropertyAccessor()->getValue($element, $propertyPath);
375
    }
376
377
    /**
378
     * Get the identifiers as a string that is safe to use in a url.
379
     *
380
     * @param object $model
381
     *
382
     * @return string string representation of the id that is safe to use in a url
383
     */
384
    public function getUrlsafeIdentifier($model, AdminInterface $admin = null)
385
    {
386
        if (null === $admin) {
387
            $admin = $this->pool->getAdminByClass(ClassUtils::getClass($model));
388
        }
389
390
        return $admin->getUrlsafeIdentifier($model);
0 ignored issues
show
Bug introduced by
It seems like $admin is not always an object, but can also be of type null. Maybe add an additional type check?

If a variable is not always an object, we recommend to add an additional type check to ensure your method call is safe:

function someFunction(A $objectMaybe = null)
{
    if ($objectMaybe instanceof A) {
        $objectMaybe->doSomething();
    }
}
Loading history...
391
    }
392
393
    /**
394
     * @param string[] $xEditableTypeMapping
395
     */
396
    public function setXEditableTypeMapping($xEditableTypeMapping)
397
    {
398
        $this->xEditableTypeMapping = $xEditableTypeMapping;
399
    }
400
401
    /**
402
     * @return string|bool
403
     */
404
    public function getXEditableType($type)
405
    {
406
        return isset($this->xEditableTypeMapping[$type]) ? $this->xEditableTypeMapping[$type] : false;
407
    }
408
409
    /**
410
     * Return xEditable choices based on the field description choices options & catalogue options.
411
     * With the following choice options:
412
     *     ['Status1' => 'Alias1', 'Status2' => 'Alias2']
413
     * The method will return:
414
     *     [['value' => 'Status1', 'text' => 'Alias1'], ['value' => 'Status2', 'text' => 'Alias2']].
415
     *
416
     * @return array
417
     */
418
    public function getXEditableChoices(FieldDescriptionInterface $fieldDescription)
419
    {
420
        $choices = $fieldDescription->getOption('choices', []);
421
        $catalogue = $fieldDescription->getOption('catalogue');
422
        $xEditableChoices = [];
423
        if (!empty($choices)) {
424
            reset($choices);
425
            $first = current($choices);
426
            // the choices are already in the right format
427
            if (\is_array($first) && array_key_exists('value', $first) && array_key_exists('text', $first)) {
428
                $xEditableChoices = $choices;
429
            } else {
430
                foreach ($choices as $value => $text) {
431
                    if ($catalogue) {
432
                        if (null !== $this->translator) {
433
                            $text = $this->translator->trans($text, [], $catalogue);
434
                        // NEXT_MAJOR: Remove this check
435
                        } elseif (method_exists($fieldDescription->getAdmin(), 'trans')) {
436
                            $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...
437
                        }
438
                    }
439
440
                    $xEditableChoices[] = [
441
                        'value' => $value,
442
                        'text' => $text,
443
                    ];
444
                }
445
            }
446
        }
447
448
        if (false === $fieldDescription->getOption('required', true)
449
            && false === $fieldDescription->getOption('multiple', false)
450
        ) {
451
            $xEditableChoices = array_merge([[
452
                'value' => '',
453
                'text' => '',
454
            ]], $xEditableChoices);
455
        }
456
457
        return $xEditableChoices;
458
    }
459
460
    /**
461
     * Returns a canonicalized locale for "moment" NPM library,
462
     * or `null` if the locale's language is "en", which doesn't require localization.
463
     *
464
     * @return null|string
465
     */
466
    final public function getCanonicalizedLocaleForMoment(array $context)
467
    {
468
        $locale = strtolower(str_replace('_', '-', $context['app']->getRequest()->getLocale()));
469
470
        // "en" language doesn't require localization.
471
        if (('en' === $lang = substr($locale, 0, 2)) && !\in_array($locale, ['en-au', 'en-ca', 'en-gb', 'en-ie', 'en-nz'], true)) {
472
            return null;
473
        }
474
475
        foreach (self::MOMENT_UNSUPPORTED_LOCALES as $language => $locales) {
476
            if ($language === $lang && !\in_array($locale, $locales)) {
477
                $locale = $language;
478
            }
479
        }
480
481
        return $locale;
482
    }
483
484
    /**
485
     * Returns a canonicalized locale for "select2" NPM library,
486
     * or `null` if the locale's language is "en", which doesn't require localization.
487
     *
488
     * @return null|string
489
     */
490
    final public function getCanonicalizedLocaleForSelect2(array $context)
491
    {
492
        $locale = str_replace('_', '-', $context['app']->getRequest()->getLocale());
493
494
        // "en" language doesn't require localization.
495
        if ('en' === $lang = substr($locale, 0, 2)) {
496
            return null;
497
        }
498
499
        switch ($locale) {
500
            case 'pt':
501
                $locale = 'pt-PT';
502
                break;
503
            case 'ug':
504
                $locale = 'ug-CN';
505
                break;
506
            case 'zh':
507
                $locale = 'zh-CN';
508
                break;
509
            default:
510
                if (!\in_array($locale, ['pt-BR', 'pt-PT', 'ug-CN', 'zh-CN', 'zh-TW'], true)) {
511
                    $locale = $lang;
512
                }
513
        }
514
515
        return $locale;
516
    }
517
518
    /**
519
     * @param string|array $role
520
     * @param object|null  $object
521
     * @param string|null  $field
522
     *
523
     * @return bool
524
     */
525
    public function isGrantedAffirmative($role, $object = null, $field = null)
526
    {
527
        if (null === $this->securityChecker) {
528
            return false;
529
        }
530
531
        if (null !== $field) {
532
            $object = new FieldVote($object, $field);
533
        }
534
535
        if (!\is_array($role)) {
536
            $role = [$role];
537
        }
538
539
        foreach ($role as $oneRole) {
540
            try {
541
                if ($this->securityChecker->isGranted($oneRole, $object)) {
542
                    return true;
543
                }
544
            } catch (AuthenticationCredentialsNotFoundException $e) {
545
                // empty on purpose
546
            }
547
        }
548
549
        return false;
550
    }
551
552
    /**
553
     * Get template.
554
     *
555
     * @param string $defaultTemplate
556
     *
557
     * @return TemplateWrapper
558
     */
559
    protected function getTemplate(
560
        FieldDescriptionInterface $fieldDescription,
561
        $defaultTemplate,
562
        Environment $environment
563
    ) {
564
        $templateName = $fieldDescription->getTemplate() ?: $defaultTemplate;
565
566
        try {
567
            $template = $environment->load($templateName);
568
        } catch (LoaderError $e) {
569
            @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...
570
                'Relying on default template loading on field template loading exception '.
571
                'is deprecated since 3.1 and will be removed in 4.0. '.
572
                'A \Twig_Error_Loader exception will be thrown instead',
573
                E_USER_DEPRECATED
574
            );
575
            $template = $environment->load($defaultTemplate);
576
577
            if (null !== $this->logger) {
578
                $this->logger->warning(sprintf(
579
                    'An error occured trying to load the template "%s" for the field "%s", '.
580
                    'the default template "%s" was used instead.',
581
                    $templateName,
582
                    $fieldDescription->getFieldName(),
583
                    $defaultTemplate
584
                ), ['exception' => $e]);
585
            }
586
        }
587
588
        return $template;
589
    }
590
591
    /**
592
     * @return string
593
     */
594
    private function render(
595
        FieldDescriptionInterface $fieldDescription,
596
        TemplateWrapper $template,
597
        array $parameters,
598
        Environment $environment
599
    ) {
600
        $content = $template->render($parameters);
601
602
        if ($environment->isDebug()) {
603
            $commentTemplate = <<<'EOT'
604
605
<!-- START
606
    fieldName: %s
607
    template: %s
608
    compiled template: %s
609
    -->
610
    %s
611
<!-- END - fieldName: %s -->
612
EOT;
613
614
            return sprintf(
615
                $commentTemplate,
616
                $fieldDescription->getFieldName(),
617
                $fieldDescription->getTemplate(),
618
                $template->getSourceContext()->getName(),
619
                $content,
620
                $fieldDescription->getFieldName()
621
            );
622
        }
623
624
        return $content;
625
    }
626
627
    /**
628
     * @param string $adminCode
629
     *
630
     * @throws ServiceCircularReferenceException
631
     * @throws ServiceNotFoundException
632
     *
633
     * @return TemplateRegistryInterface
634
     */
635
    private function getTemplateRegistry($adminCode)
636
    {
637
        $serviceId = $adminCode.'.template_registry';
638
        $templateRegistry = $this->templateRegistries->get($serviceId);
639
640
        if ($templateRegistry instanceof TemplateRegistryInterface) {
641
            return $templateRegistry;
642
        }
643
644
        throw new ServiceNotFoundException($serviceId);
645
    }
646
}
647