From 495c3d12a21055ad9047472e0c8b2d6f579fa649 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 9 May 2026 16:03:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(v029):=20NoteCard=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20(status=204=EB=B6=84=EA=B8=B0=20dropdown)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cut B Task 6 — 모든 view 공통 "이동 ▾" dropdown. - 기존 휴지통/삭제 버튼 위치에 dropdown 추가 (모든 mode 공통) - 현재 status 외 3개 목적지만 표시 (active 노트 → 완료/보관/휴지통) - 메뉴 항목 클릭 → MoveStatusModal(initialTarget) 열기 - onMoved → local 상태 갱신 + onUpdated + (status 변경 시) onDeleted (list 제거) - trash mode 의 영구 삭제/복구 버튼은 보존 (휴지통 단독 액션) - 사용되지 않게 된 handleDelete 제거 (deleteNote 는 capture path 만) - NoteCard 메뉴 단위 테스트 2건 (메뉴 표시 / 클릭 → modal → setStatus) --- src/renderer/inbox/components/NoteCard.tsx | 139 +++++++++++++++++---- tests/unit/NoteCard.test.tsx | 47 ++++++- 2 files changed, 155 insertions(+), 31 deletions(-) diff --git a/src/renderer/inbox/components/NoteCard.tsx b/src/renderer/inbox/components/NoteCard.tsx index 7c9c98a..66994a2 100644 --- a/src/renderer/inbox/components/NoteCard.tsx +++ b/src/renderer/inbox/components/NoteCard.tsx @@ -1,10 +1,11 @@ import React, { useState } from 'react'; -import type { Note } from '@shared/types'; +import type { Note, NoteStatus } from '@shared/types'; import { inboxApi } from '../api.js'; import { useInbox } from '../store.js'; import { EditableField } from './EditableField.js'; import { IntentBanner } from './IntentBanner.js'; import { pushTagUndo } from './TagUndoToast.js'; +import { MoveStatusModal, statusLabel } from './MoveStatusModal.js'; interface Props { note: Note; @@ -111,17 +112,18 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore const isTrash = mode === 'trash'; const [rawOpen, setRawOpen] = useState(note.aiStatus !== 'done'); const [local, setLocal] = useState(note); + // v0.2.9 Cut B Task 6 — 이동 메뉴 dropdown + MoveStatusModal target. + const [moveTarget, setMoveTarget] = useState(null); + const [menuOpen, setMenuOpen] = useState(false); + + const possibleTargets: NoteStatus[] = ( + ['active', 'completed', 'archived', 'trashed'] as NoteStatus[] + ).filter((s) => s !== local.status); React.useEffect(() => { setLocal(note); }, [note]); const formatted = new Date(note.createdAt).toLocaleString('ko-KR'); - async function handleDelete() { - if (!window.confirm('이 기억을 버릴까요? 되돌릴 수 없습니다.')) return; - await inboxApi.deleteNote(note.id); - onDeleted?.(); - } - async function saveTitle(next: string) { await inboxApi.updateAiFields(note.id, { title: next }); const updated = { ...local, aiTitle: next, titleEditedByUser: true }; @@ -366,33 +368,116 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
- {isTrash ? ( -
+
+ {/* v0.2.9 Cut B Task 6 — 모든 view 공통 "이동 ▾" dropdown. + 현재 status 와 다른 3개 목적지만 표시. */} +
- + {menuOpen && ( +
+ {possibleTargets.map((t) => ( + + ))} +
+ )}
- ) : ( - - )} + + {/* trash mode 만 영구 삭제 + 복구 보존 (휴지통 단독 액션). */} + {isTrash && ( + <> + + + + )} +
+ + {moveTarget !== null && ( + setMoveTarget(null)} + onMoved={(newStatus, reason) => { + const updated = { ...local, status: newStatus, moveReason: reason }; + setLocal(updated); + onUpdated(updated); + // inbox/trash mode 의 list 가 status 별로 필터되므로 onDeleted (제거) 도 호출. + if (newStatus !== local.status) onDeleted?.(); + setMoveTarget(null); + }} + /> + )}
); } diff --git a/tests/unit/NoteCard.test.tsx b/tests/unit/NoteCard.test.tsx index 7d64ddb..ba7c9e0 100644 --- a/tests/unit/NoteCard.test.tsx +++ b/tests/unit/NoteCard.test.tsx @@ -1,11 +1,16 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from 'vitest'; import '@testing-library/jest-dom/vitest'; -import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; import type { Note } from '@shared/types'; -const { mockOpenMedia } = vi.hoisted(() => ({ - mockOpenMedia: vi.fn(async () => ({ ok: true })) +const { mockOpenMedia, mockSetStatus, mockClassify } = 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' + })) })); vi.mock('../../src/renderer/inbox/api.js', () => ({ @@ -17,7 +22,9 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({ updateAiFields: vi.fn(), setDueDate: vi.fn(), setIntent: vi.fn(), - dismissIntent: vi.fn() + dismissIntent: vi.fn(), + setStatus: mockSetStatus, + classifyStatus: mockClassify } })); @@ -82,3 +89,35 @@ describe('NoteCard — image rendering', () => { expect(mockOpenMedia).toHaveBeenCalledWith('media/n1/img1.png'); }); }); + +describe('NoteCard — 이동 메뉴 (v0.2.9 Cut B Task 6)', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + }); + + it('이동 ▾ 클릭 → 현재 status 외 3개 목적지 메뉴 표시', () => { + // baseNote.status = 'active' → 완료/보관/휴지통 만 표시 + render( {}} mode="inbox" />); + fireEvent.click(screen.getByRole('button', { name: '이동' })); + expect(screen.getByRole('button', { name: '완료로 이동' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '보관로 이동' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '휴지통로 이동' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '활성로 이동' })).toBeNull(); + }); + + it('메뉴 항목 클릭 → MoveStatusModal 열림 + 확정 시 setStatus 호출', async () => { + const onUpdated = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: '이동' })); + fireEvent.click(screen.getByRole('button', { name: '완료로 이동' })); + // Modal 의 dialog role 등장 + expect(screen.getByRole('dialog', { name: '이동' })).toBeInTheDocument(); + // Modal 내부의 "완료" 버튼 클릭 → setStatus + fireEvent.click(screen.getByRole('button', { name: '완료' })); + await waitFor(() => { + expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', null); + expect(onUpdated).toHaveBeenCalled(); + }); + }); +});