Skip to content

Commit b4d546d

Browse files
committed
[Live] add batch action controller
1 parent f6efbb6 commit b4d546d

File tree

7 files changed

+236
-17
lines changed

7 files changed

+236
-17
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\LiveComponent\Controller;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\HttpFoundation\Response;
16+
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
17+
use Symfony\Component\HttpKernel\HttpKernelInterface;
18+
use Symfony\UX\LiveComponent\LiveComponentHydrator;
19+
use Symfony\UX\TwigComponent\ComponentFactory;
20+
21+
/**
22+
* @author Kevin Bond <[email protected]>
23+
*
24+
* @internal
25+
*/
26+
final class BatchActionController
27+
{
28+
public function __construct(
29+
private HttpKernelInterface $kernel,
30+
private ComponentFactory $factory,
31+
private LiveComponentHydrator $hydrator,
32+
) {
33+
}
34+
35+
public function __invoke(Request $request, string $component): Response
36+
{
37+
try {
38+
$json = json_decode($request->getContent(), true, 512, \JSON_THROW_ON_ERROR);
39+
} catch (\JsonException) {
40+
throw new BadRequestHttpException('Invalid JSON');
41+
}
42+
43+
$data = $json['data'] ?? throw new BadRequestHttpException('Invalid JSON');
44+
$actions = $json['actions'] ?? throw new BadRequestHttpException('Invalid JSON');
45+
46+
if (!\is_array($actions) || !$actions) {
47+
throw new BadRequestHttpException('Invalid JSON');
48+
}
49+
50+
$componentObject = $this->factory->get($component);
51+
$mountedComponent = $this->hydrator->hydrate($componentObject, $data, $component);
52+
$response = null;
53+
54+
// todo ensure live
55+
// todo check csrf token
56+
57+
foreach ($actions as $action) {
58+
$name = $action['name'] ?? throw new BadRequestHttpException('Invalid JSON');
59+
60+
$subRequest = $request->duplicate(attributes: [
61+
'_controller' => [$componentObject, $name],
62+
'_mounted_component' => $mountedComponent,
63+
'_component_action_args' => $action['args'] ?? [],
64+
'_route' => 'live_component',
65+
]);
66+
// todo - only render on final action
67+
68+
$response = $this->kernel->handle($subRequest, HttpKernelInterface::SUB_REQUEST);
69+
70+
// todo - handle redirect
71+
}
72+
73+
return $response;
74+
}
75+
}

src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
2121
use Symfony\UX\LiveComponent\ComponentValidator;
2222
use Symfony\UX\LiveComponent\ComponentValidatorInterface;
23+
use Symfony\UX\LiveComponent\Controller\BatchActionController;
2324
use Symfony\UX\LiveComponent\EventListener\AddLiveAttributesSubscriber;
2425
use Symfony\UX\LiveComponent\EventListener\LiveComponentSubscriber;
2526
use Symfony\UX\LiveComponent\Form\Type\LiveCollectionType;
@@ -70,6 +71,15 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
7071
])
7172
;
7273

74+
$container->register('ux.live_component.batch_action_controller', BatchActionController::class)
75+
->setPublic(true)
76+
->setArguments([
77+
new Reference('http_kernel'),
78+
new Reference('ux.twig_component.component_factory'),
79+
new Reference('ux.live_component.component_hydrator'),
80+
])
81+
;
82+
7383
$container->register('ux.live_component.event_subscriber', LiveComponentSubscriber::class)
7484
->addTag('kernel.event_subscriber')
7585
->addTag('container.service_subscriber', ['key' => ComponentFactory::class, 'id' => 'ux.twig_component.component_factory'])

src/LiveComponent/src/EventListener/LiveComponentSubscriber.php

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ public function onKernelRequest(RequestEvent $event): void
6969
return;
7070
}
7171

72+
if ($request->attributes->has('_controller')) {
73+
// is sub-request
74+
return;
75+
}
76+
7277
// the default "action" is get, which does nothing
7378
$action = $request->get('action', 'get');
7479
$componentName = (string) $request->get('component');
@@ -118,17 +123,6 @@ public function onKernelController(ControllerEvent $event): void
118123
return;
119124
}
120125

121-
$actionArguments = [];
122-
if ($request->query->has('data')) {
123-
// ?data=
124-
$data = json_decode($request->query->get('data'), true, 512, \JSON_THROW_ON_ERROR);
125-
} else {
126-
// OR body of the request is JSON
127-
$requestData = json_decode($request->getContent(), true, 512, \JSON_THROW_ON_ERROR);
128-
$data = $requestData['data'] ?? [];
129-
$actionArguments = $requestData['args'] ?? [];
130-
}
131-
132126
if (!\is_array($controller = $event->getController()) || 2 !== \count($controller)) {
133127
throw new \RuntimeException('Not a valid live component.');
134128
}
@@ -143,13 +137,9 @@ public function onKernelController(ControllerEvent $event): void
143137
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)));
144138
}
145139

146-
$mounted = $this->container->get(LiveComponentHydrator::class)->hydrate(
147-
$component,
148-
$data,
149-
$request->attributes->get('_component_name')
150-
);
140+
$this->mountComponent($component, $request);
151141

152-
$request->attributes->set('_mounted_component', $mounted);
142+
$actionArguments = $request->attributes->get('_component_action_args', []);
153143

154144
// extra variables to be made available to the controller
155145
// (for "actions" only)
@@ -236,4 +226,28 @@ private function isLiveComponentRequest(Request $request): bool
236226
{
237227
return 'live_component' === $request->attributes->get('_route');
238228
}
229+
230+
private function mountComponent(object $component, Request $request): void
231+
{
232+
if ($request->attributes->has('_mounted_component')) {
233+
// is sub-request
234+
return;
235+
}
236+
237+
if ($request->query->has('data')) {
238+
// ?data=
239+
$data = json_decode($request->query->get('data'), true, 512, \JSON_THROW_ON_ERROR);
240+
} else {
241+
// OR body of the request is JSON
242+
$requestData = json_decode($request->getContent(), true, 512, \JSON_THROW_ON_ERROR);
243+
$data = $requestData['data'] ?? [];
244+
$request->attributes->set('_component_action_args', $requestData['args'] ?? []);
245+
}
246+
247+
$request->attributes->set('_mounted_component', $this->container->get(LiveComponentHydrator::class)->hydrate(
248+
$component,
249+
$data,
250+
$request->attributes->get('_component_name')
251+
));
252+
}
239253
}

src/LiveComponent/src/Resources/config/routing/live_component.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
55
xsi:schemaLocation="http://symfony.com/schema/routing https://symfony.com/schema/routing/routing-1.0.xsd">
66

7+
<route id="live_component_batch_action" path="/_components/{component}/_batch" controller="ux.live_component.batch_action_controller" methods="POST" />
8+
79
<route id="live_component" path="/_components/{component}/{action}">
810
<default key="action">get</default>
911
</route>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;
4+
5+
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
6+
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
7+
use Symfony\UX\LiveComponent\Attribute\LiveAction;
8+
use Symfony\UX\LiveComponent\Attribute\LiveArg;
9+
use Symfony\UX\LiveComponent\Attribute\LiveProp;
10+
use Symfony\UX\LiveComponent\DefaultActionTrait;
11+
12+
#[AsLiveComponent('with_actions')]
13+
final class WithActions
14+
{
15+
use DefaultActionTrait;
16+
17+
#[LiveProp]
18+
public array $items = ['initial'];
19+
20+
#[LiveAction]
21+
public function add(#[LiveArg] string $what, UrlGeneratorInterface $router): void
22+
{
23+
$this->items[] = $what;
24+
}
25+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<ul{{ attributes }}>
2+
{% for item in items %}
3+
<li>{{ item }}</li>
4+
{% endfor %}
5+
</ul>
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\LiveComponent\Tests\Functional\Controller;
13+
14+
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
15+
use Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper;
16+
use Zenstruck\Browser\KernelBrowser;
17+
use Zenstruck\Browser\Response\HtmlResponse;
18+
use Zenstruck\Browser\Test\HasBrowser;
19+
20+
/**
21+
* @author Kevin Bond <[email protected]>
22+
*/
23+
final class BatchActionControllerTest extends KernelTestCase
24+
{
25+
use HasBrowser;
26+
use LiveComponentTestHelper;
27+
28+
public function testCanBatchActions(): void
29+
{
30+
$dehydrated = $this->dehydrateComponent($this->mountComponent('with_actions'));
31+
32+
$this->browser()
33+
->throwExceptions()
34+
->get('/_components/with_actions', ['json' => ['data' => $dehydrated]])
35+
->assertSuccessful()
36+
->assertSee('initial')
37+
->use(function (HtmlResponse $response, KernelBrowser $browser) {
38+
$browser->post('/_components/with_actions/add', [
39+
'json' => [
40+
'data' => json_decode($response->crawler()->filter('ul')->first()->attr('data-live-data-value')),
41+
'args' => ['what' => 'first'],
42+
],
43+
'headers' => ['X-CSRF-TOKEN' => $response->crawler()->filter('ul')->first()->attr('data-live-csrf-value')],
44+
]);
45+
})
46+
->assertSee('initial')
47+
->assertSee('first')
48+
->use(function (HtmlResponse $response, KernelBrowser $browser) {
49+
$browser->post('/_components/with_actions/_batch', [
50+
'json' => [
51+
'data' => json_decode($response->crawler()->filter('ul')->first()->attr('data-live-data-value')),
52+
'actions' => [
53+
['name' => 'add', 'args' => ['what' => 'second']],
54+
['name' => 'add', 'args' => ['what' => 'third']],
55+
['name' => 'add', 'args' => ['what' => 'fourth']],
56+
],
57+
],
58+
'headers' => ['X-CSRF-TOKEN' => $response->crawler()->filter('ul')->first()->attr('data-live-csrf-value')],
59+
]);
60+
})
61+
->assertSee('initial')
62+
->assertSee('first')
63+
->assertSee('second')
64+
->assertSee('third')
65+
->assertSee('fourth')
66+
;
67+
}
68+
69+
public function testCsrfTokenIsChecked(): void
70+
{
71+
$this->markTestIncomplete();
72+
}
73+
74+
public function testMustBeLiveComponent(): void
75+
{
76+
$this->markTestIncomplete();
77+
}
78+
79+
public function testRedirect(): void
80+
{
81+
$this->markTestIncomplete();
82+
}
83+
84+
public function testException(): void
85+
{
86+
$this->markTestIncomplete();
87+
}
88+
}

0 commit comments

Comments
 (0)