Skip to content

Commit 4987525

Browse files
authored
feat: implement empty state for server list (#111)
* chore: add mock for "no servers" example * feat: add empty state for server list when there are 0 servers * feat: add empty search state for servers * remove button concern from empty state component * address ai based feedback * . * . * .
1 parent fec7c24 commit 4987525

File tree

8 files changed

+298
-23
lines changed

8 files changed

+298
-23
lines changed

src/app/catalog/components/__tests__/servers-wrapper.test.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,43 @@ describe("ServersWrapper", () => {
9898
await user.type(searchInput, "nonexistent");
9999

100100
await waitFor(() => {
101+
expect(screen.getByText("No results found")).toBeVisible();
101102
expect(
102-
screen.getByText('No servers found matching "nonexistent"'),
103+
screen.getByText(/couldn't find any servers matching "nonexistent"/),
103104
).toBeVisible();
104105
});
105106
});
106107

108+
it("clears search when clear button in empty state is clicked", async () => {
109+
const user = userEvent.setup();
110+
renderWithNuqs(<ServersWrapper servers={mockServers} />);
111+
112+
const searchInput = screen.getByPlaceholderText(
113+
"Search",
114+
) as HTMLInputElement;
115+
await user.type(searchInput, "nonexistent");
116+
117+
await waitFor(() => {
118+
expect(screen.getByText("No results found")).toBeVisible();
119+
});
120+
121+
const clearButtons = screen.getAllByRole("button", {
122+
name: /clear search/i,
123+
});
124+
const emptyStateClearButton = clearButtons.find(
125+
(btn) => btn.textContent === "Clear search",
126+
);
127+
expect(emptyStateClearButton).toBeDefined();
128+
await user.click(emptyStateClearButton as HTMLElement);
129+
130+
await waitFor(() => {
131+
expect(screen.getByText("aws-nova-canvas")).toBeVisible();
132+
expect(screen.getByText("google-applications")).toBeVisible();
133+
});
134+
135+
expect(searchInput.value).toBe("");
136+
});
137+
107138
it("maintains search when switching view modes", async () => {
108139
const user = userEvent.setup();
109140
renderWithNuqs(<ServersWrapper servers={mockServers} />);

src/app/catalog/components/__tests__/servers.test.tsx

Lines changed: 123 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { render, screen } from "@testing-library/react";
2-
import { describe, expect, it } from "vitest";
2+
import userEvent from "@testing-library/user-event";
3+
import { describe, expect, it, vi } from "vitest";
34
import type { V0ServerJson } from "@/generated/types.gen";
45
import { Servers } from "../servers";
56

7+
const mockOnClearSearch = vi.fn();
8+
69
const mockServers: V0ServerJson[] = [
710
{
811
name: "aws-nova-canvas",
@@ -27,7 +30,14 @@ const mockServers: V0ServerJson[] = [
2730
describe("Servers", () => {
2831
describe("grid mode", () => {
2932
it("displays servers in grid layout", () => {
30-
render(<Servers servers={mockServers} viewMode="grid" searchQuery="" />);
33+
render(
34+
<Servers
35+
servers={mockServers}
36+
viewMode="grid"
37+
searchQuery=""
38+
onClearSearch={mockOnClearSearch}
39+
/>,
40+
);
3141

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

3747
it("displays grid container", () => {
3848
const { container } = render(
39-
<Servers servers={mockServers} viewMode="grid" searchQuery="" />,
49+
<Servers
50+
servers={mockServers}
51+
viewMode="grid"
52+
searchQuery=""
53+
onClearSearch={mockOnClearSearch}
54+
/>,
4055
);
4156

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

4762
describe("list mode", () => {
4863
it("displays servers in table layout", () => {
49-
render(<Servers servers={mockServers} viewMode="list" searchQuery="" />);
64+
render(
65+
<Servers
66+
servers={mockServers}
67+
viewMode="list"
68+
searchQuery=""
69+
onClearSearch={mockOnClearSearch}
70+
/>,
71+
);
5072

5173
expect(screen.getByText("aws-nova-canvas")).toBeVisible();
5274
expect(screen.getByText("google-applications")).toBeVisible();
5375
expect(screen.getByText("azure-mcp")).toBeVisible();
5476
});
5577

5678
it("displays table headers", () => {
57-
render(<Servers servers={mockServers} viewMode="list" searchQuery="" />);
79+
render(
80+
<Servers
81+
servers={mockServers}
82+
viewMode="list"
83+
searchQuery=""
84+
onClearSearch={mockOnClearSearch}
85+
/>,
86+
);
5887

5988
expect(screen.getByText("Server")).toBeVisible();
6089
expect(screen.getByText("About")).toBeVisible();
@@ -64,7 +93,12 @@ describe("Servers", () => {
6493
describe("search functionality", () => {
6594
it("filters servers by name", () => {
6695
render(
67-
<Servers servers={mockServers} viewMode="grid" searchQuery="aws" />,
96+
<Servers
97+
servers={mockServers}
98+
viewMode="grid"
99+
searchQuery="aws"
100+
onClearSearch={mockOnClearSearch}
101+
/>,
68102
);
69103

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

75109
it("filters servers by title", () => {
76110
render(
77-
<Servers servers={mockServers} viewMode="grid" searchQuery="google" />,
111+
<Servers
112+
servers={mockServers}
113+
viewMode="grid"
114+
searchQuery="google"
115+
onClearSearch={mockOnClearSearch}
116+
/>,
78117
);
79118

80119
expect(screen.getByText("google-applications")).toBeVisible();
@@ -87,6 +126,7 @@ describe("Servers", () => {
87126
servers={mockServers}
88127
viewMode="grid"
89128
searchQuery="workspace"
129+
onClearSearch={mockOnClearSearch}
90130
/>,
91131
);
92132

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

97137
it("is case insensitive", () => {
98138
render(
99-
<Servers servers={mockServers} viewMode="grid" searchQuery="AWS" />,
139+
<Servers
140+
servers={mockServers}
141+
viewMode="grid"
142+
searchQuery="AWS"
143+
onClearSearch={mockOnClearSearch}
144+
/>,
100145
);
101146

102147
expect(screen.getByText("aws-nova-canvas")).toBeVisible();
@@ -108,26 +153,93 @@ describe("Servers", () => {
108153
servers={mockServers}
109154
viewMode="grid"
110155
searchQuery="nonexistent"
156+
onClearSearch={mockOnClearSearch}
111157
/>,
112158
);
113159

160+
expect(screen.getByText("No results found")).toBeVisible();
114161
expect(
115-
screen.getByText('No servers found matching "nonexistent"'),
162+
screen.getByText(/couldn't find any servers matching "nonexistent"/),
116163
).toBeVisible();
117164
});
165+
166+
it("shows clear search button when search has no matches", async () => {
167+
const user = userEvent.setup();
168+
const onClearSearch = vi.fn();
169+
170+
render(
171+
<Servers
172+
servers={mockServers}
173+
viewMode="grid"
174+
searchQuery="nonexistent"
175+
onClearSearch={onClearSearch}
176+
/>,
177+
);
178+
179+
const clearButton = screen.getByRole("button", { name: /clear search/i });
180+
expect(clearButton).toBeVisible();
181+
182+
await user.click(clearButton);
183+
expect(onClearSearch).toHaveBeenCalledTimes(1);
184+
});
118185
});
119186

120187
describe("empty state", () => {
121188
it("shows no servers message when list is empty", () => {
122-
render(<Servers servers={[]} viewMode="grid" searchQuery="" />);
189+
render(
190+
<Servers
191+
servers={[]}
192+
viewMode="grid"
193+
searchQuery=""
194+
onClearSearch={mockOnClearSearch}
195+
/>,
196+
);
123197

124198
expect(screen.getByText("No servers available")).toBeVisible();
199+
expect(
200+
screen.getByText(/no MCP servers in the catalog yet/i),
201+
).toBeVisible();
125202
});
126203

127204
it("shows no servers message in list mode", () => {
128-
render(<Servers servers={[]} viewMode="list" searchQuery="" />);
205+
render(
206+
<Servers
207+
servers={[]}
208+
viewMode="list"
209+
searchQuery=""
210+
onClearSearch={mockOnClearSearch}
211+
/>,
212+
);
129213

130214
expect(screen.getByText("No servers available")).toBeVisible();
131215
});
216+
217+
it("displays illustration in empty state", () => {
218+
const { container } = render(
219+
<Servers
220+
servers={[]}
221+
viewMode="grid"
222+
searchQuery=""
223+
onClearSearch={mockOnClearSearch}
224+
/>,
225+
);
226+
227+
expect(container.querySelector("svg")).toBeInTheDocument();
228+
});
229+
230+
it("does not show clear search button when no servers and no search", () => {
231+
render(
232+
<Servers
233+
servers={[]}
234+
viewMode="grid"
235+
searchQuery=""
236+
onClearSearch={mockOnClearSearch}
237+
/>,
238+
);
239+
240+
expect(
241+
screen.queryByRole("button", { name: /clear search/i }),
242+
).not.toBeInTheDocument();
243+
});
132244
});
133245
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { ReactNode } from "react";
2+
import { IllustrationEmptyInbox } from "@/components/illustrations/illustration-empty-inbox";
3+
import { IllustrationNoSearchResults } from "@/components/illustrations/illustration-no-search-results";
4+
5+
interface EmptyStateProps {
6+
variant: "no-items" | "no-matching-items";
7+
title: string;
8+
description: string;
9+
actions?: ReactNode;
10+
}
11+
12+
export function EmptyState({
13+
variant,
14+
title,
15+
description,
16+
actions,
17+
}: EmptyStateProps) {
18+
return (
19+
<div className="flex items-center justify-center py-20">
20+
<div className="flex flex-col items-center text-center gap-4 max-w-md">
21+
{variant === "no-matching-items" ? (
22+
<IllustrationNoSearchResults className="size-32" />
23+
) : (
24+
<IllustrationEmptyInbox className="size-32" />
25+
)}
26+
27+
<div className="space-y-2">
28+
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
29+
<p className="text-muted-foreground">{description}</p>
30+
</div>
31+
32+
{actions}
33+
</div>
34+
</div>
35+
);
36+
}

src/app/catalog/components/servers-wrapper.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export function ServersWrapper({ servers }: ServersWrapperProps) {
3535
setFilters((prev) => ({ ...prev, search: newSearch }));
3636
};
3737

38+
const handleClearSearch = () => {
39+
setFilters((prev) => ({ ...prev, search: "" }));
40+
};
41+
3842
return (
3943
<div className="flex flex-col h-full">
4044
<PageHeader title="MCP Server Catalog">
@@ -47,7 +51,12 @@ export function ServersWrapper({ servers }: ServersWrapperProps) {
4751
</PageHeader>
4852

4953
<div className="flex-1 overflow-auto">
50-
<Servers servers={servers} viewMode={viewMode} searchQuery={search} />
54+
<Servers
55+
servers={servers}
56+
viewMode={viewMode}
57+
searchQuery={search}
58+
onClearSearch={handleClearSearch}
59+
/>
5160
</div>
5261
</div>
5362
);

src/app/catalog/components/servers.tsx

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,28 @@
22

33
import { useRouter } from "next/navigation";
44
import { useMemo } from "react";
5+
import { Button } from "@/components/ui/button";
56
import type { V0ServerJson } from "@/generated/types.gen";
7+
import { EmptyState } from "./empty-state";
68
import { ServerCard } from "./server-card";
79
import { ServersTable } from "./servers-table";
810

911
interface ServersProps {
1012
servers: V0ServerJson[];
1113
viewMode: "grid" | "list";
1214
searchQuery: string;
15+
onClearSearch: () => void;
1316
}
1417

1518
/**
1619
* Client component that displays filtered servers based on view mode and search query
1720
*/
18-
export function Servers({ servers, viewMode, searchQuery }: ServersProps) {
21+
export function Servers({
22+
servers,
23+
viewMode,
24+
searchQuery,
25+
onClearSearch,
26+
}: ServersProps) {
1927
const router = useRouter();
2028

2129
// this will be replace by nuqs later
@@ -41,12 +49,26 @@ export function Servers({ servers, viewMode, searchQuery }: ServersProps) {
4149
};
4250

4351
if (filteredServers.length === 0) {
52+
if (searchQuery) {
53+
return (
54+
<EmptyState
55+
variant="no-matching-items"
56+
title="No results found"
57+
description={`We couldn't find any servers matching "${searchQuery}". Try adjusting your search.`}
58+
actions={
59+
<Button variant="outline" onClick={onClearSearch}>
60+
Clear search
61+
</Button>
62+
}
63+
/>
64+
);
65+
}
4466
return (
45-
<div className="p-12 text-center">
46-
{searchQuery
47-
? `No servers found matching "${searchQuery}"`
48-
: "No servers available"}
49-
</div>
67+
<EmptyState
68+
variant="no-items"
69+
title="No servers available"
70+
description="There are no MCP servers in the catalog yet. Check back later."
71+
/>
5072
);
5173
}
5274

0 commit comments

Comments
 (0)