diff --git a/src/app/catalog/components/__tests__/servers-wrapper.test.tsx b/src/app/catalog/components/__tests__/servers-wrapper.test.tsx index 996bf51..6a5645d 100644 --- a/src/app/catalog/components/__tests__/servers-wrapper.test.tsx +++ b/src/app/catalog/components/__tests__/servers-wrapper.test.tsx @@ -98,12 +98,43 @@ describe("ServersWrapper", () => { await user.type(searchInput, "nonexistent"); await waitFor(() => { + expect(screen.getByText("No results found")).toBeVisible(); expect( - screen.getByText('No servers found matching "nonexistent"'), + screen.getByText(/couldn't find any servers matching "nonexistent"/), ).toBeVisible(); }); }); + it("clears search when clear button in empty state is clicked", async () => { + const user = userEvent.setup(); + renderWithNuqs(); + + const searchInput = screen.getByPlaceholderText( + "Search", + ) as HTMLInputElement; + await user.type(searchInput, "nonexistent"); + + await waitFor(() => { + expect(screen.getByText("No results found")).toBeVisible(); + }); + + const clearButtons = screen.getAllByRole("button", { + name: /clear search/i, + }); + const emptyStateClearButton = clearButtons.find( + (btn) => btn.textContent === "Clear search", + ); + expect(emptyStateClearButton).toBeDefined(); + await user.click(emptyStateClearButton as HTMLElement); + + await waitFor(() => { + expect(screen.getByText("aws-nova-canvas")).toBeVisible(); + expect(screen.getByText("google-applications")).toBeVisible(); + }); + + expect(searchInput.value).toBe(""); + }); + it("maintains search when switching view modes", async () => { const user = userEvent.setup(); renderWithNuqs(); diff --git a/src/app/catalog/components/__tests__/servers.test.tsx b/src/app/catalog/components/__tests__/servers.test.tsx index 9e6ebbc..a3ed6ed 100644 --- a/src/app/catalog/components/__tests__/servers.test.tsx +++ b/src/app/catalog/components/__tests__/servers.test.tsx @@ -1,8 +1,11 @@ import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; import type { V0ServerJson } from "@/generated/types.gen"; import { Servers } from "../servers"; +const mockOnClearSearch = vi.fn(); + const mockServers: V0ServerJson[] = [ { name: "aws-nova-canvas", @@ -27,7 +30,14 @@ const mockServers: V0ServerJson[] = [ describe("Servers", () => { describe("grid mode", () => { it("displays servers in grid layout", () => { - render(); + render( + , + ); expect(screen.getByText("aws-nova-canvas")).toBeVisible(); expect(screen.getByText("google-applications")).toBeVisible(); @@ -36,7 +46,12 @@ describe("Servers", () => { it("displays grid container", () => { const { container } = render( - , + , ); const grid = container.querySelector(".grid"); @@ -46,7 +61,14 @@ describe("Servers", () => { describe("list mode", () => { it("displays servers in table layout", () => { - render(); + render( + , + ); expect(screen.getByText("aws-nova-canvas")).toBeVisible(); expect(screen.getByText("google-applications")).toBeVisible(); @@ -54,7 +76,14 @@ describe("Servers", () => { }); it("displays table headers", () => { - render(); + render( + , + ); expect(screen.getByText("Server")).toBeVisible(); expect(screen.getByText("About")).toBeVisible(); @@ -64,7 +93,12 @@ describe("Servers", () => { describe("search functionality", () => { it("filters servers by name", () => { render( - , + , ); expect(screen.getByText("aws-nova-canvas")).toBeVisible(); @@ -74,7 +108,12 @@ describe("Servers", () => { it("filters servers by title", () => { render( - , + , ); expect(screen.getByText("google-applications")).toBeVisible(); @@ -87,6 +126,7 @@ describe("Servers", () => { servers={mockServers} viewMode="grid" searchQuery="workspace" + onClearSearch={mockOnClearSearch} />, ); @@ -96,7 +136,12 @@ describe("Servers", () => { it("is case insensitive", () => { render( - , + , ); expect(screen.getByText("aws-nova-canvas")).toBeVisible(); @@ -108,26 +153,93 @@ describe("Servers", () => { servers={mockServers} viewMode="grid" searchQuery="nonexistent" + onClearSearch={mockOnClearSearch} />, ); + expect(screen.getByText("No results found")).toBeVisible(); expect( - screen.getByText('No servers found matching "nonexistent"'), + screen.getByText(/couldn't find any servers matching "nonexistent"/), ).toBeVisible(); }); + + it("shows clear search button when search has no matches", async () => { + const user = userEvent.setup(); + const onClearSearch = vi.fn(); + + render( + , + ); + + const clearButton = screen.getByRole("button", { name: /clear search/i }); + expect(clearButton).toBeVisible(); + + await user.click(clearButton); + expect(onClearSearch).toHaveBeenCalledTimes(1); + }); }); describe("empty state", () => { it("shows no servers message when list is empty", () => { - render(); + render( + , + ); expect(screen.getByText("No servers available")).toBeVisible(); + expect( + screen.getByText(/no MCP servers in the catalog yet/i), + ).toBeVisible(); }); it("shows no servers message in list mode", () => { - render(); + render( + , + ); expect(screen.getByText("No servers available")).toBeVisible(); }); + + it("displays illustration in empty state", () => { + const { container } = render( + , + ); + + expect(container.querySelector("svg")).toBeInTheDocument(); + }); + + it("does not show clear search button when no servers and no search", () => { + render( + , + ); + + expect( + screen.queryByRole("button", { name: /clear search/i }), + ).not.toBeInTheDocument(); + }); }); }); diff --git a/src/app/catalog/components/empty-state.tsx b/src/app/catalog/components/empty-state.tsx new file mode 100644 index 0000000..12df3a4 --- /dev/null +++ b/src/app/catalog/components/empty-state.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; +import { IllustrationEmptyInbox } from "@/components/illustrations/illustration-empty-inbox"; +import { IllustrationNoSearchResults } from "@/components/illustrations/illustration-no-search-results"; + +interface EmptyStateProps { + variant: "no-items" | "no-matching-items"; + title: string; + description: string; + actions?: ReactNode; +} + +export function EmptyState({ + variant, + title, + description, + actions, +}: EmptyStateProps) { + return ( +
+
+ {variant === "no-matching-items" ? ( + + ) : ( + + )} + +
+

{title}

+

{description}

+
+ + {actions} +
+
+ ); +} diff --git a/src/app/catalog/components/servers-wrapper.tsx b/src/app/catalog/components/servers-wrapper.tsx index 45a4a65..e729232 100644 --- a/src/app/catalog/components/servers-wrapper.tsx +++ b/src/app/catalog/components/servers-wrapper.tsx @@ -35,6 +35,10 @@ export function ServersWrapper({ servers }: ServersWrapperProps) { setFilters((prev) => ({ ...prev, search: newSearch })); }; + const handleClearSearch = () => { + setFilters((prev) => ({ ...prev, search: "" })); + }; + return (
@@ -47,7 +51,12 @@ export function ServersWrapper({ servers }: ServersWrapperProps) {
- +
); diff --git a/src/app/catalog/components/servers.tsx b/src/app/catalog/components/servers.tsx index 7521c97..0d53f5b 100644 --- a/src/app/catalog/components/servers.tsx +++ b/src/app/catalog/components/servers.tsx @@ -2,7 +2,9 @@ import { useRouter } from "next/navigation"; import { useMemo } from "react"; +import { Button } from "@/components/ui/button"; import type { V0ServerJson } from "@/generated/types.gen"; +import { EmptyState } from "./empty-state"; import { ServerCard } from "./server-card"; import { ServersTable } from "./servers-table"; @@ -10,12 +12,18 @@ interface ServersProps { servers: V0ServerJson[]; viewMode: "grid" | "list"; searchQuery: string; + onClearSearch: () => void; } /** * Client component that displays filtered servers based on view mode and search query */ -export function Servers({ servers, viewMode, searchQuery }: ServersProps) { +export function Servers({ + servers, + viewMode, + searchQuery, + onClearSearch, +}: ServersProps) { const router = useRouter(); // this will be replace by nuqs later @@ -41,12 +49,26 @@ export function Servers({ servers, viewMode, searchQuery }: ServersProps) { }; if (filteredServers.length === 0) { + if (searchQuery) { + return ( + + Clear search + + } + /> + ); + } return ( -
- {searchQuery - ? `No servers found matching "${searchQuery}"` - : "No servers available"} -
+ ); } diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 03bbe4d..a238271 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -13,13 +13,16 @@ "use server"; -import { headers as nextHeaders } from "next/headers"; +import { cookies, headers as nextHeaders } from "next/headers"; import { redirect } from "next/navigation"; import { createClient, createConfig } from "@/generated/client"; import * as apiServices from "@/generated/sdk.gen"; import { auth } from "./auth/auth"; import { getValidOidcToken } from "./auth/token"; +const MOCK_SCENARIO_COOKIE = "mock-scenario"; +const MOCK_SCENARIO_HEADER = "X-Mock-Scenario"; + // Validate required environment variables at module load time (fail-fast) const API_BASE_URL = process.env.API_BASE_URL; @@ -65,13 +68,24 @@ export async function getAuthenticatedClient(accessToken?: string) { accessToken = token; } + const requestHeaders: Record = { + Authorization: `Bearer ${accessToken}`, + }; + + // Mock scenario header is only used in development for testing different backend states + if (process.env.NODE_ENV === "development") { + const cookieStore = await cookies(); + const mockScenario = cookieStore.get(MOCK_SCENARIO_COOKIE)?.value; + if (mockScenario) { + requestHeaders[MOCK_SCENARIO_HEADER] = mockScenario; + } + } + // Create a new client instance per request to avoid race conditions const authenticatedClient = createClient( createConfig({ baseUrl: API_BASE_URL, - headers: { - Authorization: `Bearer ${accessToken}`, - }, + headers: requestHeaders, }), ); diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 44c296c..5e8553d 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -1,8 +1,19 @@ import type { RequestHandler } from "msw"; +import { HttpResponse } from "msw"; import { autoGeneratedHandlers } from "./mocker"; +import { mockScenario } from "./mockScenario"; import { serverDetailHandlers } from "./server-detail"; +// Scenario handlers (activate via cookie: mock-scenario=) +const scenarioHandlers = [ + mockScenario("empty-servers").get("*/registry/v0.1/servers", () => { + return HttpResponse.json({ servers: [], metadata: { count: 0 } }); + }), +]; + export const handlers: RequestHandler[] = [ + // Scenario handlers must come first (MSW uses first match) + ...scenarioHandlers, ...serverDetailHandlers, ...autoGeneratedHandlers, ]; diff --git a/src/mocks/mockScenario.ts b/src/mocks/mockScenario.ts new file mode 100644 index 0000000..4269c60 --- /dev/null +++ b/src/mocks/mockScenario.ts @@ -0,0 +1,40 @@ +import { type HttpHandler, http } from "msw"; + +type HttpMethod = + | "get" + | "post" + | "put" + | "patch" + | "delete" + | "head" + | "options"; + +const SCENARIO_HEADER = "x-mock-scenario"; + +/** + * Creates scenario-specific mock handlers that only activate when the X-Mock-Scenario header matches. + * The header is set by the API client based on the "mock-scenario" cookie. + */ +export function mockScenario( + scenario: string, +): Record { + return new Proxy({} as Record, { + get(_, method: HttpMethod) { + const httpMethod = http[method]; + if (typeof httpMethod !== "function") return undefined; + + return ( + path: string, + handler: Parameters[1], + ): HttpHandler => + httpMethod(path, (info) => { + const headerValue = info.request.headers.get(SCENARIO_HEADER); + + if (headerValue !== scenario) { + return; + } + return handler(info); + }); + }, + }); +}