feat(ui): NoteCard 의 notebook chip + 1-click 이동
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<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 {
|
||||
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
|
||||
))}
|
||||
</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 && (
|
||||
<div style={{ marginTop: 10, padding: 8, background: '#fffbe9', borderRadius: 6 }}>
|
||||
<span style={{ fontSize: 12, color: '#7a5a00', marginRight: 6 }}>💡</span>
|
||||
|
||||
@@ -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(<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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user