feat: implement CommandPalette for quick filter and search
- Add text search across all focus items - Support filter commands: type: and stale: - Keyboard navigation with arrow keys + Enter - Click to select items - Escape or backdrop click to close - 17 tests covering search, filters, keyboard nav, and empty state Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
295
tests/components/CommandPalette.test.tsx
Normal file
295
tests/components/CommandPalette.test.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Tests for CommandPalette component.
|
||||
*
|
||||
* TDD: These tests define the expected behavior before implementation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { CommandPalette } from "@/components/CommandPalette";
|
||||
import type { FocusItem } from "@/lib/types";
|
||||
|
||||
const mockItems: FocusItem[] = [
|
||||
{
|
||||
id: "mr-1",
|
||||
title: "Fix auth token refresh",
|
||||
type: "mr_review",
|
||||
project: "platform/core",
|
||||
url: "https://gitlab.com/platform/core/-/merge_requests/1",
|
||||
iid: 1,
|
||||
updatedAt: "2026-02-25T10:00:00Z",
|
||||
contextQuote: null,
|
||||
requestedBy: "alice",
|
||||
},
|
||||
{
|
||||
id: "mr-2",
|
||||
title: "Update README",
|
||||
type: "mr_authored",
|
||||
project: "platform/docs",
|
||||
url: "https://gitlab.com/platform/docs/-/merge_requests/2",
|
||||
iid: 2,
|
||||
updatedAt: "2026-02-20T10:00:00Z",
|
||||
contextQuote: null,
|
||||
requestedBy: null,
|
||||
},
|
||||
{
|
||||
id: "issue-1",
|
||||
title: "Auth bug in production",
|
||||
type: "issue",
|
||||
project: "platform/core",
|
||||
url: "https://gitlab.com/platform/core/-/issues/42",
|
||||
iid: 42,
|
||||
updatedAt: "2026-02-24T10:00:00Z",
|
||||
contextQuote: null,
|
||||
requestedBy: null,
|
||||
},
|
||||
{
|
||||
id: "manual-1",
|
||||
title: "Review quarterly goals",
|
||||
type: "manual",
|
||||
project: "personal",
|
||||
url: "",
|
||||
iid: 0,
|
||||
updatedAt: null,
|
||||
contextQuote: null,
|
||||
requestedBy: null,
|
||||
},
|
||||
];
|
||||
|
||||
describe("CommandPalette", () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
items: mockItems,
|
||||
onFilter: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders when open", () => {
|
||||
render(<CommandPalette {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText(/Search or filter/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render when closed", () => {
|
||||
render(<CommandPalette {...defaultProps} isOpen={false} />);
|
||||
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes on Escape key", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(<CommandPalette {...defaultProps} onClose={onClose} />);
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes when clicking backdrop", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(<CommandPalette {...defaultProps} onClose={onClose} />);
|
||||
|
||||
// Click the backdrop (outer div)
|
||||
await user.click(screen.getByTestId("command-palette-backdrop"));
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("text search", () => {
|
||||
it("filters items by text", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CommandPalette {...defaultProps} />);
|
||||
|
||||
await user.type(screen.getByRole("textbox"), "auth");
|
||||
|
||||
// Should show items containing "auth"
|
||||
expect(screen.getByText("Fix auth token refresh")).toBeInTheDocument();
|
||||
expect(screen.getByText("Auth bug in production")).toBeInTheDocument();
|
||||
// Should not show items without "auth"
|
||||
expect(screen.queryByText("Update README")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Review quarterly goals")
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows all items when search is empty", () => {
|
||||
render(<CommandPalette {...defaultProps} />);
|
||||
|
||||
// Should show all items (or top 10)
|
||||
expect(screen.getByText("Fix auth token refresh")).toBeInTheDocument();
|
||||
expect(screen.getByText("Update README")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects item and closes on click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<CommandPalette
|
||||
{...defaultProps}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("Fix auth token refresh"));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("mr-1");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("selects item on Enter when typing", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<CommandPalette
|
||||
{...defaultProps}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
// Type to filter to one item
|
||||
await user.type(screen.getByRole("textbox"), "README");
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("mr-2");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("filter commands", () => {
|
||||
it("shows filter command options when typing type:", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CommandPalette {...defaultProps} />);
|
||||
|
||||
await user.type(screen.getByRole("textbox"), "type:");
|
||||
|
||||
expect(screen.getByText("type:mr_review")).toBeInTheDocument();
|
||||
expect(screen.getByText("type:issue")).toBeInTheDocument();
|
||||
expect(screen.getByText("type:manual")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters by type when type: command used", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilter = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<CommandPalette
|
||||
{...defaultProps}
|
||||
onFilter={onFilter}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.type(screen.getByRole("textbox"), "type:");
|
||||
await user.click(screen.getByText("type:mr_review"));
|
||||
|
||||
expect(onFilter).toHaveBeenCalledWith({ type: "mr_review" });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows staleness filter options when typing stale:", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CommandPalette {...defaultProps} />);
|
||||
|
||||
await user.type(screen.getByRole("textbox"), "stale:");
|
||||
|
||||
expect(screen.getByText("stale:1d")).toBeInTheDocument();
|
||||
expect(screen.getByText("stale:3d")).toBeInTheDocument();
|
||||
expect(screen.getByText("stale:7d")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters by staleness when stale: command used", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilter = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<CommandPalette
|
||||
{...defaultProps}
|
||||
onFilter={onFilter}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.type(screen.getByRole("textbox"), "stale:");
|
||||
await user.click(screen.getByText("stale:7d"));
|
||||
|
||||
expect(onFilter).toHaveBeenCalledWith({ minAge: 7 });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows command suggestions before typing a command", () => {
|
||||
render(<CommandPalette {...defaultProps} />);
|
||||
|
||||
// Commands section header should exist
|
||||
expect(screen.getByText("Commands")).toBeInTheDocument();
|
||||
// Command buttons should be present
|
||||
expect(screen.getByText("type:...")).toBeInTheDocument();
|
||||
expect(screen.getByText("stale:...")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyboard navigation", () => {
|
||||
it("navigates down with arrow key", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CommandPalette {...defaultProps} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
await user.click(input);
|
||||
await user.keyboard("{ArrowDown}");
|
||||
|
||||
// First item should be highlighted
|
||||
const items = screen.getAllByRole("option");
|
||||
expect(items[0]).toHaveAttribute("data-highlighted", "true");
|
||||
});
|
||||
|
||||
it("navigates up with arrow key", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CommandPalette {...defaultProps} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
await user.click(input);
|
||||
await user.keyboard("{ArrowDown}{ArrowDown}{ArrowUp}");
|
||||
|
||||
// First item should be highlighted again
|
||||
const items = screen.getAllByRole("option");
|
||||
expect(items[0]).toHaveAttribute("data-highlighted", "true");
|
||||
});
|
||||
|
||||
it("selects highlighted item on Enter", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<CommandPalette {...defaultProps} onSelect={onSelect} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
await user.click(input);
|
||||
await user.keyboard("{ArrowDown}{Enter}");
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("mr-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty state", () => {
|
||||
it("shows no results message when no items match", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CommandPalette {...defaultProps} />);
|
||||
|
||||
await user.type(screen.getByRole("textbox"), "xyznonexistent");
|
||||
|
||||
expect(screen.getByText(/No results found/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user