diff --git a/src/components/mui/__tests__/search-input.test.js b/src/components/mui/__tests__/search-input.test.js index ad8f8533..2a488d49 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,52 @@ 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("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 }); + 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 e83e2499..9898ee74 100644 --- a/src/components/mui/search-input.js +++ b/src/components/mui/search-input.js @@ -11,53 +11,78 @@ * limitations under the License. * */ -import React, { useEffect, useState } from "react"; -import { TextField, IconButton } from "@mui/material"; +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"; +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); + const onSearchRef = useRef(onSearch); + useEffect(() => { + onSearchRef.current = onSearch; + }, [onSearch]); + + const onSearchDebouncedRef = useRef( + debounce((value) => onSearchRef.current(value), DEBOUNCE_WAIT) + ); + useEffect(() => { setSearchTerm(term || ""); }, [term]); - const handleSearch = (ev) => { - if (ev.key === "Enter") { - onSearch(searchTerm); - } - }; + useEffect(() => () => onSearchDebouncedRef.current?.cancel(), []); const handleClear = () => { + onSearchDebouncedRef.current?.cancel(); setSearchTerm(""); onSearch(""); }; + const handleChange = (value) => { + setSearchTerm(value); + if (debounced) onSearchDebouncedRef.current?.(value); + }; + + const handleKeyDown = (ev) => { + if (!debounced && ev.key === "Enter") { + onSearch(searchTerm); + } + }; + return ( - - - ) : ( - + input: { + startAdornment: debounced && ( + + + + ), + endAdornment: ( + + {searchTerm ? ( + + + + ) : + ( + !debounced && + )} + ) } }} - onChange={(event) => setSearchTerm(event.target.value)} - onKeyDown={handleSearch} + onChange={(ev) => handleChange(ev.target.value)} + onKeyDown={handleKeyDown} fullWidth sx={{ "& .MuiOutlinedInput-root": {