Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion src/app/catalog/components/__tests__/servers-wrapper.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ServersWrapper servers={mockServers} />);

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(<ServersWrapper servers={mockServers} />);
Expand Down
134 changes: 123 additions & 11 deletions src/app/catalog/components/__tests__/servers.test.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -27,7 +30,14 @@ const mockServers: V0ServerJson[] = [
describe("Servers", () => {
describe("grid mode", () => {
it("displays servers in grid layout", () => {
render(<Servers servers={mockServers} viewMode="grid" searchQuery="" />);
render(
<Servers
servers={mockServers}
viewMode="grid"
searchQuery=""
onClearSearch={mockOnClearSearch}
/>,
);

expect(screen.getByText("aws-nova-canvas")).toBeVisible();
expect(screen.getByText("google-applications")).toBeVisible();
Expand All @@ -36,7 +46,12 @@ describe("Servers", () => {

it("displays grid container", () => {
const { container } = render(
<Servers servers={mockServers} viewMode="grid" searchQuery="" />,
<Servers
servers={mockServers}
viewMode="grid"
searchQuery=""
onClearSearch={mockOnClearSearch}
/>,
);

const grid = container.querySelector(".grid");
Expand All @@ -46,15 +61,29 @@ describe("Servers", () => {

describe("list mode", () => {
it("displays servers in table layout", () => {
render(<Servers servers={mockServers} viewMode="list" searchQuery="" />);
render(
<Servers
servers={mockServers}
viewMode="list"
searchQuery=""
onClearSearch={mockOnClearSearch}
/>,
);

expect(screen.getByText("aws-nova-canvas")).toBeVisible();
expect(screen.getByText("google-applications")).toBeVisible();
expect(screen.getByText("azure-mcp")).toBeVisible();
});

it("displays table headers", () => {
render(<Servers servers={mockServers} viewMode="list" searchQuery="" />);
render(
<Servers
servers={mockServers}
viewMode="list"
searchQuery=""
onClearSearch={mockOnClearSearch}
/>,
);

expect(screen.getByText("Server")).toBeVisible();
expect(screen.getByText("About")).toBeVisible();
Expand All @@ -64,7 +93,12 @@ describe("Servers", () => {
describe("search functionality", () => {
it("filters servers by name", () => {
render(
<Servers servers={mockServers} viewMode="grid" searchQuery="aws" />,
<Servers
servers={mockServers}
viewMode="grid"
searchQuery="aws"
onClearSearch={mockOnClearSearch}
/>,
);

expect(screen.getByText("aws-nova-canvas")).toBeVisible();
Expand All @@ -74,7 +108,12 @@ describe("Servers", () => {

it("filters servers by title", () => {
render(
<Servers servers={mockServers} viewMode="grid" searchQuery="google" />,
<Servers
servers={mockServers}
viewMode="grid"
searchQuery="google"
onClearSearch={mockOnClearSearch}
/>,
);

expect(screen.getByText("google-applications")).toBeVisible();
Expand All @@ -87,6 +126,7 @@ describe("Servers", () => {
servers={mockServers}
viewMode="grid"
searchQuery="workspace"
onClearSearch={mockOnClearSearch}
/>,
);

Expand All @@ -96,7 +136,12 @@ describe("Servers", () => {

it("is case insensitive", () => {
render(
<Servers servers={mockServers} viewMode="grid" searchQuery="AWS" />,
<Servers
servers={mockServers}
viewMode="grid"
searchQuery="AWS"
onClearSearch={mockOnClearSearch}
/>,
);

expect(screen.getByText("aws-nova-canvas")).toBeVisible();
Expand All @@ -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(
<Servers
servers={mockServers}
viewMode="grid"
searchQuery="nonexistent"
onClearSearch={onClearSearch}
/>,
);

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(<Servers servers={[]} viewMode="grid" searchQuery="" />);
render(
<Servers
servers={[]}
viewMode="grid"
searchQuery=""
onClearSearch={mockOnClearSearch}
/>,
);

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(<Servers servers={[]} viewMode="list" searchQuery="" />);
render(
<Servers
servers={[]}
viewMode="list"
searchQuery=""
onClearSearch={mockOnClearSearch}
/>,
);

expect(screen.getByText("No servers available")).toBeVisible();
});

it("displays illustration in empty state", () => {
const { container } = render(
<Servers
servers={[]}
viewMode="grid"
searchQuery=""
onClearSearch={mockOnClearSearch}
/>,
);

expect(container.querySelector("svg")).toBeInTheDocument();
});

it("does not show clear search button when no servers and no search", () => {
render(
<Servers
servers={[]}
viewMode="grid"
searchQuery=""
onClearSearch={mockOnClearSearch}
/>,
);

expect(
screen.queryByRole("button", { name: /clear search/i }),
).not.toBeInTheDocument();
});
});
});
36 changes: 36 additions & 0 deletions src/app/catalog/components/empty-state.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center justify-center py-20">
<div className="flex flex-col items-center text-center gap-4 max-w-md">
{variant === "no-matching-items" ? (
<IllustrationNoSearchResults className="size-32" />
) : (
<IllustrationEmptyInbox className="size-32" />
)}

<div className="space-y-2">
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
<p className="text-muted-foreground">{description}</p>
</div>

{actions}
</div>
</div>
);
}
11 changes: 10 additions & 1 deletion src/app/catalog/components/servers-wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ export function ServersWrapper({ servers }: ServersWrapperProps) {
setFilters((prev) => ({ ...prev, search: newSearch }));
};

const handleClearSearch = () => {
setFilters((prev) => ({ ...prev, search: "" }));
};

return (
<div className="flex flex-col h-full">
<PageHeader title="MCP Server Catalog">
Expand All @@ -47,7 +51,12 @@ export function ServersWrapper({ servers }: ServersWrapperProps) {
</PageHeader>

<div className="flex-1 overflow-auto">
<Servers servers={servers} viewMode={viewMode} searchQuery={search} />
<Servers
servers={servers}
viewMode={viewMode}
searchQuery={search}
onClearSearch={handleClearSearch}
/>
</div>
</div>
);
Expand Down
34 changes: 28 additions & 6 deletions src/app/catalog/components/servers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,28 @@

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";

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
Expand All @@ -41,12 +49,26 @@ export function Servers({ servers, viewMode, searchQuery }: ServersProps) {
};

if (filteredServers.length === 0) {
if (searchQuery) {
return (
<EmptyState
variant="no-matching-items"
title="No results found"
description={`We couldn't find any servers matching "${searchQuery}". Try adjusting your search.`}
actions={
<Button variant="outline" onClick={onClearSearch}>
Clear search
</Button>
}
/>
);
}
return (
<div className="p-12 text-center">
{searchQuery
? `No servers found matching "${searchQuery}"`
: "No servers available"}
</div>
<EmptyState
variant="no-items"
title="No servers available"
description="There are no MCP servers in the catalog yet. Check back later."
/>
);
}

Expand Down
Loading