Skip to content

Commit d36b0b0

Browse files
authored
add debounce functionality to TextInput widget (#1075)
1 parent 845354a commit d36b0b0

File tree

4 files changed

+131
-7
lines changed

4 files changed

+131
-7
lines changed

.changeset/dirty-sheep-listen.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@ensembleui/react-kitchen-sink": patch
3+
"@ensembleui/react-runtime": patch
4+
---
5+
6+
add debounce functionality to TextInput widget

apps/kitchen-sink/src/ensemble/screens/forms.yaml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,9 @@ View:
177177
label:
178178
Text:
179179
text: Text mask input
180-
onChange: console.log("formTextInput onChange", value)
180+
onChange:
181+
debounceMs: ${ensemble.storage.get('mockApiStatusCode')}
182+
executeCode: console.log("formTextInput onChange", value)
181183
onKeyDown: console.log("formTextInput onKeyDown", event)
182184
- TextInput:
183185
id: minMaxTextInput
@@ -190,7 +192,9 @@ View:
190192
required: true
191193
maxLength: 3
192194
label: Text input with min and max length
193-
onChange: console.log("minMaxTextInput onChange", value)
195+
onChange:
196+
debounceMs: 1000
197+
executeCode: console.log("minMaxTextInput onChange", value)
194198
onKeyDown: console.log("minMaxTextInput onKeyDown", event)
195199
- TextInput:
196200
id: regexTextInput

packages/runtime/src/widgets/Form/TextInput.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useState, useCallback, useRef } from "react";
55
import type { RefCallback, FormEvent } from "react";
66
import { runes } from "runes2";
77
import type { Rule } from "antd/es/form";
8-
import { forEach, isObject, omitBy } from "lodash-es";
8+
import { forEach, isObject, omitBy, debounce } from "lodash-es";
99
import IMask, { type InputMask } from "imask";
1010
import type { EnsembleWidgetProps } from "../../shared/types";
1111
import { WidgetRegistry } from "../../registry";
@@ -29,7 +29,9 @@ export type TextInputProps = {
2929
"none" | "enforced" | "truncateAfterCompositionEnds"
3030
>;
3131
inputType?: "email" | "phone" | "number" | "text" | "url"; //| "ipAddress";
32-
onChange?: EnsembleAction;
32+
onChange?: {
33+
debounceMs?: number;
34+
} & EnsembleAction;
3335
mask?: string;
3436
validator?: {
3537
minLength?: number;
@@ -61,12 +63,20 @@ export const TextInput: React.FC<TextInputProps> = (props) => {
6163
const action = useEnsembleAction(props.onChange);
6264
const onKeyDownAction = useEnsembleAction(props.onKeyDown);
6365

66+
const debouncedOnChange = useMemo(
67+
() =>
68+
debounce((inputValue: string) => {
69+
action?.callback({ value: inputValue });
70+
}, values?.onChange?.debounceMs ?? 0),
71+
[action?.callback, values?.onChange?.debounceMs],
72+
);
73+
6474
const handleChange = useCallback(
6575
(newValue: string) => {
6676
setValue(newValue);
67-
action?.callback({ value: newValue });
77+
debouncedOnChange(newValue);
6878
},
69-
[action?.callback],
79+
[debouncedOnChange],
7080
);
7181

7282
const handleRef: RefCallback<never> = (node) => {
@@ -123,6 +133,19 @@ export const TextInput: React.FC<TextInputProps> = (props) => {
123133
}
124134
}, [values?.mask]);
125135

136+
// cleanup debounced function when component unmounts or changes
137+
useEffect(() => {
138+
return () => {
139+
if (
140+
debouncedOnChange &&
141+
typeof debouncedOnChange === "function" &&
142+
"cancel" in debouncedOnChange
143+
) {
144+
(debouncedOnChange as ReturnType<typeof debounce>).cancel();
145+
}
146+
};
147+
}, [debouncedOnChange]);
148+
126149
const inputType = useMemo(() => {
127150
switch (values?.inputType) {
128151
case "email":

packages/runtime/src/widgets/Form/__tests__/TextInput.test.tsx

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
/* eslint-disable react/no-children-prop */
22
import "@testing-library/jest-dom";
3-
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
3+
import {
4+
fireEvent,
5+
render,
6+
screen,
7+
waitFor,
8+
act,
9+
} from "@testing-library/react";
410
import userEvent from "@testing-library/user-event";
511
import { Form } from "../../index";
612
import { FormTestWrapper } from "./__shared__/fixtures";
@@ -357,5 +363,90 @@ describe("TextInput", () => {
357363
);
358364
});
359365
});
366+
367+
test("debounces onChange events according to debounceMs", () => {
368+
jest.useFakeTimers();
369+
370+
render(
371+
<Form
372+
children={[
373+
{
374+
name: "TextInput",
375+
properties: {
376+
label: "Debounced input",
377+
id: "debouncedInput",
378+
onChange: {
379+
debounceMs: 500,
380+
executeCode: "console.log('changed:', value)",
381+
},
382+
},
383+
},
384+
]}
385+
id="form"
386+
/>,
387+
{ wrapper: FormTestWrapper },
388+
);
389+
390+
const input = screen.getByLabelText("Debounced input");
391+
392+
fireEvent.change(input, { target: { value: "test value" } });
393+
expect(logSpy).not.toHaveBeenCalledWith("changed:", "test value");
394+
395+
act(() => {
396+
jest.advanceTimersByTime(300);
397+
});
398+
expect(logSpy).not.toHaveBeenCalledWith("changed:", "test value");
399+
400+
act(() => {
401+
jest.advanceTimersByTime(210);
402+
});
403+
expect(logSpy).toHaveBeenCalledWith("changed:", "test value");
404+
405+
jest.useRealTimers();
406+
});
407+
408+
test("cancels debounced onChange when component unmounts", () => {
409+
jest.useFakeTimers();
410+
411+
const { unmount } = render(
412+
<Form
413+
children={[
414+
{
415+
name: "TextInput",
416+
properties: {
417+
label: "Debounced input",
418+
id: "debouncedInput",
419+
onChange: {
420+
debounceMs: 500,
421+
executeCode: "console.log('unmount test:', value)",
422+
},
423+
},
424+
},
425+
]}
426+
id="form"
427+
/>,
428+
{ wrapper: FormTestWrapper },
429+
);
430+
431+
const input = screen.getByLabelText("Debounced input");
432+
433+
fireEvent.change(input, { target: { value: "should be canceled" } });
434+
expect(logSpy).not.toHaveBeenCalledWith(
435+
"unmount test:",
436+
"should be canceled",
437+
);
438+
439+
unmount(); // unmount the component before debounce completes
440+
441+
act(() => {
442+
jest.advanceTimersByTime(600);
443+
});
444+
expect(logSpy).not.toHaveBeenCalledWith(
445+
"unmount test:",
446+
"should be canceled",
447+
);
448+
449+
jest.useRealTimers();
450+
});
360451
});
361452
/* eslint-enable react/no-children-prop */

0 commit comments

Comments
 (0)