From 343624dceb380eec5e8a5b5ab7af436929c1c673 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Fri, 15 May 2026 11:07:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(search):=20scope=20=ED=86=A0=EA=B8=80=20?= =?UTF-8?q?=E2=80=94=20=EC=9D=B4=20=EB=85=B8=ED=8A=B8=EB=B6=81=20/=20?= =?UTF-8?q?=EB=AA=A8=EB=93=A0=20=EB=85=B8=ED=8A=B8=EB=B6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SearchBox 에 scope dropdown 추가. 기본 'current' (현재 notebook ID 전달), 'all' 선택 시 notebookId=undefined 로 전체 검색. store.searchNotes opts 인자 추가. --- src/renderer/inbox/components/SearchBox.tsx | 56 ++++++++++++++------- src/renderer/inbox/store.ts | 12 +++-- tests/unit/SearchBox.test.tsx | 38 ++++++++++++-- 3 files changed, 80 insertions(+), 26 deletions(-) diff --git a/src/renderer/inbox/components/SearchBox.tsx b/src/renderer/inbox/components/SearchBox.tsx index 2de58a8..bfebff8 100644 --- a/src/renderer/inbox/components/SearchBox.tsx +++ b/src/renderer/inbox/components/SearchBox.tsx @@ -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 ( - setDraft(e.target.value)} - aria-label="노트 검색" - style={{ - marginLeft: 12, - padding: '4px 8px', - fontSize: 12, - border: '1px solid #bbb', - borderRadius: 4, - width: 200 - }} - /> + + setDraft(e.target.value)} + aria-label="노트 검색" + style={{ + marginLeft: 12, + padding: '4px 8px', + fontSize: 12, + border: '1px solid #bbb', + borderRadius: 4, + width: 200 + }} + /> + + ); } diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index 601b230..eb12e60 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -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; // v0.2.11 Cut D — search + review actions. setSearchQuery: (q: string) => void; - searchNotes: (q: string) => Promise; + // v0.4 Task 18 — scope 토글. notebookId 전달 시 해당 notebook 안 검색, undefined 시 전체 검색. + searchNotes: (q: string, opts?: { notebookId?: string }) => Promise; clearSearch: () => void; loadReview: (period: 'daily' | 'weekly' | 'monthly') => Promise; // v0.4 — Notebook actions. @@ -383,7 +384,7 @@ export const useInbox = create((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((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 → 빈 결과로. diff --git a/tests/unit/SearchBox.test.tsx b/tests/unit/SearchBox.test.tsx index 9292ae7..92b1b9c 100644 --- a/tests/unit/SearchBox.test.tsx +++ b/tests/unit/SearchBox.test.tsx @@ -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(); + 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(); + 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(); + const input = screen.getByRole('searchbox'); + fireEvent.change(input, { target: { value: '테스트' } }); + vi.advanceTimersByTime(200); + expect(mockSearchNotes).toHaveBeenCalledWith('테스트', { notebookId: undefined }); + }); });