Skip to content

Commit 0b44242

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

File tree

11 files changed

+223
-8
lines changed

11 files changed

+223
-8
lines changed

.github/workflows/test.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ jobs:
1010
- uses: shivammathur/setup-php@v2
1111
with:
1212
php-version: '7.4'
13+
tools: cs2pr
1314
- name: php-cs-fixer
1415
run: |
1516
wget https://github.com/FriendsOfPHP/PHP-CS-Fixer/releases/download/v2.18.2/php-cs-fixer.phar -q
16-
php php-cs-fixer.phar fix --dry-run --diff
17+
php php-cs-fixer.phar fix --dry-run --diff --format=checkstyle | cs2pr
1718
1819
coding-style-js:
1920
runs-on: ubuntu-latest

src/LiveComponent/README.md

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

544+
##### Passing arguments to live action
545+
546+
You can also provide custom arguments to your action.
547+
548+
```twig
549+
<form>
550+
<button data-action="live#action" data-action-name="addItem(id={{ item.id }}, name=CustomItem)">Add Item</button>
551+
</form>
552+
```
553+
554+
In component for custom arguments to be injected we need to use `#[LiveArg()]` attribute, otherwise it would be
555+
ignored. Optionally you can provide `name` argument like: `[#LiveArg('itemName')]` so it will use custom name from
556+
args but inject to your defined parameter with another name.
557+
558+
```php
559+
// src/Components/ItemComponent.php
560+
namespace App\Components;
561+
562+
// ...
563+
use Symfony\UX\LiveComponent\Attribute\LiveArg;
564+
use Psr\Log\LoggerInterface;
565+
566+
class ItemComponent
567+
{
568+
// ...
569+
#[LiveAction]
570+
public function addItem(#[LiveArg] int $id, #[LiveArg('itemName')] string $name)
571+
{
572+
$this->id = $id;
573+
$this->name = $name;
574+
}
575+
}
576+
```
577+
544578
### Actions and CSRF Protection
545579

546580
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 <[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\Attribute;
13+
14+
/**
15+
* @author Tomas Norkūnas <[email protected]>
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 <[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\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 <[email protected]>
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;
@@ -240,4 +241,45 @@ 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+
$dehydratedWithArgs = array_merge($dehydrated, [
260+
'args' => http_build_query(['arg1' => 'hello', 'arg2' => 666, 'custom' => '33.3']),
261+
]);
262+
263+
$this->browser()
264+
->throwExceptions()
265+
->get('/_components/component6?'.http_build_query($dehydrated))
266+
->assertSuccessful()
267+
->assertHeaderContains('Content-Type', 'html')
268+
->assertContains('Arg1: not provided')
269+
->assertContains('Arg2: not provided')
270+
->assertContains('Arg3: not provided')
271+
->use(function (HtmlResponse $response) use (&$token) {
272+
// get a valid token to use for actions
273+
$token = $response->crawler()->filter('div')->first()->attr('data-live-csrf-value');
274+
})
275+
->post('/_components/component6/inject?'.http_build_query($dehydratedWithArgs), [
276+
'headers' => ['X-CSRF-TOKEN' => $token],
277+
])
278+
->assertSuccessful()
279+
->assertHeaderContains('Content-Type', 'html')
280+
->assertContains('Arg1: hello')
281+
->assertContains('Arg2: 666')
282+
->assertContains('Arg3: 33.3')
283+
;
284+
}
243285
}

0 commit comments

Comments
 (0)