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