feat(v0211): SearchBox + 헤더 mount + inbox 결과 렌더 분기
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<boolean | null>(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 {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SearchBox />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, marginLeft: 'auto' }}>
|
||||
<ContinuityBadge />
|
||||
<IdentityCounter />
|
||||
@@ -155,12 +159,14 @@ export function App(): React.ReactElement {
|
||||
)}
|
||||
{loading && notes.length === 0 ? (
|
||||
<div className="empty">불러오는 중…</div>
|
||||
) : searchResults !== null && displayed.length === 0 ? (
|
||||
<div className="empty">검색 결과가 없습니다.</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="empty">머릿속에 떠다니는 한 줄을 적어보세요. <code>Ctrl+Shift+J</code></div>
|
||||
) : filtered.length === 0 ? (
|
||||
) : displayed.length === 0 ? (
|
||||
<div className="empty">이 태그의 노트가 없습니다.</div>
|
||||
) : (
|
||||
filtered.map((n) => (
|
||||
displayed.map((n) => (
|
||||
<NoteCard
|
||||
key={n.id} note={n} mode="inbox"
|
||||
onDeleted={() => removeNote(n.id)}
|
||||
|
||||
34
src/renderer/inbox/components/SearchBox.tsx
Normal file
34
src/renderer/inbox/components/SearchBox.tsx
Normal file
@@ -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 (
|
||||
<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
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
47
tests/unit/SearchBox.test.tsx
Normal file
47
tests/unit/SearchBox.test.tsx
Normal file
@@ -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(<SearchBox />);
|
||||
const input = screen.getByRole('searchbox');
|
||||
fireEvent.change(input, { target: { value: '회의' } });
|
||||
expect(mockSearchNotes).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(mockSearchNotes).toHaveBeenCalledWith('회의');
|
||||
});
|
||||
|
||||
it('빈 값 → clearSearch 호출', () => {
|
||||
render(<SearchBox />);
|
||||
const input = screen.getByRole('searchbox');
|
||||
fireEvent.change(input, { target: { value: '' } });
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(mockClearSearch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user