From fbce75aba0eefcb4033176b19fc322dfc91d9702 Mon Sep 17 00:00:00 2001 From: Cameron Roberts Date: Sun, 17 Aug 2025 23:10:35 +0100 Subject: [PATCH 1/4] feat: Add search functionality to the ListPane component which exposes it for - Resources - Prompts - Tools --- client/src/components/ListPane.tsx | 137 ++++++++++++++++++++++------- 1 file changed, 104 insertions(+), 33 deletions(-) diff --git a/client/src/components/ListPane.tsx b/client/src/components/ListPane.tsx index 81cc196de..296fb7531 100644 --- a/client/src/components/ListPane.tsx +++ b/client/src/components/ListPane.tsx @@ -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 = { items: T[]; @@ -20,41 +23,109 @@ const ListPane = ({ title, buttonText, isButtonDisabled, -}: ListPaneProps) => ( -
-
-

{title}

-
-
- - -
- {items.map((item, index) => ( -
setSelectedItem(item)} - > - {renderItem(item)} +}: ListPaneProps) => { + const [searchQuery, setSearchQuery] = useState(""); + const [isSearchExpanded, setIsSearchExpanded] = useState(false); + const searchInputRef = useRef(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 ( +
+
+
+

{title}

+
+ + +
+
+ + setSearchQuery(e.target.value)} + onBlur={handleSearchBlur} + className="pl-10 w-full transition-all duration-300 ease-in-out" + /> +
+
- ))} +
+
+
+ + +
+ {filteredItems.map((item, index) => ( +
setSelectedItem(item)} + > + {renderItem(item)} +
+ ))} + {filteredItems.length === 0 && searchQuery && items.length > 0 && ( +
+ No items found matching "{searchQuery}" +
+ )} +
-
-); + ); +}; export default ListPane; From a733b239a6875a18a2759f8997d0579123c52816 Mon Sep 17 00:00:00 2001 From: Cameron Roberts Date: Sun, 17 Aug 2025 23:13:00 +0100 Subject: [PATCH 2/4] feat: Add UT for the added search functionality to the ListPane component --- .../components/__tests__/ListPane.test.tsx | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 client/src/components/__tests__/ListPane.test.tsx diff --git a/client/src/components/__tests__/ListPane.test.tsx b/client/src/components/__tests__/ListPane.test.tsx new file mode 100644 index 000000000..672bad042 --- /dev/null +++ b/client/src/components/__tests__/ListPane.test.tsx @@ -0,0 +1,205 @@ +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]) =>
{item.name}
, + title: "List tools", + buttonText: "Load Tools", + }; + + const renderListPane = (props = {}) => { + return render(); + }; + + 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]) => ( +
+ {item.name} + {item.description} +
+ ); + + 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: "" }); + 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: "" }); + 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: "" }); + 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: "" }); + 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: "" }); + 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); + }); + + // The search input is hidden with CSS but still in the DOM + // We should check that the search button is visible again + const searchButtonAfterCollapse = screen.getByRole("button", { + name: "", + }); + 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: "" }); + 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: "" }); + 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(); + }); + }); +}); From c25f84e6bbc175cd8d4d875fc6d5ab4299a4bc4d Mon Sep 17 00:00:00 2001 From: Cameron Roberts Date: Sun, 17 Aug 2025 23:46:16 +0100 Subject: [PATCH 3/4] feat: adjust UT to have better test case --- client/src/components/ListPane.tsx | 3 +++ .../src/components/__tests__/ListPane.test.tsx | 16 ++++++++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/client/src/components/ListPane.tsx b/client/src/components/ListPane.tsx index 296fb7531..09c9f57da 100644 --- a/client/src/components/ListPane.tsx +++ b/client/src/components/ListPane.tsx @@ -57,6 +57,8 @@ const ListPane = ({

{title}