Skip to content

Commit ba60fc9

Browse files
committed
test(ui-react): add container feature tests
1 parent 1d47eda commit ba60fc9

3 files changed

Lines changed: 1255 additions & 0 deletions

File tree

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { render, screen, waitFor } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
import { fireEvent } from "@testing-library/react";
5+
6+
// ── Module mocks ──────────────────────────────────────────────────────────────
7+
8+
vi.mock("@/hooks/useTags", () => ({
9+
useTags: vi.fn(),
10+
}));
11+
12+
// useEscapeKey attaches a keydown listener to document; keep the real impl.
13+
// The popover uses createPortal — RTL queries document.body, so portal
14+
// content is reachable without any mock.
15+
16+
// ── Imports (after mocks) ─────────────────────────────────────────────────────
17+
18+
import { useTags } from "@/hooks/useTags";
19+
import TagFilterDropdown from "../TagFilterDropdown";
20+
21+
// ── Helpers ───────────────────────────────────────────────────────────────────
22+
23+
function defaultTagObjects(names: string[]) {
24+
return names.map((name) => ({ name }));
25+
}
26+
27+
beforeEach(() => {
28+
vi.clearAllMocks();
29+
vi.mocked(useTags).mockReturnValue({
30+
tags: defaultTagObjects(["alpha", "beta", "gamma"]),
31+
totalCount: 3,
32+
isLoading: false,
33+
error: null,
34+
} as never);
35+
});
36+
37+
function renderDropdown(
38+
overrides: Partial<{
39+
filterTags: string[];
40+
onAdd: (tag: string) => void;
41+
onRemove: (tag: string) => void;
42+
onClearAll: () => void;
43+
onManageTags: (() => void) | undefined;
44+
}> = {},
45+
) {
46+
const defaults = {
47+
filterTags: [],
48+
onAdd: vi.fn(),
49+
onRemove: vi.fn(),
50+
onClearAll: vi.fn(),
51+
onManageTags: undefined,
52+
};
53+
const props = { ...defaults, ...overrides };
54+
return {
55+
onAdd: props.onAdd,
56+
onRemove: props.onRemove,
57+
onClearAll: props.onClearAll,
58+
onManageTags: props.onManageTags,
59+
...render(<TagFilterDropdown {...props} />),
60+
};
61+
}
62+
63+
// ── Tests ─────────────────────────────────────────────────────────────────────
64+
65+
describe("TagFilterDropdown", () => {
66+
describe("trigger button", () => {
67+
it("renders the Tags trigger button", () => {
68+
renderDropdown();
69+
expect(screen.getByRole("button", { name: /tags/i })).toBeInTheDocument();
70+
});
71+
72+
it("does not show a count badge when no tags are active", () => {
73+
renderDropdown({ filterTags: [] });
74+
// The badge would show a number; no digits should be in the button text
75+
const btn = screen.getByRole("button", { name: /tags/i });
76+
expect(btn.textContent?.match(/\d/)).toBeNull();
77+
});
78+
79+
it("shows a count badge with the number of active filter tags", () => {
80+
renderDropdown({ filterTags: ["alpha", "beta"] });
81+
// The badge renders the count as text inside the button
82+
const btn = screen.getByRole("button", { name: /tags/i });
83+
expect(btn.textContent).toContain("2");
84+
});
85+
86+
it("shows badge with count 1 when one tag is active", () => {
87+
renderDropdown({ filterTags: ["alpha"] });
88+
const btn = screen.getByRole("button", { name: /tags/i });
89+
expect(btn.textContent).toContain("1");
90+
});
91+
});
92+
93+
describe("opening the popover", () => {
94+
it("opens the popover when the trigger is clicked", async () => {
95+
renderDropdown();
96+
await userEvent.click(screen.getByRole("button", { name: /tags/i }));
97+
expect(screen.getByPlaceholderText("Search tags...")).toBeInTheDocument();
98+
});
99+
100+
it("renders all available tags in the list when opened", async () => {
101+
renderDropdown();
102+
await userEvent.click(screen.getByRole("button", { name: /tags/i }));
103+
expect(
104+
screen.getByRole("button", { name: /^alpha$/i }),
105+
).toBeInTheDocument();
106+
expect(
107+
screen.getByRole("button", { name: /^beta$/i }),
108+
).toBeInTheDocument();
109+
expect(
110+
screen.getByRole("button", { name: /^gamma$/i }),
111+
).toBeInTheDocument();
112+
});
113+
114+
it("clears the search state when reopened", async () => {
115+
renderDropdown();
116+
const trigger = screen.getByRole("button", { name: /tags/i });
117+
118+
// Open and type
119+
await userEvent.click(trigger);
120+
const input = screen.getByPlaceholderText("Search tags...");
121+
await userEvent.type(input, "alp");
122+
expect(input).toHaveValue("alp");
123+
124+
// Close by clicking trigger again
125+
await userEvent.click(trigger);
126+
// Reopen
127+
await userEvent.click(trigger);
128+
129+
// Search should be cleared
130+
expect(screen.getByPlaceholderText("Search tags...")).toHaveValue("");
131+
});
132+
});
133+
134+
describe("closing the popover", () => {
135+
it("closes the popover on Escape key", async () => {
136+
renderDropdown();
137+
await userEvent.click(screen.getByRole("button", { name: /tags/i }));
138+
expect(screen.getByPlaceholderText("Search tags...")).toBeInTheDocument();
139+
140+
fireEvent.keyDown(document, { key: "Escape" });
141+
142+
await waitFor(() => {
143+
expect(
144+
screen.queryByPlaceholderText("Search tags..."),
145+
).not.toBeInTheDocument();
146+
});
147+
});
148+
149+
it("toggles closed when trigger is clicked while open", async () => {
150+
renderDropdown();
151+
const trigger = screen.getByRole("button", { name: /tags/i });
152+
153+
await userEvent.click(trigger);
154+
expect(screen.getByPlaceholderText("Search tags...")).toBeInTheDocument();
155+
156+
await userEvent.click(trigger);
157+
expect(
158+
screen.queryByPlaceholderText("Search tags..."),
159+
).not.toBeInTheDocument();
160+
});
161+
});
162+
163+
describe("search filtering", () => {
164+
it("filters the tag list as the user types in the search input", async () => {
165+
renderDropdown();
166+
await userEvent.click(screen.getByRole("button", { name: /tags/i }));
167+
168+
await userEvent.type(
169+
screen.getByPlaceholderText("Search tags..."),
170+
"alp",
171+
);
172+
173+
expect(
174+
screen.getByRole("button", { name: /^alpha$/i }),
175+
).toBeInTheDocument();
176+
expect(
177+
screen.queryByRole("button", { name: /^beta$/i }),
178+
).not.toBeInTheDocument();
179+
expect(
180+
screen.queryByRole("button", { name: /^gamma$/i }),
181+
).not.toBeInTheDocument();
182+
});
183+
184+
it("shows 'No tags found' when search matches nothing", async () => {
185+
renderDropdown();
186+
await userEvent.click(screen.getByRole("button", { name: /tags/i }));
187+
188+
await userEvent.type(
189+
screen.getByPlaceholderText("Search tags..."),
190+
"zzznomatch",
191+
);
192+
193+
expect(screen.getByText("No tags found")).toBeInTheDocument();
194+
});
195+
196+
it("is case-insensitive when filtering", async () => {
197+
renderDropdown();
198+
await userEvent.click(screen.getByRole("button", { name: /tags/i }));
199+
200+
await userEvent.type(
201+
screen.getByPlaceholderText("Search tags..."),
202+
"ALP",
203+
);
204+
205+
expect(
206+
screen.getByRole("button", { name: /^alpha$/i }),
207+
).toBeInTheDocument();
208+
});
209+
});
210+
211+
describe("adding a tag", () => {
212+
it("calls onAdd when an inactive tag is clicked", async () => {
213+
const onAdd = vi.fn();
214+
renderDropdown({ filterTags: [], onAdd });
215+
216+
await userEvent.click(screen.getByRole("button", { name: /tags/i }));
217+
await userEvent.click(screen.getByRole("button", { name: /^alpha$/i }));
218+
219+
expect(onAdd).toHaveBeenCalledWith("alpha");
220+
});
221+
222+
it("does not call onRemove when clicking an inactive tag", async () => {
223+
const onRemove = vi.fn();
224+
renderDropdown({ filterTags: [], onRemove });
225+
226+
await userEvent.click(screen.getByRole("button", { name: /tags/i }));
227+
await userEvent.click(screen.getByRole("button", { name: /^alpha$/i }));
228+
229+
expect(onRemove).not.toHaveBeenCalled();
230+
});
231+
});
232+
233+
describe("removing a tag (toggle)", () => {
234+
it("calls onRemove when an active tag is clicked", async () => {
235+
const onRemove = vi.fn();
236+
renderDropdown({ filterTags: ["alpha"], onRemove });
237+
238+
await userEvent.click(screen.getByRole("button", { name: /tags/i }));
239+
await userEvent.click(screen.getByRole("button", { name: /^alpha$/i }));
240+
241+
expect(onRemove).toHaveBeenCalledWith("alpha");
242+
});
243+
244+
it("does not call onAdd when clicking an active tag", async () => {
245+
const onAdd = vi.fn();
246+
renderDropdown({ filterTags: ["alpha"], onAdd });
247+
248+
await userEvent.click(screen.getByRole("button", { name: /tags/i }));
249+
await userEvent.click(screen.getByRole("button", { name: /^alpha$/i }));
250+
251+
expect(onAdd).not.toHaveBeenCalled();
252+
});
253+
});
254+
255+
describe("clear all button", () => {
256+
it("is not rendered when no tags are active", async () => {
257+
renderDropdown({ filterTags: [] });
258+
await userEvent.click(screen.getByRole("button", { name: /tags/i }));
259+
expect(
260+
screen.queryByRole("button", { name: /clear all/i }),
261+
).not.toBeInTheDocument();
262+
});
263+
264+
it("is rendered when at least one tag is active", async () => {
265+
renderDropdown({ filterTags: ["alpha"] });
266+
await userEvent.click(screen.getByRole("button", { name: /tags/i }));
267+
expect(
268+
screen.getByRole("button", { name: /clear all/i }),
269+
).toBeInTheDocument();
270+
});
271+
272+
it("calls onClearAll when Clear all is clicked", async () => {
273+
const onClearAll = vi.fn();
274+
renderDropdown({ filterTags: ["alpha"], onClearAll });
275+
276+
await userEvent.click(screen.getByRole("button", { name: /tags/i }));
277+
await userEvent.click(screen.getByRole("button", { name: /clear all/i }));
278+
279+
expect(onClearAll).toHaveBeenCalledTimes(1);
280+
});
281+
282+
it("closes the popover after clearing all", async () => {
283+
const onClearAll = vi.fn();
284+
renderDropdown({ filterTags: ["alpha"], onClearAll });
285+
286+
await userEvent.click(screen.getByRole("button", { name: /tags/i }));
287+
await userEvent.click(screen.getByRole("button", { name: /clear all/i }));
288+
289+
await waitFor(() => {
290+
expect(
291+
screen.queryByPlaceholderText("Search tags..."),
292+
).not.toBeInTheDocument();
293+
});
294+
});
295+
});
296+
297+
describe("manage tags button — absent", () => {
298+
it("is NOT rendered when onManageTags is not provided", async () => {
299+
renderDropdown({ onManageTags: undefined });
300+
await userEvent.click(screen.getByRole("button", { name: /tags/i }));
301+
expect(
302+
screen.queryByRole("button", { name: /manage tags/i }),
303+
).not.toBeInTheDocument();
304+
});
305+
});
306+
307+
describe("manage tags button — present", () => {
308+
it("IS rendered when onManageTags is provided", async () => {
309+
renderDropdown({ onManageTags: vi.fn() });
310+
await userEvent.click(screen.getByRole("button", { name: /tags/i }));
311+
expect(
312+
screen.getByRole("button", { name: /manage tags/i }),
313+
).toBeInTheDocument();
314+
});
315+
316+
it("calls onManageTags when the button is clicked", async () => {
317+
const onManageTags = vi.fn();
318+
renderDropdown({ onManageTags });
319+
320+
await userEvent.click(screen.getByRole("button", { name: /tags/i }));
321+
await userEvent.click(
322+
screen.getByRole("button", { name: /manage tags/i }),
323+
);
324+
325+
expect(onManageTags).toHaveBeenCalledTimes(1);
326+
});
327+
328+
it("closes the popover after clicking Manage tags", async () => {
329+
const onManageTags = vi.fn();
330+
renderDropdown({ onManageTags });
331+
332+
await userEvent.click(screen.getByRole("button", { name: /tags/i }));
333+
await userEvent.click(
334+
screen.getByRole("button", { name: /manage tags/i }),
335+
);
336+
337+
await waitFor(() => {
338+
expect(
339+
screen.queryByPlaceholderText("Search tags..."),
340+
).not.toBeInTheDocument();
341+
});
342+
});
343+
});
344+
});

0 commit comments

Comments
 (0)