Skip to content

Commit 004b969

Browse files
committed
feature #116 [LiveComponent] Add a required "default" action for live components (kbond)
This PR was squashed before being merged into the main branch. Discussion ---------- [LiveComponent] Add a required "default" action for live components | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Tickets | #102 (feature A/D) | License | MIT In order to use the ``@Security`/`@Cache`` annotations for your components, we need the default action to be on the component itself. Currently, for the default action, the component is wrapped inside an internal callable `DefaultComponentController` object. This won't inherit these annotations from the component class. The best solution `@weaverryan` and I could think of is to enforce a "default action" method exists on your component itself. By default, `__invoke()` is used but this can be customized via the `AsLiveComponent::$defaultAction` property. A `DefaultActionTrait` is provided that just adds an empty `__invoke()` method. Commits ------- 04d355f [LiveComponent] Add a required "default" action for live components
2 parents 94a13aa + 04d355f commit 004b969

File tree

13 files changed

+104
-46
lines changed

13 files changed

+104
-46
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## NEXT
44

5+
- Require live components have a default action (`__invoke()` by default) to enable
6+
controller annotations/attributes (ie `@Security/@Cache`). Added `DefaultActionTrait`
7+
helper.
8+
59
- When a model is updated, a new `live:update-model` event is dispatched. Parent
610
components (in a parent-child component setup) listen to this and automatically
711
try to update any model with a matching name. A `data-model-map` was also added

src/LiveComponent/README.md

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,13 @@ A real-time product search component might look like this:
1616
namespace App\Components;
1717

1818
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
19+
use Symfony\UX\LiveComponent\DefaultActionTrait;
1920

2021
#[AsLiveComponent('product_search')]
2122
class ProductSearchComponent
2223
{
24+
use DefaultActionTrait;
25+
2326
public string $query = '';
2427

2528
private ProductRepository $productRepository;
@@ -130,18 +133,21 @@ class RandomNumberComponent
130133

131134
To transform this into a "live" component (i.e. one that
132135
can be re-rendered live on the frontend), replace the
133-
component's `AsTwigComponent` attribute with `AsLiveComponent`:
136+
component's `AsTwigComponent` attribute with `AsLiveComponent`
137+
and add the `DefaultActionTrait`:
134138

135139
```diff
136140
// src/Components/RandomNumberComponent.php
137141

138142
-use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
139143
+use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
144+
+use Symfony\UX\LiveComponent\DefaultActionTrait;
140145

141146
-#[AsTwigComponent('random_number')]
142147
-#[AsLiveComponent('random_number')]
143148
class RandomNumberComponent
144149
{
150+
+ use DefaultActionTrait;
145151
}
146152
```
147153

@@ -433,10 +439,18 @@ changes until loading has taken longer than a certain amount of time:
433439

434440
## Actions
435441

436-
You can also trigger actions on your component. Let's pretend we
437-
want to add a "Reset Min/Max" button to our "random number"
438-
component that, when clicked, sets the min/max numbers back
439-
to a default value.
442+
Live components require a single "default action" that is
443+
used to re-render it. By default, this is an empty `__invoke()`
444+
method and can be added with the `DefaultActionTrait`.
445+
Live components are actually Symfony controllers so you
446+
can add the normal controller attributes/annotations (ie
447+
`@Cache`/`@Security`) to either the entire class just a
448+
single action.
449+
450+
You can also trigger custom actions on your component. Let's
451+
pretend we want to add a "Reset Min/Max" button to our "random
452+
number" component that, when clicked, sets the min/max numbers
453+
back to a default value.
440454

441455
First, add a method with a `LiveAction` attribute above it that
442456
does the work:

src/LiveComponent/src/Attribute/AsLiveComponent.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,32 @@
2121
#[\Attribute(\Attribute::TARGET_CLASS)]
2222
final class AsLiveComponent extends AsTwigComponent
2323
{
24+
private string $defaultAction;
25+
26+
public function __construct(string $name, ?string $template = null, string $defaultAction = '__invoke')
27+
{
28+
parent::__construct($name, $template);
29+
30+
$this->defaultAction = trim($defaultAction, '()');
31+
}
32+
33+
/**
34+
* @internal
35+
*
36+
* @param string|object $component
37+
*/
38+
public static function defaultActionFor($component): string
39+
{
40+
$component = \is_object($component) ? \get_class($component) : $component;
41+
$method = self::forClass($component)->defaultAction;
42+
43+
if (!method_exists($component, $method)) {
44+
throw new \LogicException(sprintf('Live component "%s" requires the default action method "%s".%s', $component, $method, '__invoke' === $method ? ' Either add this method or use the DefaultActionTrait' : ''));
45+
}
46+
47+
return $method;
48+
}
49+
2450
/**
2551
* @internal
2652
*
@@ -51,6 +77,10 @@ public static function liveProps(object $component): \Traversable
5177
*/
5278
public static function isActionAllowed(object $component, string $action): bool
5379
{
80+
if (self::defaultActionFor($component) === $action) {
81+
return true;
82+
}
83+
5484
foreach (self::attributeMethodsFor(LiveAction::class, $component) as $method) {
5585
if ($action === $method->getName()) {
5686
return true;

src/LiveComponent/src/DefaultComponentController.php renamed to src/LiveComponent/src/DefaultActionTrait.php

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,19 @@
1515
* @author Kevin Bond <[email protected]>
1616
*
1717
* @experimental
18-
*
19-
* @internal
2018
*/
21-
final class DefaultComponentController
19+
trait DefaultActionTrait
2220
{
23-
private object $component;
24-
25-
public function __construct(object $component)
26-
{
27-
$this->component = $component;
28-
}
29-
21+
/**
22+
* The "default" action for a component.
23+
*
24+
* This is executed when your component is being re-rendered,
25+
* but no custom action is being called. You probably don't
26+
* want to do any work here because this method is *not*
27+
* executed when a custom action is triggered.
28+
*/
3029
public function __invoke(): void
3130
{
32-
}
33-
34-
public function getComponent(): object
35-
{
36-
return $this->component;
31+
// noop - this is the default action
3732
}
3833
}

src/LiveComponent/src/DependencyInjection/Compiler/LiveComponentPass.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,18 @@ public function process(ContainerBuilder $container): void
2727
$componentServiceMap = [];
2828

2929
foreach (array_keys($container->findTaggedServiceIds('twig.component')) as $id) {
30+
$class = $container->findDefinition($id)->getClass();
31+
3032
try {
31-
$attribute = AsLiveComponent::forClass($container->findDefinition($id)->getClass());
33+
$attribute = AsLiveComponent::forClass($class);
3234
} catch (\InvalidArgumentException $e) {
3335
continue;
3436
}
3537

36-
$componentServiceMap[$attribute->getName()] = $id;
38+
$componentServiceMap[$attribute->getName()] = [$id, $class];
39+
40+
// Ensure default action method is configured correctly
41+
AsLiveComponent::defaultActionFor($class);
3742
}
3843

3944
$container->findDefinition('ux.live_component.event_subscriber')->setArgument(0, $componentServiceMap);

src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
use Symfony\UX\LiveComponent\PropertyHydratorInterface;
3131
use Symfony\UX\LiveComponent\Twig\LiveComponentExtension as LiveComponentTwigExtension;
3232
use Symfony\UX\LiveComponent\Twig\LiveComponentRuntime;
33-
use Symfony\UX\TwigComponent\ComponentFactory;
3433
use Symfony\UX\TwigComponent\ComponentRenderer;
3534

3635
/**
@@ -78,7 +77,6 @@ public function load(array $configs, ContainerBuilder $container): void
7877
class_exists(AbstractArgument::class) ? new AbstractArgument(sprintf('Added in %s.', LiveComponentPass::class)) : [],
7978
])
8079
->addTag('kernel.event_subscriber')
81-
->addTag('container.service_subscriber', ['key' => ComponentFactory::class, 'id' => 'ux.twig_component.component_factory'])
8280
->addTag('container.service_subscriber', ['key' => ComponentRenderer::class, 'id' => 'ux.twig_component.component_renderer'])
8381
->addTag('container.service_subscriber', ['key' => LiveComponentHydrator::class, 'id' => 'ux.live_component.component_hydrator'])
8482
->addTag('container.service_subscriber')

src/LiveComponent/src/EventListener/LiveComponentSubscriber.php

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,7 @@
2929
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
3030
use Symfony\Contracts\Service\ServiceSubscriberInterface;
3131
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
32-
use Symfony\UX\LiveComponent\DefaultComponentController;
3332
use Symfony\UX\LiveComponent\LiveComponentHydrator;
34-
use Symfony\UX\TwigComponent\ComponentFactory;
3533
use Symfony\UX\TwigComponent\ComponentRenderer;
3634

3735
/**
@@ -45,7 +43,7 @@ class LiveComponentSubscriber implements EventSubscriberInterface, ServiceSubscr
4543
private const JSON_FORMAT = 'live-component-json';
4644
private const JSON_CONTENT_TYPE = 'application/vnd.live-component+json';
4745

48-
/** @var array<string, string> */
46+
/** @var array<string, string[]> */
4947
private array $componentServiceMap;
5048
private ContainerInterface $container;
5149

@@ -58,7 +56,6 @@ public function __construct(array $componentServiceMap, ContainerInterface $cont
5856
public static function getSubscribedServices(): array
5957
{
6058
return [
61-
ComponentFactory::class,
6259
ComponentRenderer::class,
6360
LiveComponentHydrator::class,
6461
'?'.CsrfTokenManagerInterface::class,
@@ -79,11 +76,17 @@ public function onKernelRequest(RequestEvent $event): void
7976
$action = $request->get('action', 'get');
8077
$componentName = (string) $request->get('component');
8178

79+
if (!\array_key_exists($componentName, $this->componentServiceMap)) {
80+
throw new NotFoundHttpException(sprintf('Component "%s" not found.', $componentName));
81+
}
82+
83+
[$componentServiceId, $componentClass] = $this->componentServiceMap[$componentName];
84+
8285
if ('get' === $action) {
8386
// set default controller for "default" action
8487
$request->attributes->set(
8588
'_controller',
86-
new DefaultComponentController($this->container->get(ComponentFactory::class)->get($componentName))
89+
sprintf('%s::%s', $componentServiceId, AsLiveComponent::defaultActionFor($componentClass))
8790
);
8891

8992
return;
@@ -99,11 +102,7 @@ public function onKernelRequest(RequestEvent $event): void
99102
throw new BadRequestHttpException('Invalid CSRF token.');
100103
}
101104

102-
if (!\array_key_exists($componentName, $this->componentServiceMap)) {
103-
throw new NotFoundHttpException(sprintf('Component "%s" not found.', $componentName));
104-
}
105-
106-
$request->attributes->set('_controller', sprintf('%s::%s', $this->componentServiceMap[$componentName], $action));
105+
$request->attributes->set('_controller', sprintf('%s::%s', $componentServiceId, $action));
107106
}
108107

109108
public function onKernelController(ControllerEvent $event): void
@@ -119,20 +118,17 @@ public function onKernelController(ControllerEvent $event): void
119118
$request->request->all()
120119
);
121120

122-
$component = $event->getController();
123-
$action = null;
124-
125-
if (\is_array($component)) {
126-
// action is being called
127-
$action = $component[1];
128-
$component = $component[0];
121+
if (!\is_array($controller = $event->getController()) || 2 !== \count($controller)) {
122+
throw new \RuntimeException('Not a valid live component.');
129123
}
130124

131-
if ($component instanceof DefaultComponentController) {
132-
$component = $component->getComponent();
125+
[$component, $action] = $controller;
126+
127+
if (!\is_object($component)) {
128+
throw new \RuntimeException('Not a valid live component.');
133129
}
134130

135-
if (null !== $action && !AsLiveComponent::isActionAllowed($component, $action)) {
131+
if (!AsLiveComponent::isActionAllowed($component, $action)) {
136132
throw new NotFoundHttpException(sprintf('The action "%s" either doesn\'t exist or is not allowed in "%s". Make sure it exist and has the LiveAction attribute above it.', $action, \get_class($component)));
137133
}
138134

src/LiveComponent/tests/Fixture/Component/Component1.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
1515
use Symfony\UX\LiveComponent\Attribute\LiveAction;
1616
use Symfony\UX\LiveComponent\Attribute\LiveProp;
17+
use Symfony\UX\LiveComponent\DefaultActionTrait;
1718
use Symfony\UX\LiveComponent\Tests\Fixture\Entity\Entity1;
1819

1920
/**
@@ -22,6 +23,8 @@
2223
#[AsLiveComponent('component1')]
2324
final class Component1
2425
{
26+
use DefaultActionTrait;
27+
2528
#[LiveProp]
2629
public ?Entity1 $prop1;
2730

src/LiveComponent/tests/Fixture/Component/Component2.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
/**
2424
* @author Kevin Bond <[email protected]>
2525
*/
26-
#[AsLiveComponent('component2')]
26+
#[AsLiveComponent('component2', defaultAction: 'defaultAction()')]
2727
final class Component2
2828
{
2929
#[LiveProp]
@@ -35,6 +35,10 @@ final class Component2
3535

3636
public bool $beforeReRenderCalled = false;
3737

38+
public function defaultAction(): void
39+
{
40+
}
41+
3842
#[LiveAction]
3943
public function increase(): void
4044
{

src/LiveComponent/tests/Fixture/Component/Component3.php

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

1414
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
1515
use Symfony\UX\LiveComponent\Attribute\LiveProp;
16+
use Symfony\UX\LiveComponent\DefaultActionTrait;
1617

1718
/**
1819
* @author Kevin Bond <[email protected]>
1920
*/
2021
#[AsLiveComponent('component3')]
2122
final class Component3
2223
{
24+
use DefaultActionTrait;
25+
2326
#[LiveProp(fieldName: 'myProp1')]
2427
public $prop1;
2528

src/LiveComponent/tests/Fixture/Component/Component5.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,14 @@
1111

1212
namespace Symfony\UX\LiveComponent\Tests\Fixture\Component;
1313

14+
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
15+
use Symfony\UX\LiveComponent\DefaultActionTrait;
16+
1417
/**
1518
* @author Kevin Bond <[email protected]>
1619
*/
20+
#[AsLiveComponent('component5')]
1721
final class Component5 extends Component4
1822
{
23+
use DefaultActionTrait;
1924
}

src/LiveComponent/tests/Unit/Attribute/AsLiveComponentTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,6 @@ public function testCanCheckIfMethodIsAllowed(): void
5959

6060
$this->assertTrue(AsLiveComponent::isActionAllowed($component, 'method1'));
6161
$this->assertFalse(AsLiveComponent::isActionAllowed($component, 'method2'));
62+
$this->assertTrue(AsLiveComponent::isActionAllowed($component, '__invoke'));
6263
}
6364
}

src/TwigComponent/src/Attribute/AsTwigComponent.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ final public static function forClass(string $class): self
4848
$class = new \ReflectionClass($class);
4949

5050
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));
51+
throw new \InvalidArgumentException(sprintf('"%s" is not a Twig Component, did you forget to add the "%s" attribute?', $class->getName(), static::class));
5252
}
5353

5454
return $attribute->newInstance();

0 commit comments

Comments
 (0)