diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md
index a3fbc530f23..b4bd77bcc4a 100644
--- a/src/LiveComponent/CHANGELOG.md
+++ b/src/LiveComponent/CHANGELOG.md
@@ -26,6 +26,12 @@
```
+- Added the ability to add `data-loading` behavior, which is only activated
+ when a specific **action** is triggered - e.g. `Loading`.
+
+- Added the ability to add `data-loading` behavior, which is only activated
+ when a specific **model** has been updated - e.g. `Loading`.
+
## 2.4.0
- [BC BREAK] Previously, the `id` attribute was used with `morphdom` as the
diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js
index 5c5544ff180..c23dca0b21b 100644
--- a/src/LiveComponent/assets/dist/live_controller.js
+++ b/src/LiveComponent/assets/dist/live_controller.js
@@ -1030,7 +1030,9 @@ class ValueStore {
}
set(name, value) {
const normalizedName = normalizeModelName(name);
- this.updatedModels.push(normalizedName);
+ if (!this.updatedModels.includes(normalizedName)) {
+ this.updatedModels.push(normalizedName);
+ }
this.controller.dataValue = setDeepData(this.controller.dataValue, normalizedName, value);
}
hasAtTopLevel(name) {
@@ -1043,6 +1045,9 @@ class ValueStore {
all() {
return this.controller.dataValue;
}
+ areAnyModelsUpdated(targetedModels) {
+ return (this.updatedModels.filter(modelName => targetedModels.includes(modelName))).length > 0;
+ }
}
function getValueFromElement(element, valueStore) {
@@ -1298,32 +1303,35 @@ class default_1 extends Controller {
args: directive.named
});
let handled = false;
+ const validModifiers = new Map();
+ validModifiers.set('prevent', () => {
+ event.preventDefault();
+ });
+ validModifiers.set('stop', () => {
+ event.stopPropagation();
+ });
+ validModifiers.set('self', () => {
+ if (event.target !== event.currentTarget) {
+ return;
+ }
+ });
+ validModifiers.set('debounce', (modifier) => {
+ const length = modifier.value ? parseInt(modifier.value) : this.getDefaultDebounce();
+ __classPrivateFieldGet(this, _instances, "m", _clearRequestDebounceTimeout).call(this);
+ this.requestDebounceTimeout = window.setTimeout(() => {
+ this.requestDebounceTimeout = null;
+ __classPrivateFieldGet(this, _instances, "m", _startPendingRequest).call(this);
+ }, length);
+ handled = true;
+ });
directive.modifiers.forEach((modifier) => {
- switch (modifier.name) {
- case 'prevent':
- event.preventDefault();
- break;
- case 'stop':
- event.stopPropagation();
- break;
- case 'self':
- if (event.target !== event.currentTarget) {
- return;
- }
- break;
- case 'debounce': {
- const length = modifier.value ? parseInt(modifier.value) : this.getDefaultDebounce();
- __classPrivateFieldGet(this, _instances, "m", _clearRequestDebounceTimeout).call(this);
- this.requestDebounceTimeout = window.setTimeout(() => {
- this.requestDebounceTimeout = null;
- __classPrivateFieldGet(this, _instances, "m", _startPendingRequest).call(this);
- }, length);
- handled = true;
- break;
- }
- default:
- console.warn(`Unknown modifier ${modifier.name} in action ${rawAction}`);
+ var _a;
+ if (validModifiers.has(modifier.name)) {
+ const callable = (_a = validModifiers.get(modifier.name)) !== null && _a !== void 0 ? _a : (() => { });
+ callable(modifier);
+ return;
}
+ console.warn(`Unknown modifier ${modifier.name} in action "${rawAction}". Available modifiers are: ${Array.from(validModifiers.keys()).join(', ')}.`);
});
if (!handled) {
if (getModelDirectiveFromElement(event.currentTarget, false)) {
@@ -1436,11 +1444,17 @@ class default_1 extends Controller {
_onLoadingStart() {
this._handleLoadingToggle(true);
}
- _onLoadingFinish() {
- this._handleLoadingToggle(false);
+ _onLoadingFinish(targetElement = null) {
+ this._handleLoadingToggle(false, targetElement);
}
- _handleLoadingToggle(isLoading) {
- this._getLoadingDirectives().forEach(({ element, directives }) => {
+ _handleLoadingToggle(isLoading, targetElement = null) {
+ if (isLoading) {
+ this._addAttributes(this.element, ['busy']);
+ }
+ else {
+ this._removeAttributes(this.element, ['busy']);
+ }
+ this._getLoadingDirectives(targetElement).forEach(({ element, directives }) => {
if (isLoading) {
this._addAttributes(element, ['data-live-is-loading']);
}
@@ -1454,6 +1468,43 @@ class default_1 extends Controller {
}
_handleLoadingDirective(element, isLoading, directive) {
const finalAction = parseLoadingAction(directive.action, isLoading);
+ const targetedActions = [];
+ const targetedModels = [];
+ let delay = 0;
+ const validModifiers = new Map();
+ validModifiers.set('delay', (modifier) => {
+ if (!isLoading) {
+ return;
+ }
+ delay = modifier.value ? parseInt(modifier.value) : 200;
+ });
+ validModifiers.set('action', (modifier) => {
+ if (!modifier.value) {
+ throw new Error(`The "action" in data-loading must have an action name - e.g. action(foo). It's missing for "${directive.getString()}"`);
+ }
+ targetedActions.push(modifier.value);
+ });
+ validModifiers.set('model', (modifier) => {
+ if (!modifier.value) {
+ throw new Error(`The "model" in data-loading must have an action name - e.g. model(foo). It's missing for "${directive.getString()}"`);
+ }
+ targetedModels.push(modifier.value);
+ });
+ directive.modifiers.forEach((modifier) => {
+ var _a;
+ if (validModifiers.has(modifier.name)) {
+ const callable = (_a = validModifiers.get(modifier.name)) !== null && _a !== void 0 ? _a : (() => { });
+ callable(modifier);
+ return;
+ }
+ throw new Error(`Unknown modifier "${modifier.name}" used in data-loading="${directive.getString()}". Available modifiers are: ${Array.from(validModifiers.keys()).join(', ')}.`);
+ });
+ if (isLoading && targetedActions.length > 0 && this.backendRequest && !this.backendRequest.containsOneOfActions(targetedActions)) {
+ return;
+ }
+ if (isLoading && targetedModels.length > 0 && !this.valueStore.areAnyModelsUpdated(targetedModels)) {
+ return;
+ }
let loadingDirective;
switch (finalAction) {
case 'show':
@@ -1479,33 +1530,20 @@ class default_1 extends Controller {
default:
throw new Error(`Unknown data-loading action "${finalAction}"`);
}
- let isHandled = false;
- directive.modifiers.forEach((modifier => {
- switch (modifier.name) {
- case 'delay': {
- if (!isLoading) {
- break;
- }
- const delayLength = modifier.value ? parseInt(modifier.value) : 200;
- window.setTimeout(() => {
- if (element.hasAttribute('data-live-is-loading')) {
- loadingDirective();
- }
- }, delayLength);
- isHandled = true;
- break;
+ if (delay) {
+ window.setTimeout(() => {
+ if (this.isRequestActive()) {
+ loadingDirective();
}
- default:
- throw new Error(`Unknown modifier ${modifier.name} used in the loading directive ${directive.getString()}`);
- }
- }));
- if (!isHandled) {
- loadingDirective();
+ }, delay);
+ return;
}
+ loadingDirective();
}
- _getLoadingDirectives() {
+ _getLoadingDirectives(targetElement = null) {
const loadingDirectives = [];
- this.element.querySelectorAll('[data-loading]').forEach((element => {
+ const element = targetElement || this.element;
+ element.querySelectorAll('[data-loading]').forEach((element => {
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
throw new Error('Invalid Element Type');
}
@@ -1548,6 +1586,7 @@ class default_1 extends Controller {
}
_executeMorphdom(newHtml, modifiedElements) {
const newElement = htmlToElement(newHtml);
+ this._onLoadingFinish(newElement);
morphdom(this.element, newElement, {
getNodeKey: (node) => {
if (!(node instanceof HTMLElement)) {
@@ -1578,19 +1617,13 @@ class default_1 extends Controller {
&& !this._shouldChildLiveElementUpdate(fromEl, toEl)) {
return false;
}
- if (fromEl.hasAttribute('data-live-ignore')) {
- return false;
- }
- return true;
+ return !fromEl.hasAttribute('data-live-ignore');
},
onBeforeNodeDiscarded(node) {
if (!(node instanceof HTMLElement)) {
return true;
}
- if (node.hasAttribute('data-live-ignore')) {
- return false;
- }
- return true;
+ return !node.hasAttribute('data-live-ignore');
}
});
this._exposeOriginalData();
@@ -1846,6 +1879,9 @@ class default_1 extends Controller {
}
});
}
+ isRequestActive() {
+ return !!this.backendRequest;
+ }
}
_instances = new WeakSet(), _startPendingRequest = function _startPendingRequest() {
if (!this.backendRequest && (this.pendingActions.length > 0 || this.isRerenderRequested)) {
@@ -1865,7 +1901,6 @@ _instances = new WeakSet(), _startPendingRequest = function _startPendingRequest
'Accept': 'application/vnd.live-component+html',
};
const updatedModels = this.valueStore.updatedModels;
- this.valueStore.updatedModels = [];
if (actions.length === 0 && this._willDataFitInUrl(this.valueStore.asJson(), params)) {
params.set('data', this.valueStore.asJson());
updatedModels.forEach((model) => {
@@ -1893,10 +1928,11 @@ _instances = new WeakSet(), _startPendingRequest = function _startPendingRequest
}
fetchOptions.body = JSON.stringify(requestData);
}
- this._onLoadingStart();
const paramsString = params.toString();
const thisPromise = fetch(`${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`, fetchOptions);
- this.backendRequest = new BackendRequest(thisPromise);
+ this.backendRequest = new BackendRequest(thisPromise, actions.map(action => action.name));
+ this._onLoadingStart();
+ this.valueStore.updatedModels = [];
thisPromise.then(async (response) => {
const html = await response.text();
if (response.headers.get('Content-Type') !== 'application/vnd.live-component+html') {
@@ -1946,8 +1982,12 @@ default_1.values = {
debounce: Number,
};
class BackendRequest {
- constructor(promise) {
+ constructor(promise, actions) {
this.promise = promise;
+ this.actions = actions;
+ }
+ containsOneOfActions(targetedActions) {
+ return (this.actions.filter(action => targetedActions.includes(action))).length > 0;
}
}
const parseLoadingAction = function (action, isLoading) {
diff --git a/src/LiveComponent/assets/src/ValueStore.ts b/src/LiveComponent/assets/src/ValueStore.ts
index 15a797a6955..500331fa8ca 100644
--- a/src/LiveComponent/assets/src/ValueStore.ts
+++ b/src/LiveComponent/assets/src/ValueStore.ts
@@ -34,7 +34,9 @@ export default class {
*/
set(name: string, value: any): void {
const normalizedName = normalizeModelName(name);
- this.updatedModels.push(normalizedName);
+ if (!this.updatedModels.includes(normalizedName)) {
+ this.updatedModels.push(normalizedName);
+ }
this.controller.dataValue = setDeepData(this.controller.dataValue, normalizedName, value);
}
@@ -55,4 +57,11 @@ export default class {
all(): any {
return this.controller.dataValue;
}
+
+ /**
+ * Are any of the passed models currently "updated"?
+ */
+ areAnyModelsUpdated(targetedModels: string[]): boolean {
+ return (this.updatedModels.filter(modelName => targetedModels.includes(modelName))).length > 0;
+ }
}
diff --git a/src/LiveComponent/assets/src/directives_parser.ts b/src/LiveComponent/assets/src/directives_parser.ts
index 6f278b9de07..2e2bc85d341 100644
--- a/src/LiveComponent/assets/src/directives_parser.ts
+++ b/src/LiveComponent/assets/src/directives_parser.ts
@@ -1,7 +1,7 @@
/**
* A modifier for a directive
*/
-interface DirectiveModifier {
+export interface DirectiveModifier {
/**
* The name of the modifier (e.g. delay)
*/
diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts
index 4ac09479bd8..0c3596ff122 100644
--- a/src/LiveComponent/assets/src/live_controller.ts
+++ b/src/LiveComponent/assets/src/live_controller.ts
@@ -1,6 +1,6 @@
import { Controller } from '@hotwired/stimulus';
import morphdom from 'morphdom';
-import { parseDirectives, Directive } from './directives_parser';
+import { parseDirectives, Directive, DirectiveModifier } from './directives_parser';
import { combineSpacedArray, normalizeModelName } from './string_utils';
import { haveRenderedValuesChanged } from './have_rendered_values_changed';
import { normalizeAttributesForComparison } from './normalize_attributes_for_comparison';
@@ -170,36 +170,40 @@ export default class extends Controller implements LiveController {
});
let handled = false;
- directive.modifiers.forEach((modifier) => {
- switch (modifier.name) {
- case 'prevent':
- event.preventDefault();
- break;
- case 'stop':
- event.stopPropagation();
- break;
- case 'self':
- if (event.target !== event.currentTarget) {
- return;
- }
- break;
- case 'debounce': {
- const length: number = modifier.value ? parseInt(modifier.value) : this.getDefaultDebounce();
+ const validModifiers: Map void> = new Map();
+ validModifiers.set('prevent', () => {
+ event.preventDefault();
+ });
+ validModifiers.set('stop', () => {
+ event.stopPropagation();
+ });
+ validModifiers.set('self', () => {
+ if (event.target !== event.currentTarget) {
+ return;
+ }
+ });
+ validModifiers.set('debounce', (modifier: DirectiveModifier) => {
+ const length: number = modifier.value ? parseInt(modifier.value) : this.getDefaultDebounce();
- this.#clearRequestDebounceTimeout();
- this.requestDebounceTimeout = window.setTimeout(() => {
- this.requestDebounceTimeout = null;
- this.#startPendingRequest();
- }, length);
+ this.#clearRequestDebounceTimeout();
+ this.requestDebounceTimeout = window.setTimeout(() => {
+ this.requestDebounceTimeout = null;
+ this.#startPendingRequest();
+ }, length);
- handled = true;
+ handled = true;
+ });
- break;
- }
+ directive.modifiers.forEach((modifier) => {
+ if (validModifiers.has(modifier.name)) {
+ // variable is entirely to make ts happy
+ const callable = validModifiers.get(modifier.name) ?? (() => {});
+ callable(modifier);
- default:
- console.warn(`Unknown modifier ${modifier.name} in action ${rawAction}`);
+ return;
}
+
+ console.warn(`Unknown modifier ${modifier.name} in action "${rawAction}". Available modifiers are: ${Array.from(validModifiers.keys()).join(', ')}.`);
});
if (!handled) {
@@ -426,7 +430,6 @@ export default class extends Controller implements LiveController {
};
const updatedModels = this.valueStore.updatedModels;
- this.valueStore.updatedModels = [];
if (actions.length === 0 && this._willDataFitInUrl(this.valueStore.asJson(), params)) {
params.set('data', this.valueStore.asJson());
updatedModels.forEach((model) => {
@@ -459,10 +462,14 @@ export default class extends Controller implements LiveController {
fetchOptions.body = JSON.stringify(requestData);
}
- this._onLoadingStart();
const paramsString = params.toString();
const thisPromise = fetch(`${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`, fetchOptions);
- this.backendRequest = new BackendRequest(thisPromise);
+ this.backendRequest = new BackendRequest(thisPromise, actions.map(action => action.name));
+ // loading should start after this.backendRequest is started but before
+ // updateModels is cleared so it has full data about actions in the
+ // current request and also updated models.
+ this._onLoadingStart();
+ this.valueStore.updatedModels = [];
thisPromise.then(async (response) => {
// if the response does not contain a component, render as an error
const html = await response.text();
@@ -536,12 +543,18 @@ export default class extends Controller implements LiveController {
this._handleLoadingToggle(true);
}
- _onLoadingFinish() {
- this._handleLoadingToggle(false);
+ _onLoadingFinish(targetElement: HTMLElement|SVGElement|null = null) {
+ this._handleLoadingToggle(false, targetElement);
}
- _handleLoadingToggle(isLoading: boolean) {
- this._getLoadingDirectives().forEach(({ element, directives }) => {
+ _handleLoadingToggle(isLoading: boolean, targetElement: HTMLElement|SVGElement|null = null) {
+ if (isLoading) {
+ this._addAttributes(this.element, ['busy']);
+ } else {
+ this._removeAttributes(this.element, ['busy']);
+ }
+
+ this._getLoadingDirectives(targetElement).forEach(({ element, directives }) => {
// so we can track, at any point, if an element is in a "loading" state
if (isLoading) {
this._addAttributes(element, ['data-live-is-loading']);
@@ -561,6 +574,54 @@ export default class extends Controller implements LiveController {
_handleLoadingDirective(element: HTMLElement|SVGElement, isLoading: boolean, directive: Directive) {
const finalAction = parseLoadingAction(directive.action, isLoading);
+ const targetedActions: string[] = [];
+ const targetedModels: string[] = [];
+ let delay = 0;
+
+ const validModifiers: Map void> = new Map();
+ validModifiers.set('delay', (modifier: DirectiveModifier) => {
+ // if loading has *stopped*, the delay modifier has no effect
+ if (!isLoading) {
+ return;
+ }
+
+ delay = modifier.value ? parseInt(modifier.value) : 200;
+ });
+ validModifiers.set('action', (modifier: DirectiveModifier) => {
+ if (!modifier.value) {
+ throw new Error(`The "action" in data-loading must have an action name - e.g. action(foo). It's missing for "${directive.getString()}"`);
+ }
+ targetedActions.push(modifier.value);
+ });
+ validModifiers.set('model', (modifier: DirectiveModifier) => {
+ if (!modifier.value) {
+ throw new Error(`The "model" in data-loading must have an action name - e.g. model(foo). It's missing for "${directive.getString()}"`);
+ }
+ targetedModels.push(modifier.value);
+ });
+
+ directive.modifiers.forEach((modifier) => {
+ if (validModifiers.has(modifier.name)) {
+ // variable is entirely to make ts happy
+ const callable = validModifiers.get(modifier.name) ?? (() => {});
+ callable(modifier);
+
+ return;
+ }
+
+ throw new Error(`Unknown modifier "${modifier.name}" used in data-loading="${directive.getString()}". Available modifiers are: ${Array.from(validModifiers.keys()).join(', ')}.`)
+ });
+
+ // if loading is being activated + action modifier, only apply if the action is on the request
+ if (isLoading && targetedActions.length > 0 && this.backendRequest && !this.backendRequest.containsOneOfActions(targetedActions)) {
+ return;
+ }
+
+ // if loading is being activated + model modifier, only apply if the model is modified
+ if (isLoading && targetedModels.length > 0 && !this.valueStore.areAnyModelsUpdated(targetedModels)) {
+ return;
+ }
+
let loadingDirective: (() => void);
switch (finalAction) {
@@ -594,41 +655,24 @@ export default class extends Controller implements LiveController {
throw new Error(`Unknown data-loading action "${finalAction}"`);
}
- let isHandled = false;
- directive.modifiers.forEach((modifier => {
- switch (modifier.name) {
- case 'delay': {
- // if loading has *stopped*, the delay modifier has no effect
- if (!isLoading) {
- break;
- }
-
- const delayLength = modifier.value ? parseInt(modifier.value) : 200;
- window.setTimeout(() => {
- if (element.hasAttribute('data-live-is-loading')) {
- loadingDirective();
- }
- }, delayLength);
-
- isHandled = true;
-
- break;
+ if (delay) {
+ window.setTimeout(() => {
+ if (this.isRequestActive()) {
+ loadingDirective();
}
- default:
- throw new Error(`Unknown modifier ${modifier.name} used in the loading directive ${directive.getString()}`)
- }
- }));
+ }, delay);
- // execute the loading directive
- if (!isHandled) {
- loadingDirective();
+ return;
}
+
+ loadingDirective();
}
- _getLoadingDirectives() {
+ _getLoadingDirectives(targetElement: HTMLElement|SVGElement|null = null) {
const loadingDirectives: ElementLoadingDirectives[] = [];
+ const element = targetElement || this.element;
- this.element.querySelectorAll('[data-loading]').forEach((element => {
+ element.querySelectorAll('[data-loading]').forEach((element => {
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
throw new Error('Invalid Element Type');
}
@@ -687,6 +731,8 @@ export default class extends Controller implements LiveController {
_executeMorphdom(newHtml: string, modifiedElements: Array) {
const newElement = htmlToElement(newHtml);
+ // make sure everything is in non-loading state, the same as the HTML currently on the page
+ this._onLoadingFinish(newElement);
morphdom(this.element, newElement, {
getNodeKey: (node: Node) => {
if (!(node instanceof HTMLElement)) {
@@ -733,11 +779,7 @@ export default class extends Controller implements LiveController {
}
// look for data-live-ignore, and don't update
- if (fromEl.hasAttribute('data-live-ignore')) {
- return false;
- }
-
- return true;
+ return !fromEl.hasAttribute('data-live-ignore');
},
onBeforeNodeDiscarded(node) {
@@ -746,10 +788,7 @@ export default class extends Controller implements LiveController {
return true;
}
- if (node.hasAttribute('data-live-ignore')) {
- return false;
- }
- return true;
+ return !node.hasAttribute('data-live-ignore');
}
});
// restore the data-original-data attribute
@@ -1132,13 +1171,26 @@ export default class extends Controller implements LiveController {
}
})
}
+
+ private isRequestActive(): boolean {
+ return !!this.backendRequest;
+ }
}
class BackendRequest {
promise: Promise;
+ actions: string[];
- constructor(promise: Promise) {
+ constructor(promise: Promise, actions: string[]) {
this.promise = promise;
+ this.actions = actions;
+ }
+
+ /**
+ * Does this BackendRequest contain at least on action in targetedActions?
+ */
+ containsOneOfActions(targetedActions: string[]) {
+ return (this.actions.filter(action => targetedActions.includes(action))).length > 0;
}
}
diff --git a/src/LiveComponent/assets/test/controller/loading.test.ts b/src/LiveComponent/assets/test/controller/loading.test.ts
new file mode 100644
index 00000000000..1357f975479
--- /dev/null
+++ b/src/LiveComponent/assets/test/controller/loading.test.ts
@@ -0,0 +1,207 @@
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+'use strict';
+
+import {createTest, initComponent, shutdownTest} from '../tools';
+import {getByTestId, getByText, waitFor} from '@testing-library/dom';
+import userEvent from "@testing-library/user-event";
+
+describe('LiveController data-loading Tests', () => {
+ afterEach(() => {
+ shutdownTest();
+ })
+
+ it('executes basic loading functionality on an element', async () => {
+ const test = await createTest({food: 'pizza'}, (data: any) => `
+
+ I like: ${data.food}
+ Loading...
+
+
+ `);
+
+ test.expectsAjaxCall('get')
+ .expectSentData(test.initialData)
+ .serverWillChangeData((data: any) => {
+ // to help detect when rendering is done
+ data.food = 'popcorn';
+ })
+ // delay so we can check loading
+ .delayResponse(50)
+ .init();
+
+ // wait for element to hide itself on start up
+ await waitFor(() => expect(getByTestId(test.element, 'loading-element')).not.toBeVisible());
+
+ getByText(test.element, 'Re-Render').click();
+ // element should instantly be visible
+ expect(getByTestId(test.element, 'loading-element')).toBeVisible();
+
+ // wait for loading to finish
+ await waitFor(() => expect(test.element).toHaveTextContent('I like: popcorn'));
+ // loading element should now be hidden
+ expect(getByTestId(test.element, 'loading-element')).not.toBeVisible();
+ });
+
+ it('takes into account the "action" modifier', async () => {
+ const test = await createTest({}, (data: any) => `
+
+ Loading...
+
+
+
+
+
+ `);
+
+ test.expectsAjaxCall('get')
+ .expectSentData(test.initialData)
+ // delay so we can check loading
+ .delayResponse(50)
+ .init();
+
+ getByText(test.element, 'Re-Render').click();
+ // it should not be loading yet
+ expect(getByTestId(test.element, 'loading-element')).not.toBeVisible();
+ await waitFor(() => expect(test.element).not.toHaveAttribute('busy'));
+
+ test.expectsAjaxCall('post')
+ .expectSentData(test.initialData)
+ .expectActionCalled('otherAction')
+ // delay so we can check loading
+ .delayResponse(50)
+ .init();
+ getByText(test.element, 'Other Action').click();
+ // it should not be loading yet
+ expect(getByTestId(test.element, 'loading-element')).not.toBeVisible();
+ await waitFor(() => expect(test.element).not.toHaveAttribute('busy'));
+
+ test.expectsAjaxCall('post')
+ .expectSentData(test.initialData)
+ .expectActionCalled('save')
+ // delay so we can check loading
+ .delayResponse(50)
+ .init();
+ getByText(test.element, 'Save').click();
+ // it SHOULD be loading now
+ expect(getByTestId(test.element, 'loading-element')).toBeVisible();
+ await waitFor(() => expect(test.element).not.toHaveAttribute('busy'));
+ // now it should be hidden again
+ expect(getByTestId(test.element, 'loading-element')).not.toBeVisible();
+ });
+
+ it('takes into account the "model" modifier', async () => {
+ const test = await createTest({ comments: '', user: { email: '' }}, (data: any) => `
+
+
+ Comments change loading...
+
+
+ Checking if email is taken...
+
+ `);
+
+ test.expectsAjaxCall('get')
+ .expectSentData({ comments: 'Changing the comments!', user: { email: '' } })
+ // delay so we can check loading
+ .delayResponse(50)
+ .init();
+
+ userEvent.type(test.queryByDataModel('comments'), 'Changing the comments!')
+ // it should not be loading yet due to debouncing
+ expect(getByTestId(test.element, 'comments-loading')).not.toBeVisible();
+ // wait for ajax call to start
+ await waitFor(() => expect(test.element).toHaveAttribute('busy'));
+ // NOW it should be loading
+ expect(getByTestId(test.element, 'comments-loading')).toBeVisible();
+ // but email-loading is not loading
+ expect(getByTestId(test.element, 'email-loading')).not.toBeVisible();
+ // wait for Ajax call to finish
+ await waitFor(() => expect(test.element).not.toHaveAttribute('busy'));
+ // loading is no longer visible
+ expect(getByTestId(test.element, 'comments-loading')).not.toBeVisible();
+
+ // now try the user.email "child property" field
+ test.expectsAjaxCall('get')
+ .expectSentData({ comments: 'Changing the comments!', user: { email: 'ryan@symfonycasts.com' } })
+ // delay so we can check loading
+ .delayResponse(50)
+ .init();
+
+ userEvent.type(test.queryByDataModel('user.email'), 'ryan@symfonycasts.com');
+ // it should not be loading yet due to debouncing
+ expect(getByTestId(test.element, 'email-loading')).not.toBeVisible();
+ // wait for ajax call to start
+ await waitFor(() => expect(test.element).toHaveAttribute('busy'));
+ // NOW it should be loading
+ expect(getByTestId(test.element, 'email-loading')).toBeVisible();
+ // but comments-loading is not loading
+ expect(getByTestId(test.element, 'comments-loading')).not.toBeVisible();
+ // wait for Ajax call to finish
+ await waitFor(() => expect(test.element).not.toHaveAttribute('busy'));
+ // loading is no longer visible
+ expect(getByTestId(test.element, 'email-loading')).not.toBeVisible();
+ });
+
+ it('can handle multiple actions on the same request', async () => {
+ const test = await createTest({}, (data: any) => `
+
+ Loading...
+
+
+
+
+ `);
+
+ // 1 ajax request with both actions
+ test.expectsAjaxCall('post')
+ .expectSentData(test.initialData)
+ .expectActionCalled('save')
+ .expectActionCalled('otherAction')
+ // delay so we can check loading
+ .delayResponse(50)
+ .init();
+
+ getByText(test.element, 'Save').click();
+ getByText(test.element, 'Other Action').click();
+ // it SHOULD be loading now
+ expect(getByTestId(test.element, 'loading-element')).toBeVisible();
+ await waitFor(() => expect(test.element).not.toHaveAttribute('busy'));
+ // now it should be hidden again
+ expect(getByTestId(test.element, 'loading-element')).not.toBeVisible();
+ });
+
+ it('does not trigger loading if request finishes first', async () => {
+ const test = await createTest({}, (data: any) => `
+
+ Loading...
+
+
+
+ `);
+
+ test.expectsAjaxCall('post')
+ .expectSentData(test.initialData)
+ .expectActionCalled('save')
+ // delay, but not as long as the loading delay
+ .delayResponse(30)
+ .init();
+
+ getByText(test.element, 'Save').click();
+ // it should NOT be loading: loading is delayed
+ expect(getByTestId(test.element, 'loading-element')).not.toBeVisible();
+ await waitFor(() => expect(test.element).not.toHaveAttribute('busy'));
+
+ // request took 30ms, action loading is delayed for 50
+ // wait 30 more (30+30=60) and verify the element did not switch into loading
+ await (new Promise(resolve => setTimeout(resolve, 30)));
+ expect(getByTestId(test.element, 'loading-element')).not.toBeVisible();
+ });
+});
diff --git a/src/LiveComponent/assets/test/tools.ts b/src/LiveComponent/assets/test/tools.ts
index b0c01a6b22b..bc70c4e4478 100644
--- a/src/LiveComponent/assets/test/tools.ts
+++ b/src/LiveComponent/assets/test/tools.ts
@@ -112,11 +112,11 @@ class FunctionalTest {
class MockedAjaxCall {
method: string;
test: FunctionalTest;
- expectedSentData?: any;
- expectedActions: Array<{ name: string, args: any }> = [];
- expectedHeaders: any = {};
- changeDataCallback?: (data: any) => void;
- template?: (data: any) => string
+ private expectedSentData?: any;
+ private expectedActions: Array<{ name: string, args: any }> = [];
+ private expectedHeaders: any = {};
+ private changeDataCallback?: (data: any) => void;
+ private template?: (data: any) => string
options: any = {};
fetchMock?: typeof fetchMock;
routeName?: string;
diff --git a/src/LiveComponent/src/Resources/doc/index.rst b/src/LiveComponent/src/Resources/doc/index.rst
index 9f407bdc5e1..5947da36930 100644
--- a/src/LiveComponent/src/Resources/doc/index.rst
+++ b/src/LiveComponent/src/Resources/doc/index.rst
@@ -480,6 +480,44 @@ changes until loading has taken longer than a certain amount of time:
Loading
+Targeting Loading for a Specific Action
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 2.5
+
+ The ``action()`` modifier was introduced in Live Components 2.5.
+
+To only toggle the loading behavior when a specific action is triggered,
+use the ``action()`` modifier with the name of the action - e.g. ``saveForm()``:
+
+.. code-block:: twig
+
+
+ Loading
+
+
...
+
+Targeting Loading When a Specific Model Changes
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 2.5
+
+ The ``model()`` modifier was introduced in Live Components 2.5.
+
+You can also toggle the loading behavior only if a specific model value
+was just changed using the ``model()`` modifier:
+
+.. code-block:: twig
+
+
+
+
+ Checking if email is available...
+
+
+
+ ...
+
.. _actions:
Actions
diff --git a/ux.symfony.com/assets/bootstrap.js b/ux.symfony.com/assets/bootstrap.js
index 4181142e856..780523314b8 100644
--- a/ux.symfony.com/assets/bootstrap.js
+++ b/ux.symfony.com/assets/bootstrap.js
@@ -1,5 +1,6 @@
import { startStimulusApp } from '@symfony/stimulus-bridge';
import Clipboard from 'stimulus-clipboard'
+import LiveController from '../live_assets/dist/live_controller';
// Registers Stimulus controllers from controllers.json and in the controllers/ directory
export const app = startStimulusApp(require.context(
@@ -12,5 +13,5 @@ app.debug = process.env.NODE_ENV === 'development';
app.register('clipboard', Clipboard);
// register any custom, 3rd party controllers here
-// app.register('some_controller_name', SomeImportedController);
+app.register('live', LiveController);