From 10075aa247358f0c386420a69c9018421e9270bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 2 Apr 2026 15:11:04 -0300 Subject: [PATCH 1/7] fix: add debounced prop and ux to mui search input component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/mui/search-input.js | 41 ++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/src/components/mui/search-input.js b/src/components/mui/search-input.js index e83e2499..d6d3c7ff 100644 --- a/src/components/mui/search-input.js +++ b/src/components/mui/search-input.js @@ -11,29 +11,43 @@ * limitations under the License. * */ -import React, { useEffect, useState } from "react"; -import { TextField, IconButton } from "@mui/material"; +import React, { useEffect, useState, useCallback } from "react"; +import { TextField, IconButton, InputAdornment } from "@mui/material"; import SearchIcon from "@mui/icons-material/Search"; import ClearIcon from "@mui/icons-material/Clear"; +import { debounce } from "lodash"; +import { DEBOUNCE_WAIT } from "../../utils/constants"; -const SearchInput = ({ term, onSearch, placeholder = "Search..." }) => { +const SearchInput = ({ term, onSearch, placeholder = "Search...", debounced }) => { const [searchTerm, setSearchTerm] = useState(term); useEffect(() => { setSearchTerm(term || ""); }, [term]); - const handleSearch = (ev) => { - if (ev.key === "Enter") { - onSearch(searchTerm); - } - }; - const handleClear = () => { setSearchTerm(""); onSearch(""); }; + const onSearchDebounced = useCallback( + debounced ? debounce((value) => onSearch(value), DEBOUNCE_WAIT) : null, + [onSearch, debounced] + ); + + useEffect(() => () => onSearchDebounced?.cancel(), [onSearchDebounced]); + + const handleChange = (value) => { + setSearchTerm(value); + if (debounced) onSearchDebounced(value); + }; + + const handleKeyDown = (ev) => { + if (!debounced && ev.key === "Enter") { + onSearch(searchTerm); + } + }; + return ( { placeholder={placeholder} slotProps={{ input: { + startAdornment: ( + + + + ), endAdornment: term ? ( { ) } }} - onChange={(event) => setSearchTerm(event.target.value)} - onKeyDown={handleSearch} + onChange={(ev) => handleChange(ev.target.value)} + onKeyDown={handleKeyDown} fullWidth sx={{ "& .MuiOutlinedInput-root": { From baef89669553dca270249c35a03047562e98c2db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 2 Apr 2026 15:29:21 -0300 Subject: [PATCH 2/7] fix: replace useCallback with useMemo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/mui/search-input.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/mui/search-input.js b/src/components/mui/search-input.js index d6d3c7ff..d537699b 100644 --- a/src/components/mui/search-input.js +++ b/src/components/mui/search-input.js @@ -11,7 +11,7 @@ * limitations under the License. * */ -import React, { useEffect, useState, useCallback } from "react"; +import React, { useEffect, useState, useMemo } from "react"; import { TextField, IconButton, InputAdornment } from "@mui/material"; import SearchIcon from "@mui/icons-material/Search"; import ClearIcon from "@mui/icons-material/Clear"; @@ -30,7 +30,7 @@ const SearchInput = ({ term, onSearch, placeholder = "Search...", debounced }) = onSearch(""); }; - const onSearchDebounced = useCallback( + const onSearchDebounced = useMemo( debounced ? debounce((value) => onSearch(value), DEBOUNCE_WAIT) : null, [onSearch, debounced] ); From 39bac298838dca3d7b574624c7323591d11a8182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 2 Apr 2026 16:03:13 -0300 Subject: [PATCH 3/7] fix: add unit test cases, adjust useMemo function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../mui/__tests__/search-input.test.js | 38 ++++++++++++++++++- src/components/mui/search-input.js | 2 +- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/components/mui/__tests__/search-input.test.js b/src/components/mui/__tests__/search-input.test.js index ad8f8533..109a8219 100644 --- a/src/components/mui/__tests__/search-input.test.js +++ b/src/components/mui/__tests__/search-input.test.js @@ -12,10 +12,11 @@ * */ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { render, screen, act } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; import SearchInput from "../search-input"; +import { DEBOUNCE_WAIT } from "../../../utils/constants"; describe("SearchInput", () => { test("renders with custom placeholder", () => { @@ -58,4 +59,39 @@ describe("SearchInput", () => { rerender(); expect(screen.getByDisplayValue("new")).toBeInTheDocument(); }); + + describe("debounced prop", () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + test("calls onSearch after debounce delay when debounced is true", async () => { + const onSearch = jest.fn(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + const input = screen.getByPlaceholderText("Search..."); + await user.type(input, "something"); + expect(onSearch).not.toHaveBeenCalled(); + act(() => jest.advanceTimersByTime(DEBOUNCE_WAIT)); + expect(onSearch).toHaveBeenCalledWith("something"); + }); + + test("does not call onSearch on Enter when debounced is true", async () => { + const onSearch = jest.fn(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + const input = screen.getByPlaceholderText("Search..."); + await user.type(input, "something{Enter}"); + expect(onSearch).not.toHaveBeenCalled(); + }); + + test("does not call onSearch on typing without Enter when not debounced", async () => { + const onSearch = jest.fn(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + const input = screen.getByPlaceholderText("Search..."); + await user.type(input, "something"); + act(() => jest.runAllTimers()); + expect(onSearch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/components/mui/search-input.js b/src/components/mui/search-input.js index d537699b..1d65f832 100644 --- a/src/components/mui/search-input.js +++ b/src/components/mui/search-input.js @@ -31,7 +31,7 @@ const SearchInput = ({ term, onSearch, placeholder = "Search...", debounced }) = }; const onSearchDebounced = useMemo( - debounced ? debounce((value) => onSearch(value), DEBOUNCE_WAIT) : null, + () => debounced ? debounce((value) => onSearch(value), DEBOUNCE_WAIT) : null, [onSearch, debounced] ); From 0dd6a003fae8136f339122d59e69ef0133eea2e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 2 Apr 2026 16:10:19 -0300 Subject: [PATCH 4/7] fix: add cancel onSearchDebounced at handleClear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/mui/search-input.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/mui/search-input.js b/src/components/mui/search-input.js index 1d65f832..86c77c28 100644 --- a/src/components/mui/search-input.js +++ b/src/components/mui/search-input.js @@ -26,6 +26,7 @@ const SearchInput = ({ term, onSearch, placeholder = "Search...", debounced }) = }, [term]); const handleClear = () => { + onSearchDebounced?.cancel(); setSearchTerm(""); onSearch(""); }; From 9f8f4b073b2983685dd390a66a59c73833a09867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 2 Apr 2026 16:32:20 -0300 Subject: [PATCH 5/7] fix: adjust duplicate search icon, adjust icon positioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/mui/search-input.js | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/components/mui/search-input.js b/src/components/mui/search-input.js index 86c77c28..defb8bdb 100644 --- a/src/components/mui/search-input.js +++ b/src/components/mui/search-input.js @@ -61,18 +61,12 @@ const SearchInput = ({ term, onSearch, placeholder = "Search...", debounced }) = ), - endAdornment: term ? ( - - - - ) : ( - + endAdornment: term && ( + + + + + ) } }} From 97cfb0bb8259ecfa97b2f2b1e93999e066e3e9ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 8 Apr 2026 00:19:47 -0300 Subject: [PATCH 6/7] fix: style changes, replace memo with ref to avoid lost searches on parent re render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../mui/__tests__/search-input.test.js | 13 ++++++ src/components/mui/search-input.js | 42 ++++++++++++------- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/src/components/mui/__tests__/search-input.test.js b/src/components/mui/__tests__/search-input.test.js index 109a8219..2a488d49 100644 --- a/src/components/mui/__tests__/search-input.test.js +++ b/src/components/mui/__tests__/search-input.test.js @@ -75,6 +75,19 @@ describe("SearchInput", () => { expect(onSearch).toHaveBeenCalledWith("something"); }); + test("pending debounced call is not lost when parent re-renders with a new onSearch reference", async () => { + const firstSearch = jest.fn(); + const secondSearch = jest.fn(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const { rerender } = render(); + const input = screen.getByPlaceholderText("Search..."); + await user.type(input, "hello"); + rerender(); + act(() => jest.advanceTimersByTime(DEBOUNCE_WAIT)); + expect(firstSearch).not.toHaveBeenCalled(); + expect(secondSearch).toHaveBeenCalledWith("hello"); + }); + test("does not call onSearch on Enter when debounced is true", async () => { const onSearch = jest.fn(); const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); diff --git a/src/components/mui/search-input.js b/src/components/mui/search-input.js index defb8bdb..3d447bfa 100644 --- a/src/components/mui/search-input.js +++ b/src/components/mui/search-input.js @@ -11,7 +11,7 @@ * limitations under the License. * */ -import React, { useEffect, useState, useMemo } from "react"; +import React, { useEffect, useState, useRef } from "react"; import { TextField, IconButton, InputAdornment } from "@mui/material"; import SearchIcon from "@mui/icons-material/Search"; import ClearIcon from "@mui/icons-material/Clear"; @@ -21,26 +21,32 @@ import { DEBOUNCE_WAIT } from "../../utils/constants"; const SearchInput = ({ term, onSearch, placeholder = "Search...", debounced }) => { const [searchTerm, setSearchTerm] = useState(term); + const onSearchRef = useRef(onSearch); + useEffect(() => { + onSearchRef.current = onSearch; + }, [onSearch]); + + const onSearchDebouncedRef = useRef( + debounced + ? debounce((value) => onSearchRef.current(value), DEBOUNCE_WAIT) + : null + ); + useEffect(() => { setSearchTerm(term || ""); }, [term]); + useEffect(() => () => onSearchDebouncedRef.current?.cancel(), []); + const handleClear = () => { - onSearchDebounced?.cancel(); + onSearchDebouncedRef.current?.cancel(); setSearchTerm(""); onSearch(""); }; - const onSearchDebounced = useMemo( - () => debounced ? debounce((value) => onSearch(value), DEBOUNCE_WAIT) : null, - [onSearch, debounced] - ); - - useEffect(() => () => onSearchDebounced?.cancel(), [onSearchDebounced]); - const handleChange = (value) => { setSearchTerm(value); - if (debounced) onSearchDebounced(value); + if (debounced) onSearchDebouncedRef.current?.(value); }; const handleKeyDown = (ev) => { @@ -56,16 +62,22 @@ const SearchInput = ({ term, onSearch, placeholder = "Search...", debounced }) = placeholder={placeholder} slotProps={{ input: { - startAdornment: ( + startAdornment: !debounced && ( ), - endAdornment: term && ( + endAdornment: ( - - - + {searchTerm ? ( + + + + ) : ( + + )} ) } From d8e2d070764e289f41e1cfebbebe6491e19e9e9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 8 Apr 2026 10:59:02 -0300 Subject: [PATCH 7/7] fix: adjust icons to keep original style, clean validation on searchDebouncedRef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/mui/search-input.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/components/mui/search-input.js b/src/components/mui/search-input.js index 3d447bfa..9898ee74 100644 --- a/src/components/mui/search-input.js +++ b/src/components/mui/search-input.js @@ -27,9 +27,7 @@ const SearchInput = ({ term, onSearch, placeholder = "Search...", debounced }) = }, [onSearch]); const onSearchDebouncedRef = useRef( - debounced - ? debounce((value) => onSearchRef.current(value), DEBOUNCE_WAIT) - : null + debounce((value) => onSearchRef.current(value), DEBOUNCE_WAIT) ); useEffect(() => { @@ -61,8 +59,8 @@ const SearchInput = ({ term, onSearch, placeholder = "Search...", debounced }) = value={searchTerm} placeholder={placeholder} slotProps={{ - input: { - startAdornment: !debounced && ( + input: { + startAdornment: debounced && ( @@ -73,8 +71,9 @@ const SearchInput = ({ term, onSearch, placeholder = "Search...", debounced }) = - ) : ( - )}