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:
133
apps/web/src/app/(authenticated)/knowledge/search/page.tsx
Normal file
133
apps/web/src/app/(authenticated)/knowledge/search/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user