feat(#67): implement search UI with filters and shortcuts
Implements comprehensive search interface for knowledge base: Components: - SearchInput: Debounced search with Cmd+K (Ctrl+K) shortcut - SearchResults: Main results view with highlighted snippets - SearchFilters: Sidebar for filtering by status and tags - Search page: Full search experience at /knowledge/search Features: - Search-as-you-type with 300ms debounce - HTML snippet highlighting (using <mark> from API) - Tag and status filters with PDA-friendly language - Keyboard shortcuts (Cmd+K/Ctrl+K to open, Escape to clear) - No results state with helpful suggestions - Loading states - Visual status indicators (🟢 Active, 🔵 Scheduled, etc.) Navigation: - Added search button to header with keyboard hint - Global Cmd+K shortcut redirects to search page - Added "Knowledge" link to main navigation Infrastructure: - Updated Input component to support forwardRef for proper ref handling - Comprehensive test coverage (100% on main components) - All tests passing (339 passed) - TypeScript strict mode compliant - ESLint compliant Fixes #67 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
163
apps/web/src/components/search/__tests__/SearchFilters.test.tsx
Normal file
163
apps/web/src/components/search/__tests__/SearchFilters.test.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SearchFilters } from "../SearchFilters";
|
||||
|
||||
describe("SearchFilters", () => {
|
||||
const mockTags = [
|
||||
{ id: "tag-1", name: "Testing", slug: "testing", color: "#3b82f6" },
|
||||
{ id: "tag-2", name: "Documentation", slug: "docs", color: "#10b981" },
|
||||
{ id: "tag-3", name: "Development", slug: "dev", color: "#f59e0b" },
|
||||
];
|
||||
|
||||
it("should render status filter section", () => {
|
||||
render(<SearchFilters availableTags={[]} selectedTags={[]} onFilterChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText(/status/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render tags filter section", () => {
|
||||
render(<SearchFilters availableTags={mockTags} selectedTags={[]} onFilterChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText(/tags/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display all available tags", () => {
|
||||
render(<SearchFilters availableTags={mockTags} selectedTags={[]} onFilterChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("Testing")).toBeInTheDocument();
|
||||
expect(screen.getByText("Documentation")).toBeInTheDocument();
|
||||
expect(screen.getByText("Development")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onFilterChange when status is selected", async () => {
|
||||
const onFilterChange = vi.fn();
|
||||
render(<SearchFilters availableTags={[]} selectedTags={[]} onFilterChange={onFilterChange} />);
|
||||
|
||||
const activeFilter = screen.getByText("Active");
|
||||
await userEvent.click(activeFilter);
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledWith({ status: "ACTIVE", tags: [] });
|
||||
});
|
||||
|
||||
it("should call onFilterChange when tag is selected", async () => {
|
||||
const onFilterChange = vi.fn();
|
||||
render(
|
||||
<SearchFilters availableTags={mockTags} selectedTags={[]} onFilterChange={onFilterChange} />
|
||||
);
|
||||
|
||||
const testingTag = screen.getByText("Testing");
|
||||
await userEvent.click(testingTag);
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledWith({ tags: ["testing"] });
|
||||
});
|
||||
|
||||
it("should show selected tags as checked", () => {
|
||||
render(
|
||||
<SearchFilters
|
||||
availableTags={mockTags}
|
||||
selectedTags={["testing", "docs"]}
|
||||
onFilterChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Check for visual indication of selected state
|
||||
const testingTag = screen.getByText("Testing").closest("button");
|
||||
const docsTag = screen.getByText("Documentation").closest("button");
|
||||
|
||||
expect(testingTag).toHaveClass(/selected|active|checked/i);
|
||||
expect(docsTag).toHaveClass(/selected|active|checked/i);
|
||||
});
|
||||
|
||||
it("should allow multiple tag selection", async () => {
|
||||
const onFilterChange = vi.fn();
|
||||
render(
|
||||
<SearchFilters
|
||||
availableTags={mockTags}
|
||||
selectedTags={["testing"]}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const docsTag = screen.getByText("Documentation");
|
||||
await userEvent.click(docsTag);
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledWith({ tags: ["testing", "docs"] });
|
||||
});
|
||||
|
||||
it("should allow deselecting tags", async () => {
|
||||
const onFilterChange = vi.fn();
|
||||
render(
|
||||
<SearchFilters
|
||||
availableTags={mockTags}
|
||||
selectedTags={["testing", "docs"]}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const testingTag = screen.getByText("Testing");
|
||||
await userEvent.click(testingTag);
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledWith({ tags: ["docs"] });
|
||||
});
|
||||
|
||||
it("should display status options with PDA-friendly language", () => {
|
||||
render(<SearchFilters availableTags={[]} selectedTags={[]} onFilterChange={vi.fn()} />);
|
||||
|
||||
// Should use visual indicators and friendly language
|
||||
expect(screen.getByText("🟢")).toBeInTheDocument();
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
expect(screen.getByText("🔵")).toBeInTheDocument();
|
||||
expect(screen.getByText("Scheduled")).toBeInTheDocument();
|
||||
expect(screen.getByText("⏸️")).toBeInTheDocument();
|
||||
expect(screen.getByText("Paused")).toBeInTheDocument();
|
||||
|
||||
// Should NOT use demanding language
|
||||
expect(screen.queryByText(/urgent/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/overdue/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have clear filters button", () => {
|
||||
render(
|
||||
<SearchFilters
|
||||
availableTags={mockTags}
|
||||
selectedTags={["testing"]}
|
||||
selectedStatus="ACTIVE"
|
||||
onFilterChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/clear filters/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onFilterChange with empty values when clear is clicked", async () => {
|
||||
const onFilterChange = vi.fn();
|
||||
render(
|
||||
<SearchFilters
|
||||
availableTags={mockTags}
|
||||
selectedTags={["testing"]}
|
||||
selectedStatus="ACTIVE"
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const clearButton = screen.getByText(/clear filters/i);
|
||||
await userEvent.click(clearButton);
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledWith({ status: undefined, tags: [] });
|
||||
});
|
||||
|
||||
it("should show count of selected filters", () => {
|
||||
render(
|
||||
<SearchFilters
|
||||
availableTags={mockTags}
|
||||
selectedTags={["testing", "docs"]}
|
||||
selectedStatus="ACTIVE"
|
||||
onFilterChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show count of active filters (3 total: 1 status + 2 tags)
|
||||
expect(screen.getByText(/3/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
128
apps/web/src/components/search/__tests__/SearchInput.test.tsx
Normal file
128
apps/web/src/components/search/__tests__/SearchInput.test.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { SearchInput } from "../SearchInput";
|
||||
|
||||
describe("SearchInput", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should render search input field", () => {
|
||||
render(<SearchInput onSearch={vi.fn()} />);
|
||||
|
||||
const input = screen.getByPlaceholderText(/search/i);
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onSearch after debounce delay", () => {
|
||||
const onSearch = vi.fn();
|
||||
render(<SearchInput onSearch={onSearch} debounceMs={300} />);
|
||||
|
||||
const input = screen.getByPlaceholderText(/search/i);
|
||||
fireEvent.change(input, { target: { value: "test query" } });
|
||||
|
||||
// Should not call immediately
|
||||
expect(onSearch).not.toHaveBeenCalled();
|
||||
|
||||
// Fast-forward time
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(onSearch).toHaveBeenCalledWith("test query");
|
||||
});
|
||||
|
||||
it("should debounce multiple keystrokes", () => {
|
||||
const onSearch = vi.fn();
|
||||
render(<SearchInput onSearch={onSearch} debounceMs={300} />);
|
||||
|
||||
const input = screen.getByPlaceholderText(/search/i);
|
||||
|
||||
fireEvent.change(input, { target: { value: "a" } });
|
||||
vi.advanceTimersByTime(100);
|
||||
fireEvent.change(input, { target: { value: "ab" } });
|
||||
vi.advanceTimersByTime(100);
|
||||
fireEvent.change(input, { target: { value: "abc" } });
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
// Should only call once after final delay
|
||||
expect(onSearch).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(onSearch).toHaveBeenCalledTimes(1);
|
||||
expect(onSearch).toHaveBeenCalledWith("abc");
|
||||
});
|
||||
|
||||
it("should focus input when Cmd+K is pressed", () => {
|
||||
render(<SearchInput onSearch={vi.fn()} />);
|
||||
|
||||
const input = screen.getByPlaceholderText(/search/i);
|
||||
|
||||
// Simulate Cmd+K
|
||||
fireEvent.keyDown(document, { key: "k", metaKey: true });
|
||||
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
it("should focus input when Ctrl+K is pressed on Windows/Linux", () => {
|
||||
render(<SearchInput onSearch={vi.fn()} />);
|
||||
|
||||
const input = screen.getByPlaceholderText(/search/i);
|
||||
|
||||
// Simulate Ctrl+K
|
||||
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
|
||||
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
it("should clear input when Escape is pressed", () => {
|
||||
const onSearch = vi.fn();
|
||||
render(<SearchInput onSearch={onSearch} />);
|
||||
|
||||
const input = screen.getByPlaceholderText(/search/i);
|
||||
|
||||
fireEvent.change(input, { target: { value: "test query" } });
|
||||
expect((input as HTMLInputElement).value).toBe("test query");
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
|
||||
expect((input as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("should display initial value", () => {
|
||||
render(<SearchInput onSearch={vi.fn()} initialValue="initial query" />);
|
||||
|
||||
const input = screen.getByPlaceholderText(/search/i);
|
||||
expect((input as HTMLInputElement).value).toBe("initial query");
|
||||
});
|
||||
|
||||
it("should show loading indicator when isLoading is true", () => {
|
||||
render(<SearchInput onSearch={vi.fn()} isLoading={true} />);
|
||||
|
||||
const loader = screen.getByTestId("search-loading");
|
||||
expect(loader).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not call onSearch for empty strings", () => {
|
||||
const onSearch = vi.fn();
|
||||
render(<SearchInput onSearch={onSearch} debounceMs={300} />);
|
||||
|
||||
const input = screen.getByPlaceholderText(/search/i);
|
||||
fireEvent.change(input, { target: { value: " " } });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(onSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show keyboard shortcut hint", () => {
|
||||
render(<SearchInput onSearch={vi.fn()} />);
|
||||
|
||||
const hint = screen.getByText(/⌘K|Ctrl\+K/i);
|
||||
expect(hint).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
195
apps/web/src/components/search/__tests__/SearchResults.test.tsx
Normal file
195
apps/web/src/components/search/__tests__/SearchResults.test.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { SearchResults } from "../SearchResults";
|
||||
import type { SearchResult } from "../types";
|
||||
|
||||
const mockResults: SearchResult[] = [
|
||||
{
|
||||
id: "1",
|
||||
workspaceId: "ws-1",
|
||||
slug: "test-entry",
|
||||
title: "Test Entry",
|
||||
content: "This is test content",
|
||||
contentHtml: "<p>This is test content</p>",
|
||||
summary: "Test summary",
|
||||
status: "ACTIVE",
|
||||
visibility: "WORKSPACE",
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-02"),
|
||||
createdBy: "user-1",
|
||||
updatedBy: "user-1",
|
||||
rank: 0.85,
|
||||
headline: "This is <mark>test</mark> content",
|
||||
tags: [
|
||||
{ id: "tag-1", name: "Testing", slug: "testing", color: "#3b82f6" },
|
||||
{ id: "tag-2", name: "Documentation", slug: "docs", color: "#10b981" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
workspaceId: "ws-1",
|
||||
slug: "another-entry",
|
||||
title: "Another Entry",
|
||||
content: "Different content here",
|
||||
contentHtml: "<p>Different content here</p>",
|
||||
summary: null,
|
||||
status: "ACTIVE",
|
||||
visibility: "WORKSPACE",
|
||||
createdAt: new Date("2026-01-03"),
|
||||
updatedAt: new Date("2026-01-04"),
|
||||
createdBy: "user-1",
|
||||
updatedBy: "user-1",
|
||||
rank: 0.72,
|
||||
headline: "Different <mark>content</mark> here",
|
||||
tags: [],
|
||||
},
|
||||
];
|
||||
|
||||
describe("SearchResults", () => {
|
||||
it("should render search results", () => {
|
||||
render(
|
||||
<SearchResults
|
||||
results={mockResults}
|
||||
query="test"
|
||||
totalResults={2}
|
||||
isLoading={false}
|
||||
onFilterChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Test Entry")).toBeInTheDocument();
|
||||
expect(screen.getByText("Another Entry")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display result count", () => {
|
||||
render(
|
||||
<SearchResults
|
||||
results={mockResults}
|
||||
query="test"
|
||||
totalResults={2}
|
||||
isLoading={false}
|
||||
onFilterChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/2 results/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display query text", () => {
|
||||
render(
|
||||
<SearchResults
|
||||
results={mockResults}
|
||||
query="test query"
|
||||
totalResults={2}
|
||||
isLoading={false}
|
||||
onFilterChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/test query/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render highlighted snippets", () => {
|
||||
render(
|
||||
<SearchResults
|
||||
results={mockResults}
|
||||
query="test"
|
||||
totalResults={2}
|
||||
isLoading={false}
|
||||
onFilterChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should render mark tags for highlighting
|
||||
const marks = screen.getAllByText("test");
|
||||
expect(marks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should render tags for results with tags", () => {
|
||||
render(
|
||||
<SearchResults
|
||||
results={mockResults}
|
||||
query="test"
|
||||
totalResults={2}
|
||||
isLoading={false}
|
||||
onFilterChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Testing")).toBeInTheDocument();
|
||||
expect(screen.getByText("Documentation")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show loading state", () => {
|
||||
render(
|
||||
<SearchResults
|
||||
results={[]}
|
||||
query="test"
|
||||
totalResults={0}
|
||||
isLoading={true}
|
||||
onFilterChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/searching/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show no results state when results are empty", () => {
|
||||
render(
|
||||
<SearchResults
|
||||
results={[]}
|
||||
query="nonexistent"
|
||||
totalResults={0}
|
||||
isLoading={false}
|
||||
onFilterChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/no results found/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display helpful suggestions in no results state", () => {
|
||||
render(
|
||||
<SearchResults
|
||||
results={[]}
|
||||
query="test"
|
||||
totalResults={0}
|
||||
isLoading={false}
|
||||
onFilterChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show PDA-friendly suggestions (not demanding)
|
||||
expect(screen.getByText(/try different keywords/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render filter sidebar", () => {
|
||||
render(
|
||||
<SearchResults
|
||||
results={mockResults}
|
||||
query="test"
|
||||
totalResults={2}
|
||||
isLoading={false}
|
||||
onFilterChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/filters/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show result status indicators", () => {
|
||||
render(
|
||||
<SearchResults
|
||||
results={mockResults}
|
||||
query="test"
|
||||
totalResults={2}
|
||||
isLoading={false}
|
||||
onFilterChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show visual indicators (🟢 for ACTIVE)
|
||||
const indicators = screen.getAllByText("🟢");
|
||||
expect(indicators.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user