Skip to content

Commit

Permalink
[Live] Using a query string format for action arguments
Browse files Browse the repository at this point in the history
  • Loading branch information
weaverryan committed Jan 30, 2024
1 parent 02f3547 commit 788c7d0
Show file tree
Hide file tree
Showing 8 changed files with 136 additions and 61 deletions.
16 changes: 8 additions & 8 deletions src/LiveComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@
>Save</button>
```

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
<button
data-action="live#action"
- data-action-name="addItem(id={{ item.id }}, itemName=CustomItem)"
+ data-live-action-param="addItem"
+ data-live-id-param="{{ item.id }}"
+ data-live-item-name-param="CustomItem"
- data-action-name="addItem(id={{ item.id }}&itemName=Custom Item)"
+ data-live-action-param="addItem(id={{ item.id }}&itemName=Custom%20Item)"
>Add Item</button>
```

Expand All @@ -35,16 +35,16 @@
```diff
<button
- data-action="live#action
+ data-action="live#action:prevent"
- data-action-name="prevent|save"
+ data-action="live#action:prevent"
+ data-live-action-param="save"
>Save</button>
```

- [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
Expand Down
59 changes: 39 additions & 20 deletions src/LiveComponent/assets/src/Directive/directives_parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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
Expand All @@ -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';

Expand All @@ -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
Expand All @@ -79,23 +94,34 @@ 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() {
if (currentArguments.length > 1) {
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,
Expand All @@ -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;
Expand Down Expand Up @@ -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;

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

pushInstruction();
pushDirective();

break;
}
Expand All @@ -184,7 +203,7 @@ export function parseDirectives(content: string|null): Directive[] {
case 'action':
case 'after_arguments':
if (currentActionName) {
pushInstruction();
pushDirective();
}

break;
Expand Down
4 changes: 2 additions & 2 deletions src/LiveComponent/assets/src/dom_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
);
Expand All @@ -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.`
);
Expand Down
7 changes: 2 additions & 5 deletions src/LiveComponent/assets/src/live_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,8 @@ export default class LiveControllerDefault extends Controller<HTMLElement> 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;

Expand Down Expand Up @@ -215,7 +212,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe('directives parser', () => {
assertDirectiveEquals(directives[0], {
action: 'hide',
args: [],
named: {},
modifiers: [],
})
});
Expand All @@ -39,6 +40,7 @@ describe('directives parser', () => {
assertDirectiveEquals(directives[0], {
action: 'addClass',
args: ['opacity-50'],
named: {},
modifiers: [],
})
});
Expand All @@ -49,6 +51,7 @@ describe('directives parser', () => {
assertDirectiveEquals(directives[0], {
action: 'addClass',
args: ['opacity-50 disabled'],
named: {},
modifiers: [],
})
});
Expand All @@ -60,6 +63,7 @@ describe('directives parser', () => {
action: 'addClass',
// space between arguments is trimmed
args: ['opacity-50', 'disabled'],
named: {},
modifiers: [],
})
});
Expand All @@ -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: [],
})
});
Expand All @@ -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: [],
})
});
Expand All @@ -105,6 +147,7 @@ describe('directives parser', () => {
assertDirectiveEquals(directives[0], {
action: 'addClass',
args: ['disabled'],
named: {},
modifiers: [
{ name: 'delay', value: null }
],
Expand All @@ -117,18 +160,20 @@ describe('directives parser', () => {
assertDirectiveEquals(directives[0], {
action: 'addClass',
args: ['disabled'],
named: {},
modifiers: [
{ name: 'delay', value: '400' },
],
})
});

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' },
Expand Down Expand Up @@ -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.')
});
});
});
11 changes: 4 additions & 7 deletions src/LiveComponent/assets/test/controller/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,17 +78,14 @@ describe('LiveController Action Tests', () => {
<button
data-action="live#action"
data-live-action-param="sendNamedArgs"
data-live-a-param="1"
data-live-b-param="2"
data-live-c-param="banana"
data-live-action-param="sendNamedArgs(a=1&b=2&c=this%20and%20that)"
>Send named args</button>
</div>
`);

// 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;
Expand Down Expand Up @@ -177,15 +174,15 @@ describe('LiveController Action Tests', () => {
<div ${initComponent(data)}>
${data.isSaved ? 'Component Saved!' : ''}
<button data-action="live#action" data-live-action-param="debounce(10)|save">Save</button>
<button data-action="live#action" data-live-action-param="debounce(10)|sync" data-live-sync-all-param="1">Sync</button>
<button data-action="live#action" data-live-action-param="debounce(10)|sync(syncAll=1)">Sync</button>
</div>
`);

// 1 request with all 3 actions
test.expectsAjaxCall()
// 3 actions called
.expectActionCalled('save')
.expectActionCalled('sync', { syncAll: 1 })
.expectActionCalled('sync', { syncAll: '1' })
.expectActionCalled('save')
.serverWillChangeProps((data: any) => {
data.isSaved = true;
Expand Down
Loading

0 comments on commit 788c7d0

Please sign in to comment.