feat(app): Sidebar 통합 + 헤더 3탭 + Cmd+B 단축키 + PromotionBanner
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,8 @@ import { SettingsPage } from './components/SettingsPage.js';
|
||||
import { OnboardingWizard } from './components/OnboardingWizard.js';
|
||||
import { SearchBox } from './components/SearchBox.js';
|
||||
import { ReviewView } from './components/ReviewView.js';
|
||||
import { Sidebar } from './components/Sidebar.js';
|
||||
import { PromotionBanner } from './components/PromotionBanner.js';
|
||||
import type { InboxView } from './store.js';
|
||||
|
||||
// QuickCapture 단축키 modifier — macOS 는 Cmd, 그 외는 Ctrl.
|
||||
@@ -51,6 +53,23 @@ export function App(): React.ReactElement {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void useInbox.getState().loadNotebooks();
|
||||
void useInbox.getState().loadPromotionCandidates();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const isMac = /Mac/i.test(navigator.platform);
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'b' && (isMac ? e.metaKey : e.ctrlKey) && !e.shiftKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
useInbox.getState().toggleSidebar();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadInitial();
|
||||
const unsubNote = inboxApi.onNoteUpdated((note) => {
|
||||
@@ -95,7 +114,9 @@ export function App(): React.ReactElement {
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', height: '100vh' }}>
|
||||
<Sidebar />
|
||||
<main style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<div className="header">
|
||||
<h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1>
|
||||
<div style={{ display: 'flex', gap: 6, marginLeft: 12 }}>
|
||||
@@ -103,7 +124,6 @@ export function App(): React.ReactElement {
|
||||
[
|
||||
{ key: 'inbox', label: 'Inbox', count: counts.active },
|
||||
{ key: 'completed', label: '완료', count: counts.completed },
|
||||
{ key: 'archived', label: '보관', count: counts.archived },
|
||||
{ key: 'trash', label: '휴지통', count: counts.trashed }
|
||||
] as const
|
||||
).map((t) => (
|
||||
@@ -153,7 +173,7 @@ export function App(): React.ReactElement {
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
<main className="main">
|
||||
<div className="main">
|
||||
{!showTrash && (
|
||||
<>
|
||||
{/* AI/만료/회상 배너는 active 노트 컨텍스트 — inbox 탭에서만 노출.
|
||||
@@ -169,6 +189,7 @@ export function App(): React.ReactElement {
|
||||
<FailedBanner />
|
||||
<ExpiryBanner />
|
||||
<RecallBanner />
|
||||
<PromotionBanner />
|
||||
</>
|
||||
)}
|
||||
{tagFilter !== null && (
|
||||
@@ -237,8 +258,9 @@ export function App(): React.ReactElement {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
<TagUndoToast />
|
||||
</>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
notebookApi: {
|
||||
list: vi.fn(async () => [])
|
||||
},
|
||||
inboxApi: {
|
||||
listNotes: vi.fn(async () => []),
|
||||
listByStatus: vi.fn(async () => []),
|
||||
@@ -66,7 +69,11 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
// v0.3.1 Cut F — VisionSection 이 AiProviderSection 에 마운트되어 호출.
|
||||
getVisionModels: vi.fn(async () => ({ models: [], at: null, selected: null })),
|
||||
setVisionModel: vi.fn(async () => ({ ok: true as const })),
|
||||
refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] }))
|
||||
refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] })),
|
||||
// v0.4 Task 15 — loadPromotionCandidates 초기화 stub.
|
||||
listPromotionCandidates: vi.fn(async () => []),
|
||||
getPromotionDismissedTags: vi.fn(async () => []),
|
||||
getPromotionSnoozeUntil: vi.fn(async () => 0)
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -81,7 +88,8 @@ describe('App — settings view', () => {
|
||||
view: 'inbox',
|
||||
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
|
||||
showSettings: false, showTrash: false,
|
||||
notes: [], trashNotes: [], trashCount: 0
|
||||
notes: [], trashNotes: [], trashCount: 0,
|
||||
sidebarVisible: false, notebooks: [], promotionCandidates: []
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,14 +122,15 @@ describe('App — settings view', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('App header — 4 tabs', () => {
|
||||
describe('App header — 3 tabs (v0.4)', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
useInbox.setState({
|
||||
view: 'inbox',
|
||||
counts: { active: 5, completed: 3, archived: 2, trashed: 1 },
|
||||
notes: [], trashNotes: [], trashCount: 0,
|
||||
showTrash: false, showSettings: false
|
||||
showTrash: false, showSettings: false,
|
||||
sidebarVisible: false, notebooks: [], promotionCandidates: []
|
||||
});
|
||||
// loadInitial 이 비동기로 counts 를 덮어씀 — onboarding wizard async gate (Task 12) 도입
|
||||
// 후 render 가 await 후 발생하므로 mock 의 countsByStatus 가 테스트 기대값을 반환하도록 갱신.
|
||||
@@ -129,35 +138,48 @@ describe('App header — 4 tabs', () => {
|
||||
vi.mocked(inboxApi.countsByStatus).mockResolvedValue({ active: 5, completed: 3, trashed: 1 });
|
||||
});
|
||||
|
||||
it('renders 4 tabs with counts', async () => {
|
||||
it('renders 3 tabs (Inbox/완료/휴지통) with counts', async () => {
|
||||
render(<App />);
|
||||
expect(await screen.findByRole('tab', { name: /Inbox\(5\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /완료\(3\)/ })).toBeInTheDocument();
|
||||
// v0.4 — archived count 는 IPC 응답에서 제거됨 → store 가 0 fallback. 보관 탭은 Task 15 에서 제거 예정.
|
||||
expect(screen.getByRole('tab', { name: /보관\(0\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /휴지통\(1\)/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('보관 탭이 헤더에 없음', async () => {
|
||||
render(<App />);
|
||||
await screen.findByRole('tab', { name: /Inbox/ });
|
||||
expect(screen.queryByRole('tab', { name: /보관/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('clicking 완료 tab sets view=completed', async () => {
|
||||
render(<App />);
|
||||
fireEvent.click(await screen.findByRole('tab', { name: /완료/ }));
|
||||
expect(useInbox.getState().view).toBe('completed');
|
||||
});
|
||||
|
||||
it('aria-selected reflects current view', async () => {
|
||||
useInbox.setState({ view: 'archived' });
|
||||
render(<App />);
|
||||
const archivedBtn = await screen.findByRole('tab', { name: /보관/ });
|
||||
expect(archivedBtn.getAttribute('aria-selected')).toBe('true');
|
||||
const inboxBtn = screen.getByRole('tab', { name: /Inbox/ });
|
||||
expect(inboxBtn.getAttribute('aria-selected')).toBe('false');
|
||||
});
|
||||
|
||||
it('inbox tab has aria-selected="true" when active', async () => {
|
||||
render(<App />);
|
||||
const inboxTab = await screen.findByRole('tab', { name: /Inbox/ });
|
||||
expect(inboxTab).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('Cmd+B 키 이벤트가 toggleSidebar 호출', async () => {
|
||||
const initialVisible = useInbox.getState().sidebarVisible;
|
||||
render(<App />);
|
||||
await screen.findByRole('tab', { name: /Inbox/ });
|
||||
// jsdom 에서 navigator.platform = '' → isMac=false → ctrlKey 로 판단.
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true }));
|
||||
// toggleSidebar 가 호출되면 sidebarVisible 이 반전됨.
|
||||
expect(useInbox.getState().sidebarVisible).toBe(!initialVisible);
|
||||
});
|
||||
|
||||
it('Sidebar 컴포넌트가 렌더 트리에 포함됨 (sidebarVisible=true)', async () => {
|
||||
useInbox.setState({ sidebarVisible: true, notebooks: [] });
|
||||
render(<App />);
|
||||
await screen.findByRole('tab', { name: /Inbox/ });
|
||||
// Sidebar renders an <aside> element when visible
|
||||
expect(document.querySelector('aside')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('App — onboarding wizard', () => {
|
||||
@@ -167,7 +189,8 @@ describe('App — onboarding wizard', () => {
|
||||
view: 'inbox',
|
||||
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
|
||||
showSettings: false, showTrash: false,
|
||||
notes: [], trashNotes: [], trashCount: 0
|
||||
notes: [], trashNotes: [], trashCount: 0,
|
||||
sidebarVisible: false, notebooks: [], promotionCandidates: []
|
||||
});
|
||||
// 각 테스트가 getSettings 의 default mock 을 직접 override.
|
||||
vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: true });
|
||||
|
||||
Reference in New Issue
Block a user