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 { export function SearchBox(): React.ReactElement {
const [draft, setDraft] = useState(''); const [draft, setDraft] = useState('');
const [scope, setScope] = useState<'current' | 'all'>('current');
const selectedNotebookId = useInbox((s) => s.selectedNotebookId);
useEffect(() => { useEffect(() => {
const handle = setTimeout(() => { const handle = setTimeout(() => {
const trimmed = draft.trim(); const trimmed = draft.trim();
if (trimmed.length === 0) useInbox.getState().clearSearch(); if (trimmed.length === 0) {
else void useInbox.getState().searchNotes(trimmed); 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); }, 200);
return () => clearTimeout(handle); return () => clearTimeout(handle);
}, [draft]); }, [draft, scope, selectedNotebookId]);
return ( return (
<input <span style={{ display: 'inline-flex', alignItems: 'center' }}>
type="search" <input
role="searchbox" type="search"
placeholder="검색…" role="searchbox"
value={draft} placeholder="검색…"
onChange={(e) => setDraft(e.target.value)} value={draft}
aria-label="노트 검색" onChange={(e) => setDraft(e.target.value)}
style={{ aria-label="노트 검색"
marginLeft: 12, style={{
padding: '4px 8px', marginLeft: 12,
fontSize: 12, padding: '4px 8px',
border: '1px solid #bbb', fontSize: 12,
borderRadius: 4, border: '1px solid #bbb',
width: 200 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 { 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 { inboxApi, notebookApi } from './api.js';
import { nextKstMidnightMs } from '@shared/util/kstDate.js'; import { nextKstMidnightMs } from '@shared/util/kstDate.js';
@@ -79,7 +79,8 @@ interface InboxState {
snoozeRecall: () => Promise<void>; snoozeRecall: () => Promise<void>;
// v0.2.11 Cut D — search + review actions. // v0.2.11 Cut D — search + review actions.
setSearchQuery: (q: string) => void; 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; clearSearch: () => void;
loadReview: (period: 'daily' | 'weekly' | 'monthly') => Promise<void>; loadReview: (period: 'daily' | 'weekly' | 'monthly') => Promise<void>;
// v0.4 — Notebook actions. // v0.4 — Notebook actions.
@@ -383,7 +384,7 @@ export const useInbox = create<InboxState>((set, get) => ({
set({ searchQuery: q }); set({ searchQuery: q });
if (q.trim().length === 0) set({ searchResults: null }); if (q.trim().length === 0) set({ searchResults: null });
}, },
async searchNotes(q) { async searchNotes(q, opts) {
if (q.trim().length === 0) { if (q.trim().length === 0) {
set({ searchResults: null }); set({ searchResults: null });
return; return;
@@ -394,7 +395,10 @@ export const useInbox = create<InboxState>((set, get) => ({
? (view === 'trash' ? 'trashed' : view) ? (view === 'trash' ? 'trashed' : view)
: view === 'inbox' ? 'active' : undefined; : view === 'inbox' ? 'active' : undefined;
try { 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 }); set({ searchResults: r });
} catch (e) { } catch (e) {
// FTS5 query parse error (special char 미escape) / IPC fail → 빈 결과로. // FTS5 query parse error (special char 미escape) / IPC fail → 빈 결과로.

View File

@@ -9,10 +9,13 @@ const { mockSearchNotes, mockClearSearch } = vi.hoisted(() => ({
mockClearSearch: vi.fn() mockClearSearch: vi.fn()
})); }));
// selectedNotebookId 를 테스트 간 변경할 수 있도록 ref 로 관리.
let mockSelectedNotebookId: string | null = 'nb-1';
vi.mock('../../src/renderer/inbox/store.js', () => ({ vi.mock('../../src/renderer/inbox/store.js', () => ({
useInbox: Object.assign( useInbox: Object.assign(
(selector?: (s: { searchQuery: string }) => unknown) => { (selector?: (s: { searchQuery: string; selectedNotebookId: string | null }) => unknown) => {
const state = { searchQuery: '' }; const state = { searchQuery: '', selectedNotebookId: mockSelectedNotebookId };
return selector ? selector(state) : state; return selector ? selector(state) : state;
}, },
{ getState: () => ({ searchNotes: mockSearchNotes, clearSearch: mockClearSearch }) } { getState: () => ({ searchNotes: mockSearchNotes, clearSearch: mockClearSearch }) }
@@ -26,6 +29,7 @@ describe('SearchBox', () => {
vi.clearAllMocks(); vi.clearAllMocks();
cleanup(); cleanup();
vi.useFakeTimers(); vi.useFakeTimers();
mockSelectedNotebookId = 'nb-1';
}); });
it('타이핑 → 200ms debounce 후 searchNotes 호출', () => { it('타이핑 → 200ms debounce 후 searchNotes 호출', () => {
@@ -34,7 +38,7 @@ describe('SearchBox', () => {
fireEvent.change(input, { target: { value: '회의' } }); fireEvent.change(input, { target: { value: '회의' } });
expect(mockSearchNotes).not.toHaveBeenCalled(); expect(mockSearchNotes).not.toHaveBeenCalled();
vi.advanceTimersByTime(200); vi.advanceTimersByTime(200);
expect(mockSearchNotes).toHaveBeenCalledWith('회의'); expect(mockSearchNotes).toHaveBeenCalledWith('회의', { notebookId: 'nb-1' });
}); });
it('빈 값 → clearSearch 호출', () => { it('빈 값 → clearSearch 호출', () => {
@@ -44,4 +48,32 @@ describe('SearchBox', () => {
vi.advanceTimersByTime(200); vi.advanceTimersByTime(200);
expect(mockClearSearch).toHaveBeenCalled(); 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 });
});
}); });