Skip to content

Commit db824b2

Browse files
committed
feature #218 [LiveComponent] Send live action arguments to backend (norkunas)
This PR was merged into the 2.x branch. Discussion ---------- [LiveComponent] Send live action arguments to backend | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Tickets | #102 Feature B | License | MIT Include to the request to backend the named arguments from live action. Commits ------- 1f0fc2f Send live action arguments to backend
2 parents b7bc7e0 + 1f0fc2f commit db824b2

File tree

11 files changed

+221
-7
lines changed

11 files changed

+221
-7
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
- The Live Component AJAX endpoints now return HTML in all situations
66
instead of JSON.
77

8+
- Send live action arguments to backend
9+
810
## 2.0.0
911

1012
- Support for `stimulus` version 2 was removed and support for `@hotwired/stimulus`

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,7 +1070,7 @@ class default_1 extends Controller {
10701070
directives.forEach((directive) => {
10711071
const _executeAction = () => {
10721072
this._clearWaitingDebouncedRenders();
1073-
this._makeRequest(directive.action);
1073+
this._makeRequest(directive.action, directive.named);
10741074
};
10751075
let handled = false;
10761076
directive.modifiers.forEach((modifier) => {
@@ -1173,11 +1173,14 @@ class default_1 extends Controller {
11731173
}, this.debounceValue || DEFAULT_DEBOUNCE);
11741174
}
11751175
}
1176-
_makeRequest(action) {
1176+
_makeRequest(action, args) {
11771177
const splitUrl = this.urlValue.split('?');
11781178
let [url] = splitUrl;
11791179
const [, queryString] = splitUrl;
11801180
const params = new URLSearchParams(queryString || '');
1181+
if (typeof args === 'object' && Object.keys(args).length > 0) {
1182+
params.set('args', new URLSearchParams(args).toString());
1183+
}
11811184
const fetchOptions = {};
11821185
fetchOptions.headers = {
11831186
'Accept': 'application/vnd.live-component+json',

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export default class extends Controller {
138138
// taking precedence
139139
this._clearWaitingDebouncedRenders();
140140

141-
this._makeRequest(directive.action);
141+
this._makeRequest(directive.action, directive.named);
142142
}
143143

144144
let handled = false;
@@ -294,12 +294,16 @@ export default class extends Controller {
294294
}
295295
}
296296

297-
_makeRequest(action: string|null) {
297+
_makeRequest(action: string|null, args: Record<string,unknown>) {
298298
const splitUrl = this.urlValue.split('?');
299299
let [url] = splitUrl
300300
const [, queryString] = splitUrl;
301301
const params = new URLSearchParams(queryString || '');
302302

303+
if (typeof args === 'object' && Object.keys(args).length > 0) {
304+
params.set('args', new URLSearchParams(args).toString());
305+
}
306+
303307
const fetchOptions: RequestInit = {};
304308
fetchOptions.headers = {
305309
'Accept': 'application/vnd.live-component+html',

src/LiveComponent/assets/test/controller/action.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ describe('LiveController Action Tests', () => {
3535
data-action="live#action"
3636
data-action-name="save"
3737
>Save</button>
38+
39+
<button data-action="live#action" data-action-name="sendNamedArgs(a=1, b=2, c=3)">Send named args</button>
3840
</div>
3941
`;
4042

@@ -64,4 +66,15 @@ describe('LiveController Action Tests', () => {
6466

6567
expect(postMock.lastOptions().body.get('comments')).toEqual('hi WEAVER');
6668
});
69+
70+
it('Sends action named args', async () => {
71+
const data = { comments: 'hi' };
72+
const { element } = await startStimulus(template(data));
73+
74+
fetchMock.postOnce('http://localhost/_components/my_component/sendNamedArgs?values=a%3D1%26b%3D2%26c%3D3', {
75+
html: template({ comments: 'hi' }),
76+
});
77+
78+
getByText(element, 'Send named args').click();
79+
});
6780
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\Attribute;
13+
14+
/**
15+
* @author Tomas Norkūnas <norkunas.tom@gmail.com>
16+
*
17+
* @experimental
18+
*/
19+
#[\Attribute(\Attribute::TARGET_PARAMETER)]
20+
final class LiveArg
21+
{
22+
public function __construct(public ?string $name = null)
23+
{
24+
}
25+
26+
/**
27+
* @return array<string, string>
28+
*/
29+
public static function liveArgs(object $component, string $action): array
30+
{
31+
$method = new \ReflectionMethod($component, $action);
32+
$liveArgs = [];
33+
34+
foreach ($method->getParameters() as $parameter) {
35+
foreach ($parameter->getAttributes(self::class) as $liveArg) {
36+
/** @var LiveArg $attr */
37+
$attr = $liveArg->newInstance();
38+
$parameterName = $parameter->getName();
39+
40+
$liveArgs[$parameterName] = $attr->name ?? $parameterName;
41+
}
42+
}
43+
44+
return $liveArgs;
45+
}
46+
}

src/LiveComponent/src/EventListener/LiveComponentSubscriber.php

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
2929
use Symfony\Contracts\Service\ServiceSubscriberInterface;
3030
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
31+
use Symfony\UX\LiveComponent\Attribute\LiveArg;
3132
use Symfony\UX\LiveComponent\LiveComponentHydrator;
3233
use Symfony\UX\TwigComponent\ComponentFactory;
3334
use Symfony\UX\TwigComponent\ComponentRenderer;
@@ -137,11 +138,21 @@ public function onKernelController(ControllerEvent $event): void
137138

138139
$this->container->get(LiveComponentHydrator::class)->hydrate($component, $data);
139140

141+
$request->attributes->set('_component', $component);
142+
143+
if (!\is_string($queryString = $request->query->get('args'))) {
144+
return;
145+
}
146+
140147
// extra variables to be made available to the controller
141148
// (for "actions" only)
142-
parse_str($request->query->get('values'), $values);
143-
$request->attributes->add($values);
144-
$request->attributes->set('_component', $component);
149+
parse_str($queryString, $args);
150+
151+
foreach (LiveArg::liveArgs($component, $action) as $parameter => $arg) {
152+
if (isset($args[$arg])) {
153+
$request->attributes->set($parameter, $args[$arg]);
154+
}
155+
}
145156
}
146157

147158
public function onKernelView(ViewEvent $event): void

src/LiveComponent/src/Resources/doc/index.rst

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,38 @@ This means that, for example, you can use action autowiring::
552552
// ...
553553
}
554554

555+
Actions & Arguments
556+
^^^^^^^^^^^^^^^^^^^
557+
558+
You can also provide custom arguments to your action::
559+
560+
.. code-block:: twig
561+
<form>
562+
<button data-action="live#action" data-action-name="addItem(id={{ item.id }}, name=CustomItem)">Add Item</button>
563+
</form>
564+
565+
In component for custom arguments to be injected we need to use `#[LiveArg()]` attribute, otherwise it would be
566+
ignored. Optionally you can provide `name` argument like: `[#LiveArg('itemName')]` so it will use custom name from
567+
args but inject to your defined parameter with another name.::
568+
569+
// src/Components/ItemComponent.php
570+
namespace App\Components;
571+
572+
// ...
573+
use Symfony\UX\LiveComponent\Attribute\LiveArg;
574+
use Psr\Log\LoggerInterface;
575+
576+
class ItemComponent
577+
{
578+
// ...
579+
#[LiveAction]
580+
public function addItem(#[LiveArg] int $id, #[LiveArg('itemName')] string $name)
581+
{
582+
$this->id = $id;
583+
$this->name = $name;
584+
}
585+
}
586+
555587
Actions and CSRF Protection
556588
~~~~~~~~~~~~~~~~~~~~~~~~~~~
557589

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\Fixture\Component;
13+
14+
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
15+
use Symfony\UX\LiveComponent\Attribute\LiveAction;
16+
use Symfony\UX\LiveComponent\Attribute\LiveArg;
17+
use Symfony\UX\LiveComponent\Attribute\LiveProp;
18+
use Symfony\UX\LiveComponent\DefaultActionTrait;
19+
20+
/**
21+
* @author Tomas Norkūnas <norkunas.tom@gmail.com>
22+
*/
23+
#[AsLiveComponent('component6')]
24+
class Component6
25+
{
26+
use DefaultActionTrait;
27+
28+
#[LiveProp]
29+
public bool $called = false;
30+
31+
#[LiveProp]
32+
public $arg1;
33+
34+
#[LiveProp]
35+
public $arg2;
36+
37+
#[LiveProp]
38+
public $arg3;
39+
40+
#[LiveAction]
41+
public function inject(
42+
#[LiveArg] string $arg1,
43+
#[LiveArg] int $arg2,
44+
#[LiveArg('custom')] float $arg3,
45+
) {
46+
$this->called = true;
47+
$this->arg1 = $arg1;
48+
$this->arg2 = $arg2;
49+
$this->arg3 = $arg3;
50+
}
51+
}

src/LiveComponent/tests/Fixture/Kernel.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component1;
2727
use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component2;
2828
use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component3;
29+
use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component6;
2930
use Symfony\UX\TwigComponent\TwigComponentBundle;
3031
use Twig\Environment;
3132

@@ -65,12 +66,14 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load
6566
$componentA = $c->register(Component1::class)->setAutoconfigured(true)->setAutowired(true);
6667
$componentB = $c->register(Component2::class)->setAutoconfigured(true)->setAutowired(true);
6768
$componentC = $c->register(Component3::class)->setAutoconfigured(true)->setAutowired(true);
69+
$componentF = $c->register(Component6::class)->setAutoconfigured(true)->setAutowired(true);
6870

6971
if (self::VERSION_ID < 50300) {
7072
// add tag manually
7173
$componentA->addTag('twig.component', ['key' => 'component1'])->addTag('controller.service_arguments');
7274
$componentB->addTag('twig.component', ['key' => 'component2', 'default_action' => 'defaultAction'])->addTag('controller.service_arguments');
7375
$componentC->addTag('twig.component', ['key' => 'component3'])->addTag('controller.service_arguments');
76+
$componentF->addTag('twig.component', ['key' => 'component6'])->addTag('controller.service_arguments');
7477
}
7578

7679
$sessionConfig = self::VERSION_ID < 50300 ? ['storage_id' => 'session.storage.mock_file'] : ['storage_factory_id' => 'session.storage.factory.mock_file'];
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<div
2+
{{ init_live_component(this) }}
3+
>
4+
Arg1: {{ this.called ? this.arg1 : 'not provided' }}
5+
Arg2: {{ this.called ? this.arg2 : 'not provided' }}
6+
Arg3: {{ this.called ? this.arg3 : 'not provided' }}
7+
</div>

src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Symfony\UX\LiveComponent\Tests\ContainerBC;
1818
use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component1;
1919
use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component2;
20+
use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component6;
2021
use Symfony\UX\LiveComponent\Tests\Fixture\Entity\Entity1;
2122
use Symfony\UX\TwigComponent\ComponentFactory;
2223
use Zenstruck\Browser\Response\HtmlResponse;
@@ -215,4 +216,45 @@ public function testCanRedirectFromComponentAction(): void
215216
->assertHeaderEquals('Location', '/')
216217
;
217218
}
219+
220+
public function testInjectsLiveArgs(): void
221+
{
222+
/** @var LiveComponentHydrator $hydrator */
223+
$hydrator = self::getContainer()->get('ux.live_component.component_hydrator');
224+
225+
/** @var ComponentFactory $factory */
226+
$factory = self::getContainer()->get('ux.twig_component.component_factory');
227+
228+
/** @var Component6 $component */
229+
$component = $factory->create('component6');
230+
231+
$dehydrated = $hydrator->dehydrate($component);
232+
$token = null;
233+
234+
$dehydratedWithArgs = array_merge($dehydrated, [
235+
'args' => http_build_query(['arg1' => 'hello', 'arg2' => 666, 'custom' => '33.3']),
236+
]);
237+
238+
$this->browser()
239+
->throwExceptions()
240+
->get('/_components/component6?'.http_build_query($dehydrated))
241+
->assertSuccessful()
242+
->assertHeaderContains('Content-Type', 'html')
243+
->assertContains('Arg1: not provided')
244+
->assertContains('Arg2: not provided')
245+
->assertContains('Arg3: not provided')
246+
->use(function (HtmlResponse $response) use (&$token) {
247+
// get a valid token to use for actions
248+
$token = $response->crawler()->filter('div')->first()->attr('data-live-csrf-value');
249+
})
250+
->post('/_components/component6/inject?'.http_build_query($dehydratedWithArgs), [
251+
'headers' => ['X-CSRF-TOKEN' => $token],
252+
])
253+
->assertSuccessful()
254+
->assertHeaderContains('Content-Type', 'html')
255+
->assertContains('Arg1: hello')
256+
->assertContains('Arg2: 666')
257+
->assertContains('Arg3: 33.3')
258+
;
259+
}
218260
}

0 commit comments

Comments
 (0)