Skip to content

Commit 788c7d0

Browse files
committed
[Live] Using a query string format for action arguments
1 parent 02f3547 commit 788c7d0

File tree

8 files changed

+136
-61
lines changed

8 files changed

+136
-61
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,15 @@
1717
>Save</button>
1818
```
1919

20-
To pass arguments to an action, also use the Stimulus "action parameters" syntax:
20+
To pass arguments to an action, use a query string syntax. For simple,
21+
single arguments, this will look the same as before (e.g. `save(id=123)`).
22+
For multiple arguments or any special characters, it will look like:
2123

2224
```diff
2325
<button
2426
data-action="live#action"
25-
- data-action-name="addItem(id={{ item.id }}, itemName=CustomItem)"
26-
+ data-live-action-param="addItem"
27-
+ data-live-id-param="{{ item.id }}"
28-
+ data-live-item-name-param="CustomItem"
27+
- data-action-name="addItem(id={{ item.id }}&itemName=Custom Item)"
28+
+ data-live-action-param="addItem(id={{ item.id }}&itemName=Custom%20Item)"
2929
>Add Item</button>
3030
```
3131

@@ -35,16 +35,16 @@
3535
```diff
3636
<button
3737
- data-action="live#action
38-
+ data-action="live#action:prevent"
3938
- data-action-name="prevent|save"
39+
+ data-action="live#action:prevent"
4040
+ data-live-action-param="save"
4141
>Save</button>
4242
```
4343

4444
- [BC BREAK] The `data-event` attribute was removed in favor of using Stimulus
4545
"action parameters": rename `data-event` to `data-live-event-param`. Additionally,
46-
if you were passing arguments to the event name, use action parameter attributes
47-
for those as well - e.g. `data-live-foo-param="bar"`.
46+
if you were passing arguments to the event name, those should now be formatted
47+
as query strings as shown above for actions.
4848
- Add support for URL binding in `LiveProp`
4949
- Allow multiple `LiveListener` attributes on a single method.
5050
- Requests to LiveComponent are sent as POST by default

src/LiveComponent/assets/src/Directive/directives_parser.ts

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ export interface Directive {
2525
* An array of unnamed arguments passed to the action
2626
*/
2727
args: string[];
28+
/**
29+
* An object of named arguments
30+
*/
31+
named: any;
2832
/**
2933
* Any modifiers applied to the action
3034
*/
@@ -37,8 +41,17 @@ export interface Directive {
3741
* into an array of directives, with this format:
3842
*
3943
* [
40-
* { action: 'addClass', args: ['foo'], modifiers: [] },
41-
* { action: 'removeAttribute', args: ['bar'], modifiers: [] }
44+
* { action: 'addClass', args: ['foo'], named: {}, modifiers: [] },
45+
* { action: 'removeAttribute', args: ['bar'], named: {}, modifiers: [] }
46+
* ]
47+
*
48+
* This also handles named arguments, which are query string-like:
49+
*
50+
* save(foo=bar&baz=this%that)
51+
*
52+
* Which would return:
53+
* [
54+
* { action: 'save', args: [], named: { foo: 'bar', baz: 'this that' }, modifiers: [] }
4255
* ]
4356
*
4457
* @param {string} content The value of the attribute
@@ -51,8 +64,9 @@ export function parseDirectives(content: string|null): Directive[] {
5164
}
5265

5366
let currentActionName = '';
54-
let currentArgumentValue = '';
67+
let currentArgumentsString = '';
5568
let currentArguments: string[] = [];
69+
let currentNamedArguments: any = {};
5670
let currentModifiers: { name: string, value: string | null }[] = [];
5771
let state = 'action';
5872

@@ -67,10 +81,11 @@ export function parseDirectives(content: string|null): Directive[] {
6781

6882
return directives[directives.length - 1].action;
6983
}
70-
const pushInstruction = function() {
84+
const pushDirective = function() {
7185
directives.push({
7286
action: currentActionName,
7387
args: currentArguments,
88+
named: currentNamedArguments,
7489
modifiers: currentModifiers,
7590
getString: () => {
7691
// TODO - make a string representation of JUST this directive
@@ -79,23 +94,34 @@ export function parseDirectives(content: string|null): Directive[] {
7994
}
8095
});
8196
currentActionName = '';
82-
currentArgumentValue = '';
8397
currentArguments = [];
98+
currentNamedArguments = {};
8499
currentModifiers = [];
85100
state = 'action';
86101
}
87102
const pushArgument = function() {
88-
// value is trimmed to avoid space after ","
89-
// "foo, bar"
90-
currentArguments.push(currentArgumentValue.trim());
91-
currentArgumentValue = '';
103+
const urlParams = new URLSearchParams('?'+currentArgumentsString);
104+
105+
// no "=" -> unnamed argument
106+
if (currentArgumentsString.indexOf('=') === -1) {
107+
currentArguments = currentArgumentsString.split(',').map((arg) => arg.trim());
108+
} else {
109+
// named arguments
110+
currentNamedArguments = Object.fromEntries(urlParams);
111+
}
112+
113+
currentArgumentsString = '';
92114
}
93115

94116
const pushModifier = function() {
95117
if (currentArguments.length > 1) {
96118
throw new Error(`The modifier "${currentActionName}()" does not support multiple arguments.`)
97119
}
98120

121+
if (Object.keys(currentNamedArguments).length > 0) {
122+
throw new Error(`The modifier "${currentActionName}()" does not support named arguments.`)
123+
}
124+
99125
currentModifiers.push({
100126
name: currentActionName,
101127
value: currentArguments.length > 0 ? currentArguments[0] : null,
@@ -119,7 +145,7 @@ export function parseDirectives(content: string|null): Directive[] {
119145
// this is the end of the action and it has no arguments
120146
// if the action had args(), it was already recorded
121147
if (currentActionName) {
122-
pushInstruction();
148+
pushDirective();
123149
}
124150

125151
break;
@@ -147,15 +173,8 @@ export function parseDirectives(content: string|null): Directive[] {
147173
break;
148174
}
149175

150-
if (char === ',') {
151-
// end of current argument
152-
pushArgument();
153-
154-
break;
155-
}
156-
157176
// add next character to argument
158-
currentArgumentValue += char;
177+
currentArgumentsString += char;
159178

160179
break;
161180

@@ -174,7 +193,7 @@ export function parseDirectives(content: string|null): Directive[] {
174193
throw new Error(`Missing space after ${getLastActionName()}()`)
175194
}
176195

177-
pushInstruction();
196+
pushDirective();
178197

179198
break;
180199
}
@@ -184,7 +203,7 @@ export function parseDirectives(content: string|null): Directive[] {
184203
case 'action':
185204
case 'after_arguments':
186205
if (currentActionName) {
187-
pushInstruction();
206+
pushDirective();
188207
}
189208

190209
break;

src/LiveComponent/assets/src/dom_utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export function getAllModelDirectiveFromElements(element: HTMLElement): Directiv
139139
const directives = parseDirectives(element.dataset.model);
140140

141141
directives.forEach((directive) => {
142-
if (directive.args.length > 0) {
142+
if (directive.args.length > 0 || directive.named.length > 0) {
143143
throw new Error(
144144
`The data-model="${element.dataset.model}" format is invalid: it does not support passing arguments to the model.`
145145
);
@@ -165,7 +165,7 @@ export function getModelDirectiveFromElement(element: HTMLElement, throwOnMissin
165165
const directives = parseDirectives(formElement.dataset.model || '*');
166166
const directive = directives[0];
167167

168-
if (directive.args.length > 0) {
168+
if (directive.args.length > 0 || directive.named.length > 0) {
169169
throw new Error(
170170
`The data-model="${formElement.dataset.model}" format is invalid: it does not support passing arguments to the model.`
171171
);

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,8 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
163163
);
164164
}
165165
const rawAction = params.action;
166-
// all other params are considered action arguments
167-
const actionArgs = { ...params };
168-
delete actionArgs.action;
169166

170-
// data-live-action-param="debounce(1000)|save"
167+
// data-live-action-param="debounce(1000)|save(foo=bar)"
171168
const directives = parseDirectives(rawAction);
172169
let debounce: number | boolean = false;
173170

@@ -215,7 +212,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
215212
}
216213
delete this.pendingFiles[key];
217214
}
218-
this.component.action(directive.action, actionArgs, debounce);
215+
this.component.action(directive.action, directive.named, debounce);
219216

220217
// possible case where this element is also a "model" element
221218
// if so, to be safe, slightly delay the action so that the

src/LiveComponent/assets/test/Directive/directives_parser.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ describe('directives parser', () => {
2929
assertDirectiveEquals(directives[0], {
3030
action: 'hide',
3131
args: [],
32+
named: {},
3233
modifiers: [],
3334
})
3435
});
@@ -39,6 +40,7 @@ describe('directives parser', () => {
3940
assertDirectiveEquals(directives[0], {
4041
action: 'addClass',
4142
args: ['opacity-50'],
43+
named: {},
4244
modifiers: [],
4345
})
4446
});
@@ -49,6 +51,7 @@ describe('directives parser', () => {
4951
assertDirectiveEquals(directives[0], {
5052
action: 'addClass',
5153
args: ['opacity-50 disabled'],
54+
named: {},
5255
modifiers: [],
5356
})
5457
});
@@ -60,6 +63,7 @@ describe('directives parser', () => {
6063
action: 'addClass',
6164
// space between arguments is trimmed
6265
args: ['opacity-50', 'disabled'],
66+
named: {},
6367
modifiers: [],
6468
})
6569
});
@@ -70,11 +74,13 @@ describe('directives parser', () => {
7074
assertDirectiveEquals(directives[0], {
7175
action: 'addClass',
7276
args: ['opacity-50'],
77+
named: {},
7378
modifiers: [],
7479
})
7580
assertDirectiveEquals(directives[1], {
7681
action: 'addAttribute',
7782
args: ['disabled'],
83+
named: {},
7884
modifiers: [],
7985
})
8086
});
@@ -85,16 +91,52 @@ describe('directives parser', () => {
8591
assertDirectiveEquals(directives[0], {
8692
action: 'hide',
8793
args: [],
94+
named: {},
8895
modifiers: [],
8996
})
9097
assertDirectiveEquals(directives[1], {
9198
action: 'addClass',
9299
args: ['opacity-50 disabled'],
100+
named: {},
93101
modifiers: [],
94102
})
95103
assertDirectiveEquals(directives[2], {
96104
action: 'addAttribute',
97105
args: ['disabled'],
106+
named: {},
107+
modifiers: [],
108+
})
109+
});
110+
111+
it('parses single named argument', () => {
112+
const directives = parseDirectives('save(foo=bar)');
113+
expect(directives).toHaveLength(1);
114+
assertDirectiveEquals(directives[0], {
115+
action: 'save',
116+
args: [],
117+
named: { foo: 'bar' },
118+
modifiers: [],
119+
})
120+
});
121+
122+
it('parses multiple named arguments', () => {
123+
const directives = parseDirectives('save(foo=bar&baz=bazzles)');
124+
expect(directives).toHaveLength(1);
125+
assertDirectiveEquals(directives[0], {
126+
action: 'save',
127+
args: [],
128+
named: { foo: 'bar', baz: 'bazzles' },
129+
modifiers: [],
130+
})
131+
});
132+
133+
it('parses arguments and decodes URL string', () => {
134+
const directives = parseDirectives('save(foo=%20bar)');
135+
expect(directives).toHaveLength(1);
136+
assertDirectiveEquals(directives[0], {
137+
action: 'save',
138+
args: [],
139+
named: { foo: ' bar' },
98140
modifiers: [],
99141
})
100142
});
@@ -105,6 +147,7 @@ describe('directives parser', () => {
105147
assertDirectiveEquals(directives[0], {
106148
action: 'addClass',
107149
args: ['disabled'],
150+
named: {},
108151
modifiers: [
109152
{ name: 'delay', value: null }
110153
],
@@ -117,18 +160,20 @@ describe('directives parser', () => {
117160
assertDirectiveEquals(directives[0], {
118161
action: 'addClass',
119162
args: ['disabled'],
163+
named: {},
120164
modifiers: [
121165
{ name: 'delay', value: '400' },
122166
],
123167
})
124168
});
125169

126170
it('parses multiple modifiers', () => {
127-
const directives = parseDirectives('prevent|debounce(400)|save');
171+
const directives = parseDirectives('prevent|debounce(400)|save(foo=bar)');
128172
expect(directives).toHaveLength(1);
129173
assertDirectiveEquals(directives[0], {
130174
action: 'save',
131175
args: [],
176+
named: { foo: 'bar' },
132177
modifiers: [
133178
{ name: 'prevent', value: null },
134179
{ name: 'debounce', value: '400' },
@@ -160,5 +205,11 @@ describe('directives parser', () => {
160205
parseDirectives('debounce(10, 20)|save');
161206
}).toThrow('The modifier "debounce()" does not support multiple arguments.')
162207
});
208+
209+
it('modifier cannot have named arguments', () => {
210+
expect(() => {
211+
parseDirectives('debounce(foo=bar)|save');
212+
}).toThrow('The modifier "debounce()" does not support named arguments.')
213+
});
163214
});
164215
});

src/LiveComponent/assets/test/controller/action.test.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,17 +78,14 @@ describe('LiveController Action Tests', () => {
7878
7979
<button
8080
data-action="live#action"
81-
data-live-action-param="sendNamedArgs"
82-
data-live-a-param="1"
83-
data-live-b-param="2"
84-
data-live-c-param="banana"
81+
data-live-action-param="sendNamedArgs(a=1&b=2&c=this%20and%20that)"
8582
>Send named args</button>
8683
</div>
8784
`);
8885

8986
// ONLY a post is sent, not a re-render GET
9087
test.expectsAjaxCall()
91-
.expectActionCalled('sendNamedArgs', {a: 1, b: 2, c: 'banana'})
88+
.expectActionCalled('sendNamedArgs', {a: '1', b: '2', c: 'this and that'})
9289
.serverWillChangeProps((data: any) => {
9390
// server marks component as "saved"
9491
data.isSaved = true;
@@ -177,15 +174,15 @@ describe('LiveController Action Tests', () => {
177174
<div ${initComponent(data)}>
178175
${data.isSaved ? 'Component Saved!' : ''}
179176
<button data-action="live#action" data-live-action-param="debounce(10)|save">Save</button>
180-
<button data-action="live#action" data-live-action-param="debounce(10)|sync" data-live-sync-all-param="1">Sync</button>
177+
<button data-action="live#action" data-live-action-param="debounce(10)|sync(syncAll=1)">Sync</button>
181178
</div>
182179
`);
183180

184181
// 1 request with all 3 actions
185182
test.expectsAjaxCall()
186183
// 3 actions called
187184
.expectActionCalled('save')
188-
.expectActionCalled('sync', { syncAll: 1 })
185+
.expectActionCalled('sync', { syncAll: '1' })
189186
.expectActionCalled('save')
190187
.serverWillChangeProps((data: any) => {
191188
data.isSaved = true;

0 commit comments

Comments
 (0)