From 788c7d03a109d2ac3ee41efe602065ebbce1a855 Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Tue, 30 Jan 2024 06:28:57 -0500 Subject: [PATCH] [Live] Using a query string format for action arguments --- src/LiveComponent/CHANGELOG.md | 16 ++--- .../assets/src/Directive/directives_parser.ts | 59 ++++++++++++------- src/LiveComponent/assets/src/dom_utils.ts | 4 +- .../assets/src/live_controller.ts | 7 +-- .../test/Directive/directives_parser.test.ts | 53 ++++++++++++++++- .../assets/test/controller/action.test.ts | 11 ++-- src/LiveComponent/doc/index.rst | 40 +++++++++---- .../src/Form/Type/LiveCollectionType.php | 7 +-- 8 files changed, 136 insertions(+), 61 deletions(-) diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index 9bc30d406ad..efa56ff6d15 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -17,15 +17,15 @@ >Save ``` - To pass arguments to an action, also use the Stimulus "action parameters" syntax: + To pass arguments to an action, use a query string syntax. For simple, + single arguments, this will look the same as before (e.g. `save(id=123)`). + For multiple arguments or any special characters, it will look like: ```diff ``` @@ -35,16 +35,16 @@ ```diff ``` - [BC BREAK] The `data-event` attribute was removed in favor of using Stimulus "action parameters": rename `data-event` to `data-live-event-param`. Additionally, - if you were passing arguments to the event name, use action parameter attributes - for those as well - e.g. `data-live-foo-param="bar"`. + if you were passing arguments to the event name, those should now be formatted + as query strings as shown above for actions. - Add support for URL binding in `LiveProp` - Allow multiple `LiveListener` attributes on a single method. - Requests to LiveComponent are sent as POST by default diff --git a/src/LiveComponent/assets/src/Directive/directives_parser.ts b/src/LiveComponent/assets/src/Directive/directives_parser.ts index 0b5f1c9d789..c7c8aa58a36 100644 --- a/src/LiveComponent/assets/src/Directive/directives_parser.ts +++ b/src/LiveComponent/assets/src/Directive/directives_parser.ts @@ -25,6 +25,10 @@ export interface Directive { * An array of unnamed arguments passed to the action */ args: string[]; + /** + * An object of named arguments + */ + named: any; /** * Any modifiers applied to the action */ @@ -37,8 +41,17 @@ export interface Directive { * into an array of directives, with this format: * * [ - * { action: 'addClass', args: ['foo'], modifiers: [] }, - * { action: 'removeAttribute', args: ['bar'], modifiers: [] } + * { action: 'addClass', args: ['foo'], named: {}, modifiers: [] }, + * { action: 'removeAttribute', args: ['bar'], named: {}, modifiers: [] } + * ] + * + * This also handles named arguments, which are query string-like: + * + * save(foo=bar&baz=this%that) + * + * Which would return: + * [ + * { action: 'save', args: [], named: { foo: 'bar', baz: 'this that' }, modifiers: [] } * ] * * @param {string} content The value of the attribute @@ -51,8 +64,9 @@ export function parseDirectives(content: string|null): Directive[] { } let currentActionName = ''; - let currentArgumentValue = ''; + let currentArgumentsString = ''; let currentArguments: string[] = []; + let currentNamedArguments: any = {}; let currentModifiers: { name: string, value: string | null }[] = []; let state = 'action'; @@ -67,10 +81,11 @@ export function parseDirectives(content: string|null): Directive[] { return directives[directives.length - 1].action; } - const pushInstruction = function() { + const pushDirective = function() { directives.push({ action: currentActionName, args: currentArguments, + named: currentNamedArguments, modifiers: currentModifiers, getString: () => { // TODO - make a string representation of JUST this directive @@ -79,16 +94,23 @@ export function parseDirectives(content: string|null): Directive[] { } }); currentActionName = ''; - currentArgumentValue = ''; currentArguments = []; + currentNamedArguments = {}; currentModifiers = []; state = 'action'; } const pushArgument = function() { - // value is trimmed to avoid space after "," - // "foo, bar" - currentArguments.push(currentArgumentValue.trim()); - currentArgumentValue = ''; + const urlParams = new URLSearchParams('?'+currentArgumentsString); + + // no "=" -> unnamed argument + if (currentArgumentsString.indexOf('=') === -1) { + currentArguments = currentArgumentsString.split(',').map((arg) => arg.trim()); + } else { + // named arguments + currentNamedArguments = Object.fromEntries(urlParams); + } + + currentArgumentsString = ''; } const pushModifier = function() { @@ -96,6 +118,10 @@ export function parseDirectives(content: string|null): Directive[] { throw new Error(`The modifier "${currentActionName}()" does not support multiple arguments.`) } + if (Object.keys(currentNamedArguments).length > 0) { + throw new Error(`The modifier "${currentActionName}()" does not support named arguments.`) + } + currentModifiers.push({ name: currentActionName, value: currentArguments.length > 0 ? currentArguments[0] : null, @@ -119,7 +145,7 @@ export function parseDirectives(content: string|null): Directive[] { // this is the end of the action and it has no arguments // if the action had args(), it was already recorded if (currentActionName) { - pushInstruction(); + pushDirective(); } break; @@ -147,15 +173,8 @@ export function parseDirectives(content: string|null): Directive[] { break; } - if (char === ',') { - // end of current argument - pushArgument(); - - break; - } - // add next character to argument - currentArgumentValue += char; + currentArgumentsString += char; break; @@ -174,7 +193,7 @@ export function parseDirectives(content: string|null): Directive[] { throw new Error(`Missing space after ${getLastActionName()}()`) } - pushInstruction(); + pushDirective(); break; } @@ -184,7 +203,7 @@ export function parseDirectives(content: string|null): Directive[] { case 'action': case 'after_arguments': if (currentActionName) { - pushInstruction(); + pushDirective(); } break; diff --git a/src/LiveComponent/assets/src/dom_utils.ts b/src/LiveComponent/assets/src/dom_utils.ts index 8ae6dba0948..310692e3a3c 100644 --- a/src/LiveComponent/assets/src/dom_utils.ts +++ b/src/LiveComponent/assets/src/dom_utils.ts @@ -139,7 +139,7 @@ export function getAllModelDirectiveFromElements(element: HTMLElement): Directiv const directives = parseDirectives(element.dataset.model); directives.forEach((directive) => { - if (directive.args.length > 0) { + if (directive.args.length > 0 || directive.named.length > 0) { throw new Error( `The data-model="${element.dataset.model}" format is invalid: it does not support passing arguments to the model.` ); @@ -165,7 +165,7 @@ export function getModelDirectiveFromElement(element: HTMLElement, throwOnMissin const directives = parseDirectives(formElement.dataset.model || '*'); const directive = directives[0]; - if (directive.args.length > 0) { + if (directive.args.length > 0 || directive.named.length > 0) { throw new Error( `The data-model="${formElement.dataset.model}" format is invalid: it does not support passing arguments to the model.` ); diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index 6b56ae50097..11feeba4df7 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -163,11 +163,8 @@ export default class LiveControllerDefault extends Controller imple ); } const rawAction = params.action; - // all other params are considered action arguments - const actionArgs = { ...params }; - delete actionArgs.action; - // data-live-action-param="debounce(1000)|save" + // data-live-action-param="debounce(1000)|save(foo=bar)" const directives = parseDirectives(rawAction); let debounce: number | boolean = false; @@ -215,7 +212,7 @@ export default class LiveControllerDefault extends Controller imple } delete this.pendingFiles[key]; } - this.component.action(directive.action, actionArgs, debounce); + this.component.action(directive.action, directive.named, debounce); // possible case where this element is also a "model" element // if so, to be safe, slightly delay the action so that the diff --git a/src/LiveComponent/assets/test/Directive/directives_parser.test.ts b/src/LiveComponent/assets/test/Directive/directives_parser.test.ts index 8c13c25d4ed..a712bb95f36 100644 --- a/src/LiveComponent/assets/test/Directive/directives_parser.test.ts +++ b/src/LiveComponent/assets/test/Directive/directives_parser.test.ts @@ -29,6 +29,7 @@ describe('directives parser', () => { assertDirectiveEquals(directives[0], { action: 'hide', args: [], + named: {}, modifiers: [], }) }); @@ -39,6 +40,7 @@ describe('directives parser', () => { assertDirectiveEquals(directives[0], { action: 'addClass', args: ['opacity-50'], + named: {}, modifiers: [], }) }); @@ -49,6 +51,7 @@ describe('directives parser', () => { assertDirectiveEquals(directives[0], { action: 'addClass', args: ['opacity-50 disabled'], + named: {}, modifiers: [], }) }); @@ -60,6 +63,7 @@ describe('directives parser', () => { action: 'addClass', // space between arguments is trimmed args: ['opacity-50', 'disabled'], + named: {}, modifiers: [], }) }); @@ -70,11 +74,13 @@ describe('directives parser', () => { assertDirectiveEquals(directives[0], { action: 'addClass', args: ['opacity-50'], + named: {}, modifiers: [], }) assertDirectiveEquals(directives[1], { action: 'addAttribute', args: ['disabled'], + named: {}, modifiers: [], }) }); @@ -85,16 +91,52 @@ describe('directives parser', () => { assertDirectiveEquals(directives[0], { action: 'hide', args: [], + named: {}, modifiers: [], }) assertDirectiveEquals(directives[1], { action: 'addClass', args: ['opacity-50 disabled'], + named: {}, modifiers: [], }) assertDirectiveEquals(directives[2], { action: 'addAttribute', args: ['disabled'], + named: {}, + modifiers: [], + }) + }); + + it('parses single named argument', () => { + const directives = parseDirectives('save(foo=bar)'); + expect(directives).toHaveLength(1); + assertDirectiveEquals(directives[0], { + action: 'save', + args: [], + named: { foo: 'bar' }, + modifiers: [], + }) + }); + + it('parses multiple named arguments', () => { + const directives = parseDirectives('save(foo=bar&baz=bazzles)'); + expect(directives).toHaveLength(1); + assertDirectiveEquals(directives[0], { + action: 'save', + args: [], + named: { foo: 'bar', baz: 'bazzles' }, + modifiers: [], + }) + }); + + it('parses arguments and decodes URL string', () => { + const directives = parseDirectives('save(foo=%20bar)'); + expect(directives).toHaveLength(1); + assertDirectiveEquals(directives[0], { + action: 'save', + args: [], + named: { foo: ' bar' }, modifiers: [], }) }); @@ -105,6 +147,7 @@ describe('directives parser', () => { assertDirectiveEquals(directives[0], { action: 'addClass', args: ['disabled'], + named: {}, modifiers: [ { name: 'delay', value: null } ], @@ -117,6 +160,7 @@ describe('directives parser', () => { assertDirectiveEquals(directives[0], { action: 'addClass', args: ['disabled'], + named: {}, modifiers: [ { name: 'delay', value: '400' }, ], @@ -124,11 +168,12 @@ describe('directives parser', () => { }); it('parses multiple modifiers', () => { - const directives = parseDirectives('prevent|debounce(400)|save'); + const directives = parseDirectives('prevent|debounce(400)|save(foo=bar)'); expect(directives).toHaveLength(1); assertDirectiveEquals(directives[0], { action: 'save', args: [], + named: { foo: 'bar' }, modifiers: [ { name: 'prevent', value: null }, { name: 'debounce', value: '400' }, @@ -160,5 +205,11 @@ describe('directives parser', () => { parseDirectives('debounce(10, 20)|save'); }).toThrow('The modifier "debounce()" does not support multiple arguments.') }); + + it('modifier cannot have named arguments', () => { + expect(() => { + parseDirectives('debounce(foo=bar)|save'); + }).toThrow('The modifier "debounce()" does not support named arguments.') + }); }); }); diff --git a/src/LiveComponent/assets/test/controller/action.test.ts b/src/LiveComponent/assets/test/controller/action.test.ts index d4f7ba9ea24..70527b293e1 100644 --- a/src/LiveComponent/assets/test/controller/action.test.ts +++ b/src/LiveComponent/assets/test/controller/action.test.ts @@ -78,17 +78,14 @@ describe('LiveController Action Tests', () => { `); // ONLY a post is sent, not a re-render GET test.expectsAjaxCall() - .expectActionCalled('sendNamedArgs', {a: 1, b: 2, c: 'banana'}) + .expectActionCalled('sendNamedArgs', {a: '1', b: '2', c: 'this and that'}) .serverWillChangeProps((data: any) => { // server marks component as "saved" data.isSaved = true; @@ -177,7 +174,7 @@ describe('LiveController Action Tests', () => {
${data.isSaved ? 'Component Saved!' : ''} - +
`); @@ -185,7 +182,7 @@ describe('LiveController Action Tests', () => { test.expectsAjaxCall() // 3 actions called .expectActionCalled('save') - .expectActionCalled('sync', { syncAll: 1 }) + .expectActionCalled('sync', { syncAll: '1' }) .expectActionCalled('save') .serverWillChangeProps((data: any) => { data.isSaved = true; diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index bbd2f1d022f..3cef3e292de 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -1040,7 +1040,7 @@ the work:: The ``data-live-action-param`` attribute way of specifying the action was added in Live Components 2.14. Previously, this was done with - ``data-live-action-name``. + ``data-action-name``. To call this, trigger the ``action`` method on the ``live`` Stimulus controller and pass ``resetMax`` as a `Stimulus action parameter`_ called @@ -1106,22 +1106,18 @@ Actions & Arguments .. versionadded:: 2.14 - The ``data-live-{NAME}-param`` attribute way of specifying action + The ``data-live-action-param`` attribute way of specifying action arguments was added in Live Components 2.14. Previously, this was done - inside the ``data-live-action-name`` attribute. + inside the ``data-action-name`` attribute & had a slightly different syntax. -You can also pass arguments to your action by adding each as a -`Stimulus action parameter`_: +You can also pass arguments to your action: .. code-block:: html+twig
@@ -1139,13 +1135,32 @@ the ``#[LiveArg()]`` attribute:: { // ... #[LiveAction] - public function addItem(#[LiveArg] int $id, #[LiveArg('itemName')] string $name) + public function addItem(#[LiveArg] int $id) { $this->id = $id; - $this->name = $name; } } +The arguments in the ``data-live-action-param`` attribute are formatted +as a query string. To pass an ``id`` argument set to a dynamic value and +a ``name`` argument set to ``this and that``, use: + +.. code-block:: html+twig + + + +Or use Twig's ``url_encode()`` filter: + +.. code-block:: html + + + Actions and CSRF Protection ~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -1765,8 +1780,7 @@ and ``removeComment()`` actions: {% for key, commentForm in form.comments %} diff --git a/src/LiveComponent/src/Form/Type/LiveCollectionType.php b/src/LiveComponent/src/Form/Type/LiveCollectionType.php index 5da11a16849..37276a1e87c 100644 --- a/src/LiveComponent/src/Form/Type/LiveCollectionType.php +++ b/src/LiveComponent/src/Form/Type/LiveCollectionType.php @@ -47,8 +47,7 @@ public function buildView(FormView $view, FormInterface $form, array $options): $attr = $view->vars['button_add']->vars['attr']; $attr['data-action'] ??= 'live#action'; - $attr['data-live-action-param'] ??= 'addCollectionItem'; - $attr['data-live-name-param'] ??= $view->vars['full_name']; + $attr['data-live-action-param'] ??= sprintf('addCollectionItem(name=%s)', urlencode($view->vars['full_name'])); $view->vars['button_add']->vars['attr'] = $attr; array_splice($view->vars['button_add']->vars['block_prefixes'], 1, 0, 'live_collection_button_add'); @@ -86,9 +85,7 @@ public function finishView(FormView $view, FormInterface $form, array $options): $attr = $entryView->vars['button_delete']->vars['attr']; $attr['data-action'] ??= 'live#action'; - $attr['data-live-action-param'] ??= 'removeCollectionItem'; - $attr['data-live-name-param'] ??= $view->vars['full_name']; - $attr['data-live-index-param'] ??= $k; + $attr['data-live-action-param'] ??= sprintf('removeCollectionItem(name=%s&index=%s)', urlencode($view->vars['full_name']), urlencode($k)); $entryView->vars['button_delete']->vars['attr'] = $attr; array_splice($entryView->vars['button_delete']->vars['block_prefixes'], 1, 0, 'live_collection_button_delete');