Skip to content

Commit 4b52848

Browse files
committed
[Twig] add ExposeInTemplate attribute
1 parent b12ca0e commit 4b52848

File tree

10 files changed

+199
-6
lines changed

10 files changed

+199
-6
lines changed

src/TwigComponent/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313

1414
- Add `PreRenderEvent` to intercept/manipulate twig template/variables before rendering.
1515

16+
- Add `ExposeInTemplate` attribute to make methods/properties available in component templates
17+
directly.
18+
1619
## 2.0.0
1720

1821
- Support for `stimulus` version 2 was removed and support for `@hotwired/stimulus`
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[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 Symfony\UX\TwigComponent\Attribute;
13+
14+
/**
15+
* Use to expose private/protected properties and public methods
16+
* as variables directly in a component template (`someProp` vs
17+
* `this.someProp`). For properties, they must be "accessible"
18+
* (have a getter).
19+
*
20+
* @author Kevin Bond <[email protected]>
21+
*
22+
* @experimental
23+
*/
24+
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD)]
25+
final class ExposeInTemplate
26+
{
27+
/**
28+
* @param string|null $name The variable name to expose. Leave as null
29+
* to default to property/method name. Getter
30+
* methods will have their "get" prefix removed.
31+
*/
32+
public function __construct(public ?string $name = null)
33+
{
34+
}
35+
}

src/TwigComponent/src/ComponentRenderer.php

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111

1212
namespace Symfony\UX\TwigComponent;
1313

14+
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
1415
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
16+
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
1517
use Symfony\UX\TwigComponent\EventListener\PreRenderEvent;
1618
use Twig\Environment;
1719
use Twig\Extension\EscaperExtension;
@@ -25,7 +27,7 @@ final class ComponentRenderer
2527
{
2628
private bool $safeClassesRegistered = false;
2729

28-
public function __construct(private Environment $twig, private EventDispatcherInterface $dispatcher)
30+
public function __construct(private Environment $twig, private EventDispatcherInterface $dispatcher, private PropertyAccessorInterface $propertyAccessor)
2931
{
3032
}
3133

@@ -37,14 +39,44 @@ public function render(object $component, ComponentMetadata $metadata): string
3739
$this->safeClassesRegistered = true;
3840
}
3941

40-
$event = new PreRenderEvent(
41-
$component,
42-
$metadata,
43-
array_merge(['this' => $component], get_object_vars($component))
42+
$variables = array_merge(
43+
// add the component as "this"
44+
['this' => $component],
45+
46+
// expose all public properties
47+
get_object_vars($component),
48+
49+
// expose properties/methods marked with ExposeInTemplate attribute
50+
iterator_to_array($this->exposedVariables($component)),
4451
);
4552

46-
$this->dispatcher->dispatch($event);
53+
$this->dispatcher->dispatch($event = new PreRenderEvent($component, $metadata, $variables));
4754

4855
return $this->twig->render($event->getTemplate(), $event->getVariables());
4956
}
57+
58+
private function exposedVariables(object $component): \Iterator
59+
{
60+
$class = new \ReflectionClass($component);
61+
62+
foreach ($class->getProperties() as $property) {
63+
if (!$attribute = $property->getAttributes(ExposeInTemplate::class)[0] ?? null) {
64+
continue;
65+
}
66+
67+
yield $attribute->newInstance()->name ?? $property->name => $this->propertyAccessor->getValue($component, $property->name);
68+
}
69+
70+
foreach ($class->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
71+
if (!$attribute = $method->getAttributes(ExposeInTemplate::class)[0] ?? null) {
72+
continue;
73+
}
74+
75+
if (!($name = $attribute->newInstance()->name) && str_starts_with($name = $method->name, 'get')) {
76+
$name = lcfirst(substr($name, 3));
77+
}
78+
79+
yield $name => $component->{$method->name}();
80+
}
81+
}
5082
}

src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ class_exists(AbstractArgument::class) ? new AbstractArgument(sprintf('Added in %
5656
->setArguments([
5757
new Reference('twig'),
5858
new Reference('event_dispatcher'),
59+
new Reference('property_accessor'),
5960
])
6061
;
6162

src/TwigComponent/src/Resources/doc/index.rst

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,62 @@ If an option name matches an argument name in ``mount()``, the option is
219219
passed as that argument and the component system will *not* try to set
220220
it directly on a property.
221221

222+
ExposeInTemplate Attribute
223+
~~~~~~~~~~~~~~~~~~~~~~~~~~
224+
225+
.. versionadded:: 2.1
226+
227+
The ``ExposeInTemplate`` attribute was added in TwigComponents 2.1.
228+
229+
All public component properties are available directly in your component
230+
template. You can use the ``ExposeInTemplate`` attribute to expose
231+
private/protected properties and public methods as variables directly in a
232+
component template (``someProp`` vs ``this.someProp``). For properties, they
233+
must be *accessible* (have a getter).
234+
235+
// ...
236+
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
237+
238+
#[AsTwigComponent('alert')]
239+
class AlertComponent
240+
{
241+
#[ExposeInTemplate]
242+
private string $message; // available as `{{ message }}` in the template
243+
244+
#[ExposeInTemplate('alert_type')]
245+
private string $type = 'success'; // available as `{{ alert_type }}` in the template
246+
247+
#[ExposeInTemplate]
248+
public function getIcon(): string
249+
{
250+
// return value will be available as `{{ icon }}` in the template (note the get prefix is trimmed)
251+
}
252+
253+
/**
254+
* Required to access $this->message
255+
*/
256+
public function getMessage(): string
257+
{
258+
return $this->message;
259+
}
260+
261+
/**
262+
* Required to access $this->type
263+
*/
264+
public function getType(): string
265+
{
266+
return $this->type;
267+
}
268+
269+
// ...
270+
}
271+
272+
.. note::
273+
274+
When ``#[ExposeInTemplate]`` is used above a method, that method will be called one
275+
time before the template is rendered. It effectively makes the method a *non-lazy
276+
computed property*.
277+
222278
PreMount Hook
223279
~~~~~~~~~~~~~
224280

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component;
4+
5+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
6+
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
7+
8+
/**
9+
* @author Kevin Bond <[email protected]>
10+
*/
11+
#[AsTwigComponent('with_exposed_variables')]
12+
final class WithExposedVariables
13+
{
14+
#[ExposeInTemplate]
15+
private string $prop1 = 'prop1 value';
16+
17+
#[ExposeInTemplate('customProp')]
18+
private string $prop2 = 'prop2 value';
19+
20+
public function getProp1(): string
21+
{
22+
return $this->prop1;
23+
}
24+
25+
public function getProp2(): string
26+
{
27+
return $this->prop2;
28+
}
29+
30+
#[ExposeInTemplate]
31+
public function method1(): string
32+
{
33+
return 'method1 value';
34+
}
35+
36+
#[ExposeInTemplate]
37+
public function getMethod2(): string
38+
{
39+
return 'method2 value';
40+
}
41+
42+
#[ExposeInTemplate('customMethod')]
43+
public function getMethod3(): string
44+
{
45+
return 'method3 value';
46+
}
47+
}

src/TwigComponent/tests/Fixtures/Kernel.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Symfony\Component\Config\Loader\LoaderInterface;
1818
use Symfony\Component\DependencyInjection\ContainerBuilder;
1919
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
20+
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\WithExposedVariables;
2021
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentA;
2122
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentB;
2223
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentC;
@@ -59,6 +60,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load
5960
'key' => 'component_d',
6061
'template' => 'components/custom2.html.twig',
6162
]);
63+
$c->register(WithExposedVariables::class)->setAutoconfigured(true)->setAutowired(true);
6264

6365
if ('missing_key' === $this->environment) {
6466
$c->register('missing_key', ComponentB::class)->setAutowired(true)->addTag('twig.component');
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Prop1: {{ prop1 }}
2+
Prop2: {{ customProp }}
3+
Method1: {{ method1 }}
4+
Method2: {{ method2 }}
5+
Method3: {{ customMethod }}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{{ component('with_exposed_variables') }}

src/TwigComponent/tests/Integration/ComponentExtensionTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,15 @@ public function testCanRenderComponentWithAttributes(): void
6464
$this->assertStringContainsString('Component Content (prop value 2)', $output);
6565
$this->assertStringContainsString('<button class="foo baz" type="submit" style="color:red;">', $output);
6666
}
67+
68+
public function testRenderComponentWithExposedVariables(): void
69+
{
70+
$output = self::getContainer()->get(Environment::class)->render('exposed_variables.html.twig');
71+
72+
$this->assertStringContainsString('Prop1: prop1 value', $output);
73+
$this->assertStringContainsString('Prop2: prop2 value', $output);
74+
$this->assertStringContainsString('Method1: method1 value', $output);
75+
$this->assertStringContainsString('Method2: method2 value', $output);
76+
$this->assertStringContainsString('Method3: method3 value', $output);
77+
}
6778
}

0 commit comments

Comments
 (0)