Skip to content

Commit 7a5ee1b

Browse files
committed
feat: add empty search state for servers
1 parent eca75ea commit 7a5ee1b

File tree

5 files changed

+193
-29
lines changed

5 files changed

+193
-29
lines changed

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,45 @@ 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+
// Click the "Clear search" button in the empty state (the one with visible text, not the icon button)
122+
const clearButtons = screen.getAllByRole("button", {
123+
name: /clear search/i,
124+
});
125+
// The empty state button has visible text "Clear search", the search input has an icon
126+
const emptyStateClearButton = clearButtons.find(
127+
(btn) => btn.textContent === "Clear search",
128+
);
129+
expect(emptyStateClearButton).toBeDefined();
130+
await user.click(emptyStateClearButton as HTMLElement);
131+
132+
await waitFor(() => {
133+
expect(screen.getByText("aws-nova-canvas")).toBeVisible();
134+
expect(screen.getByText("google-applications")).toBeVisible();
135+
});
136+
137+
expect(searchInput.value).toBe("");
138+
});
139+
107140
it("maintains search when switching view modes", async () => {
108141
const user = userEvent.setup();
109142
renderWithNuqs(<ServersWrapper servers={mockServers} />);

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

Lines changed: 111 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,6 +153,7 @@ describe("Servers", () => {
108153
servers={mockServers}
109154
viewMode="grid"
110155
searchQuery="nonexistent"
156+
onClearSearch={mockOnClearSearch}
111157
/>,
112158
);
113159

@@ -116,11 +162,38 @@ describe("Servers", () => {
116162
screen.getByText(/couldn't find any servers matching "nonexistent"/),
117163
).toBeVisible();
118164
});
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+
});
119185
});
120186

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

125198
expect(screen.getByText("No servers available")).toBeVisible();
126199
expect(
@@ -129,17 +202,44 @@ describe("Servers", () => {
129202
});
130203

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

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

137217
it("displays illustration in empty state", () => {
138218
const { container } = render(
139-
<Servers servers={[]} viewMode="grid" searchQuery="" />,
219+
<Servers
220+
servers={[]}
221+
viewMode="grid"
222+
searchQuery=""
223+
onClearSearch={mockOnClearSearch}
224+
/>,
140225
);
141226

142227
expect(container.querySelector("svg")).toBeInTheDocument();
143228
});
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+
});
144244
});
145245
});

src/app/catalog/components/empty-state.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
1-
import type { ReactNode } from "react";
21
import { IllustrationEmptyInbox } from "@/components/illustrations/illustration-empty-inbox";
32
import { IllustrationNoSearchResults } from "@/components/illustrations/illustration-no-search-results";
3+
import { Button } from "@/components/ui/button";
44

5-
interface EmptyStateProps {
6-
variant: "no-servers" | "no-results";
7-
searchQuery?: string;
8-
actions?: ReactNode;
5+
interface NoServersEmptyStateProps {
6+
variant: "no-servers";
97
}
108

11-
export function EmptyState({ variant, searchQuery, actions }: EmptyStateProps) {
12-
const isNoResults = variant === "no-results";
9+
interface NoResultsEmptyStateProps {
10+
variant: "no-results";
11+
searchQuery: string;
12+
onClearSearch: () => void;
13+
}
14+
15+
type EmptyStateProps = NoServersEmptyStateProps | NoResultsEmptyStateProps;
16+
17+
export function EmptyState(props: EmptyStateProps) {
18+
const isNoResults = props.variant === "no-results";
1319

1420
return (
1521
<div className="flex items-center justify-center py-20">
@@ -26,12 +32,18 @@ export function EmptyState({ variant, searchQuery, actions }: EmptyStateProps) {
2632
</h2>
2733
<p className="text-muted-foreground">
2834
{isNoResults
29-
? `We couldn't find any servers matching "${searchQuery}". Try adjusting your search.`
35+
? `We couldn't find any servers matching "${props.searchQuery}". Try adjusting your search.`
3036
: "There are no MCP servers in the catalog yet. Check back later."}
3137
</p>
3238
</div>
3339

34-
{actions && <div className="flex gap-2 mt-2">{actions}</div>}
40+
{isNoResults && (
41+
<div className="flex gap-2 mt-2">
42+
<Button variant="outline" onClick={props.onClearSearch}>
43+
Clear search
44+
</Button>
45+
</div>
46+
)}
3547
</div>
3648
</div>
3749
);

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: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,18 @@ interface ServersProps {
1111
servers: V0ServerJson[];
1212
viewMode: "grid" | "list";
1313
searchQuery: string;
14+
onClearSearch: () => void;
1415
}
1516

1617
/**
1718
* Client component that displays filtered servers based on view mode and search query
1819
*/
19-
export function Servers({ servers, viewMode, searchQuery }: ServersProps) {
20+
export function Servers({
21+
servers,
22+
viewMode,
23+
searchQuery,
24+
onClearSearch,
25+
}: ServersProps) {
2026
const router = useRouter();
2127

2228
// this will be replace by nuqs later
@@ -42,12 +48,16 @@ export function Servers({ servers, viewMode, searchQuery }: ServersProps) {
4248
};
4349

4450
if (filteredServers.length === 0) {
45-
return (
46-
<EmptyState
47-
variant={searchQuery ? "no-results" : "no-servers"}
48-
searchQuery={searchQuery}
49-
/>
50-
);
51+
if (searchQuery) {
52+
return (
53+
<EmptyState
54+
variant="no-results"
55+
searchQuery={searchQuery}
56+
onClearSearch={onClearSearch}
57+
/>
58+
);
59+
}
60+
return <EmptyState variant="no-servers" />;
5161
}
5262

5363
if (viewMode === "list") {

0 commit comments

Comments
 (0)