diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx index 071b9e1..9fb8f19 100644 --- a/src/renderer/inbox/App.tsx +++ b/src/renderer/inbox/App.tsx @@ -14,6 +14,7 @@ import { FailedBanner } from './components/FailedBanner.js'; import { RecallBanner } from './components/RecallBanner.js'; import { SettingsPage } from './components/SettingsPage.js'; import { OnboardingWizard } from './components/OnboardingWizard.js'; +import { SearchBox } from './components/SearchBox.js'; export function App(): React.ReactElement { const { @@ -28,6 +29,7 @@ export function App(): React.ReactElement { const view = useInbox((s) => s.view); const counts = useInbox((s) => s.counts); const setView = useInbox((s) => s.setView); + const searchResults = useInbox((s) => s.searchResults); const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday()); // v0.2.9 Cut B Task 12 — 첫 launch onboarding 분기. null = 로딩, true = 표시, false = 미표시. const [showOnboarding, setShowOnboarding] = useState(null); @@ -71,6 +73,7 @@ export function App(): React.ReactElement { const showRecovery = continuity.showRecoveryToast && !recoveryDismissed; const filtered = selectFilteredNotes({ notes, tagFilter }); + const displayed = searchResults !== null ? searchResults : filtered; const tabBtnStyle = (active: boolean): React.CSSProperties => ({ background: active ? '#0a4b80' : 'transparent', @@ -105,6 +108,7 @@ export function App(): React.ReactElement { ))} +
@@ -155,12 +159,14 @@ export function App(): React.ReactElement { )} {loading && notes.length === 0 ? (
불러오는 중…
+ ) : searchResults !== null && displayed.length === 0 ? ( +
검색 결과가 없습니다.
) : notes.length === 0 ? (
머릿속에 떠다니는 한 줄을 적어보세요. Ctrl+Shift+J
- ) : filtered.length === 0 ? ( + ) : displayed.length === 0 ? (
이 태그의 노트가 없습니다.
) : ( - filtered.map((n) => ( + displayed.map((n) => ( removeNote(n.id)} diff --git a/src/renderer/inbox/components/SearchBox.tsx b/src/renderer/inbox/components/SearchBox.tsx new file mode 100644 index 0000000..2de58a8 --- /dev/null +++ b/src/renderer/inbox/components/SearchBox.tsx @@ -0,0 +1,34 @@ +import React, { useEffect, useState } from 'react'; +import { useInbox } from '../store.js'; + +export function SearchBox(): React.ReactElement { + const [draft, setDraft] = useState(''); + + useEffect(() => { + const handle = setTimeout(() => { + const trimmed = draft.trim(); + if (trimmed.length === 0) useInbox.getState().clearSearch(); + else void useInbox.getState().searchNotes(trimmed); + }, 200); + return () => clearTimeout(handle); + }, [draft]); + + return ( + 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/tests/unit/SearchBox.test.tsx b/tests/unit/SearchBox.test.tsx new file mode 100644 index 0000000..9292ae7 --- /dev/null +++ b/tests/unit/SearchBox.test.tsx @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import React from 'react'; + +const { mockSearchNotes, mockClearSearch } = vi.hoisted(() => ({ + mockSearchNotes: vi.fn(), + mockClearSearch: vi.fn() +})); + +vi.mock('../../src/renderer/inbox/store.js', () => ({ + useInbox: Object.assign( + (selector?: (s: { searchQuery: string }) => unknown) => { + const state = { searchQuery: '' }; + return selector ? selector(state) : state; + }, + { getState: () => ({ searchNotes: mockSearchNotes, clearSearch: mockClearSearch }) } + ) +})); + +import { SearchBox } from '../../src/renderer/inbox/components/SearchBox'; + +describe('SearchBox', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + vi.useFakeTimers(); + }); + + it('타이핑 → 200ms debounce 후 searchNotes 호출', () => { + render(); + const input = screen.getByRole('searchbox'); + fireEvent.change(input, { target: { value: '회의' } }); + expect(mockSearchNotes).not.toHaveBeenCalled(); + vi.advanceTimersByTime(200); + expect(mockSearchNotes).toHaveBeenCalledWith('회의'); + }); + + it('빈 값 → clearSearch 호출', () => { + render(); + const input = screen.getByRole('searchbox'); + fireEvent.change(input, { target: { value: '' } }); + vi.advanceTimersByTime(200); + expect(mockClearSearch).toHaveBeenCalled(); + }); +});