From 4e846d8688e8df773bd4e6861933561ec65d2455 Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Mon, 19 Sep 2022 21:02:24 -0400 Subject: [PATCH] data-load only for specific actions or model updates --- src/LiveComponent/CHANGELOG.md | 6 + .../assets/dist/live_controller.js | 168 ++++++++------ src/LiveComponent/assets/src/ValueStore.ts | 11 +- .../assets/src/directives_parser.ts | 2 +- .../assets/src/live_controller.ts | 194 ++++++++++------ .../assets/test/controller/loading.test.ts | 207 ++++++++++++++++++ src/LiveComponent/assets/test/tools.ts | 10 +- src/LiveComponent/src/Resources/doc/index.rst | 38 ++++ ux.symfony.com/assets/bootstrap.js | 3 +- 9 files changed, 496 insertions(+), 143 deletions(-) create mode 100644 src/LiveComponent/assets/test/controller/loading.test.ts 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);