From 3cb6eb7f8b29fca106c825a9dae4ee043c2a415f Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 2 Feb 2026 14:50:25 -0600 Subject: [PATCH] feat(#67): implement search UI with filters and shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- .../(authenticated)/knowledge/search/page.tsx | 133 ++++++++++++ apps/web/src/components/layout/Navigation.tsx | 31 ++- .../src/components/search/SearchFilters.tsx | 145 +++++++++++++ .../web/src/components/search/SearchInput.tsx | 112 ++++++++++ .../src/components/search/SearchResults.tsx | 174 ++++++++++++++++ .../search/__tests__/SearchFilters.test.tsx | 163 +++++++++++++++ .../search/__tests__/SearchInput.test.tsx | 128 ++++++++++++ .../search/__tests__/SearchResults.test.tsx | 195 ++++++++++++++++++ apps/web/src/components/search/index.ts | 8 + apps/web/src/components/search/types.ts | 51 +++++ docs/scratchpads/67-search-ui.md | 75 +++++++ packages/ui/src/components/Input.tsx | 17 +- 12 files changed, 1221 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/app/(authenticated)/knowledge/search/page.tsx create mode 100644 apps/web/src/components/search/SearchFilters.tsx create mode 100644 apps/web/src/components/search/SearchInput.tsx create mode 100644 apps/web/src/components/search/SearchResults.tsx create mode 100644 apps/web/src/components/search/__tests__/SearchFilters.test.tsx create mode 100644 apps/web/src/components/search/__tests__/SearchInput.test.tsx create mode 100644 apps/web/src/components/search/__tests__/SearchResults.test.tsx create mode 100644 apps/web/src/components/search/index.ts create mode 100644 apps/web/src/components/search/types.ts create mode 100644 docs/scratchpads/67-search-ui.md diff --git a/apps/web/src/app/(authenticated)/knowledge/search/page.tsx b/apps/web/src/app/(authenticated)/knowledge/search/page.tsx new file mode 100644 index 0000000..9e128ea --- /dev/null +++ b/apps/web/src/app/(authenticated)/knowledge/search/page.tsx @@ -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([]); + const [totalResults, setTotalResults] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [selectedTags, setSelectedTags] = useState([]); + const [selectedStatus, setSelectedStatus] = useState(); + const [availableTags, setAvailableTags] = useState([]); + + // Fetch available tags on mount + useEffect(() => { + const fetchTags = async (): Promise => { + try { + const response = await apiGet("/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 => { + 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(`/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 ( +
+ {/* Search header */} +
+
+

Search Knowledge Base

+ +
+
+ + {/* Results area */} + {query && ( +
+ +
+ )} + + {/* Empty state when no query */} + {!query && ( +
+
πŸ”
+

Search Your Knowledge

+

+ Enter a search term above to find entries in your knowledge base +

+
+

Tip: Press Cmd+K (or Ctrl+K) to quickly focus the search box

+
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/layout/Navigation.tsx b/apps/web/src/components/layout/Navigation.tsx index 90990a9..e961e47 100644 --- a/apps/web/src/components/layout/Navigation.tsx +++ b/apps/web/src/components/layout/Navigation.tsx @@ -1,20 +1,38 @@ "use client"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import Link from "next/link"; import { useAuth } from "@/lib/auth/auth-context"; import { LogoutButton } from "@/components/auth/LogoutButton"; +import { useEffect } from "react"; export function Navigation(): React.JSX.Element { const pathname = usePathname(); + const router = useRouter(); const { user } = useAuth(); const navItems = [ { href: "/", label: "Dashboard" }, { href: "/tasks", label: "Tasks" }, { href: "/calendar", label: "Calendar" }, + { href: "/knowledge", label: "Knowledge" }, ]; + // Global keyboard shortcut for search (Cmd+K or Ctrl+K) + useEffect((): (() => void) => { + const handleKeyDown = (e: KeyboardEvent): void => { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + router.push("/knowledge/search"); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [router]); + return (