Skip to content

Commit de50df7

Browse files
committed
[IMP] NumberInput: Add a small debounce on repetitive changes
Currently, changing the value of a numberInput will apply the change instantly, including when we increment/ decrement the value of the input using the arrow keys of the keyboard. This revision adds a small debounce on repetitive calls to limit the calls to "onChange". closes #7023 Task: 4756754 Signed-off-by: Lucas Lefèvre (lul) <[email protected]>
1 parent ff3f26c commit de50df7

File tree

9 files changed

+94
-20
lines changed

9 files changed

+94
-20
lines changed

packages/o-spreadsheet-engine/src/helpers/misc.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -261,8 +261,17 @@ export function isObjectEmptyRecursive<T extends object>(argument: T | undefined
261261
/**
262262
* Returns a function, that, as long as it continues to be invoked, will not
263263
* be triggered. The function will be called after it stops being called for
264-
* N milliseconds. If `immediate` is passed, trigger the function on the
265-
* leading edge, instead of the trailing.
264+
* N milliseconds. If `immediate` is passed, the function is called is called
265+
* immediately on the first call and the debouncing is triggered starting the second
266+
* call in the defined time window.
267+
*
268+
* Example:
269+
* debouncedFunction = debounce(() => console.log('Hello!'), 250);
270+
* debouncedFunction(); debouncedFunction(); // Will log 'Hello!' after 250ms
271+
*
272+
* debouncedFunction = debounce(() => console.log('Hello!'), 250, true);
273+
* debouncedFunction(); debouncedFunction(); // Will log 'Hello!' and relog it after 250ms
274+
*
266275
*
267276
* Also decorate the argument function with two methods: stopDebounce and isDebouncePending.
268277
*
@@ -274,21 +283,23 @@ export function debounce<T extends (...args: any) => void>(
274283
immediate?: boolean
275284
): DebouncedFunction<T> {
276285
let timeout: any | undefined = undefined;
286+
let firstCalled = false;
277287
const debounced = function (this: any): void {
278288
const context = this;
279289
const args = Array.from(arguments);
290+
if (!firstCalled && immediate) {
291+
firstCalled = true;
292+
return func.apply(context, args);
293+
}
294+
280295
function later() {
281296
timeout = undefined;
282-
if (!immediate) {
283-
func.apply(context, args);
284-
}
297+
firstCalled = false;
298+
func.apply(context, args);
285299
}
286-
const callNow = immediate && !timeout;
300+
287301
clearTimeout(timeout);
288302
timeout = setTimeout(later, wait);
289-
if (callNow) {
290-
func.apply(context, args);
291-
}
292303
};
293304
debounced.isDebouncePending = () => timeout !== undefined;
294305
debounced.stopDebounce = () => {

src/components/generic_input/generic_input.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export class GenericInput<T extends GenericInputProps> extends Component<T, Spre
2626
};
2727

2828
protected refName = "input";
29-
private inputRef!: Ref<HTMLInputElement>;
29+
protected inputRef!: Ref<HTMLInputElement>;
3030

3131
setup() {
3232
this.inputRef = useRef(this.refName);

src/components/number_input/number_input.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { debounce } from "../../helpers";
12
import { GenericInput, GenericInputProps } from "../generic_input/generic_input";
23

34
interface Props extends GenericInputProps {
@@ -16,6 +17,16 @@ export class NumberInput extends GenericInput<Props> {
1617
max: { type: Number, optional: true },
1718
};
1819

20+
// Very short debounce to prevent up/down arrow on number input to spam the onChange
21+
debouncedOnChange = debounce(this.props.onChange.bind(this), 100, true);
22+
23+
save() {
24+
const currentValue = (this.inputRef.el?.value || "").trim();
25+
if (currentValue !== this.props.value.toString()) {
26+
this.debouncedOnChange(currentValue);
27+
}
28+
}
29+
1930
get inputClass(): string {
2031
return [this.props.class, "o-input"].join(" ");
2132
}

src/components/number_input/number_input.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
t-att-class="inputClass"
77
t-att-id="props.id"
88
t-att-placeholder="props.placeholder"
9-
t-on-change="() => this.save(true)"
9+
t-on-change="save"
1010
t-on-blur="save"
1111
t-on-pointerdown="onMouseDown"
1212
t-on-pointerup="onMouseUp"

src/components/side_panel/chart/building_blocks/pie_hole_size/pie_hole_size.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { SpreadsheetChildEnv } from "@odoo/o-spreadsheet-engine/types/spreadsheet_env";
22
import { Component } from "@odoo/owl";
3-
import { clip, debounce } from "../../../../../helpers";
3+
import { clip } from "../../../../../helpers";
44
import { NumberInput } from "../../../../number_input/number_input";
55
import { Section } from "../../../components/section/section";
66

@@ -14,9 +14,6 @@ export class PieHoleSize extends Component<Props, SpreadsheetChildEnv> {
1414
static components = { Section, NumberInput };
1515
static props = { onValueChange: Function, value: Number };
1616

17-
// Very short debounce to prevent up/down arrow on number input to spam the onChange
18-
debouncedOnChange = debounce(this.onChange.bind(this), 100);
19-
2017
onChange(value: string) {
2118
if (!isNaN(Number(value))) {
2219
this.props.onValueChange(clip(Number(value), 0, 95));

src/components/side_panel/chart/building_blocks/pie_hole_size/pie_hole_size.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
class="'o-pie-hole-size-input'"
88
min="0"
99
max="95"
10-
onChange.bind="debouncedOnChange"
10+
onChange.bind="onChange"
1111
/>
1212
%
1313
</div>

tests/components/number_input.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ async function mountNumberInput(props: Props) {
2828
}
2929

3030
describe("NumberInput", () => {
31+
beforeAll(() => {
32+
jest.useFakeTimers();
33+
});
34+
35+
afterAll(() => {
36+
jest.useRealTimers();
37+
});
3138
test("Can render a number input", async () => {
3239
await mountNumberInput({ value: 5, onChange: () => {} });
3340
expect(fixture).toMatchSnapshot();
@@ -50,6 +57,7 @@ describe("NumberInput", () => {
5057
await mountNumberInput({ value: 5, onChange });
5158
setInputValueAndTrigger(fixture.querySelector("input")!, "2");
5259
await click(document.body);
60+
jest.advanceTimersByTime(100);
5361
expect(onChange).toHaveBeenCalledWith("2");
5462
});
5563

@@ -66,6 +74,7 @@ describe("NumberInput", () => {
6674
fixture.querySelector("input")!.focus();
6775
setInputValueAndTrigger(fixture.querySelector("input")!, "4");
6876
await keyDown({ key: "Enter" });
77+
jest.advanceTimersByTime(100);
6978
expect(onChange).toHaveBeenCalledWith("4");
7079
});
7180

@@ -82,6 +91,7 @@ describe("NumberInput", () => {
8291
await mountNumberInput({ value: 5, onChange });
8392
setInputValueAndTrigger(fixture.querySelector("input")!, "2");
8493
fixture.querySelector("input")!.blur();
94+
jest.advanceTimersByTime(100);
8595
expect(onChange).toHaveBeenCalledWith("2");
8696
});
8797

tests/figures/chart/sunburst/sunburst_panel_component.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,16 +174,18 @@ describe("Sunburst chart side panel", () => {
174174
});
175175
});
176176

177-
test("Can change sunburst chart hole size, and input is debounced", async () => {
177+
test("Can change sunburst chart hole size, and input is debounced on ,ultiple calls", async () => {
178178
const chartId = createSunburstChart(model, {});
179179
await openChartDesignSidePanel(model, env, fixture, chartId);
180180

181181
expect(".o-pie-hole-size-input").toHaveValue("25");
182182
jest.useFakeTimers();
183-
await setInputValueAndTrigger(".o-pie-hole-size-input", "50");
184-
expect(getSunburstDefinition(chartId).pieHolePercentage).toEqual(undefined); // debounced
183+
setInputValueAndTrigger(".o-pie-hole-size-input", "50");
184+
setInputValueAndTrigger(".o-pie-hole-size-input", "51");
185+
setInputValueAndTrigger(".o-pie-hole-size-input", "52");
186+
expect(getSunburstDefinition(chartId).pieHolePercentage).toEqual(50); // debounced
185187
jest.advanceTimersByTime(1000);
186-
expect(getSunburstDefinition(chartId).pieHolePercentage).toEqual(50);
188+
expect(getSunburstDefinition(chartId).pieHolePercentage).toEqual(52);
187189
jest.useRealTimers();
188190
});
189191
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { debounce } from "../../src/helpers";
2+
3+
describe("Debounce Helper", () => {
4+
beforeAll(() => {
5+
jest.useFakeTimers();
6+
});
7+
8+
afterAll(() => {
9+
jest.useRealTimers();
10+
});
11+
12+
test("debounce calls function after wait time", () => {
13+
const func = jest.fn();
14+
const debouncedFunc = debounce(func, 200);
15+
16+
debouncedFunc();
17+
debouncedFunc();
18+
debouncedFunc();
19+
debouncedFunc();
20+
expect(func).not.toBeCalled();
21+
jest.advanceTimersByTime(100);
22+
expect(func).not.toBeCalled();
23+
jest.advanceTimersByTime(100);
24+
expect(func).toBeCalledTimes(1);
25+
});
26+
27+
test("debounce with immediate=true calls function immediately and then after wait time", () => {
28+
const func = jest.fn();
29+
const debouncedFunc = debounce(func, 200, true);
30+
31+
debouncedFunc();
32+
expect(func).toBeCalledTimes(1);
33+
debouncedFunc();
34+
debouncedFunc();
35+
expect(func).toBeCalledTimes(1);
36+
jest.advanceTimersByTime(200);
37+
expect(func).toBeCalledTimes(2);
38+
debouncedFunc();
39+
expect(func).toBeCalledTimes(3);
40+
jest.advanceTimersByTime(200);
41+
expect(func).toBeCalledTimes(3);
42+
});
43+
});

0 commit comments

Comments
 (0)