feat(search): scope 토글 — 이 노트북 / 모든 노트북
SearchBox 에 scope dropdown 추가. 기본 'current' (현재 notebook ID 전달), 'all' 선택 시 notebookId=undefined 로 전체 검색. store.searchNotes opts 인자 추가.
This commit is contained in:
@@ -3,32 +3,50 @@ import { useInbox } from '../store.js';
|
||||
|
||||
export function SearchBox(): React.ReactElement {
|
||||
const [draft, setDraft] = useState('');
|
||||
const [scope, setScope] = useState<'current' | 'all'>('current');
|
||||
const selectedNotebookId = useInbox((s) => s.selectedNotebookId);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
const trimmed = draft.trim();
|
||||
if (trimmed.length === 0) useInbox.getState().clearSearch();
|
||||
else void useInbox.getState().searchNotes(trimmed);
|
||||
if (trimmed.length === 0) {
|
||||
useInbox.getState().clearSearch();
|
||||
} else {
|
||||
// v0.4 Task 18 — scope='current' 이면 현재 notebook ID 전달, 'all' 이면 미전달(전체 검색).
|
||||
const notebookId = scope === 'current' ? (selectedNotebookId ?? undefined) : undefined;
|
||||
void useInbox.getState().searchNotes(trimmed, { notebookId });
|
||||
}
|
||||
}, 200);
|
||||
return () => clearTimeout(handle);
|
||||
}, [draft]);
|
||||
}, [draft, scope, selectedNotebookId]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type="search"
|
||||
role="searchbox"
|
||||
placeholder="검색…"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
aria-label="노트 검색"
|
||||
style={{
|
||||
marginLeft: 12,
|
||||
padding: '4px 8px',
|
||||
fontSize: 12,
|
||||
border: '1px solid #bbb',
|
||||
borderRadius: 4,
|
||||
width: 200
|
||||
}}
|
||||
/>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||
<input
|
||||
type="search"
|
||||
role="searchbox"
|
||||
placeholder="검색…"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
aria-label="노트 검색"
|
||||
style={{
|
||||
marginLeft: 12,
|
||||
padding: '4px 8px',
|
||||
fontSize: 12,
|
||||
border: '1px solid #bbb',
|
||||
borderRadius: 4,
|
||||
width: 200
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={scope}
|
||||
onChange={(e) => setScope(e.target.value as 'current' | 'all')}
|
||||
aria-label="검색 범위"
|
||||
style={{ fontSize: 11, padding: '2px 4px', marginLeft: 4 }}
|
||||
>
|
||||
<option value="current">이 노트북</option>
|
||||
<option value="all">모든 노트북</option>
|
||||
</select>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Note, Notebook, PromotionCandidate, ReviewAggregate, WeeklyContinuity } from '@shared/types';
|
||||
import type { Note, NoteStatus, Notebook, PromotionCandidate, ReviewAggregate, WeeklyContinuity } from '@shared/types';
|
||||
import { inboxApi, notebookApi } from './api.js';
|
||||
import { nextKstMidnightMs } from '@shared/util/kstDate.js';
|
||||
|
||||
@@ -79,7 +79,8 @@ interface InboxState {
|
||||
snoozeRecall: () => Promise<void>;
|
||||
// v0.2.11 Cut D — search + review actions.
|
||||
setSearchQuery: (q: string) => void;
|
||||
searchNotes: (q: string) => Promise<void>;
|
||||
// v0.4 Task 18 — scope 토글. notebookId 전달 시 해당 notebook 안 검색, undefined 시 전체 검색.
|
||||
searchNotes: (q: string, opts?: { notebookId?: string }) => Promise<void>;
|
||||
clearSearch: () => void;
|
||||
loadReview: (period: 'daily' | 'weekly' | 'monthly') => Promise<void>;
|
||||
// v0.4 — Notebook actions.
|
||||
@@ -383,7 +384,7 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
set({ searchQuery: q });
|
||||
if (q.trim().length === 0) set({ searchResults: null });
|
||||
},
|
||||
async searchNotes(q) {
|
||||
async searchNotes(q, opts) {
|
||||
if (q.trim().length === 0) {
|
||||
set({ searchResults: null });
|
||||
return;
|
||||
@@ -394,7 +395,10 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
? (view === 'trash' ? 'trashed' : view)
|
||||
: view === 'inbox' ? 'active' : undefined;
|
||||
try {
|
||||
const r = await inboxApi.search(q, status ? { status } : {});
|
||||
// v0.4 Task 18 — opts.notebookId 전달 시 해당 notebook 안 검색, undefined 시 전체 검색.
|
||||
const searchOpts: { status?: NoteStatus; notebookId?: string } = status ? { status: status as NoteStatus } : {};
|
||||
if (opts?.notebookId !== undefined) searchOpts.notebookId = opts.notebookId;
|
||||
const r = await inboxApi.search(q, searchOpts);
|
||||
set({ searchResults: r });
|
||||
} catch (e) {
|
||||
// FTS5 query parse error (special char 미escape) / IPC fail → 빈 결과로.
|
||||
|
||||
@@ -9,10 +9,13 @@ const { mockSearchNotes, mockClearSearch } = vi.hoisted(() => ({
|
||||
mockClearSearch: vi.fn()
|
||||
}));
|
||||
|
||||
// selectedNotebookId 를 테스트 간 변경할 수 있도록 ref 로 관리.
|
||||
let mockSelectedNotebookId: string | null = 'nb-1';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/store.js', () => ({
|
||||
useInbox: Object.assign(
|
||||
(selector?: (s: { searchQuery: string }) => unknown) => {
|
||||
const state = { searchQuery: '' };
|
||||
(selector?: (s: { searchQuery: string; selectedNotebookId: string | null }) => unknown) => {
|
||||
const state = { searchQuery: '', selectedNotebookId: mockSelectedNotebookId };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
{ getState: () => ({ searchNotes: mockSearchNotes, clearSearch: mockClearSearch }) }
|
||||
@@ -26,6 +29,7 @@ describe('SearchBox', () => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
vi.useFakeTimers();
|
||||
mockSelectedNotebookId = 'nb-1';
|
||||
});
|
||||
|
||||
it('타이핑 → 200ms debounce 후 searchNotes 호출', () => {
|
||||
@@ -34,7 +38,7 @@ describe('SearchBox', () => {
|
||||
fireEvent.change(input, { target: { value: '회의' } });
|
||||
expect(mockSearchNotes).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(mockSearchNotes).toHaveBeenCalledWith('회의');
|
||||
expect(mockSearchNotes).toHaveBeenCalledWith('회의', { notebookId: 'nb-1' });
|
||||
});
|
||||
|
||||
it('빈 값 → clearSearch 호출', () => {
|
||||
@@ -44,4 +48,32 @@ describe('SearchBox', () => {
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(mockClearSearch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('기본 scope=current — searchNotes 에 selectedNotebookId 전달', () => {
|
||||
render(<SearchBox />);
|
||||
const input = screen.getByRole('searchbox');
|
||||
fireEvent.change(input, { target: { value: '노트' } });
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(mockSearchNotes).toHaveBeenCalledWith('노트', { notebookId: 'nb-1' });
|
||||
});
|
||||
|
||||
it('scope=all 변경 시 다음 검색에서 notebookId 미전달 (undefined)', () => {
|
||||
render(<SearchBox />);
|
||||
const input = screen.getByRole('searchbox');
|
||||
const scopeSelect = screen.getByRole('combobox', { name: '검색 범위' });
|
||||
|
||||
fireEvent.change(scopeSelect, { target: { value: 'all' } });
|
||||
fireEvent.change(input, { target: { value: '리뷰' } });
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(mockSearchNotes).toHaveBeenCalledWith('리뷰', { notebookId: undefined });
|
||||
});
|
||||
|
||||
it('selectedNotebookId=null 이면 scope=current 에서 notebookId=undefined 전달', () => {
|
||||
mockSelectedNotebookId = null;
|
||||
render(<SearchBox />);
|
||||
const input = screen.getByRole('searchbox');
|
||||
fireEvent.change(input, { target: { value: '테스트' } });
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(mockSearchNotes).toHaveBeenCalledWith('테스트', { notebookId: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user