Skip to content

Commit bc1bedb

Browse files
committed
Send live action arguments to backend
1 parent 3a3dbb1 commit bc1bedb

File tree

11 files changed

+224
-8
lines changed

11 files changed

+224
-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/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
@@ -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;
@@ -312,12 +312,16 @@ export default class extends Controller {
312312
}
313313
}
314314

315-
_makeRequest(action: string|null) {
315+
_makeRequest(action: string|null, args: Record<string,unknown>) {
316316
const splitUrl = this.urlValue.split('?');
317317
let [url] = splitUrl
318318
const [, queryString] = splitUrl;
319319
const params = new URLSearchParams(queryString || '');
320320

321+
if (typeof args === 'object' && Object.keys(args).length > 0) {
322+
params.set('args', new URLSearchParams(args).toString());
323+
}
324+
321325
const fetchOptions: RequestInit = {};
322326
fetchOptions.headers = {
323327
'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: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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 ?string $name;
23+
24+
public function __construct(?string $name = null)
25+
{
26+
$this->name = $name;
27+
}
28+
29+
/**
30+
* @return array<string, string>
31+
*/
32+
public static function liveArgs(object $component, string $action): array
33+
{
34+
$method = new \ReflectionMethod($component, $action);
35+
$liveArgs = [];
36+
37+
foreach ($method->getParameters() as $parameter) {
38+
foreach ($parameter->getAttributes(self::class) as $liveArg) {
39+
/** @var LiveArg $attr */
40+
$attr = $liveArg->newInstance();
41+
$parameterName = $parameter->getName();
42+
43+
$liveArgs[$parameterName] = $attr->name ?? $parameterName;
44+
}
45+
}
46+
47+
return $liveArgs;
48+
}
49+
}

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

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

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

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

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;
@@ -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)