Skip to content

[LiveComponent] Add a required "default" action for live components #116

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 8, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/LiveComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 19 additions & 5 deletions src/LiveComponent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
```

Expand Down Expand Up @@ -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:
Expand Down
30 changes: 30 additions & 0 deletions src/LiveComponent/src/Attribute/AsLiveComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,19 @@
* @author Kevin Bond <kevinbond@gmail.com>
*
* @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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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')
Expand Down
36 changes: 16 additions & 20 deletions src/LiveComponent/src/EventListener/LiveComponentSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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<string, string> */
/** @var array<string, string[]> */
private array $componentServiceMap;
private ContainerInterface $container;

Expand All @@ -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,
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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)));
}

Expand Down
3 changes: 3 additions & 0 deletions src/LiveComponent/tests/Fixture/Component/Component1.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -22,6 +23,8 @@
#[AsLiveComponent('component1')]
final class Component1
{
use DefaultActionTrait;

#[LiveProp]
public ?Entity1 $prop1;

Expand Down
6 changes: 5 additions & 1 deletion src/LiveComponent/tests/Fixture/Component/Component2.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
#[AsLiveComponent('component2')]
#[AsLiveComponent('component2', defaultAction: 'defaultAction()')]
final class Component2
{
#[LiveProp]
Expand All @@ -35,6 +35,10 @@ final class Component2

public bool $beforeReRenderCalled = false;

public function defaultAction(): void
{
}

#[LiveAction]
public function increase(): void
{
Expand Down
3 changes: 3 additions & 0 deletions src/LiveComponent/tests/Fixture/Component/Component3.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@

use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;

/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
#[AsLiveComponent('component3')]
final class Component3
{
use DefaultActionTrait;

#[LiveProp(fieldName: 'myProp1')]
public $prop1;

Expand Down
5 changes: 5 additions & 0 deletions src/LiveComponent/tests/Fixture/Component/Component5.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kevinbond@gmail.com>
*/
#[AsLiveComponent('component5')]
final class Component5 extends Component4
{
use DefaultActionTrait;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
}
2 changes: 1 addition & 1 deletion src/TwigComponent/src/Attribute/AsTwigComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down