Skip to content
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
52 changes: 52 additions & 0 deletions src/LiveComponent/src/Controller/BatchActionController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\LiveComponent\Controller;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\UX\TwigComponent\MountedComponent;

/**
* @author Kevin Bond <[email protected]>
*
* @internal
*/
final class BatchActionController
{
public function __construct(private HttpKernelInterface $kernel)
{
}

public function __invoke(Request $request, MountedComponent $_mounted_component, string $serviceId, array $actions): ?Response
{
foreach ($actions as $action) {
$name = $action['name'] ?? throw new BadRequestHttpException('Invalid JSON');

$subRequest = $request->duplicate(attributes: [
'_controller' => [$serviceId, $name],
'_component_action_args' => $action['args'] ?? [],
'_mounted_component' => $_mounted_component,
'_route' => 'live_component',
]);

$response = $this->kernel->handle($subRequest, HttpKernelInterface::SUB_REQUEST, false);

if ($response->isRedirection()) {
return $response;
}
}

return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\ComponentValidator;
use Symfony\UX\LiveComponent\ComponentValidatorInterface;
use Symfony\UX\LiveComponent\Controller\BatchActionController;
use Symfony\UX\LiveComponent\EventListener\AddLiveAttributesSubscriber;
use Symfony\UX\LiveComponent\EventListener\LiveComponentSubscriber;
use Symfony\UX\LiveComponent\Form\Type\LiveCollectionType;
Expand Down Expand Up @@ -70,6 +71,13 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
])
;

$container->register('ux.live_component.batch_action_controller', BatchActionController::class)
->setPublic(true)
->setArguments([
new Reference('http_kernel'),
])
;

$container->register('ux.live_component.event_subscriber', LiveComponentSubscriber::class)
->addTag('kernel.event_subscriber')
->addTag('container.service_subscriber', ['key' => ComponentFactory::class, 'id' => 'ux.twig_component.component_factory'])
Expand Down
102 changes: 85 additions & 17 deletions src/LiveComponent/src/EventListener/LiveComponentSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ public function onKernelRequest(RequestEvent $event): void
return;
}

if ($request->attributes->has('_controller')) {
return;
}

// the default "action" is get, which does nothing
$action = $request->get('action', 'get');
$componentName = (string) $request->get('component');
Expand Down Expand Up @@ -107,6 +111,23 @@ public function onKernelRequest(RequestEvent $event): void
throw new BadRequestHttpException('Invalid CSRF token.');
}

if ('_batch' === $action) {
// use batch controller
$data = $this->parseDataFor($request);

$request->attributes->set('_controller', 'ux.live_component.batch_action_controller');
$request->attributes->set('serviceId', $metadata->getServiceId());
$request->attributes->set('actions', $data['actions']);
$request->attributes->set('_mounted_component', $this->container->get(LiveComponentHydrator::class)->hydrate(
$this->container->get(ComponentFactory::class)->get($componentName),
$data['data'],
$componentName,
));
$request->attributes->set('_is_live_batch_action', true);

return;
}

$request->attributes->set('_controller', sprintf('%s::%s', $metadata->getServiceId(), $action));
}

Expand All @@ -118,18 +139,13 @@ public function onKernelController(ControllerEvent $event): void
return;
}

$actionArguments = [];
if ($request->query->has('data')) {
// ?data=
$data = json_decode($request->query->get('data'), true, 512, \JSON_THROW_ON_ERROR);
} else {
// OR body of the request is JSON
$requestData = json_decode($request->getContent(), true, 512, \JSON_THROW_ON_ERROR);
$data = $requestData['data'] ?? [];
$actionArguments = $requestData['args'] ?? [];
if ($request->attributes->get('_is_live_batch_action')) {
return;
}

if (!\is_array($controller = $event->getController()) || 2 !== \count($controller)) {
$controller = $event->getController();

if (!\is_array($controller) || 2 !== \count($controller)) {
throw new \RuntimeException('Not a valid live component.');
}

Expand All @@ -143,14 +159,29 @@ public function onKernelController(ControllerEvent $event): void
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)));
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could move this up into onKernelRequest. It's inside of that method that we're reading the action from the user input, which is what we need to validate. We did it down here because this is where we finally, for sure, know the final controller. But if we moved the check up to onKernelRequest(), and then someone (via a listener) decided to change from one action to another action on their live component, who cares? That's not user input doing that.

Btw: motivation for this is potential simplification... but there are a lot of moving pieces, so I'm not sure how it would all "land".

Copy link
Author

@kbond kbond Sep 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wouldn't catch the case where a batch request is sent with invalid action (because the request event is skipped for sub-requests). Or are you thinking for _batch to loop over and validate?

I can't move this up because it requires a component instance. For non-batch requests, this isn't available until onKernelController.


$mounted = $this->container->get(LiveComponentHydrator::class)->hydrate(
$component,
$data,
$request->attributes->get('_component_name')
);

$request->attributes->set('_mounted_component', $mounted);
/*
* Either we:
* A) To not have a _mounted_component, so hydrate $component
* B) We DO have a _mounted_component, so no need to hydrate,
* but we DO need to make sure it's set as the controller.
*/
if (!$request->attributes->has('_mounted_component')) {
$request->attributes->set('_mounted_component', $this->container->get(LiveComponentHydrator::class)->hydrate(
$component,
$this->parseDataFor($request)['data'],
$request->attributes->get('_component_name')
));
} else {
// override the component with our already-mounted version
$component = $request->attributes->get('_mounted_component')->getComponent();
$event->setController([
$component,
$action,
]);
}

// read the action arguments from the request, unless they're already set (batch sub-requests)
$actionArguments = $request->attributes->get('_component_action_args', $this->parseDataFor($request)['args']);
// extra variables to be made available to the controller
// (for "actions" only)
foreach (LiveArg::liveArgs($component, $action) as $parameter => $arg) {
Expand All @@ -160,12 +191,49 @@ public function onKernelController(ControllerEvent $event): void
}
}

/**
* @return array{
* data: array,
* args: array,
* actions: array
* }
*/
private function parseDataFor(Request $request): array
{
if (!$request->attributes->has('_live_request_data')) {
if ($request->query->has('data')) {
return [
'data' => json_decode($request->query->get('data'), true, 512, \JSON_THROW_ON_ERROR),
'args' => [],
'actions' => [],
];
}

$requestData = $request->toArray();

$request->attributes->set('_live_request_data', [
'data' => $requestData['data'] ?? [],
'args' => $requestData['args'] ?? [],
'actions' => $requestData['actions'] ?? [],
]);
}

return $request->attributes->get('_live_request_data');
}

public function onKernelView(ViewEvent $event): void
{
if (!$this->isLiveComponentRequest($request = $event->getRequest())) {
return;
}

if (!$event->isMainRequest()) {
// sub-request, so skip rendering
$event->setResponse(new Response());

return;
}

$event->setResponse($this->createResponse($request->attributes->get('_mounted_component')));
}

Expand Down
51 changes: 51 additions & 0 deletions src/LiveComponent/tests/Fixtures/Component/WithActions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;

use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent('with_actions')]
final class WithActions
{
use DefaultActionTrait;

#[LiveProp]
public array $items = ['initial'];

#[LiveAction]
public function add(#[LiveArg] string $what, UrlGeneratorInterface $router): void
{
$this->items[] = $what;
}

#[LiveAction]
public function redirect(UrlGeneratorInterface $router): RedirectResponse
{
return new RedirectResponse($router->generate('homepage'));
}

#[LiveAction]
public function exception(): void
{
throw new \RuntimeException('Exception message');
}

public function nonLive(): void
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<ul{{ attributes }}>
{% for item in items %}
<li>{{ item }}</li>
{% endfor %}
</ul>
Loading