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:
Jason Woltje
2026-02-02 14:50:25 -06:00
parent c3500783d1
commit 3cb6eb7f8b
12 changed files with 1221 additions and 11 deletions

View File

@@ -0,0 +1,133 @@
"use client";
import { useState, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { SearchInput, SearchResults } from "@/components/search";
import type { SearchFiltersState, SearchResult, Tag } from "@/components/search/types";
import { apiGet } from "@/lib/api/client";
import type { SearchResponse } from "@/components/search/types";
interface TagsResponse {
data: Tag[];
}
/**
* Knowledge search page
* Supports full-text search with filters for tags and status
*/
export default function SearchPage(): React.JSX.Element {
const searchParams = useSearchParams();
const router = useRouter();
const [query, setQuery] = useState(searchParams.get("q") ?? "");
const [results, setResults] = useState<SearchResult[]>([]);
const [totalResults, setTotalResults] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string | undefined>();
const [availableTags, setAvailableTags] = useState<Tag[]>([]);
// Fetch available tags on mount
useEffect(() => {
const fetchTags = async (): Promise<void> => {
try {
const response = await apiGet<TagsResponse>("/api/knowledge/tags");
setAvailableTags(response.data);
} catch (error) {
console.error("Failed to fetch tags:", error);
}
};
void fetchTags();
}, []);
// Perform search when query changes
useEffect(() => {
const performSearch = async (): Promise<void> => {
if (!query.trim()) {
setResults([]);
setTotalResults(0);
return;
}
setIsLoading(true);
try {
// Build query params
const params = new URLSearchParams({ q: query });
if (selectedStatus) {
params.append("status", selectedStatus);
}
if (selectedTags.length > 0) {
params.append("tags", selectedTags.join(","));
}
const response = await apiGet<SearchResponse>(`/api/knowledge/search?${params.toString()}`);
setResults(response.data);
setTotalResults(response.pagination.total);
} catch (error) {
console.error("Search failed:", error);
setResults([]);
setTotalResults(0);
} finally {
setIsLoading(false);
}
};
void performSearch();
}, [query, selectedTags, selectedStatus]);
const handleSearch = (newQuery: string): void => {
setQuery(newQuery);
// Update URL with query
const params = new URLSearchParams({ q: newQuery });
router.push(`/knowledge/search?${params.toString()}`);
};
const handleFilterChange = (filters: SearchFiltersState): void => {
setSelectedStatus(filters.status);
setSelectedTags(filters.tags ?? []);
};
return (
<div className="min-h-screen bg-gray-50">
{/* Search header */}
<div className="bg-white border-b border-gray-200 p-6">
<div className="container mx-auto max-w-6xl">
<h1 className="text-3xl font-bold text-gray-900 mb-4">Search Knowledge Base</h1>
<SearchInput onSearch={handleSearch} initialValue={query} isLoading={isLoading} />
</div>
</div>
{/* Results area */}
{query && (
<div className="container mx-auto max-w-7xl">
<SearchResults
results={results}
query={query}
totalResults={totalResults}
isLoading={isLoading}
selectedTags={selectedTags}
selectedStatus={selectedStatus}
availableTags={availableTags}
onFilterChange={handleFilterChange}
/>
</div>
)}
{/* Empty state when no query */}
{!query && (
<div className="container mx-auto max-w-6xl py-12 text-center">
<div className="text-6xl mb-4">🔍</div>
<h2 className="text-2xl font-semibold text-gray-900 mb-2">Search Your Knowledge</h2>
<p className="text-gray-600">
Enter a search term above to find entries in your knowledge base
</p>
<div className="mt-6 text-sm text-gray-500">
<p>Tip: Press Cmd+K (or Ctrl+K) to quickly focus the search box</p>
</div>
</div>
)}
</div>
);
}