diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx new file mode 100644 index 0000000..c5cac0d --- /dev/null +++ b/frontend/src/components/SearchBar.tsx @@ -0,0 +1,52 @@ +import { useEffect, useRef, useState } from 'react'; +import { search, type SearchResult } from '../api/client'; + +interface SearchBarProps { + onQueryChange: (query: string) => void; + onResults: (results: SearchResult[]) => void; + debounceMs?: number; +} + +export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 }: SearchBarProps) { + const [value, setValue] = useState(''); + const timerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, []); + + function handleChange(event: React.ChangeEvent) { + const nextValue = event.target.value; + setValue(nextValue); + onQueryChange(nextValue); + + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + if (nextValue.trim() === '') { + onResults([]); + return; + } + + timerRef.current = setTimeout(() => { + search(nextValue).then((response) => { + onResults(response.results); + }); + }, debounceMs); + } + + return ( + + ); +} diff --git a/frontend/src/components/SearchResults.tsx b/frontend/src/components/SearchResults.tsx new file mode 100644 index 0000000..973fc77 --- /dev/null +++ b/frontend/src/components/SearchResults.tsx @@ -0,0 +1,28 @@ +import type { SearchResult } from '../api/client'; + +interface SearchResultsProps { + results: SearchResult[]; + query: string; + onJumpToResult: (key: string) => void; +} + +export default function SearchResults({ results, query, onJumpToResult }: SearchResultsProps) { + if (results.length === 0) { + return
没有找到匹配的文档
; + } + + return ( +
+
+ {results.length} 条与「{query}」相关的结果 +
+ {results.map((result) => ( +
onJumpToResult(result.key)}> +
{result.title}
+
{result.path}
+
+
+ ))} +
+ ); +} diff --git a/frontend/tests/components/SearchBar.test.tsx b/frontend/tests/components/SearchBar.test.tsx new file mode 100644 index 0000000..9a9dc63 --- /dev/null +++ b/frontend/tests/components/SearchBar.test.tsx @@ -0,0 +1,48 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi, test, expect, beforeEach, afterEach } from 'vitest'; +import SearchBar from '../../src/components/SearchBar'; +import { search } from '../../src/api/client'; + +vi.mock('../../src/api/client'); + +beforeEach(() => { + vi.mocked(search).mockReset(); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +test('debounces input for 300ms before calling search', async () => { + vi.mocked(search).mockResolvedValue({ results: [] }); + const onQueryChange = vi.fn(); + const onResults = vi.fn(); + + render(); + + const input = screen.getByRole('textbox') as HTMLInputElement; + fireEvent.change(input, { target: { value: '检索' } }); + + expect(onQueryChange).toHaveBeenLastCalledWith('检索'); + expect(search).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(300); + + expect(search).toHaveBeenCalledWith('检索'); +}); + +test('clearing the input immediately reports empty results without calling search', async () => { + vi.mocked(search).mockResolvedValue({ results: [] }); + const onQueryChange = vi.fn(); + const onResults = vi.fn(); + + render(); + + const input = screen.getByRole('textbox') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'x' } }); + vi.advanceTimersByTime(300); + fireEvent.change(input, { target: { value: '' } }); + + expect(onResults).toHaveBeenCalledWith([]); +}); diff --git a/frontend/tests/components/SearchResults.test.tsx b/frontend/tests/components/SearchResults.test.tsx new file mode 100644 index 0000000..b440e56 --- /dev/null +++ b/frontend/tests/components/SearchResults.test.tsx @@ -0,0 +1,44 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { test, expect, vi } from 'vitest'; +import SearchResults from '../../src/components/SearchResults'; + +test('renders result count, titles, and highlighted snippets', () => { + render( + 中文全文检索。', + }, + ]} + onJumpToResult={vi.fn()} + /> + ); + + expect(screen.getByTestId('search-results-count')).toHaveTextContent('1 条与「检索」相关的结果'); + expect(screen.getByText('架构设计.md')).toBeInTheDocument(); + expect(document.querySelector('em')).toHaveTextContent('中文全文检索'); +}); + +test('clicking a result calls onJumpToResult with its key', () => { + const onJumpToResult = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByTestId('search-result-产品文档/架构设计.md')); + + expect(onJumpToResult).toHaveBeenCalledWith('产品文档/架构设计.md'); +}); + +test('shows an empty state when there are no results', () => { + render(); + expect(screen.getByTestId('search-no-results')).toBeInTheDocument(); +});