diff --git a/src/LiveComponent/src/Controller/BatchActionController.php b/src/LiveComponent/src/Controller/BatchActionController.php new file mode 100644 index 00000000000..61d65a32166 --- /dev/null +++ b/src/LiveComponent/src/Controller/BatchActionController.php @@ -0,0 +1,52 @@ + + * + * 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 + * + * @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; + } +} diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index 946886ca3b6..a983c6dbcfa 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -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; @@ -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']) diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index 4ad4fe91969..8e26a8c2114 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -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'); @@ -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)); } @@ -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.'); } @@ -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))); } - $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) { @@ -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'))); } diff --git a/src/LiveComponent/tests/Fixtures/Component/WithActions.php b/src/LiveComponent/tests/Fixtures/Component/WithActions.php new file mode 100644 index 00000000000..6bd75ef12e5 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/Component/WithActions.php @@ -0,0 +1,51 @@ + + * + * 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 + { + } +} diff --git a/src/LiveComponent/tests/Fixtures/templates/components/with_actions.html.twig b/src/LiveComponent/tests/Fixtures/templates/components/with_actions.html.twig new file mode 100644 index 00000000000..138e09c8605 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/templates/components/with_actions.html.twig @@ -0,0 +1,5 @@ + + {% for item in items %} +
  • {{ item }}
  • + {% endfor %} + diff --git a/src/LiveComponent/tests/Functional/Controller/BatchActionControllerTest.php b/src/LiveComponent/tests/Functional/Controller/BatchActionControllerTest.php new file mode 100644 index 00000000000..53a0f34a822 --- /dev/null +++ b/src/LiveComponent/tests/Functional/Controller/BatchActionControllerTest.php @@ -0,0 +1,157 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Functional\Controller; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper; +use Zenstruck\Browser\KernelBrowser; +use Zenstruck\Browser\Response\HtmlResponse; +use Zenstruck\Browser\Test\HasBrowser; + +/** + * @author Kevin Bond + */ +final class BatchActionControllerTest extends KernelTestCase +{ + use HasBrowser; + use LiveComponentTestHelper; + + public function testCanBatchActions(): void + { + $dehydrated = $this->dehydrateComponent($this->mountComponent('with_actions')); + + $this->browser() + ->throwExceptions() + ->get('/_components/with_actions', ['json' => ['data' => $dehydrated]]) + ->assertSuccessful() + ->assertSee('initial') + ->use(function (HtmlResponse $response, KernelBrowser $browser) { + $browser->post('/_components/with_actions/add', [ + 'json' => [ + 'data' => json_decode($response->crawler()->filter('ul')->first()->attr('data-live-data-value')), + 'args' => ['what' => 'first'], + ], + 'headers' => ['X-CSRF-TOKEN' => $response->crawler()->filter('ul')->first()->attr('data-live-csrf-value')], + ]); + }) + ->assertSee('initial') + ->assertSee('first') + ->use(function (HtmlResponse $response, KernelBrowser $browser) { + $browser->post('/_components/with_actions/_batch', [ + 'json' => [ + 'data' => json_decode($response->crawler()->filter('ul')->first()->attr('data-live-data-value')), + 'actions' => [ + ['name' => 'add', 'args' => ['what' => 'second']], + ['name' => 'add', 'args' => ['what' => 'third']], + ['name' => 'add', 'args' => ['what' => 'fourth']], + ], + ], + 'headers' => ['X-CSRF-TOKEN' => $response->crawler()->filter('ul')->first()->attr('data-live-csrf-value')], + ]); + }) + ->assertSee('initial') + ->assertSee('first') + ->assertSee('second') + ->assertSee('third') + ->assertSee('fourth') + ; + } + + public function testCsrfTokenIsChecked(): void + { + $dehydrated = $this->dehydrateComponent($this->mountComponent('with_actions')); + + $this->browser() + ->post('/_components/with_actions/_batch', ['json' => [ + 'data' => $dehydrated, + 'actions' => [], + ]]) + ->assertStatus(400) + ; + } + + public function testRedirect(): void + { + $dehydrated = $this->dehydrateComponent($this->mountComponent('with_actions')); + + $this->browser() + ->throwExceptions() + ->get('/_components/with_actions', ['json' => ['data' => $dehydrated]]) + ->assertSuccessful() + ->interceptRedirects() + ->use(function (HtmlResponse $response, KernelBrowser $browser) { + $browser->post('/_components/with_actions/_batch', [ + 'json' => [ + 'data' => json_decode($response->crawler()->filter('ul')->first()->attr('data-live-data-value')), + 'actions' => [ + ['name' => 'add', 'args' => ['what' => 'second']], + ['name' => 'redirect'], + ['name' => 'add', 'args' => ['what' => 'fourth']], + ], + ], + 'headers' => ['X-CSRF-TOKEN' => $response->crawler()->filter('ul')->first()->attr('data-live-csrf-value')], + ]); + }) + ->assertRedirectedTo('/') + ; + } + + public function testException(): void + { + $dehydrated = $this->dehydrateComponent($this->mountComponent('with_actions')); + + $this->browser() + ->get('/_components/with_actions', ['json' => ['data' => $dehydrated]]) + ->assertSuccessful() + ->use(function (HtmlResponse $response, KernelBrowser $browser) { + $browser->post('/_components/with_actions/_batch', [ + 'json' => [ + 'data' => json_decode($response->crawler()->filter('ul')->first()->attr('data-live-data-value')), + 'actions' => [ + ['name' => 'add', 'args' => ['what' => 'second']], + ['name' => 'exception'], + ['name' => 'add', 'args' => ['what' => 'fourth']], + ], + ], + 'headers' => ['X-CSRF-TOKEN' => $response->crawler()->filter('ul')->first()->attr('data-live-csrf-value')], + ]); + }) + ->assertStatus(500) + ->assertContains('Exception message') + ; + } + + public function testCannotBatchWithNonLiveAction(): void + { + $dehydrated = $this->dehydrateComponent($this->mountComponent('with_actions')); + + $this->browser() + ->get('/_components/with_actions', ['json' => ['data' => $dehydrated]]) + ->assertSuccessful() + ->use(function (HtmlResponse $response, KernelBrowser $browser) { + $browser->post('/_components/with_actions/_batch', [ + 'json' => [ + 'data' => json_decode($response->crawler()->filter('ul')->first()->attr('data-live-data-value')), + 'actions' => [ + ['name' => 'add', 'args' => ['what' => 'second']], + ['name' => 'nonLive'], + ['name' => 'add', 'args' => ['what' => 'fourth']], + ], + ], + 'headers' => ['X-CSRF-TOKEN' => $response->crawler()->filter('ul')->first()->attr('data-live-csrf-value')], + ]); + }) + ->assertStatus(404) + ->assertContains('The action \"nonLive\" either doesn\'t exist or is not allowed') + ; + } +}