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);
+ });
+ },
+ });
+}