Skip to content

Commit 3668d96

Browse files
authored
Numeric debounce for Input and Textarea (#1056)
* Implement numeric debounce * Add tests * Use onKeyUp in Textarea * Update debounce prop of Textarea + tests * Compare to true
1 parent 5128d21 commit 3668d96

File tree

4 files changed

+97
-26
lines changed

4 files changed

+97
-26
lines changed

src/components/input/Input.js

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const Input = props => {
4949
...otherProps
5050
} = props;
5151
const inputRef = useRef(null);
52+
const debounceRef = useRef(null);
5253

5354
const formControlClass = plaintext
5455
? 'form-control-plaintext'
@@ -63,7 +64,12 @@ const Input = props => {
6364
);
6465

6566
const onChange = () => {
66-
if (!debounce) {
67+
if (debounce) {
68+
if (Number.isFinite(debounce)) {
69+
clearTimeout(debounceRef.current);
70+
debounceRef.current = setTimeout(onEvent, debounce);
71+
}
72+
} else {
6773
onEvent();
6874
}
6975
};
@@ -114,21 +120,23 @@ const Input = props => {
114120
n_blur: n_blur + 1,
115121
n_blur_timestamp: Date.now()
116122
};
117-
if (debounce) {
123+
if (debounce === true) {
124+
// numeric debounce here has no effect, we only care about boolean debounce
118125
onEvent(payload);
119126
} else {
120127
setProps(payload);
121128
}
122129
}
123130
};
124131

125-
const onKeyPress = e => {
132+
const onKeyUp = e => {
126133
if (setProps && e.key === 'Enter') {
127134
const payload = {
128135
n_submit: n_submit + 1,
129136
n_submit_timestamp: Date.now()
130137
};
131-
if (debounce) {
138+
if (debounce === true) {
139+
// numeric debounce here has no effect, we only care about boolean debounce
132140
onEvent(payload);
133141
} else {
134142
setProps(payload);
@@ -143,7 +151,7 @@ const Input = props => {
143151
className={classes}
144152
onChange={onChange}
145153
onBlur={onBlur}
146-
onKeyPress={onKeyPress}
154+
onKeyUp={onKeyUp}
147155
{...omit(
148156
[
149157
'n_blur_timestamp',
@@ -593,10 +601,12 @@ Input.propTypes = {
593601
/**
594602
* If true, changes to input will be sent back to the Dash server
595603
* only when the enter key is pressed or when the component loses
596-
* focus. If it's false, it will sent the value back on every
597-
* change.
604+
* focus. If it's false, it will sent the value back on every
605+
* change. If debounce is a number, the value will be sent to the
606+
* server only after the user has stopped typing for that number
607+
* of milliseconds.
598608
*/
599-
debounce: PropTypes.bool,
609+
debounce: PropTypes.oneOfType([PropTypes.bool, PropTypes.number]),
600610

601611
/**
602612
* Object that holds the loading state object coming from dash-renderer

src/components/input/Textarea.js

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, {useEffect, useState} from 'react';
1+
import React, {useEffect, useRef, useState} from 'react';
22
import PropTypes from 'prop-types';
33
import {omit} from 'ramda';
44
import classNames from 'classnames';
@@ -43,6 +43,7 @@ const Textarea = props => {
4343
...otherProps
4444
} = props;
4545
const [valueState, setValueState] = useState(value || '');
46+
const debounceRef = useRef(null);
4647

4748
useEffect(() => {
4849
if (value !== valueState) {
@@ -53,7 +54,15 @@ const Textarea = props => {
5354
const onChange = e => {
5455
const newValue = e.target.value;
5556
setValueState(newValue);
56-
if (!debounce && setProps) {
57+
if (debounce) {
58+
if (Number.isFinite(debounce)) {
59+
clearTimeout(debounceRef.current);
60+
debounceRef.current = setTimeout(
61+
() => setProps({value: newValue}),
62+
debounce
63+
);
64+
}
65+
} else {
5766
setProps({value: newValue});
5867
}
5968
};
@@ -64,21 +73,21 @@ const Textarea = props => {
6473
n_blur: n_blur + 1,
6574
n_blur_timestamp: Date.now()
6675
};
67-
if (debounce) {
76+
if (debounce === true) {
6877
payload.value = e.target.value;
6978
}
7079
setProps(payload);
7180
}
7281
};
7382

74-
const onKeyPress = e => {
83+
const onKeyUp = e => {
7584
if (submit_on_enter && setProps && e.key === 'Enter' && !e.shiftKey) {
7685
e.preventDefault(); // don't create newline if submitting
7786
const payload = {
7887
n_submit: n_submit + 1,
7988
n_submit_timestamp: Date.now()
8089
};
81-
if (debounce) {
90+
if (debounce === true) {
8291
payload.value = e.target.value;
8392
}
8493
setProps(payload);
@@ -108,7 +117,7 @@ const Textarea = props => {
108117
className={classes}
109118
onChange={onChange}
110119
onBlur={onBlur}
111-
onKeyPress={onKeyPress}
120+
onKeyUp={onKeyUp}
112121
onClick={onClick}
113122
autoFocus={autofocus || autoFocus}
114123
maxLength={maxlength || maxLength}
@@ -435,8 +444,10 @@ Textarea.propTypes = {
435444
n_clicks_timestamp: PropTypes.number,
436445

437446
/**
438-
* If true, changes to input will be sent back to the Dash server only on enter or when losing focus.
439-
* If it's false, it will sent the value back on every change.
447+
* If true, changes to input will be sent back to the Dash server only on enter or
448+
* when losing focus. If it's false, it will sent the value back on every change.
449+
* If debounce is a number, the value will be sent to the server only after the user
450+
* has stopped typing for that number of milliseconds
440451
*/
441452
debounce: PropTypes.bool,
442453

src/components/input/__tests__/Input.test.js

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
import React from 'react';
6-
import {render, fireEvent} from '@testing-library/react';
6+
import {act, render, fireEvent} from '@testing-library/react';
77
import userEvent from '@testing-library/user-event';
88
import Input from '../Input';
99

@@ -127,7 +127,7 @@ describe('Input', () => {
127127

128128
test('tracks submit with "n_submit" and "n_submit_timestamp"', () => {
129129
const before = Date.now();
130-
fireEvent.keyPress(inputElement, {key: 'Enter', code: 13, charCode: 13});
130+
fireEvent.keyUp(inputElement, {key: 'Enter', code: 13, charCode: 13});
131131
const after = Date.now();
132132

133133
expect(mockSetProps.mock.calls).toHaveLength(1);
@@ -156,10 +156,10 @@ describe('Input', () => {
156156

157157
test("don't call setProps on change if debounce is true", () => {
158158
fireEvent.change(inputElement, {
159-
target: {value: 'some-input-value'}
159+
target: {value: 'some-new-input-value'}
160160
});
161161
expect(mockSetProps.mock.calls).toHaveLength(0);
162-
expect(inputElement).toHaveValue('some-input-value');
162+
expect(inputElement).toHaveValue('some-new-input-value');
163163
});
164164

165165
test('dispatch value on blur if debounce is true', () => {
@@ -178,7 +178,7 @@ describe('Input', () => {
178178

179179
test('dispatch value on submit if debounce is true', () => {
180180
const before = Date.now();
181-
fireEvent.keyPress(inputElement, {
181+
fireEvent.keyUp(inputElement, {
182182
key: 'Enter',
183183
code: 13,
184184
charCode: 13
@@ -195,6 +195,31 @@ describe('Input', () => {
195195
});
196196
});
197197

198+
describe('numeric debounce', () => {
199+
let inputElement, mockSetProps;
200+
201+
beforeEach(() => {
202+
jest.useFakeTimers();
203+
mockSetProps = jest.fn();
204+
const {container} = render(
205+
<Input setProps={mockSetProps} value="" debounce={2000} />
206+
);
207+
inputElement = container.firstChild;
208+
});
209+
210+
test('call setProps after delay if debounce is number', () => {
211+
fireEvent.change(inputElement, {
212+
target: {value: 'some-input-value'}
213+
});
214+
expect(mockSetProps.mock.calls).toHaveLength(0);
215+
expect(inputElement).toHaveValue('some-input-value');
216+
act(() => jest.advanceTimersByTime(1000));
217+
expect(mockSetProps.mock.calls).toHaveLength(0);
218+
act(() => jest.advanceTimersByTime(1000));
219+
expect(mockSetProps.mock.calls).toHaveLength(1);
220+
});
221+
});
222+
198223
describe('number input', () => {
199224
let inputElement, mockSetProps;
200225

src/components/input/__tests__/Textarea.test.js

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
import React from 'react';
6-
import {render, fireEvent} from '@testing-library/react';
6+
import {act, render, fireEvent} from '@testing-library/react';
77
import userEvent from '@testing-library/user-event';
88
import Textarea from '../Textarea';
99

@@ -132,7 +132,7 @@ describe('Textarea', () => {
132132

133133
test('tracks submit with "n_submit" and "n_submit_timestamp"', () => {
134134
const before = Date.now();
135-
fireEvent.keyPress(textarea, {
135+
fireEvent.keyUp(textarea, {
136136
key: 'Enter',
137137
code: 13,
138138
charCode: 13
@@ -148,7 +148,7 @@ describe('Textarea', () => {
148148
});
149149

150150
test("don't increment n_submit if key is not Enter", () => {
151-
fireEvent.keyPress(textarea, {key: 'a', code: 65, charCode: 65});
151+
fireEvent.keyUp(textarea, {key: 'a', code: 65, charCode: 65});
152152
expect(mockSetProps.mock.calls).toHaveLength(0);
153153
});
154154

@@ -157,7 +157,7 @@ describe('Textarea', () => {
157157
const {
158158
container: {firstChild: ta}
159159
} = render(<Textarea submit_on_enter={false} setProps={mockSetProps} />);
160-
fireEvent.keyPress(ta, {key: 'Enter', code: 13, charCode: 13});
160+
fireEvent.keyUp(ta, {key: 'Enter', code: 13, charCode: 13});
161161
expect(mockSetProps.mock.calls).toHaveLength(0);
162162
});
163163

@@ -206,7 +206,7 @@ describe('Textarea', () => {
206206
expect(n_submit).toEqual(1);
207207
expect(n_submit_timestamp).toBeGreaterThanOrEqual(before);
208208
expect(n_submit_timestamp).toBeLessThanOrEqual(after);
209-
expect(value).toEqual('some text');
209+
expect(value).toEqual('some text\n');
210210
});
211211

212212
test('submit not dispatched if shift+enter pressed', () => {
@@ -218,5 +218,30 @@ describe('Textarea', () => {
218218
expect(mockSetProps.mock.calls).toHaveLength(1);
219219
});
220220
});
221+
222+
describe('numeric debounce', () => {
223+
let textarea, mockSetProps;
224+
225+
beforeEach(() => {
226+
jest.useFakeTimers();
227+
mockSetProps = jest.fn();
228+
const {container} = render(
229+
<Textarea setProps={mockSetProps} value="" debounce={2000} />
230+
);
231+
textarea = container.firstChild;
232+
});
233+
234+
test('call setProps after delay if debounce is number', () => {
235+
fireEvent.change(textarea, {
236+
target: {value: 'some-input-value'}
237+
});
238+
expect(mockSetProps.mock.calls).toHaveLength(0);
239+
expect(textarea).toHaveValue('some-input-value');
240+
act(() => jest.advanceTimersByTime(1000));
241+
expect(mockSetProps.mock.calls).toHaveLength(0);
242+
act(() => jest.advanceTimersByTime(1000));
243+
expect(mockSetProps.mock.calls).toHaveLength(1);
244+
});
245+
});
221246
});
222247
});

0 commit comments

Comments
 (0)