Skip to content

Commit ab5832a

Browse files
committed
Send live action arguments to backend
1 parent 39d4e49 commit ab5832a

File tree

10 files changed

+235
-7
lines changed

10 files changed

+235
-7
lines changed

src/LiveComponent/README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,56 @@ class RandomNumberComponent
541541
}
542542
```
543543

544+
##### Passing arguments to live action
545+
546+
You can also provide custom arguments to your action. For example Symfony Form CollectionType needs some JavaScript
547+
to make form dynamic, but it can be avoided.
548+
549+
In component template we use "add entry" and "remove" entry buttons and configure the custom actions that are
550+
implemented in your custom live component. Specific part for remove entry is using an named action argument to provide
551+
the index of the entry which we need to remove. You can provide as many arguments as you need.
552+
553+
```twig
554+
<form>
555+
<div>
556+
Entries:
557+
<button data-action="live#action" data-action-name="addEntry">Add new entry</button>
558+
</div>
559+
{% for entry in this.formValues.entries %}
560+
.. render your entry ..
561+
<button data-action="live#action" data-action-name="removeEntry(index={{ loop.index0 }})">remove entry</button>
562+
{% endfor %}
563+
</form>
564+
```
565+
566+
In component for custom arguments to be injected we need to use `#[LiveArg('..')]` attribute, otherwise it would be
567+
ignored.
568+
569+
```php
570+
// src/Components/CollectionComponent.php
571+
namespace App\Components;
572+
573+
// ...
574+
use Symfony\UX\LiveComponent\Attribute\LiveArg;
575+
use Psr\Log\LoggerInterface;
576+
577+
class CollectionComponent
578+
{
579+
// ...
580+
581+
public function addEntry()
582+
{
583+
$this->formValues['entries'][] = [];
584+
}
585+
586+
#[LiveAction]
587+
public function removeEntry(#[LiveArg] int $index)
588+
{
589+
array_splice($this->formValues['entries'], $index, 1);
590+
}
591+
}
592+
```
593+
544594
### Actions and CSRF Protection
545595

546596
When you trigger an action, a POST request is sent that contains

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) => {
@@ -1162,11 +1162,14 @@ class default_1 extends Controller {
11621162
}, this.debounceValue || DEFAULT_DEBOUNCE);
11631163
}
11641164
}
1165-
_makeRequest(action) {
1165+
_makeRequest(action, args) {
11661166
const splitUrl = this.urlValue.split('?');
11671167
let [url] = splitUrl;
11681168
const [, queryString] = splitUrl;
11691169
const params = new URLSearchParams(queryString || '');
1170+
if (typeof args === 'object' && Object.keys(args).length > 0) {
1171+
params.set('args', new URLSearchParams(args).toString());
1172+
}
11701173
const fetchOptions = {};
11711174
fetchOptions.headers = {
11721175
'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
@@ -144,7 +144,7 @@ export default class extends Controller {
144144
// taking precedence
145145
this._clearWaitingDebouncedRenders();
146146

147-
this._makeRequest(directive.action);
147+
this._makeRequest(directive.action, directive.named);
148148
}
149149

150150
let handled = false;
@@ -296,12 +296,16 @@ export default class extends Controller {
296296
}
297297
}
298298

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

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

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
@@ -29,6 +29,7 @@
2929
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
3030
use Symfony\Contracts\Service\ServiceSubscriberInterface;
3131
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
32+
use Symfony\UX\LiveComponent\Attribute\LiveArg;
3233
use Symfony\UX\LiveComponent\LiveComponentHydrator;
3334
use Symfony\UX\TwigComponent\ComponentFactory;
3435
use Symfony\UX\TwigComponent\ComponentRenderer;
@@ -141,11 +142,21 @@ public function onKernelController(ControllerEvent $event): void
141142

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

145+
$request->attributes->set('_component', $component);
146+
147+
if (!\is_string($queryString = $request->query->get('args'))) {
148+
return;
149+
}
150+
144151
// extra variables to be made available to the controller
145152
// (for "actions" only)
146-
parse_str($request->query->get('values'), $values);
147-
$request->attributes->add($values);
148-
$request->attributes->set('_component', $component);
153+
parse_str($queryString, $args);
154+
155+
foreach (LiveArg::liveArgs($component, $action) as $parameter => $arg) {
156+
if (isset($args[$arg])) {
157+
$request->attributes->set($parameter, $args[$arg]);
158+
}
159+
}
149160
}
150161

151162
public function onKernelView(ViewEvent $event): void
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: 40 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;
@@ -240,4 +241,43 @@ public function testCanRedirectFromComponentAction(): void
240241
->assertJsonMatches('redirect_url', '/')
241242
;
242243
}
244+
245+
public function testInjectsLiveArgs(): void
246+
{
247+
/** @var LiveComponentHydrator $hydrator */
248+
$hydrator = self::getContainer()->get('ux.live_component.component_hydrator');
249+
250+
/** @var ComponentFactory $factory */
251+
$factory = self::getContainer()->get('ux.twig_component.component_factory');
252+
253+
/** @var Component6 $component */
254+
$component = $factory->create('component6');
255+
256+
$dehydrated = $hydrator->dehydrate($component);
257+
$token = null;
258+
259+
$dehydrated['args'] = http_build_query(['arg1' => 'hello', 'arg2' => 666, 'custom' => '33.3']);
260+
261+
$this->browser()
262+
->throwExceptions()
263+
->get('/_components/component6?'.http_build_query($dehydrated))
264+
->assertSuccessful()
265+
->assertHeaderContains('Content-Type', 'html')
266+
->assertContains('Arg1: not provided')
267+
->assertContains('Arg2: not provided')
268+
->assertContains('Arg3: not provided')
269+
->use(function (HtmlResponse $response) use (&$token) {
270+
// get a valid token to use for actions
271+
$token = $response->crawler()->filter('div')->first()->attr('data-live-csrf-value');
272+
})
273+
->post('/_components/component6/inject?'.http_build_query($dehydrated), [
274+
'headers' => ['X-CSRF-TOKEN' => $token],
275+
])
276+
->assertSuccessful()
277+
->assertHeaderContains('Content-Type', 'html')
278+
->assertContains('Arg1: hello')
279+
->assertContains('Arg2: 666')
280+
->assertContains('Arg3: 33.3')
281+
;
282+
}
243283
}

0 commit comments

Comments
 (0)