feat: add debounced SearchBar and highlighted SearchResults
This commit is contained in:
parent
e341a56fb2
commit
10b2b575d9
52
frontend/src/components/SearchBar.tsx
Normal file
52
frontend/src/components/SearchBar.tsx
Normal file
@ -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<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
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 (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="检索全部文档内容…"
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
aria-label="搜索文档"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
frontend/src/components/SearchResults.tsx
Normal file
28
frontend/src/components/SearchResults.tsx
Normal file
@ -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 <div data-testid="search-no-results">没有找到匹配的文档</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="search-results">
|
||||||
|
<div data-testid="search-results-count">
|
||||||
|
{results.length} 条与「{query}」相关的结果
|
||||||
|
</div>
|
||||||
|
{results.map((result) => (
|
||||||
|
<div key={result.key} data-testid={`search-result-${result.key}`} onClick={() => onJumpToResult(result.key)}>
|
||||||
|
<div className="result-title">{result.title}</div>
|
||||||
|
<div className="result-path">{result.path}</div>
|
||||||
|
<div className="result-snippet" dangerouslySetInnerHTML={{ __html: result.snippet }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/tests/components/SearchBar.test.tsx
Normal file
48
frontend/tests/components/SearchBar.test.tsx
Normal file
@ -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(<SearchBar onQueryChange={onQueryChange} onResults={onResults} />);
|
||||||
|
|
||||||
|
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(<SearchBar onQueryChange={onQueryChange} onResults={onResults} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: 'x' } });
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
fireEvent.change(input, { target: { value: '' } });
|
||||||
|
|
||||||
|
expect(onResults).toHaveBeenCalledWith([]);
|
||||||
|
});
|
||||||
44
frontend/tests/components/SearchResults.test.tsx
Normal file
44
frontend/tests/components/SearchResults.test.tsx
Normal file
@ -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(
|
||||||
|
<SearchResults
|
||||||
|
query="检索"
|
||||||
|
results={[
|
||||||
|
{
|
||||||
|
key: '产品文档/架构设计.md',
|
||||||
|
title: '架构设计.md',
|
||||||
|
path: '产品文档',
|
||||||
|
snippet: '检索:Meilisearch,支持 <em>中文全文检索</em>。',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
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(
|
||||||
|
<SearchResults
|
||||||
|
query="检索"
|
||||||
|
results={[{ key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...' }]}
|
||||||
|
onJumpToResult={onJumpToResult}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('search-result-产品文档/架构设计.md'));
|
||||||
|
|
||||||
|
expect(onJumpToResult).toHaveBeenCalledWith('产品文档/架构设计.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows an empty state when there are no results', () => {
|
||||||
|
render(<SearchResults query="xyz" results={[]} onJumpToResult={vi.fn()} />);
|
||||||
|
expect(screen.getByTestId('search-no-results')).toBeInTheDocument();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user