Skip to content

Commit 96ccc9f

Browse files
committed
Adding data-loading behavior to run for only specific "model" updates
1 parent 27881c2 commit 96ccc9f

File tree

5 files changed

+126
-26
lines changed

5 files changed

+126
-26
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
or actions) will be sent all at once on the next request.
1111

1212
- Added the ability to add `data-loading` behavior, which is only activated
13-
when a specific action is triggered - e.g. `<span data-loading="action(save)|show">Loading</span>`
13+
when a specific **action** is triggered - e.g. `<span data-loading="action(save)|show">Loading</span>`.
14+
15+
- Added the ability to add `data-loading` behavior, which is only activated
16+
when a specific **model** has been updated - e.g. `<span data-loading="model(firstName)|show">Loading</span>`.
1417

1518
## 2.4.0
1619

src/LiveComponent/assets/src/ValueStore.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ export default class {
3434
*/
3535
set(name: string, value: any): void {
3636
const normalizedName = normalizeModelName(name);
37-
this.updatedModels.push(normalizedName);
37+
if (!this.updatedModels.includes(normalizedName)) {
38+
this.updatedModels.push(normalizedName);
39+
}
3840

3941
this.controller.dataValue = setDeepData(this.controller.dataValue, normalizedName, value);
4042
}
@@ -55,4 +57,11 @@ export default class {
5557
all(): any {
5658
return this.controller.dataValue;
5759
}
60+
61+
/**
62+
* Are any of the passed models currently "updated"?
63+
*/
64+
areAnyModelsUpdated(targetedModels: string[]): boolean {
65+
return (this.updatedModels.filter(modelName => targetedModels.includes(modelName))).length > 0;
66+
}
5867
}

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ export default class extends Controller implements LiveController {
194194
return;
195195
}
196196

197-
console.warn(`Unknown modifier ${modifier.name} in action "${rawAction}". Available modifiers are ${Array.from(validModifiers.keys()).join(', ')}`);
197+
console.warn(`Unknown modifier ${modifier.name} in action "${rawAction}". Available modifiers are: ${Array.from(validModifiers.keys()).join(', ')}.`);
198198
});
199199

200200
if (!handled) {
@@ -424,7 +424,6 @@ export default class extends Controller implements LiveController {
424424
};
425425

426426
const updatedModels = this.valueStore.updatedModels;
427-
this.valueStore.updatedModels = [];
428427
if (actions.length === 0 && this._willDataFitInUrl(this.valueStore.asJson(), params)) {
429428
params.set('data', this.valueStore.asJson());
430429
updatedModels.forEach((model) => {
@@ -461,7 +460,11 @@ export default class extends Controller implements LiveController {
461460
const paramsString = params.toString();
462461
const thisPromise = fetch(`${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`, fetchOptions);
463462
this.backendRequest = new BackendRequest(thisPromise, actions.map(action => action.name));
463+
// loading should start after this.backendRequest is started but before
464+
// updateModels is cleared so it has full data about actions in the
465+
// current request and also updated models.
464466
this._onLoadingStart();
467+
this.valueStore.updatedModels = [];
465468
thisPromise.then((response) => {
466469
response.text().then((html) => {
467470
this.#processRerender(html, response);
@@ -561,37 +564,53 @@ export default class extends Controller implements LiveController {
561564
const finalAction = parseLoadingAction(directive.action, isLoading);
562565

563566
const targetedActions: string[] = [];
567+
const targetedModels: string[] = [];
564568
let delay = 0;
565-
directive.modifiers.forEach((modifier => {
566-
switch (modifier.name) {
567-
case 'delay': {
568-
// if loading has *stopped*, the delay modifier has no effect
569-
if (!isLoading) {
570-
break;
571-
}
572569

573-
delay = modifier.value ? parseInt(modifier.value) : 200;
570+
const validModifiers: Map<string, (modifier: DirectiveModifier) => void> = new Map();
571+
validModifiers.set('delay', (modifier: DirectiveModifier) => {
572+
// if loading has *stopped*, the delay modifier has no effect
573+
if (!isLoading) {
574+
return;
575+
}
574576

575-
break;
576-
}
577-
case 'action': {
578-
if (!modifier.value) {
579-
throw new Error(`The "action" in data-loading must have an action name - e.g. action(foo). It's missing for ${directive.getString()}`);
580-
}
581-
targetedActions.push(modifier.value);
582-
break;
583-
}
577+
delay = modifier.value ? parseInt(modifier.value) : 200;
578+
});
579+
validModifiers.set('action', (modifier: DirectiveModifier) => {
580+
if (!modifier.value) {
581+
throw new Error(`The "action" in data-loading must have an action name - e.g. action(foo). It's missing for "${directive.getString()}"`);
582+
}
583+
targetedActions.push(modifier.value);
584+
});
585+
validModifiers.set('model', (modifier: DirectiveModifier) => {
586+
if (!modifier.value) {
587+
throw new Error(`The "model" in data-loading must have an action name - e.g. model(foo). It's missing for "${directive.getString()}"`);
588+
}
589+
targetedModels.push(modifier.value);
590+
});
584591

585-
default:
586-
throw new Error(`Unknown modifier ${modifier.name} used in the loading directive ${directive.getString()}`)
592+
directive.modifiers.forEach((modifier) => {
593+
if (validModifiers.has(modifier.name)) {
594+
// variable is entirely to make ts happy
595+
const callable = validModifiers.get(modifier.name) ?? (() => {});
596+
callable(modifier);
597+
598+
return;
587599
}
588-
}));
600+
601+
throw new Error(`Unknown modifier "${modifier.name}" used in data-loading="${directive.getString()}". Available modifiers are: ${Array.from(validModifiers.keys()).join(', ')}.`)
602+
});
589603

590604
// if loading is being activated + action modifier, only apply if the action is on the request
591605
if (isLoading && targetedActions.length > 0 && this.backendRequest && !this.backendRequest.containsOneOfActions(targetedActions)) {
592606
return;
593607
}
594608

609+
// if loading is being activated + model modifier, only apply if the model is modified
610+
if (isLoading && targetedModels.length > 0 && !this.valueStore.areAnyModelsUpdated(targetedModels)) {
611+
return;
612+
}
613+
595614
let loadingDirective: (() => void);
596615

597616
switch (finalAction) {

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

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import {createTest, initComponent, shutdownTest} from '../tools';
1313
import {getByTestId, getByText, waitFor} from '@testing-library/dom';
14+
import userEvent from "@testing-library/user-event";
1415

1516
describe('LiveController data-loading Tests', () => {
1617
afterEach(() => {
@@ -50,7 +51,6 @@ describe('LiveController data-loading Tests', () => {
5051
});
5152

5253
it('takes into account the "action" modifier', async () => {
53-
// TODO start here!
5454
const test = await createTest({}, (data: any) => `
5555
<div ${initComponent(data)}>
5656
<span data-loading="action(save)|show" data-testid="loading-element">Loading...</span>
@@ -97,8 +97,60 @@ describe('LiveController data-loading Tests', () => {
9797
expect(getByTestId(test.element, 'loading-element')).not.toBeVisible();
9898
});
9999

100+
it('takes into account the "model" modifier', async () => {
101+
const test = await createTest({ comments: '', user: { email: '' }}, (data: any) => `
102+
<div ${initComponent(data)}>
103+
<textarea data-model="comments"></textarea>
104+
<span data-loading="model(comments)|show" data-testid="comments-loading">Comments change loading...</span>
105+
106+
<textarea data-model="user.email"></textarea>
107+
<span data-loading="model(user.email)|show" data-testid="email-loading">Checking if email is taken...</span>
108+
</div>
109+
`);
110+
111+
test.expectsAjaxCall('get')
112+
.expectSentData({ comments: 'Changing the comments!', user: { email: '' } })
113+
// delay so we can check loading
114+
.delayResponse(50)
115+
.init();
116+
117+
userEvent.type(test.queryByDataModel('comments'), 'Changing the comments!')
118+
// it should not be loading yet due to debouncing
119+
expect(getByTestId(test.element, 'comments-loading')).not.toBeVisible();
120+
// wait for ajax call to start
121+
await waitFor(() => expect(test.element).toHaveAttribute('busy'));
122+
// NOW it should be loading
123+
expect(getByTestId(test.element, 'comments-loading')).toBeVisible();
124+
// but email-loading is not loading
125+
expect(getByTestId(test.element, 'email-loading')).not.toBeVisible();
126+
// wait for Ajax call to finish
127+
await waitFor(() => expect(test.element).not.toHaveAttribute('busy'));
128+
// loading is no longer visible
129+
expect(getByTestId(test.element, 'comments-loading')).not.toBeVisible();
130+
131+
// now try the user.email "child property" field
132+
test.expectsAjaxCall('get')
133+
.expectSentData({ comments: 'Changing the comments!', user: { email: '[email protected]' } })
134+
// delay so we can check loading
135+
.delayResponse(50)
136+
.init();
137+
138+
userEvent.type(test.queryByDataModel('user.email'), '[email protected]');
139+
// it should not be loading yet due to debouncing
140+
expect(getByTestId(test.element, 'email-loading')).not.toBeVisible();
141+
// wait for ajax call to start
142+
await waitFor(() => expect(test.element).toHaveAttribute('busy'));
143+
// NOW it should be loading
144+
expect(getByTestId(test.element, 'email-loading')).toBeVisible();
145+
// but comments-loading is not loading
146+
expect(getByTestId(test.element, 'comments-loading')).not.toBeVisible();
147+
// wait for Ajax call to finish
148+
await waitFor(() => expect(test.element).not.toHaveAttribute('busy'));
149+
// loading is no longer visible
150+
expect(getByTestId(test.element, 'email-loading')).not.toBeVisible();
151+
});
152+
100153
it('can handle multiple actions on the same request', async () => {
101-
// TODO start here!
102154
const test = await createTest({}, (data: any) => `
103155
<div ${initComponent(data)}>
104156
<span data-loading="action(otherAction)|show" data-testid="loading-element">Loading...</span>

src/LiveComponent/src/Resources/doc/index.rst

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,23 @@ use the ``action()`` modifier with the name of the action - e.g. ``saveForm()``:
473473
<!-- multiple modifiers -->
474474
<div data-loading="action(saveForm)|delay|addClass(opacity-50)">...</div>
475475
476+
Targeting Loading When a Specific Model Changes
477+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
478+
479+
You can also toggle the loading behavior only if a specific model value
480+
was just changed using the ``model()`` modifier:
481+
482+
.. code-block:: twig
483+
484+
<input data-model="email" type="email">
485+
486+
<span data-loading="model(email)|show">
487+
Checking if email is available...
488+
</span>
489+
490+
<!-- multiple modifiers & child properties -->
491+
<span data-loading="model(user.email)|delay|addClass(opacity-50)">...</span>
492+
476493
.. _actions:
477494

478495
Actions

0 commit comments

Comments
 (0)