SyncController - add better performance & enhance

- Add support for a default 100ms debounce on apply calls so that multiple events (e.g. keyup) do not impact performance
- Refine the delay for updating the field to not use setTimeout by default as this async behaviour is no longer needed for basic usage
- Add unit tests for debounce functionality and clean up unit testing to ensure each test can run in isolation
- Add explicit tests for non-inpu fields such as select (update code to ensure we use setter not setAttribute to support these)
- Add ability to support an action param for a fixed value being synced to another field
- Relates to #10517
pull/10546/head
LB Johnston 2023-06-11 13:51:47 +10:00 zatwierdzone przez Matt Westcott
rodzic 9c92e394af
commit ba8a975273
2 zmienionych plików z 157 dodań i 19 usunięć

Wyświetl plik

@ -1,13 +1,15 @@
import { Application } from '@hotwired/stimulus';
import { SyncController } from './SyncController';
import { range } from '../utils/range';
jest.useFakeTimers();
describe('SyncController', () => {
let application;
describe('basic sync between two fields', () => {
beforeAll(() => {
beforeEach(() => {
application?.stop();
document.body.innerHTML = `
@ -19,7 +21,7 @@ describe('SyncController', () => {
name="event-date"
value="2025-07-22"
data-controller="w-sync"
data-action="change->w-sync#apply cut->w-sync#clear custom:event->w-sync#ping"
data-action="change->w-sync#apply keyup->w-sync#apply cut->w-sync#clear custom:event->w-sync#ping"
data-w-sync-target-value="#title"
/>
</section>`;
@ -92,7 +94,7 @@ describe('SyncController', () => {
expect(event.detail).toEqual({
element: document.getElementById('event-date'),
value: '2025-05-05',
value: '2025-07-22',
});
});
@ -102,7 +104,8 @@ describe('SyncController', () => {
.getElementById('title')
.addEventListener('change', changeListener);
expect(document.getElementById('title').value).toEqual('2025-05-05');
const titleElement = document.getElementById('title');
titleElement.setAttribute('value', 'initial title');
expect(changeListener).not.toHaveBeenCalled();
application.register('w-sync', SyncController);
@ -134,6 +137,52 @@ describe('SyncController', () => {
expect(document.getElementById('title').value).toEqual('');
});
it('should debounce multiple consecutive calls to apply by default', () => {
const titleInput = document.getElementById('title');
const dateInput = document.getElementById('event-date');
const changeListener = jest.fn();
titleInput.addEventListener('change', changeListener);
dateInput.value = '2027-10-14';
application.register('w-sync', SyncController);
range(0, 8).forEach(() => {
dateInput.dispatchEvent(new Event('keyup'));
jest.advanceTimersByTime(5);
});
expect(changeListener).not.toHaveBeenCalled();
expect(titleInput.value).toEqual('');
jest.advanceTimersByTime(50); // not yet reaching the 100ms debounce value
expect(changeListener).not.toHaveBeenCalled();
expect(titleInput.value).toEqual('');
jest.advanceTimersByTime(50); // pass the 100ms debounce value
// keyup run multiple times, only one change event should occur
expect(titleInput.value).toEqual('2027-10-14');
expect(changeListener).toHaveBeenCalledTimes(1);
// adjust the delay via a data attribute
dateInput.setAttribute('data-w-sync-delay-value', '500');
range(0, 8).forEach(() => {
dateInput.dispatchEvent(new Event('keyup'));
jest.advanceTimersByTime(5);
});
jest.advanceTimersByTime(300); // not yet reaching the custom debounce value
expect(changeListener).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(295); // passing the custom debounce value
expect(changeListener).toHaveBeenCalledTimes(2);
});
});
describe('delayed sync between two fields', () => {
@ -169,9 +218,10 @@ describe('SyncController', () => {
jest.advanceTimersByTime(500);
expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 500);
expect(document.getElementById('title').value).toEqual('');
jest.runAllTimers();
expect(document.getElementById('title').value).toEqual('');
});
it('should delay the update on apply based on the set value', () => {
@ -193,10 +243,11 @@ describe('SyncController', () => {
jest.advanceTimersByTime(500);
expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 500);
expect(document.getElementById('title').value).toEqual('2025-05-05');
expect(changeListener).toHaveBeenCalledTimes(1);
jest.runAllTimers();
expect(document.getElementById('title').value).toEqual('2025-05-05');
expect(changeListener).toHaveBeenCalledTimes(1);
});
});
@ -268,4 +319,70 @@ describe('SyncController', () => {
expect(dateInput.getAttribute('data-w-sync-disabled-value')).toBeTruthy();
});
});
describe('ability to use sync for other field behaviour', () => {
beforeAll(() => {
application?.stop();
});
it('should allow the sync clear method to be used on a button to clear target fields', async () => {
document.body.innerHTML = `
<section>
<input type="text" name="title" id="title" value="a title field"/>
<button
type="button"
id="clear"
data-controller="w-sync"
data-action="w-sync#clear"
data-w-sync-target-value="#title"
>Clear</button>
</section>`;
application = Application.start();
application.register('w-sync', SyncController);
await Promise.resolve();
expect(document.getElementById('title').value).toEqual('a title field');
document.getElementById('clear').click();
expect(document.getElementById('title').innerHTML).toEqual('');
});
it('should allow the sync apply method to accept a param instead of the element value', async () => {
document.body.innerHTML = `
<section>
<select name="pets" id="pet-select">
<option value="dog">Dog</option>
<option value="cat">Cat</option>
<option value="pikachu">Pikachu</option>
<option value="goldfish">Goldfish</option>
</select>
<button
type="button"
id="choose"
data-controller="w-sync"
data-action="w-sync#apply"
data-w-sync-apply-param="pikachu"
data-w-sync-target-value="#pet-select"
>Choose Pikachu</button>
</section>`;
application = Application.start();
application.register('w-sync', SyncController);
await Promise.resolve();
expect(document.getElementById('pet-select').value).toEqual('dog');
document.getElementById('choose').dispatchEvent(new Event('click'));
jest.runAllTimers();
expect(document.getElementById('pet-select').value).toEqual('pikachu');
});
});
});

Wyświetl plik

@ -1,5 +1,7 @@
import { Controller } from '@hotwired/stimulus';
import { debounce } from '../utils/debounce';
/**
* Adds ability to sync the value or interactions with one input with one
* or more targeted other inputs.
@ -20,12 +22,14 @@ import { Controller } from '@hotwired/stimulus';
*/
export class SyncController extends Controller<HTMLInputElement> {
static values = {
debounce: { default: 100, type: Number },
delay: { default: 0, type: Number },
disabled: { default: false, type: Boolean },
quiet: { default: false, type: Boolean },
target: String,
};
declare debounceValue: number;
declare delayValue: number;
declare disabledValue: boolean;
declare quietValue: boolean;
@ -38,6 +42,7 @@ export class SyncController extends Controller<HTMLInputElement> {
*/
connect() {
this.processTargetElements('start', true);
this.apply = debounce(this.apply.bind(this), this.debounceValue);
}
/**
@ -50,20 +55,36 @@ export class SyncController extends Controller<HTMLInputElement> {
/**
* Applies a value from the controlled element to the targeted
* elements.
* elements. Calls to this method are debounced based on the
* controller's `debounceValue`.
*
* Applying of the value to the targets can be done with a delay,
* based on the controller's `delayValue`.
*/
apply() {
this.processTargetElements('apply').forEach((target) => {
setTimeout(() => {
target.setAttribute('value', this.element.value);
apply(event?: Event & { params?: { apply?: string } }) {
const valueToApply = event?.params?.apply || this.element.value;
if (this.quietValue) return;
this.dispatch('change', {
cancelable: false,
prefix: '',
target: target as HTMLInputElement,
});
}, this.delayValue);
const applyValue = (target) => {
/* use setter to correctly update value in non-inputs (e.g. select) */ // eslint-disable-next-line no-param-reassign
target.value = valueToApply;
if (this.quietValue) return;
this.dispatch('change', {
cancelable: false,
prefix: '',
target,
});
};
this.processTargetElements('apply').forEach((target) => {
if (this.delayValue) {
setTimeout(() => {
applyValue(target);
}, this.delayValue);
} else {
applyValue(target);
}
});
}