Skip to content

Commit 357c3ff

Browse files
committed
feature #106 Twig/LiveComponent Attributes (kbond)
This PR was squashed before being merged into the main branch. Discussion ---------- Twig/LiveComponent Attributes | Q | A | ------------- | --- | Bug fix? | no | New feature? | no | Tickets | - | License | MIT Based on discussions with `@weaverryan`, the consensus from the core team seems to be to require PHP8+ and use attributes for defining components instead of the interface. The updated [readme](https://github.com/symfony/ux/blob/a80d8df64a41860d2fec407dfecac36ce34809a1/src/TwigComponent/README.md) shows how this would now work. Assuming this is the direction we want to go, I will update the `LiveComponent` package. Todo: - [x] Update `LiveComponent` - [x] Fix duplicated `LiveProp` [issue](symfony/ux#106 (comment)) on parent classes Commits ------- 46777c3 Twig/LiveComponent Attributes
2 parents 78da210 + fd82f67 commit 357c3ff

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

@@ -66,28 +62,29 @@ That's it! We're ready to go!
6662

6763
Let's create a reusable "alert" element that we can use to show
6864
success or error messages across our site. Step 1 is always to create
69-
a component that implements `ComponentInterface`. Let's start as simple
70-
as possible:
65+
a component that has an `AsTwigComponent` class attribute. Let's start as
66+
simple as possible:
7167

7268
```php
7369
// src/Components/AlertComponent.php
7470
namespace App\Components;
7571

76-
use Symfony\UX\TwigComponent\ComponentInterface;
72+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
7773

78-
class AlertComponent implements ComponentInterface
74+
#[AsTwigComponent('alert')]
75+
class AlertComponent
7976
{
80-
public static function getComponentName(): string
81-
{
82-
return 'alert';
83-
}
8477
}
8578
```
8679

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

9289
```twig
9390
{# templates/components/alert.html.twig #}
@@ -118,7 +115,8 @@ that, create a public property for each:
118115
// src/Components/AlertComponent.php
119116
// ...
120117

121-
class AlertComponent implements ComponentInterface
118+
#[AsTwigComponent('alert')]
119+
class AlertComponent
122120
{
123121
+ public string $message;
124122

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

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

160175
If, for some reason, you don't want an option to the `component()`
@@ -165,7 +180,8 @@ a `mount()` method in your component:
165180
// src/Components/AlertComponent.php
166181
// ...
167182

168-
class AlertComponent implements ComponentInterface
183+
#[AsTwigComponent('alert')]
184+
class AlertComponent
169185
{
170186
public string $message;
171187
public string $type = 'success';
@@ -211,9 +227,10 @@ Doctrine entity and `ProductRepository`:
211227
namespace App\Components;
212228

213229
use App\Repository\ProductRepository;
214-
use Symfony\UX\TwigComponent\ComponentInterface;
230+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
215231

216-
class FeaturedProductsComponent implements ComponentInterface
232+
#[AsTwigComponent('featured_products')]
233+
class FeaturedProductsComponent
217234
{
218235
private ProductRepository $productRepository;
219236

@@ -227,11 +244,6 @@ class FeaturedProductsComponent implements ComponentInterface
227244
// an example method that returns an array of Products
228245
return $this->productRepository->findFeatured();
229246
}
230-
231-
public static function getComponentName() : string
232-
{
233-
return 'featured_products';
234-
}
235247
}
236248
```
237249

@@ -291,7 +303,8 @@ method), you can store its result on a private property:
291303
namespace App\Components;
292304
// ...
293305

294-
class FeaturedProductsComponent implements ComponentInterface
306+
#[AsTwigComponent('featured_products')]
307+
class FeaturedProductsComponent
295308
{
296309
private ProductRepository $productRepository;
297310

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)