Skip to content

Commit 394dcc0

Browse files
Twig/LiveComponent Attributes
1 parent b0a531f commit 394dcc0

File tree

14 files changed

+187
-159
lines changed

14 files changed

+187
-159
lines changed

README.md

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,13 @@ Every component consists of (1) a class:
1313
// src/Components/AlertComponent.php
1414
namespace App\Components;
1515

16-
use Symfony\UX\TwigComponent\ComponentInterface;
16+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
1717

18-
class AlertComponent implements ComponentInterface
18+
#[AsTwigComponent('alert')]
19+
class AlertComponent
1920
{
2021
public string $type = 'success';
2122
public string $message;
22-
23-
public static function getComponentName(): string
24-
{
25-
return 'alert';
26-
}
2723
}
2824
```
2925

@@ -64,28 +60,29 @@ That's it! We're ready to go!
6460

6561
Let's create a reusable "alert" element that we can use to show
6662
success or error messages across our site. Step 1 is always to create
67-
a component that implements `ComponentInterface`. Let's start as simple
68-
as possible:
63+
a component that has an `AsTwigComponent` class attribute. Let's start as
64+
simple as possible:
6965

7066
```php
7167
// src/Components/AlertComponent.php
7268
namespace App\Components;
7369

74-
use Symfony\UX\TwigComponent\ComponentInterface;
70+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
7571

76-
class AlertComponent implements ComponentInterface
72+
#[AsTwigComponent('alert')]
73+
class AlertComponent
7774
{
78-
public static function getComponentName(): string
79-
{
80-
return 'alert';
81-
}
8275
}
8376
```
8477

85-
Step 2 is to create a template for this component. Templates live
86-
in `templates/components/{Component Name}.html.twig`, where
87-
`{Component Name}` is whatever you return from the `getComponentName()`
88-
method:
78+
**Note:** If this class is auto-configured, _and_ you're using Symfony 5.3+,
79+
then you're all set. Otherwise, register the service and tag it with
80+
`twig.component`.
81+
82+
Step 2 is to create a template for this component. By default,
83+
templates live in `templates/components/{Component Name}.html.twig`,
84+
where `{Component Name}` is whatever you passed as the first argument
85+
to the `AsTwigComponent` class attribute:
8986

9087
```twig
9188
{# templates/components/alert.html.twig #}
@@ -116,7 +113,8 @@ that, create a public property for each:
116113
// src/Components/AlertComponent.php
117114
// ...
118115

119-
class AlertComponent implements ComponentInterface
116+
#[AsTwigComponent('alert')]
117+
class AlertComponent
120118
{
121119
+ public string $message;
122120

@@ -153,6 +151,23 @@ property of the object. Then, the component is rendered! If a
153151
property has a setter method (e.g. `setMessage()`), that will
154152
be called instead of setting the property directly.
155153

154+
### Customize the Twig Template
155+
156+
You can customize the template used to render the template by
157+
passing it as the second argument to the `AsTwigComponent` attribute:
158+
159+
```diff
160+
// src/Components/AlertComponent.php
161+
// ...
162+
163+
-#[AsTwigComponent('alert')]
164+
+#[AsTwigComponent('alert', 'my/custom/template.html.twig')]
165+
class AlertComponent
166+
{
167+
// ...
168+
}
169+
```
170+
156171
### The mount() Method
157172

158173
If, for some reason, you don't want an option to the `component()`
@@ -163,7 +178,8 @@ a `mount()` method in your component:
163178
// src/Components/AlertComponent.php
164179
// ...
165180

166-
class AlertComponent implements ComponentInterface
181+
#[AsTwigComponent('alert')]
182+
class AlertComponent
167183
{
168184
public string $message;
169185
public string $type = 'success';
@@ -209,9 +225,10 @@ Doctrine entity and `ProductRepository`:
209225
namespace App\Components;
210226

211227
use App\Repository\ProductRepository;
212-
use Symfony\UX\TwigComponent\ComponentInterface;
228+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
213229

214-
class FeaturedProductsComponent implements ComponentInterface
230+
#[AsTwigComponent('featured_products')]
231+
class FeaturedProductsComponent
215232
{
216233
private ProductRepository $productRepository;
217234

@@ -225,11 +242,6 @@ class FeaturedProductsComponent implements ComponentInterface
225242
// an example method that returns an array of Products
226243
return $this->productRepository->findFeatured();
227244
}
228-
229-
public static function getComponentName() : string
230-
{
231-
return 'featured_products';
232-
}
233245
}
234246
```
235247

@@ -289,7 +301,8 @@ method), you can store its result on a private property:
289301
namespace App\Components;
290302
// ...
291303

292-
class FeaturedProductsComponent implements ComponentInterface
304+
#[AsTwigComponent('featured_products')]
305+
class FeaturedProductsComponent
293306
{
294307
private ProductRepository $productRepository;
295308

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
}
2727
},
2828
"require": {
29-
"php": ">=7.2.5",
29+
"php": ">=8.0",
3030
"twig/twig": "^2.0|^3.0",
3131
"symfony/property-access": "^4.4|^5.0",
3232
"symfony/dependency-injection": "^4.4|^5.0"

src/Attribute/AsTwigComponent.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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+
* @author Kevin Bond <[email protected]>
16+
*
17+
* @experimental
18+
*/
19+
#[\Attribute(\Attribute::TARGET_CLASS)]
20+
class AsTwigComponent
21+
{
22+
private string $name;
23+
private ?string $template;
24+
25+
public function __construct(string $name, ?string $template = null)
26+
{
27+
$this->name = $name;
28+
$this->template = $template;
29+
}
30+
31+
final public function getName(): string
32+
{
33+
return $this->name;
34+
}
35+
36+
final public function getTemplate(): string
37+
{
38+
return $this->template ?? "components/{$this->name}.html.twig";
39+
}
40+
41+
/**
42+
* @internal
43+
*
44+
* @return static
45+
*/
46+
final public static function forClass(string $class): self
47+
{
48+
$class = new \ReflectionClass($class);
49+
50+
if (!$attribute = $class->getAttributes(static::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null) {
51+
throw new \InvalidArgumentException(sprintf('"%s" is not a Twig Component, did you forget to add the "%s" attribute?', $class, static::class));
52+
}
53+
54+
return $attribute->newInstance();
55+
}
56+
}

src/ComponentFactory.php

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,24 +21,19 @@
2121
*/
2222
final class ComponentFactory
2323
{
24-
private $components;
25-
private $propertyAccessor;
26-
private $serviceIdMap;
24+
private ServiceLocator $components;
25+
private PropertyAccessorInterface $propertyAccessor;
2726

28-
/**
29-
* @param ServiceLocator|ComponentInterface[] $components
30-
*/
31-
public function __construct(ServiceLocator $components, PropertyAccessorInterface $propertyAccessor, array $serviceIdMap)
27+
public function __construct(ServiceLocator $components, PropertyAccessorInterface $propertyAccessor)
3228
{
3329
$this->components = $components;
3430
$this->propertyAccessor = $propertyAccessor;
35-
$this->serviceIdMap = $serviceIdMap;
3631
}
3732

3833
/**
3934
* Creates the component and "mounts" it with the passed data.
4035
*/
41-
public function create(string $name, array $data = []): ComponentInterface
36+
public function create(string $name, array $data = []): object
4237
{
4338
$component = $this->getComponent($name);
4439

@@ -59,21 +54,12 @@ public function create(string $name, array $data = []): ComponentInterface
5954
/**
6055
* Returns the "unmounted" component.
6156
*/
62-
public function get(string $name): ComponentInterface
57+
public function get(string $name): object
6358
{
6459
return $this->getComponent($name);
6560
}
6661

67-
public function serviceIdFor(string $name): string
68-
{
69-
if (!isset($this->serviceIdMap[$name])) {
70-
throw new \InvalidArgumentException('Component not found.');
71-
}
72-
73-
return $this->serviceIdMap[$name];
74-
}
75-
76-
private function mount(ComponentInterface $component, array &$data): void
62+
private function mount(object $component, array &$data): void
7763
{
7864
try {
7965
$method = (new \ReflectionClass($component))->getMethod('mount');
@@ -102,10 +88,10 @@ private function mount(ComponentInterface $component, array &$data): void
10288
$component->mount(...$parameters);
10389
}
10490

105-
private function getComponent(string $name): ComponentInterface
91+
private function getComponent(string $name): object
10692
{
10793
if (!$this->components->has($name)) {
108-
throw new \InvalidArgumentException(sprintf('Unknown component "%s". The registered components are: %s', $name, implode(', ', array_keys($this->serviceIdMap))));
94+
throw new \InvalidArgumentException(sprintf('Unknown component "%s". The registered components are: %s', $name, implode(', ', array_keys($this->components->getProvidedServices()))));
10995
}
11096

11197
return $this->components->get($name);

src/ComponentInterface.php

Lines changed: 0 additions & 22 deletions
This file was deleted.

src/ComponentRenderer.php

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

1212
namespace Symfony\UX\TwigComponent;
1313

14+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
1415
use Twig\Environment;
1516

1617
/**
@@ -20,19 +21,18 @@
2021
*/
2122
final class ComponentRenderer
2223
{
23-
private $twig;
24+
private Environment $twig;
2425

2526
public function __construct(Environment $twig)
2627
{
2728
$this->twig = $twig;
2829
}
2930

30-
public function render(ComponentInterface $component): string
31+
public function render(object $component): string
3132
{
32-
// TODO: Template attribute/annotation/interface to customize
3333
// TODO: Self-Rendering components?
34-
$templateName = sprintf('components/%s.html.twig', $component::getComponentName());
34+
$attribute = AsTwigComponent::forClass($component::class);
3535

36-
return $this->twig->render($templateName, ['this' => $component]);
36+
return $this->twig->render($attribute->getTemplate(), ['this' => $component]);
3737
}
3838
}

src/DependencyInjection/Compiler/TwigComponentPass.php

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@
1111

1212
namespace Symfony\UX\TwigComponent\DependencyInjection\Compiler;
1313

14+
use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument;
1415
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
1516
use Symfony\Component\DependencyInjection\ContainerBuilder;
1617
use Symfony\Component\DependencyInjection\Exception\LogicException;
17-
use Symfony\UX\TwigComponent\ComponentFactory;
18+
use Symfony\Component\DependencyInjection\Reference;
19+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
1820

1921
/**
2022
* @author Kevin Bond <[email protected]>
@@ -25,25 +27,25 @@ final class TwigComponentPass implements CompilerPassInterface
2527
{
2628
public function process(ContainerBuilder $container): void
2729
{
28-
$serviceIdMap = [];
30+
$componentMap = [];
2931

30-
foreach (array_keys($container->findTaggedServiceIds('twig.component')) as $serviceId) {
31-
$definition = $container->getDefinition($serviceId);
32+
foreach (array_keys($container->findTaggedServiceIds('twig.component')) as $id) {
33+
$componentDefinition = $container->findDefinition($id);
3234

33-
// make all component services non-shared
34-
$definition->setShared(false);
35-
36-
$name = $definition->getClass()::getComponentName();
37-
38-
// ensure component not already defined
39-
if (\array_key_exists($name, $serviceIdMap)) {
40-
throw new LogicException(sprintf('Component "%s" is already registered as "%s", components cannot be registered more than once.', $definition->getClass(), $serviceIdMap[$name]));
35+
try {
36+
$attribute = AsTwigComponent::forClass($componentDefinition->getClass());
37+
} catch (\InvalidArgumentException $e) {
38+
throw new LogicException(sprintf('Service "%s" is tagged as a "twig.component" but does not have a "%s" class attribute.', $id, AsTwigComponent::class), 0, $e);
4139
}
4240

43-
// add to service id map for ComponentFactory
44-
$serviceIdMap[$name] = $serviceId;
41+
$componentMap[$attribute->getName()] = new Reference($id);
42+
43+
// component services must not be shared
44+
$componentDefinition->setShared(false);
4545
}
4646

47-
$container->getDefinition(ComponentFactory::class)->setArgument(2, $serviceIdMap);
47+
$container->findDefinition('ux.twig_component.component_factory')
48+
->setArgument(0, new ServiceLocatorArgument($componentMap))
49+
;
4850
}
4951
}

0 commit comments

Comments
 (0)