diff --git a/src/renderer/inbox/components/NoteCard.tsx b/src/renderer/inbox/components/NoteCard.tsx index 6abc476..70ba98c 100644 --- a/src/renderer/inbox/components/NoteCard.tsx +++ b/src/renderer/inbox/components/NoteCard.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import type { Note } from '@shared/types'; +import type { Note, Notebook } from '@shared/types'; import { KST_OFFSET_MS } from '@shared/util/kstDate.js'; import { inboxApi } from '../api.js'; import { useInbox } from '../store.js'; @@ -109,6 +109,55 @@ function DueDateBadge({ ); } +function NotebookChip({ current, notebooks, onMove }: { + current: Notebook; + notebooks: Notebook[]; + onMove: (id: string) => Promise; +}): React.ReactElement { + const [open, setOpen] = useState(false); + return ( + + + {open && ( +
+ {notebooks.filter((nb) => nb.id !== current.id).map((nb) => ( + + ))} +
+ )} +
+ ); +} + export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore, onPermanentDelete }: Props): React.ReactElement { const isTrash = mode === 'trash'; // v0.2.9 Cut B Task 13 — ai_status='disabled' 노트는 raw_text 가 1차 정보. 원문 펼침 default 켬. @@ -122,6 +171,10 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore const [draftRaw, setDraftRaw] = useState(''); const [showRevisions, setShowRevisions] = useState(false); + const notebooks = useInbox((s) => s.notebooks); + const moveNoteToNotebook = useInbox((s) => s.moveNoteToNotebook); + const currentNb = notebooks.find((nb) => nb.id === local.notebookId); + React.useEffect(() => { setLocal(note); }, [note]); const formatted = new Date(note.createdAt).toLocaleString('ko-KR'); @@ -393,6 +446,18 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore ))} )} + {currentNb && ( +
+ { + await moveNoteToNotebook(local.id, newId); + setLocal({ ...local, notebookId: newId }); + }} + /> +
+ )} {local.userIntent !== null && (
💡 diff --git a/tests/unit/NoteCard.test.tsx b/tests/unit/NoteCard.test.tsx index f8d8f15..5b5c19b 100644 --- a/tests/unit/NoteCard.test.tsx +++ b/tests/unit/NoteCard.test.tsx @@ -2,16 +2,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import '@testing-library/jest-dom/vitest'; import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; -import type { Note } from '@shared/types'; +import type { Note, Notebook } from '@shared/types'; -const { mockOpenMedia, mockSetStatus, mockClassify, mockUpdateRawText } = vi.hoisted(() => ({ +const { mockOpenMedia, mockSetStatus, mockClassify, mockUpdateRawText, mockMoveNoteToNotebook } = vi.hoisted(() => ({ mockOpenMedia: vi.fn(async () => ({ ok: true })), mockSetStatus: vi.fn(async () => ({ ok: true as const })), mockClassify: vi.fn(async () => ({ recommended: 'archived' as const, rationale: 'stub' })), - mockUpdateRawText: vi.fn(async () => ({ ok: true as const })) + mockUpdateRawText: vi.fn(async () => ({ ok: true as const })), + mockMoveNoteToNotebook: vi.fn(async () => {}) })); vi.mock('../../src/renderer/inbox/api.js', () => ({ @@ -33,9 +34,24 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({ })); const mockRefreshMeta = vi.fn(); + +// Notebooks used across notebook-chip tests. +const stubNotebooks: Notebook[] = [ + { id: 'nb-1', name: '회사', color: '#4a90d9', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', noteCount: 1 }, + { id: 'nb-2', name: '개인', color: '#e67e22', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', noteCount: 0 } +]; + vi.mock('../../src/renderer/inbox/store.js', () => ({ useInbox: Object.assign( - () => ({}), + // Selector-aware: if selector is a function, call it with the mock state. + (selector?: (s: unknown) => unknown) => { + const state = { + notebooks: stubNotebooks, + moveNoteToNotebook: mockMoveNoteToNotebook + }; + if (typeof selector === 'function') return selector(state); + return state; + }, { getState: () => ({ setTagFilter: vi.fn(), refreshMeta: mockRefreshMeta }) } ) })); @@ -190,3 +206,33 @@ describe('NoteCard — raw_text editing', () => { expect(last.rawText).toBe('new'); }); }); + +describe('NoteCard — notebook chip (Task 17)', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + }); + + it('현재 notebook 이름 chip 렌더링', () => { + render(); + expect(screen.getByTitle('다른 노트북으로 이동')).toBeInTheDocument(); + expect(screen.getByTitle('다른 노트북으로 이동').textContent).toContain('회사'); + }); + + it('chip 클릭 → 다른 notebook 목록 dropdown', () => { + render(); + fireEvent.click(screen.getByTitle('다른 노트북으로 이동')); + // 현재 nb-1('회사') 는 제외, nb-2('개인') 만 보임. + expect(screen.getByText('개인')).toBeInTheDocument(); + expect(screen.queryAllByText('회사').length).toBe(1); // chip 버튼 안에만 존재 + }); + + it('dropdown 의 notebook 클릭 → store.moveNoteToNotebook 호출', async () => { + render(); + fireEvent.click(screen.getByTitle('다른 노트북으로 이동')); + fireEvent.click(screen.getByText('개인')); + await waitFor(() => { + expect(mockMoveNoteToNotebook).toHaveBeenCalledWith('n1', 'nb-2'); + }); + }); +});