Skip to content

feat: Add search capability to the ListPane component to allow users to filter resources, prompts and tools #727

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
140 changes: 107 additions & 33 deletions client/src/components/ListPane.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { Search } from "lucide-react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { useState, useMemo, useRef } from "react";

type ListPaneProps<T> = {
items: T[];
Expand All @@ -20,41 +23,112 @@ const ListPane = <T extends object>({
title,
buttonText,
isButtonDisabled,
}: ListPaneProps<T>) => (
<div className="bg-card border border-border rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-border">
<h3 className="font-semibold dark:text-white">{title}</h3>
</div>
<div className="p-4">
<Button
variant="outline"
className="w-full mb-4"
onClick={listItems}
disabled={isButtonDisabled}
>
{buttonText}
</Button>
<Button
variant="outline"
className="w-full mb-4"
onClick={clearItems}
disabled={items.length === 0}
>
Clear
</Button>
<div className="space-y-2 overflow-y-auto max-h-96">
{items.map((item, index) => (
<div
key={index}
className="flex items-center py-2 px-4 rounded hover:bg-gray-50 dark:hover:bg-secondary cursor-pointer"
onClick={() => setSelectedItem(item)}
>
{renderItem(item)}
}: ListPaneProps<T>) => {
const [searchQuery, setSearchQuery] = useState("");
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);

const filteredItems = useMemo(() => {
if (!searchQuery.trim()) return items;

return items.filter((item) => {
const searchableText = JSON.stringify(item).toLowerCase();
return searchableText.includes(searchQuery.toLowerCase());
});
}, [items, searchQuery]);

const handleSearchClick = () => {
setIsSearchExpanded(true);
setTimeout(() => {
searchInputRef.current?.focus();
}, 100);
};

const handleSearchBlur = () => {
if (!searchQuery.trim()) {
setIsSearchExpanded(false);
}
};

return (
<div className="bg-card border border-border rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-border">
<div className="flex items-center justify-between">
<h3 className="font-semibold dark:text-white">{title}</h3>
<div className="relative flex items-center">
<button
name="search"
aria-label="Search"
onClick={handleSearchClick}
className={`p-2 hover:bg-gray-100 dark:hover:bg-secondary rounded-md transition-all duration-300 ease-in-out ${
isSearchExpanded
? "opacity-0 scale-75 pointer-events-none"
: "opacity-100 scale-100"
}`}
>
<Search className="w-4 h-4 text-muted-foreground" />
</button>

<div
className={`absolute right-0 transition-all duration-300 ease-in-out ${
isSearchExpanded
? "opacity-100 translate-x-0 w-96"
: "opacity-0 translate-x-4 w-0"
}`}
>
<div className="flex items-center">
<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" />
<Input
ref={searchInputRef}
name="search"
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onBlur={handleSearchBlur}
className="pl-10 w-full transition-all duration-300 ease-in-out"
/>
</div>
</div>
</div>
))}
</div>
</div>
<div className="p-4">
<Button
variant="outline"
className="w-full mb-4"
onClick={listItems}
disabled={isButtonDisabled}
>
{buttonText}
</Button>
<Button
variant="outline"
className="w-full mb-4"
onClick={clearItems}
disabled={items.length === 0}
>
Clear
</Button>
<div className="space-y-2 overflow-y-auto max-h-96">
{filteredItems.map((item, index) => (
<div
key={index}
className="flex items-center py-2 px-4 rounded hover:bg-gray-50 dark:hover:bg-secondary cursor-pointer"
onClick={() => setSelectedItem(item)}
>
{renderItem(item)}
</div>
))}
{filteredItems.length === 0 && searchQuery && items.length > 0 && (
<div className="text-center py-4 text-muted-foreground">
No items found matching &quot;{searchQuery}&quot;
</div>
)}
</div>
</div>
</div>
</div>
);
);
};

export default ListPane;
203 changes: 203 additions & 0 deletions client/src/components/__tests__/ListPane.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { render, screen, fireEvent, act } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, beforeEach, jest } from "@jest/globals";
import ListPane from "../ListPane";

describe("ListPane", () => {
const mockItems = [
{ id: 1, name: "Tool 1", description: "First tool" },
{ id: 2, name: "Tool 2", description: "Second tool" },
{ id: 3, name: "Another Tool", description: "Third tool" },
];

const defaultProps = {
items: mockItems,
listItems: jest.fn(),
clearItems: jest.fn(),
setSelectedItem: jest.fn(),
renderItem: (item: (typeof mockItems)[0]) => <div>{item.name}</div>,
title: "List tools",
buttonText: "Load Tools",
};

const renderListPane = (props = {}) => {
return render(<ListPane {...defaultProps} {...props} />);
};

beforeEach(() => {
jest.clearAllMocks();
});

describe("Rendering", () => {
it("should render with title and button", () => {
renderListPane();

expect(screen.getByText("List tools")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Load Tools" }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
});

it("should render items when provided", () => {
renderListPane();

expect(screen.getByText("Tool 1")).toBeInTheDocument();
expect(screen.getByText("Tool 2")).toBeInTheDocument();
expect(screen.getByText("Another Tool")).toBeInTheDocument();
});

it("should render empty state when no items", () => {
renderListPane({ items: [] });

expect(screen.queryByText("Tool 1")).not.toBeInTheDocument();
expect(screen.queryByText("Tool 2")).not.toBeInTheDocument();
});

it("should render custom item content", () => {
const customRenderItem = (item: (typeof mockItems)[0]) => (
<div>
<span>{item.name}</span>
<small>{item.description}</small>
</div>
);

renderListPane({ renderItem: customRenderItem });

expect(screen.getByText("Tool 1")).toBeInTheDocument();
expect(screen.getByText("First tool")).toBeInTheDocument();
});
});

describe("Search Functionality", () => {
it("should show search icon initially", () => {
renderListPane();

const searchButton = screen.getByRole("button", { name: "Search" });
expect(searchButton).toBeInTheDocument();
expect(searchButton.querySelector("svg")).toBeInTheDocument();
});

it("should expand search input when search icon is clicked", async () => {
renderListPane();

const searchButton = screen.getByRole("button", { name: "Search" });
await act(async () => {
fireEvent.click(searchButton);
});

const searchInput = screen.getByPlaceholderText("Search...");
expect(searchInput).toBeInTheDocument();

// Wait for the setTimeout to complete and focus to be set
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});

expect(searchInput).toHaveFocus();
});

it("should filter items based on search query", async () => {
renderListPane();

const searchButton = screen.getByRole("button", { name: "Search" });
await act(async () => {
fireEvent.click(searchButton);
});

const searchInput = screen.getByPlaceholderText("Search...");
await act(async () => {
fireEvent.change(searchInput, { target: { value: "Tool" } });
});

expect(screen.getByText("Tool 1")).toBeInTheDocument();
expect(screen.getByText("Tool 2")).toBeInTheDocument();
expect(screen.getByText("Another Tool")).toBeInTheDocument();

await act(async () => {
fireEvent.change(searchInput, { target: { value: "Another" } });
});

expect(screen.queryByText("Tool 1")).not.toBeInTheDocument();
expect(screen.queryByText("Tool 2")).not.toBeInTheDocument();
expect(screen.getByText("Another Tool")).toBeInTheDocument();
});

it("should show 'No items found of matching \"NonExistent\"' when search has no results", async () => {
renderListPane();

const searchButton = screen.getByRole("button", { name: "Search" });
await act(async () => {
fireEvent.click(searchButton);
});

const searchInput = screen.getByPlaceholderText("Search...");

await act(async () => {
fireEvent.change(searchInput, { target: { value: "NonExistent" } });
});

expect(
screen.getByText('No items found matching "NonExistent"'),
).toBeInTheDocument();
expect(screen.queryByText("Tool 1")).not.toBeInTheDocument();
});

it("should collapse search when input is empty and loses focus", async () => {
renderListPane();

const searchButton = screen.getByRole("button", { name: "Search" });
await act(async () => {
fireEvent.click(searchButton);
});

const searchInput = screen.getByPlaceholderText("Search...");

await act(async () => {
fireEvent.change(searchInput, { target: { value: "test" } });
fireEvent.change(searchInput, { target: { value: "" } });
fireEvent.blur(searchInput);
});

const searchButtonAfterCollapse = screen.getByRole("button", {
name: "Search",
});
expect(searchButtonAfterCollapse).toBeInTheDocument();
expect(searchButtonAfterCollapse).not.toHaveClass("opacity-0");
});

it("should keep search expanded when input has content and loses focus", async () => {
renderListPane();

const searchButton = screen.getByRole("button", { name: "Search" });
await act(async () => {
fireEvent.click(searchButton);
});

const searchInput = screen.getByPlaceholderText("Search...");
await act(async () => {
fireEvent.change(searchInput, { target: { value: "test" } });
fireEvent.blur(searchInput);
});

expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
});

it("should search through all item properties (description)", async () => {
renderListPane();

const searchButton = screen.getByRole("button", { name: "Search" });
await act(async () => {
fireEvent.click(searchButton);
});

const searchInput = screen.getByPlaceholderText("Search...");
await act(async () => {
fireEvent.change(searchInput, { target: { value: "First tool" } });
});

expect(screen.getByText("Tool 1")).toBeInTheDocument();
expect(screen.queryByText("Tool 2")).not.toBeInTheDocument();
});
});
});