diff --git a/ui/jest.setup.ts b/ui/jest.setup.ts index be96e4bc9..e26fda3ba 100644 --- a/ui/jest.setup.ts +++ b/ui/jest.setup.ts @@ -45,20 +45,20 @@ jest.mock('next/router', () => ({ }, })); +const mockNextUseRouter = jest.fn(() => ({ + push: jest.fn(), + replace: jest.fn(), + refresh: jest.fn(), + back: jest.fn(), +})); + +const mockNextUsePathname = jest.fn(() => ''); + +const mockNextUseSearchParams = jest.fn(() => new URLSearchParams()); + // Mock next/navigation jest.mock('next/navigation', () => ({ - useRouter() { - return { - push: jest.fn(), - replace: jest.fn(), - refresh: jest.fn(), - back: jest.fn(), - }; - }, - usePathname() { - return ''; - }, - useSearchParams() { - return new URLSearchParams(); - }, -})); \ No newline at end of file + useRouter: mockNextUseRouter, + usePathname: mockNextUsePathname, + useSearchParams: mockNextUseSearchParams, +})); diff --git a/ui/src/components/AgentList.tsx b/ui/src/components/AgentList.tsx index 45d13786f..8492cc89a 100644 --- a/ui/src/components/AgentList.tsx +++ b/ui/src/components/AgentList.tsx @@ -1,10 +1,11 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { AgentGrid } from "@/components/AgentGrid"; import { AgentListView } from "@/components/AgentListView"; import { Plus, LayoutGrid, List } from "lucide-react"; import KagentLogo from "@/components/kagent-logo"; import Link from "next/link"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { ErrorState } from "./ErrorState"; import { Button } from "./ui/button"; import { LoadingState } from "./LoadingState"; @@ -12,8 +13,11 @@ import { useAgents } from "./AgentsProvider"; import { AppPageFrame } from "@/components/layout/AppPageFrame"; import { PageHeader } from "@/components/layout/PageHeader"; import { cn } from "@/lib/utils"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; const AGENTS_VIEW_KEY = "kagent-agents-view"; +const ALL_NAMESPACES = "__all__"; +const DEFAULT_NAMESPACE = "default"; type AgentsView = "grid" | "list"; function readStoredView(): AgentsView { @@ -25,8 +29,14 @@ function readStoredView(): AgentsView { } export default function AgentList() { - const { agents , loading, error } = useAgents(); + const { agents, loading, error } = useAgents(); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); const [view, setView] = useState("grid"); + const namespaceFilter = searchParams.get("namespace") || ALL_NAMESPACES; + + const normalizeNamespace = useCallback((namespace?: string) => namespace || DEFAULT_NAMESPACE, []); useEffect(() => { const id = requestAnimationFrame(() => { @@ -44,6 +54,41 @@ export default function AgentList() { } }, []); + const onNamespaceChange = useCallback( + (namespace: string) => { + const q = new URLSearchParams(searchParams.toString()); + if (namespace === ALL_NAMESPACES) { + q.delete("namespace"); + } else { + q.set("namespace", namespace); + } + + const query = q.toString(); + router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); + }, + [pathname, router, searchParams], + ); + + const namespaces = useMemo(() => { + const uniqueNamespaces = new Set( + (agents || []).map((item) => normalizeNamespace(item.agent.metadata.namespace)), + ); + + if (namespaceFilter !== ALL_NAMESPACES) { + uniqueNamespaces.add(namespaceFilter); + } + + return Array.from(uniqueNamespaces).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); + }, [agents, namespaceFilter, normalizeNamespace]); + + const filteredAgents = useMemo(() => { + if (!agents || namespaceFilter === ALL_NAMESPACES) { + return agents || []; + } + + return agents.filter((item) => normalizeNamespace(item.agent.metadata.namespace) === namespaceFilter); + }, [agents, namespaceFilter, normalizeNamespace]); + if (error) { return ; } @@ -60,45 +105,65 @@ export default function AgentList() { className="mb-8" end={ agents && agents.length > 0 ? ( -
- - + + +
) : null } @@ -116,10 +181,21 @@ export default function AgentList() { + ) : filteredAgents.length === 0 ? ( +
+ +

No agents in this namespace

+

+ No agents match the {namespaceFilter} namespace filter. +

+ +
) : view === "list" ? ( - + ) : ( - + )} ); diff --git a/ui/src/components/__tests__/AgentList.test.tsx b/ui/src/components/__tests__/AgentList.test.tsx new file mode 100644 index 000000000..713a82e83 --- /dev/null +++ b/ui/src/components/__tests__/AgentList.test.tsx @@ -0,0 +1,114 @@ +import { render, screen } from "@testing-library/react"; +import * as nextNavigation from "next/navigation"; +import AgentList from "@/components/AgentList"; +import { AgentsContext, type AgentsContextType } from "@/components/AgentsProvider"; +import type { Agent, AgentResponse } from "@/types"; + +function createContextValue(agents: AgentResponse[]): AgentsContextType { + return { + agents, + models: [], + loading: false, + error: "", + tools: [], + refreshAgents: async () => {}, + refreshModels: async () => {}, + refreshTools: async () => {}, + createNewAgent: async () => ({ message: "ok", data: {} as Agent }), + updateAgent: async () => ({ message: "ok", data: {} as Agent }), + getAgent: async () => null, + validateAgentData: () => ({}), + }; +} + +const agents: AgentResponse[] = [ + { + id: 1, + agent: { + metadata: { name: "support-bot" }, + spec: { + type: "Declarative", + description: "Answers support questions", + }, + }, + model: "gpt-4o", + modelProvider: "openai", + deploymentReady: true, + accepted: true, + }, + { + id: 2, + agent: { + metadata: { name: "team-analyzer", namespace: "team-a" }, + spec: { + type: "Declarative", + description: "Analyzes incidents", + }, + }, + model: "claude-sonnet", + modelProvider: "anthropic", + deploymentReady: true, + accepted: true, + }, +]; + +describe("AgentList", () => { + const mockUseRouter = jest.mocked(nextNavigation.useRouter); + const mockUsePathname = jest.mocked(nextNavigation.usePathname); + const mockUseSearchParams = jest.mocked(nextNavigation.useSearchParams); + + beforeEach(() => { + mockUseRouter.mockReturnValue({ + push: jest.fn(), + replace: jest.fn(), + refresh: jest.fn(), + back: jest.fn(), + }); + mockUsePathname.mockReturnValue("/agents"); + mockUseSearchParams.mockReturnValue(new URLSearchParams()); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("filters agents by namespace from the URL", () => { + mockUseSearchParams.mockReturnValue(new URLSearchParams("namespace=team-a")); + + render( + + + , + ); + + expect(screen.getByLabelText("Namespace filter")).toBeInTheDocument(); + expect(screen.getByText("team-a/team-analyzer")).toBeInTheDocument(); + expect(screen.queryByText("default/support-bot")).not.toBeInTheDocument(); + }); + + it("treats missing namespaces as default when filtering", () => { + mockUseSearchParams.mockReturnValue(new URLSearchParams("namespace=default")); + + render( + + + , + ); + + expect(screen.getByText("default/support-bot")).toBeInTheDocument(); + expect(screen.queryByText("team-a/team-analyzer")).not.toBeInTheDocument(); + }); + + it("shows a filtered empty state when no agents match the namespace", () => { + mockUseSearchParams.mockReturnValue(new URLSearchParams("namespace=team-b")); + + render( + + + , + ); + + expect(screen.getByText("No agents in this namespace")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Show all namespaces" })).toBeInTheDocument(); + }); +});