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 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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user