feat(search): scope 토글 — 이 노트북 / 모든 노트북

SearchBox 에 scope dropdown 추가. 기본 'current' (현재 notebook ID 전달),
'all' 선택 시 notebookId=undefined 로 전체 검색. store.searchNotes opts 인자 추가.
This commit is contained in:
th-kim0823
2026-05-15 11:07:37 +09:00
parent a7a90b8701
commit 343624dceb
3 changed files with 80 additions and 26 deletions

View File

@@ -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>
);
}

View File

@@ -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 → 빈 결과로.

View File

@@ -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 });
});
});