Skip to content

Commit 92d1665

Browse files
authored
Merge pull request modelcontextprotocol#727 from cameronldroberts/tool-search
feat: Add search capability to the ListPane component to allow users to filter resources, prompts and tools
2 parents cf9acf6 + 71aadf9 commit 92d1665

File tree

2 files changed

+309
-33
lines changed

2 files changed

+309
-33
lines changed

client/src/components/ListPane.tsx

Lines changed: 106 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import { Search } from "lucide-react";
12
import { Button } from "./ui/button";
3+
import { Input } from "./ui/input";
4+
import { useState, useMemo, useRef } from "react";
25

36
type ListPaneProps<T> = {
47
items: T[];
@@ -20,41 +23,111 @@ const ListPane = <T extends object>({
2023
title,
2124
buttonText,
2225
isButtonDisabled,
23-
}: ListPaneProps<T>) => (
24-
<div className="bg-card border border-border rounded-lg shadow">
25-
<div className="p-4 border-b border-gray-200 dark:border-border">
26-
<h3 className="font-semibold dark:text-white">{title}</h3>
27-
</div>
28-
<div className="p-4">
29-
<Button
30-
variant="outline"
31-
className="w-full mb-4"
32-
onClick={listItems}
33-
disabled={isButtonDisabled}
34-
>
35-
{buttonText}
36-
</Button>
37-
<Button
38-
variant="outline"
39-
className="w-full mb-4"
40-
onClick={clearItems}
41-
disabled={items.length === 0}
42-
>
43-
Clear
44-
</Button>
45-
<div className="space-y-2 overflow-y-auto max-h-96">
46-
{items.map((item, index) => (
47-
<div
48-
key={index}
49-
className="flex items-center py-2 px-4 rounded hover:bg-gray-50 dark:hover:bg-secondary cursor-pointer"
50-
onClick={() => setSelectedItem(item)}
51-
>
52-
{renderItem(item)}
26+
}: ListPaneProps<T>) => {
27+
const [searchQuery, setSearchQuery] = useState("");
28+
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
29+
const searchInputRef = useRef<HTMLInputElement>(null);
30+
31+
const filteredItems = useMemo(() => {
32+
if (!searchQuery.trim()) return items;
33+
34+
return items.filter((item) => {
35+
const searchableText = [
36+
(item as { name?: string }).name || "",
37+
(item as { description?: string }).description || "",
38+
]
39+
.join(" ")
40+
.toLowerCase();
41+
return searchableText.includes(searchQuery.toLowerCase());
42+
});
43+
}, [items, searchQuery]);
44+
45+
const handleSearchClick = () => {
46+
setIsSearchExpanded(true);
47+
setTimeout(() => {
48+
searchInputRef.current?.focus();
49+
}, 100);
50+
};
51+
52+
const handleSearchBlur = () => {
53+
if (!searchQuery.trim()) {
54+
setIsSearchExpanded(false);
55+
}
56+
};
57+
58+
return (
59+
<div className="bg-card border border-border rounded-lg shadow">
60+
<div className="p-4 border-b border-gray-200 dark:border-border">
61+
<div className="flex items-center justify-between gap-4">
62+
<h3 className="font-semibold dark:text-white flex-shrink-0">
63+
{title}
64+
</h3>
65+
<div className="flex items-center justify-end min-w-0 flex-1">
66+
{!isSearchExpanded ? (
67+
<button
68+
name="search"
69+
aria-label="Search"
70+
onClick={handleSearchClick}
71+
className="p-2 hover:bg-gray-100 dark:hover:bg-secondary rounded-md transition-all duration-300 ease-in-out"
72+
>
73+
<Search className="w-4 h-4 text-muted-foreground" />
74+
</button>
75+
) : (
76+
<div className="flex items-center w-full max-w-xs">
77+
<div className="relative w-full">
78+
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none z-10" />
79+
<Input
80+
ref={searchInputRef}
81+
name="search"
82+
type="text"
83+
placeholder="Search..."
84+
value={searchQuery}
85+
onChange={(e) => setSearchQuery(e.target.value)}
86+
onBlur={handleSearchBlur}
87+
className="pl-10 w-full transition-all duration-300 ease-in-out"
88+
/>
89+
</div>
90+
</div>
91+
)}
5392
</div>
54-
))}
93+
</div>
94+
</div>
95+
<div className="p-4">
96+
<Button
97+
variant="outline"
98+
className="w-full mb-4"
99+
onClick={listItems}
100+
disabled={isButtonDisabled}
101+
>
102+
{buttonText}
103+
</Button>
104+
<Button
105+
variant="outline"
106+
className="w-full mb-4"
107+
onClick={clearItems}
108+
disabled={items.length === 0}
109+
>
110+
Clear
111+
</Button>
112+
<div className="space-y-2 overflow-y-auto max-h-96">
113+
{filteredItems.map((item, index) => (
114+
<div
115+
key={index}
116+
className="flex items-center py-2 px-4 rounded hover:bg-gray-50 dark:hover:bg-secondary cursor-pointer"
117+
onClick={() => setSelectedItem(item)}
118+
>
119+
{renderItem(item)}
120+
</div>
121+
))}
122+
{filteredItems.length === 0 && searchQuery && items.length > 0 && (
123+
<div className="text-center py-4 text-muted-foreground">
124+
No items found matching &quot;{searchQuery}&quot;
125+
</div>
126+
)}
127+
</div>
55128
</div>
56129
</div>
57-
</div>
58-
);
130+
);
131+
};
59132

60133
export default ListPane;
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { render, screen, fireEvent, act } from "@testing-library/react";
2+
import "@testing-library/jest-dom";
3+
import { describe, it, beforeEach, jest } from "@jest/globals";
4+
import ListPane from "../ListPane";
5+
6+
describe("ListPane", () => {
7+
const mockItems = [
8+
{ id: 1, name: "Tool 1", description: "First tool" },
9+
{ id: 2, name: "Tool 2", description: "Second tool" },
10+
{ id: 3, name: "Another Tool", description: "Third tool" },
11+
];
12+
13+
const defaultProps = {
14+
items: mockItems,
15+
listItems: jest.fn(),
16+
clearItems: jest.fn(),
17+
setSelectedItem: jest.fn(),
18+
renderItem: (item: (typeof mockItems)[0]) => <div>{item.name}</div>,
19+
title: "List tools",
20+
buttonText: "Load Tools",
21+
};
22+
23+
const renderListPane = (props = {}) => {
24+
return render(<ListPane {...defaultProps} {...props} />);
25+
};
26+
27+
beforeEach(() => {
28+
jest.clearAllMocks();
29+
});
30+
31+
describe("Rendering", () => {
32+
it("should render with title and button", () => {
33+
renderListPane();
34+
35+
expect(screen.getByText("List tools")).toBeInTheDocument();
36+
expect(
37+
screen.getByRole("button", { name: "Load Tools" }),
38+
).toBeInTheDocument();
39+
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
40+
});
41+
42+
it("should render items when provided", () => {
43+
renderListPane();
44+
45+
expect(screen.getByText("Tool 1")).toBeInTheDocument();
46+
expect(screen.getByText("Tool 2")).toBeInTheDocument();
47+
expect(screen.getByText("Another Tool")).toBeInTheDocument();
48+
});
49+
50+
it("should render empty state when no items", () => {
51+
renderListPane({ items: [] });
52+
53+
expect(screen.queryByText("Tool 1")).not.toBeInTheDocument();
54+
expect(screen.queryByText("Tool 2")).not.toBeInTheDocument();
55+
});
56+
57+
it("should render custom item content", () => {
58+
const customRenderItem = (item: (typeof mockItems)[0]) => (
59+
<div>
60+
<span>{item.name}</span>
61+
<small>{item.description}</small>
62+
</div>
63+
);
64+
65+
renderListPane({ renderItem: customRenderItem });
66+
67+
expect(screen.getByText("Tool 1")).toBeInTheDocument();
68+
expect(screen.getByText("First tool")).toBeInTheDocument();
69+
});
70+
});
71+
72+
describe("Search Functionality", () => {
73+
it("should show search icon initially", () => {
74+
renderListPane();
75+
76+
const searchButton = screen.getByRole("button", { name: "Search" });
77+
expect(searchButton).toBeInTheDocument();
78+
expect(searchButton.querySelector("svg")).toBeInTheDocument();
79+
});
80+
81+
it("should expand search input when search icon is clicked", async () => {
82+
renderListPane();
83+
84+
const searchButton = screen.getByRole("button", { name: "Search" });
85+
await act(async () => {
86+
fireEvent.click(searchButton);
87+
});
88+
89+
const searchInput = screen.getByPlaceholderText("Search...");
90+
expect(searchInput).toBeInTheDocument();
91+
92+
// Wait for the setTimeout to complete and focus to be set
93+
await act(async () => {
94+
await new Promise((resolve) => setTimeout(resolve, 150));
95+
});
96+
97+
expect(searchInput).toHaveFocus();
98+
});
99+
100+
it("should filter items based on search query", async () => {
101+
renderListPane();
102+
103+
const searchButton = screen.getByRole("button", { name: "Search" });
104+
await act(async () => {
105+
fireEvent.click(searchButton);
106+
});
107+
108+
const searchInput = screen.getByPlaceholderText("Search...");
109+
await act(async () => {
110+
fireEvent.change(searchInput, { target: { value: "Tool" } });
111+
});
112+
113+
expect(screen.getByText("Tool 1")).toBeInTheDocument();
114+
expect(screen.getByText("Tool 2")).toBeInTheDocument();
115+
expect(screen.getByText("Another Tool")).toBeInTheDocument();
116+
117+
await act(async () => {
118+
fireEvent.change(searchInput, { target: { value: "Another" } });
119+
});
120+
121+
expect(screen.queryByText("Tool 1")).not.toBeInTheDocument();
122+
expect(screen.queryByText("Tool 2")).not.toBeInTheDocument();
123+
expect(screen.getByText("Another Tool")).toBeInTheDocument();
124+
});
125+
126+
it("should show 'No items found of matching \"NonExistent\"' when search has no results", async () => {
127+
renderListPane();
128+
129+
const searchButton = screen.getByRole("button", { name: "Search" });
130+
await act(async () => {
131+
fireEvent.click(searchButton);
132+
});
133+
134+
const searchInput = screen.getByPlaceholderText("Search...");
135+
136+
await act(async () => {
137+
fireEvent.change(searchInput, { target: { value: "NonExistent" } });
138+
});
139+
140+
expect(
141+
screen.getByText('No items found matching "NonExistent"'),
142+
).toBeInTheDocument();
143+
expect(screen.queryByText("Tool 1")).not.toBeInTheDocument();
144+
});
145+
146+
it("should collapse search when input is empty and loses focus", async () => {
147+
renderListPane();
148+
149+
const searchButton = screen.getByRole("button", { name: "Search" });
150+
await act(async () => {
151+
fireEvent.click(searchButton);
152+
});
153+
154+
const searchInput = screen.getByPlaceholderText("Search...");
155+
156+
await act(async () => {
157+
fireEvent.change(searchInput, { target: { value: "test" } });
158+
fireEvent.change(searchInput, { target: { value: "" } });
159+
fireEvent.blur(searchInput);
160+
});
161+
162+
const searchButtonAfterCollapse = screen.getByRole("button", {
163+
name: "Search",
164+
});
165+
expect(searchButtonAfterCollapse).toBeInTheDocument();
166+
expect(searchButtonAfterCollapse).not.toHaveClass("opacity-0");
167+
});
168+
169+
it("should keep search expanded when input has content and loses focus", async () => {
170+
renderListPane();
171+
172+
const searchButton = screen.getByRole("button", { name: "Search" });
173+
await act(async () => {
174+
fireEvent.click(searchButton);
175+
});
176+
177+
const searchInput = screen.getByPlaceholderText("Search...");
178+
await act(async () => {
179+
fireEvent.change(searchInput, { target: { value: "test" } });
180+
fireEvent.blur(searchInput);
181+
});
182+
183+
expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
184+
});
185+
186+
it("should search through all item properties (description)", async () => {
187+
renderListPane();
188+
189+
const searchButton = screen.getByRole("button", { name: "Search" });
190+
await act(async () => {
191+
fireEvent.click(searchButton);
192+
});
193+
194+
const searchInput = screen.getByPlaceholderText("Search...");
195+
await act(async () => {
196+
fireEvent.change(searchInput, { target: { value: "First tool" } });
197+
});
198+
199+
expect(screen.getByText("Tool 1")).toBeInTheDocument();
200+
expect(screen.queryByText("Tool 2")).not.toBeInTheDocument();
201+
});
202+
});
203+
});

0 commit comments

Comments
 (0)