Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion src/components/mui/__tests__/search-input.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -58,4 +59,52 @@ describe("SearchInput", () => {
rerender(<SearchInput term="new" onSearch={jest.fn()} />);
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(<SearchInput term="" onSearch={onSearch} debounced />);
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(<SearchInput term="" onSearch={firstSearch} debounced />);
const input = screen.getByPlaceholderText("Search...");
await user.type(input, "hello");
rerender(<SearchInput term="" onSearch={secondSearch} debounced />);
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(<SearchInput term="" onSearch={onSearch} debounced />);
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(<SearchInput term="" onSearch={onSearch} />);
const input = screen.getByPlaceholderText("Search...");
await user.type(input, "something");
act(() => jest.runAllTimers());
expect(onSearch).not.toHaveBeenCalled();
});
});
});
71 changes: 48 additions & 23 deletions src/components/mui/search-input.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const handleKeyDown = (ev) => {
if (!debounced && ev.key === "Enter") {
onSearch(searchTerm);
}
};

return (
<TextField
variant="outlined"
value={searchTerm}
placeholder={placeholder}
slotProps={{
input: {
endAdornment: term ? (
<IconButton
size="small"
onClick={handleClear}
sx={{ position: "absolute", right: 0 }}
>
<ClearIcon sx={{ color: "#0000008F" }} />
</IconButton>
) : (
<SearchIcon
sx={{ mr: 1, color: "#0000008F", position: "absolute", right: 0 }}
/>
input: {
startAdornment: debounced && (
<InputAdornment position="start">
<SearchIcon sx={{ color: "#0000008F" }} />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
{searchTerm ? (
<IconButton size="small" onClick={handleClear}>
<ClearIcon fontSize="small" sx={{ color: "#0000008F" }} />
</IconButton>
) :
(
!debounced && <SearchIcon
sx={{ mr: 1, color: "#0000008F", position: "absolute", right: 0 }}
/>
)}
</InputAdornment>
)
}
}}
onChange={(event) => setSearchTerm(event.target.value)}
onKeyDown={handleSearch}
onChange={(ev) => handleChange(ev.target.value)}
onKeyDown={handleKeyDown}
fullWidth
sx={{
"& .MuiOutlinedInput-root": {
Expand Down
Loading