diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index 0ed4fc2c51f..824a8167d96 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -5,6 +5,8 @@ - The Live Component AJAX endpoints now return HTML in all situations instead of JSON. +- Send live action arguments to backend + ## 2.0.0 - Support for `stimulus` version 2 was removed and support for `@hotwired/stimulus` diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 856d309e2ca..22b1c922484 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1070,7 +1070,7 @@ class default_1 extends Controller { directives.forEach((directive) => { const _executeAction = () => { this._clearWaitingDebouncedRenders(); - this._makeRequest(directive.action); + this._makeRequest(directive.action, directive.named); }; let handled = false; directive.modifiers.forEach((modifier) => { @@ -1173,11 +1173,14 @@ class default_1 extends Controller { }, this.debounceValue || DEFAULT_DEBOUNCE); } } - _makeRequest(action) { + _makeRequest(action, args) { const splitUrl = this.urlValue.split('?'); let [url] = splitUrl; const [, queryString] = splitUrl; const params = new URLSearchParams(queryString || ''); + if (typeof args === 'object' && Object.keys(args).length > 0) { + params.set('args', new URLSearchParams(args).toString()); + } const fetchOptions = {}; fetchOptions.headers = { 'Accept': 'application/vnd.live-component+json', diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index c6962c32071..bbd5f915a6b 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -138,7 +138,7 @@ export default class extends Controller { // taking precedence this._clearWaitingDebouncedRenders(); - this._makeRequest(directive.action); + this._makeRequest(directive.action, directive.named); } let handled = false; @@ -294,12 +294,16 @@ export default class extends Controller { } } - _makeRequest(action: string|null) { + _makeRequest(action: string|null, args: Record) { const splitUrl = this.urlValue.split('?'); let [url] = splitUrl const [, queryString] = splitUrl; const params = new URLSearchParams(queryString || ''); + if (typeof args === 'object' && Object.keys(args).length > 0) { + params.set('args', new URLSearchParams(args).toString()); + } + const fetchOptions: RequestInit = {}; fetchOptions.headers = { 'Accept': 'application/vnd.live-component+html', diff --git a/src/LiveComponent/assets/test/controller/action.test.ts b/src/LiveComponent/assets/test/controller/action.test.ts index 90f0e6c652c..7f64a798a4e 100644 --- a/src/LiveComponent/assets/test/controller/action.test.ts +++ b/src/LiveComponent/assets/test/controller/action.test.ts @@ -35,6 +35,8 @@ describe('LiveController Action Tests', () => { data-action="live#action" data-action-name="save" >Save + + `; @@ -64,4 +66,15 @@ describe('LiveController Action Tests', () => { expect(postMock.lastOptions().body.get('comments')).toEqual('hi WEAVER'); }); + + it('Sends action named args', async () => { + const data = { comments: 'hi' }; + const { element } = await startStimulus(template(data)); + + fetchMock.postOnce('http://localhost/_components/my_component/sendNamedArgs?values=a%3D1%26b%3D2%26c%3D3', { + html: template({ comments: 'hi' }), + }); + + getByText(element, 'Send named args').click(); + }); }); diff --git a/src/LiveComponent/src/Attribute/LiveArg.php b/src/LiveComponent/src/Attribute/LiveArg.php new file mode 100644 index 00000000000..57d0c6de99b --- /dev/null +++ b/src/LiveComponent/src/Attribute/LiveArg.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Attribute; + +/** + * @author Tomas Norkūnas + * + * @experimental + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +final class LiveArg +{ + public function __construct(public ?string $name = null) + { + } + + /** + * @return array + */ + public static function liveArgs(object $component, string $action): array + { + $method = new \ReflectionMethod($component, $action); + $liveArgs = []; + + foreach ($method->getParameters() as $parameter) { + foreach ($parameter->getAttributes(self::class) as $liveArg) { + /** @var LiveArg $attr */ + $attr = $liveArg->newInstance(); + $parameterName = $parameter->getName(); + + $liveArgs[$parameterName] = $attr->name ?? $parameterName; + } + } + + return $liveArgs; + } +} diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index b652a59928f..06569b083fc 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -28,6 +28,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Contracts\Service\ServiceSubscriberInterface; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Attribute\LiveArg; use Symfony\UX\LiveComponent\LiveComponentHydrator; use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentRenderer; @@ -137,11 +138,21 @@ public function onKernelController(ControllerEvent $event): void $this->container->get(LiveComponentHydrator::class)->hydrate($component, $data); + $request->attributes->set('_component', $component); + + if (!\is_string($queryString = $request->query->get('args'))) { + return; + } + // extra variables to be made available to the controller // (for "actions" only) - parse_str($request->query->get('values'), $values); - $request->attributes->add($values); - $request->attributes->set('_component', $component); + parse_str($queryString, $args); + + foreach (LiveArg::liveArgs($component, $action) as $parameter => $arg) { + if (isset($args[$arg])) { + $request->attributes->set($parameter, $args[$arg]); + } + } } public function onKernelView(ViewEvent $event): void diff --git a/src/LiveComponent/src/Resources/doc/index.rst b/src/LiveComponent/src/Resources/doc/index.rst index d51380282c0..eeeda6f2946 100644 --- a/src/LiveComponent/src/Resources/doc/index.rst +++ b/src/LiveComponent/src/Resources/doc/index.rst @@ -552,6 +552,38 @@ This means that, for example, you can use action autowiring:: // ... } +Actions & Arguments +^^^^^^^^^^^^^^^^^^^ + +You can also provide custom arguments to your action:: + +.. code-block:: twig +
+ +
+ +In component for custom arguments to be injected we need to use `#[LiveArg()]` attribute, otherwise it would be +ignored. Optionally you can provide `name` argument like: `[#LiveArg('itemName')]` so it will use custom name from +args but inject to your defined parameter with another name.:: + + // src/Components/ItemComponent.php + namespace App\Components; + + // ... + use Symfony\UX\LiveComponent\Attribute\LiveArg; + use Psr\Log\LoggerInterface; + + class ItemComponent + { + // ... + #[LiveAction] + public function addItem(#[LiveArg] int $id, #[LiveArg('itemName')] string $name) + { + $this->id = $id; + $this->name = $name; + } + } + Actions and CSRF Protection ~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/LiveComponent/tests/Fixture/Component/Component6.php b/src/LiveComponent/tests/Fixture/Component/Component6.php new file mode 100644 index 00000000000..e1d1bcc529f --- /dev/null +++ b/src/LiveComponent/tests/Fixture/Component/Component6.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Fixture\Component; + +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Attribute\LiveAction; +use Symfony\UX\LiveComponent\Attribute\LiveArg; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\DefaultActionTrait; + +/** + * @author Tomas Norkūnas + */ +#[AsLiveComponent('component6')] +class Component6 +{ + use DefaultActionTrait; + + #[LiveProp] + public bool $called = false; + + #[LiveProp] + public $arg1; + + #[LiveProp] + public $arg2; + + #[LiveProp] + public $arg3; + + #[LiveAction] + public function inject( + #[LiveArg] string $arg1, + #[LiveArg] int $arg2, + #[LiveArg('custom')] float $arg3, + ) { + $this->called = true; + $this->arg1 = $arg1; + $this->arg2 = $arg2; + $this->arg3 = $arg3; + } +} diff --git a/src/LiveComponent/tests/Fixture/Kernel.php b/src/LiveComponent/tests/Fixture/Kernel.php index 6f66551abc0..f85ccfd5ba6 100644 --- a/src/LiveComponent/tests/Fixture/Kernel.php +++ b/src/LiveComponent/tests/Fixture/Kernel.php @@ -26,6 +26,7 @@ use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component1; use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component2; use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component3; +use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component6; use Symfony\UX\TwigComponent\TwigComponentBundle; use Twig\Environment; @@ -65,12 +66,14 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load $componentA = $c->register(Component1::class)->setAutoconfigured(true)->setAutowired(true); $componentB = $c->register(Component2::class)->setAutoconfigured(true)->setAutowired(true); $componentC = $c->register(Component3::class)->setAutoconfigured(true)->setAutowired(true); + $componentF = $c->register(Component6::class)->setAutoconfigured(true)->setAutowired(true); if (self::VERSION_ID < 50300) { // add tag manually $componentA->addTag('twig.component', ['key' => 'component1'])->addTag('controller.service_arguments'); $componentB->addTag('twig.component', ['key' => 'component2', 'default_action' => 'defaultAction'])->addTag('controller.service_arguments'); $componentC->addTag('twig.component', ['key' => 'component3'])->addTag('controller.service_arguments'); + $componentF->addTag('twig.component', ['key' => 'component6'])->addTag('controller.service_arguments'); } $sessionConfig = self::VERSION_ID < 50300 ? ['storage_id' => 'session.storage.mock_file'] : ['storage_factory_id' => 'session.storage.factory.mock_file']; diff --git a/src/LiveComponent/tests/Fixture/templates/components/component6.html.twig b/src/LiveComponent/tests/Fixture/templates/components/component6.html.twig new file mode 100644 index 00000000000..65ad19140b6 --- /dev/null +++ b/src/LiveComponent/tests/Fixture/templates/components/component6.html.twig @@ -0,0 +1,7 @@ +
+ Arg1: {{ this.called ? this.arg1 : 'not provided' }} + Arg2: {{ this.called ? this.arg2 : 'not provided' }} + Arg3: {{ this.called ? this.arg3 : 'not provided' }} +
diff --git a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php index 6c6255b3fd6..100a8846de7 100644 --- a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php @@ -17,6 +17,7 @@ use Symfony\UX\LiveComponent\Tests\ContainerBC; use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component1; use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component2; +use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component6; use Symfony\UX\LiveComponent\Tests\Fixture\Entity\Entity1; use Symfony\UX\TwigComponent\ComponentFactory; use Zenstruck\Browser\Response\HtmlResponse; @@ -215,4 +216,45 @@ public function testCanRedirectFromComponentAction(): void ->assertHeaderEquals('Location', '/') ; } + + public function testInjectsLiveArgs(): void + { + /** @var LiveComponentHydrator $hydrator */ + $hydrator = self::getContainer()->get('ux.live_component.component_hydrator'); + + /** @var ComponentFactory $factory */ + $factory = self::getContainer()->get('ux.twig_component.component_factory'); + + /** @var Component6 $component */ + $component = $factory->create('component6'); + + $dehydrated = $hydrator->dehydrate($component); + $token = null; + + $dehydratedWithArgs = array_merge($dehydrated, [ + 'args' => http_build_query(['arg1' => 'hello', 'arg2' => 666, 'custom' => '33.3']), + ]); + + $this->browser() + ->throwExceptions() + ->get('/_components/component6?'.http_build_query($dehydrated)) + ->assertSuccessful() + ->assertHeaderContains('Content-Type', 'html') + ->assertContains('Arg1: not provided') + ->assertContains('Arg2: not provided') + ->assertContains('Arg3: not provided') + ->use(function (HtmlResponse $response) use (&$token) { + // get a valid token to use for actions + $token = $response->crawler()->filter('div')->first()->attr('data-live-csrf-value'); + }) + ->post('/_components/component6/inject?'.http_build_query($dehydratedWithArgs), [ + 'headers' => ['X-CSRF-TOKEN' => $token], + ]) + ->assertSuccessful() + ->assertHeaderContains('Content-Type', 'html') + ->assertContains('Arg1: hello') + ->assertContains('Arg2: 666') + ->assertContains('Arg3: 33.3') + ; + } }