feat(ui): NoteCard 의 notebook chip + 1-click 이동

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-15 11:05:20 +09:00
parent 274c171ee8
commit a7a90b8701
2 changed files with 116 additions and 5 deletions

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'; 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 { KST_OFFSET_MS } from '@shared/util/kstDate.js';
import { inboxApi } from '../api.js'; import { inboxApi } from '../api.js';
import { useInbox } from '../store.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<void>;
}): React.ReactElement {
const [open, setOpen] = useState(false);
return (
<span style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={() => setOpen(!open)}
title="다른 노트북으로 이동"
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
background: '#f0f0f0', border: 'none', borderRadius: 10,
padding: '2px 8px', fontSize: 11, cursor: 'pointer'
}}
>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: current.color ?? '#bbb', display: 'inline-block' }} />
{current.name}
</button>
{open && (
<div
style={{
position: 'absolute', top: '100%', left: 0,
background: '#fff', border: '1px solid #ccc', borderRadius: 4,
zIndex: 50, minWidth: 120, boxShadow: '0 2px 6px rgba(0,0,0,0.15)'
}}
>
{notebooks.filter((nb) => nb.id !== current.id).map((nb) => (
<button
key={nb.id}
onClick={async () => { await onMove(nb.id); setOpen(false); }}
style={{
display: 'flex', alignItems: 'center', gap: 6,
width: '100%', textAlign: 'left',
padding: '4px 10px', border: 'none', background: 'transparent',
cursor: 'pointer', fontSize: 11
}}
>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: nb.color ?? '#bbb', display: 'inline-block' }} />
{nb.name}
</button>
))}
</div>
)}
</span>
);
}
export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore, onPermanentDelete }: Props): React.ReactElement { export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore, onPermanentDelete }: Props): React.ReactElement {
const isTrash = mode === 'trash'; const isTrash = mode === 'trash';
// v0.2.9 Cut B Task 13 — ai_status='disabled' 노트는 raw_text 가 1차 정보. 원문 펼침 default 켬. // 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 [draftRaw, setDraftRaw] = useState('');
const [showRevisions, setShowRevisions] = useState(false); 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]); React.useEffect(() => { setLocal(note); }, [note]);
const formatted = new Date(note.createdAt).toLocaleString('ko-KR'); const formatted = new Date(note.createdAt).toLocaleString('ko-KR');
@@ -393,6 +446,18 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
))} ))}
</div> </div>
)} )}
{currentNb && (
<div style={{ marginTop: 8 }}>
<NotebookChip
current={currentNb}
notebooks={notebooks}
onMove={async (newId) => {
await moveNoteToNotebook(local.id, newId);
setLocal({ ...local, notebookId: newId });
}}
/>
</div>
)}
{local.userIntent !== null && ( {local.userIntent !== null && (
<div style={{ marginTop: 10, padding: 8, background: '#fffbe9', borderRadius: 6 }}> <div style={{ marginTop: 10, padding: 8, background: '#fffbe9', borderRadius: 6 }}>
<span style={{ fontSize: 12, color: '#7a5a00', marginRight: 6 }}>💡</span> <span style={{ fontSize: 12, color: '#7a5a00', marginRight: 6 }}>💡</span>

View File

@@ -2,16 +2,17 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest'; import '@testing-library/jest-dom/vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; 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 })), mockOpenMedia: vi.fn(async () => ({ ok: true })),
mockSetStatus: vi.fn(async () => ({ ok: true as const })), mockSetStatus: vi.fn(async () => ({ ok: true as const })),
mockClassify: vi.fn(async () => ({ mockClassify: vi.fn(async () => ({
recommended: 'archived' as const, recommended: 'archived' as const,
rationale: 'stub' 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', () => ({ vi.mock('../../src/renderer/inbox/api.js', () => ({
@@ -33,9 +34,24 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
})); }));
const mockRefreshMeta = vi.fn(); 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', () => ({ vi.mock('../../src/renderer/inbox/store.js', () => ({
useInbox: Object.assign( 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 }) } { getState: () => ({ setTagFilter: vi.fn(), refreshMeta: mockRefreshMeta }) }
) )
})); }));
@@ -190,3 +206,33 @@ describe('NoteCard — raw_text editing', () => {
expect(last.rawText).toBe('new'); expect(last.rawText).toBe('new');
}); });
}); });
describe('NoteCard — notebook chip (Task 17)', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
it('현재 notebook 이름 chip 렌더링', () => {
render(<NoteCard note={{ ...baseNote, notebookId: 'nb-1' }} onUpdated={vi.fn()} mode="inbox" />);
expect(screen.getByTitle('다른 노트북으로 이동')).toBeInTheDocument();
expect(screen.getByTitle('다른 노트북으로 이동').textContent).toContain('회사');
});
it('chip 클릭 → 다른 notebook 목록 dropdown', () => {
render(<NoteCard note={{ ...baseNote, notebookId: 'nb-1' }} onUpdated={vi.fn()} mode="inbox" />);
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(<NoteCard note={{ ...baseNote, notebookId: 'nb-1' }} onUpdated={vi.fn()} mode="inbox" />);
fireEvent.click(screen.getByTitle('다른 노트북으로 이동'));
fireEvent.click(screen.getByText('개인'));
await waitFor(() => {
expect(mockMoveNoteToNotebook).toHaveBeenCalledWith('n1', 'nb-2');
});
});
});