diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index 9717a74ea18..a25d964280e 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -2,6 +2,10 @@ ## NEXT +- Require live components have a default action (`__invoke()` by default) to enable + controller annotations/attributes (ie `@Security/@Cache`). Added `DefaultActionTrait` + helper. + - When a model is updated, a new `live:update-model` event is dispatched. Parent components (in a parent-child component setup) listen to this and automatically try to update any model with a matching name. A `data-model-map` was also added diff --git a/src/LiveComponent/README.md b/src/LiveComponent/README.md index 32dfe340461..4f8c837ffa4 100644 --- a/src/LiveComponent/README.md +++ b/src/LiveComponent/README.md @@ -16,10 +16,13 @@ A real-time product search component might look like this: namespace App\Components; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\DefaultActionTrait; #[AsLiveComponent('product_search')] class ProductSearchComponent { + use DefaultActionTrait; + public string $query = ''; private ProductRepository $productRepository; @@ -130,18 +133,21 @@ class RandomNumberComponent To transform this into a "live" component (i.e. one that can be re-rendered live on the frontend), replace the -component's `AsTwigComponent` attribute with `AsLiveComponent`: +component's `AsTwigComponent` attribute with `AsLiveComponent` +and add the `DefaultActionTrait`: ```diff // src/Components/RandomNumberComponent.php -use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; ++use Symfony\UX\LiveComponent\DefaultActionTrait; -#[AsTwigComponent('random_number')] -#[AsLiveComponent('random_number')] class RandomNumberComponent { ++ use DefaultActionTrait; } ``` @@ -433,10 +439,18 @@ changes until loading has taken longer than a certain amount of time: ## Actions -You can also trigger actions on your component. Let's pretend we -want to add a "Reset Min/Max" button to our "random number" -component that, when clicked, sets the min/max numbers back -to a default value. +Live components require a single "default action" that is +used to re-render it. By default, this is an empty `__invoke()` +method and can be added with the `DefaultActionTrait`. +Live components are actually Symfony controllers so you +can add the normal controller attributes/annotations (ie +`@Cache`/`@Security`) to either the entire class just a +single action. + +You can also trigger custom actions on your component. Let's +pretend we want to add a "Reset Min/Max" button to our "random +number" component that, when clicked, sets the min/max numbers +back to a default value. First, add a method with a `LiveAction` attribute above it that does the work: diff --git a/src/LiveComponent/src/Attribute/AsLiveComponent.php b/src/LiveComponent/src/Attribute/AsLiveComponent.php index c5fa0c24211..a05b2e440de 100644 --- a/src/LiveComponent/src/Attribute/AsLiveComponent.php +++ b/src/LiveComponent/src/Attribute/AsLiveComponent.php @@ -21,6 +21,32 @@ #[\Attribute(\Attribute::TARGET_CLASS)] final class AsLiveComponent extends AsTwigComponent { + private string $defaultAction; + + public function __construct(string $name, ?string $template = null, string $defaultAction = '__invoke') + { + parent::__construct($name, $template); + + $this->defaultAction = trim($defaultAction, '()'); + } + + /** + * @internal + * + * @param string|object $component + */ + public static function defaultActionFor($component): string + { + $component = \is_object($component) ? \get_class($component) : $component; + $method = self::forClass($component)->defaultAction; + + if (!method_exists($component, $method)) { + 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' : '')); + } + + return $method; + } + /** * @internal * @@ -51,6 +77,10 @@ public static function liveProps(object $component): \Traversable */ public static function isActionAllowed(object $component, string $action): bool { + if (self::defaultActionFor($component) === $action) { + return true; + } + foreach (self::attributeMethodsFor(LiveAction::class, $component) as $method) { if ($action === $method->getName()) { return true; diff --git a/src/LiveComponent/src/DefaultComponentController.php b/src/LiveComponent/src/DefaultActionTrait.php similarity index 51% rename from src/LiveComponent/src/DefaultComponentController.php rename to src/LiveComponent/src/DefaultActionTrait.php index 235a613e5f9..f5323331b9c 100644 --- a/src/LiveComponent/src/DefaultComponentController.php +++ b/src/LiveComponent/src/DefaultActionTrait.php @@ -15,24 +15,19 @@ * @author Kevin Bond * * @experimental - * - * @internal */ -final class DefaultComponentController +trait DefaultActionTrait { - private object $component; - - public function __construct(object $component) - { - $this->component = $component; - } - + /** + * The "default" action for a component. + * + * This is executed when your component is being re-rendered, + * but no custom action is being called. You probably don't + * want to do any work here because this method is *not* + * executed when a custom action is triggered. + */ public function __invoke(): void { - } - - public function getComponent(): object - { - return $this->component; + // noop - this is the default action } } diff --git a/src/LiveComponent/src/DependencyInjection/Compiler/LiveComponentPass.php b/src/LiveComponent/src/DependencyInjection/Compiler/LiveComponentPass.php index dc0541b4ade..1cf045f96bb 100644 --- a/src/LiveComponent/src/DependencyInjection/Compiler/LiveComponentPass.php +++ b/src/LiveComponent/src/DependencyInjection/Compiler/LiveComponentPass.php @@ -27,13 +27,18 @@ public function process(ContainerBuilder $container): void $componentServiceMap = []; foreach (array_keys($container->findTaggedServiceIds('twig.component')) as $id) { + $class = $container->findDefinition($id)->getClass(); + try { - $attribute = AsLiveComponent::forClass($container->findDefinition($id)->getClass()); + $attribute = AsLiveComponent::forClass($class); } catch (\InvalidArgumentException $e) { continue; } - $componentServiceMap[$attribute->getName()] = $id; + $componentServiceMap[$attribute->getName()] = [$id, $class]; + + // Ensure default action method is configured correctly + AsLiveComponent::defaultActionFor($class); } $container->findDefinition('ux.live_component.event_subscriber')->setArgument(0, $componentServiceMap); diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index fd6d7680069..1c311423f2f 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -30,7 +30,6 @@ use Symfony\UX\LiveComponent\PropertyHydratorInterface; use Symfony\UX\LiveComponent\Twig\LiveComponentExtension as LiveComponentTwigExtension; use Symfony\UX\LiveComponent\Twig\LiveComponentRuntime; -use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentRenderer; /** @@ -78,7 +77,6 @@ public function load(array $configs, ContainerBuilder $container): void class_exists(AbstractArgument::class) ? new AbstractArgument(sprintf('Added in %s.', LiveComponentPass::class)) : [], ]) ->addTag('kernel.event_subscriber') - ->addTag('container.service_subscriber', ['key' => ComponentFactory::class, 'id' => 'ux.twig_component.component_factory']) ->addTag('container.service_subscriber', ['key' => ComponentRenderer::class, 'id' => 'ux.twig_component.component_renderer']) ->addTag('container.service_subscriber', ['key' => LiveComponentHydrator::class, 'id' => 'ux.live_component.component_hydrator']) ->addTag('container.service_subscriber') diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index ad9ff01f7e2..befdab3b2af 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -29,9 +29,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Contracts\Service\ServiceSubscriberInterface; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; -use Symfony\UX\LiveComponent\DefaultComponentController; use Symfony\UX\LiveComponent\LiveComponentHydrator; -use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentRenderer; /** @@ -45,7 +43,7 @@ class LiveComponentSubscriber implements EventSubscriberInterface, ServiceSubscr private const JSON_FORMAT = 'live-component-json'; private const JSON_CONTENT_TYPE = 'application/vnd.live-component+json'; - /** @var array */ + /** @var array */ private array $componentServiceMap; private ContainerInterface $container; @@ -58,7 +56,6 @@ public function __construct(array $componentServiceMap, ContainerInterface $cont public static function getSubscribedServices(): array { return [ - ComponentFactory::class, ComponentRenderer::class, LiveComponentHydrator::class, '?'.CsrfTokenManagerInterface::class, @@ -79,11 +76,17 @@ public function onKernelRequest(RequestEvent $event): void $action = $request->get('action', 'get'); $componentName = (string) $request->get('component'); + if (!\array_key_exists($componentName, $this->componentServiceMap)) { + throw new NotFoundHttpException(sprintf('Component "%s" not found.', $componentName)); + } + + [$componentServiceId, $componentClass] = $this->componentServiceMap[$componentName]; + if ('get' === $action) { // set default controller for "default" action $request->attributes->set( '_controller', - new DefaultComponentController($this->container->get(ComponentFactory::class)->get($componentName)) + sprintf('%s::%s', $componentServiceId, AsLiveComponent::defaultActionFor($componentClass)) ); return; @@ -99,11 +102,7 @@ public function onKernelRequest(RequestEvent $event): void throw new BadRequestHttpException('Invalid CSRF token.'); } - if (!\array_key_exists($componentName, $this->componentServiceMap)) { - throw new NotFoundHttpException(sprintf('Component "%s" not found.', $componentName)); - } - - $request->attributes->set('_controller', sprintf('%s::%s', $this->componentServiceMap[$componentName], $action)); + $request->attributes->set('_controller', sprintf('%s::%s', $componentServiceId, $action)); } public function onKernelController(ControllerEvent $event): void @@ -119,20 +118,17 @@ public function onKernelController(ControllerEvent $event): void $request->request->all() ); - $component = $event->getController(); - $action = null; - - if (\is_array($component)) { - // action is being called - $action = $component[1]; - $component = $component[0]; + if (!\is_array($controller = $event->getController()) || 2 !== \count($controller)) { + throw new \RuntimeException('Not a valid live component.'); } - if ($component instanceof DefaultComponentController) { - $component = $component->getComponent(); + [$component, $action] = $controller; + + if (!\is_object($component)) { + throw new \RuntimeException('Not a valid live component.'); } - if (null !== $action && !AsLiveComponent::isActionAllowed($component, $action)) { + if (!AsLiveComponent::isActionAllowed($component, $action)) { 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))); } diff --git a/src/LiveComponent/tests/Fixture/Component/Component1.php b/src/LiveComponent/tests/Fixture/Component/Component1.php index dba319bc25a..f1f7a255bca 100644 --- a/src/LiveComponent/tests/Fixture/Component/Component1.php +++ b/src/LiveComponent/tests/Fixture/Component/Component1.php @@ -14,6 +14,7 @@ use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveAction; use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\DefaultActionTrait; use Symfony\UX\LiveComponent\Tests\Fixture\Entity\Entity1; /** @@ -22,6 +23,8 @@ #[AsLiveComponent('component1')] final class Component1 { + use DefaultActionTrait; + #[LiveProp] public ?Entity1 $prop1; diff --git a/src/LiveComponent/tests/Fixture/Component/Component2.php b/src/LiveComponent/tests/Fixture/Component/Component2.php index 5755aa02b60..a52c060dedd 100644 --- a/src/LiveComponent/tests/Fixture/Component/Component2.php +++ b/src/LiveComponent/tests/Fixture/Component/Component2.php @@ -23,7 +23,7 @@ /** * @author Kevin Bond */ -#[AsLiveComponent('component2')] +#[AsLiveComponent('component2', defaultAction: 'defaultAction()')] final class Component2 { #[LiveProp] @@ -35,6 +35,10 @@ final class Component2 public bool $beforeReRenderCalled = false; + public function defaultAction(): void + { + } + #[LiveAction] public function increase(): void { diff --git a/src/LiveComponent/tests/Fixture/Component/Component3.php b/src/LiveComponent/tests/Fixture/Component/Component3.php index cbd44fe97f4..983dc03eee3 100644 --- a/src/LiveComponent/tests/Fixture/Component/Component3.php +++ b/src/LiveComponent/tests/Fixture/Component/Component3.php @@ -13,6 +13,7 @@ use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\DefaultActionTrait; /** * @author Kevin Bond @@ -20,6 +21,8 @@ #[AsLiveComponent('component3')] final class Component3 { + use DefaultActionTrait; + #[LiveProp(fieldName: 'myProp1')] public $prop1; diff --git a/src/LiveComponent/tests/Fixture/Component/Component5.php b/src/LiveComponent/tests/Fixture/Component/Component5.php index b1e47a7f565..43703f1b359 100644 --- a/src/LiveComponent/tests/Fixture/Component/Component5.php +++ b/src/LiveComponent/tests/Fixture/Component/Component5.php @@ -11,9 +11,14 @@ namespace Symfony\UX\LiveComponent\Tests\Fixture\Component; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\DefaultActionTrait; + /** * @author Kevin Bond */ +#[AsLiveComponent('component5')] final class Component5 extends Component4 { + use DefaultActionTrait; } diff --git a/src/LiveComponent/tests/Unit/Attribute/AsLiveComponentTest.php b/src/LiveComponent/tests/Unit/Attribute/AsLiveComponentTest.php index cbcc79b75cc..50c9416a8e0 100644 --- a/src/LiveComponent/tests/Unit/Attribute/AsLiveComponentTest.php +++ b/src/LiveComponent/tests/Unit/Attribute/AsLiveComponentTest.php @@ -59,5 +59,6 @@ public function testCanCheckIfMethodIsAllowed(): void $this->assertTrue(AsLiveComponent::isActionAllowed($component, 'method1')); $this->assertFalse(AsLiveComponent::isActionAllowed($component, 'method2')); + $this->assertTrue(AsLiveComponent::isActionAllowed($component, '__invoke')); } } diff --git a/src/TwigComponent/src/Attribute/AsTwigComponent.php b/src/TwigComponent/src/Attribute/AsTwigComponent.php index 3e0019984e5..8be283d3e0d 100644 --- a/src/TwigComponent/src/Attribute/AsTwigComponent.php +++ b/src/TwigComponent/src/Attribute/AsTwigComponent.php @@ -48,7 +48,7 @@ final public static function forClass(string $class): self $class = new \ReflectionClass($class); if (!$attribute = $class->getAttributes(static::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null) { - throw new \InvalidArgumentException(sprintf('"%s" is not a Twig Component, did you forget to add the "%s" attribute?', $class, static::class)); + throw new \InvalidArgumentException(sprintf('"%s" is not a Twig Component, did you forget to add the "%s" attribute?', $class->getName(), static::class)); } return $attribute->newInstance();