feat(v0210): NoteCard 원문 영역 편집 UI (textarea + 저장/취소 + updateRawText)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -118,6 +118,9 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
// v0.2.9 Cut B Task 6 — 이동 메뉴 dropdown + MoveStatusModal target.
|
||||
const [moveTarget, setMoveTarget] = useState<NoteStatus | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [editingRaw, setEditingRaw] = useState(false);
|
||||
const [draftRaw, setDraftRaw] = useState('');
|
||||
const [showRevisions, setShowRevisions] = useState(false);
|
||||
|
||||
const possibleTargets: NoteStatus[] = (
|
||||
['active', 'completed', 'archived', 'trashed'] as NoteStatus[]
|
||||
@@ -150,6 +153,17 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
setLocal(updated); onUpdated(updated);
|
||||
}
|
||||
|
||||
async function saveRaw() {
|
||||
const next = draftRaw;
|
||||
if (next.trim().length === 0) return;
|
||||
const r = await inboxApi.updateRawText(note.id, next);
|
||||
if (!r.ok) return;
|
||||
const updated = { ...local, rawText: next, updatedAt: new Date().toISOString() };
|
||||
setLocal(updated);
|
||||
onUpdated(updated);
|
||||
setEditingRaw(false);
|
||||
}
|
||||
|
||||
async function removeTag(tagName: string) {
|
||||
const removed = local.tags.find((t) => t.name === tagName);
|
||||
const nextTagNames = local.tags.filter((t) => t.name !== tagName).map((t) => t.name);
|
||||
@@ -371,9 +385,32 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
{rawOpen ? '▾ 원문 접기' : '▸ 원문 보기'}
|
||||
</button>
|
||||
{rawOpen && (
|
||||
<pre style={{ marginTop: 6, whiteSpace: 'pre-wrap', fontSize: 12, color: '#555', background: '#fafafa', padding: 8, borderRadius: 4 }}>
|
||||
{local.rawText}
|
||||
</pre>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{editingRaw ? (
|
||||
<div>
|
||||
<textarea
|
||||
aria-label="원문 편집"
|
||||
value={draftRaw}
|
||||
onChange={(e) => setDraftRaw(e.target.value)}
|
||||
style={{ width: '100%', minHeight: 80, fontSize: 12, fontFamily: 'inherit', padding: 8, border: '1px solid #ddd', borderRadius: 4, boxSizing: 'border-box' }}
|
||||
/>
|
||||
<div style={{ marginTop: 4, display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => setEditingRaw(false)} style={{ background: 'none', border: '1px solid #ccc', color: '#444', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}>취소</button>
|
||||
<button onClick={() => { void saveRaw(); }} style={{ background: '#0a4b80', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}>저장</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', fontSize: 12, color: '#555', background: '#fafafa', padding: 8, borderRadius: 4 }}>
|
||||
{local.rawText}
|
||||
</pre>
|
||||
<div style={{ marginTop: 4, display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => setShowRevisions(true)} style={{ background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 12, padding: 0 }}>이력</button>
|
||||
<button onClick={() => { setDraftRaw(local.rawText); setEditingRaw(true); }} style={{ background: 'none', border: '1px solid #ccc', color: '#444', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}>편집</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,13 +4,14 @@ import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
const { mockOpenMedia, mockSetStatus, mockClassify } = vi.hoisted(() => ({
|
||||
const { mockOpenMedia, mockSetStatus, mockClassify, mockUpdateRawText } = 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 }))
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
@@ -24,7 +25,10 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
setIntent: vi.fn(),
|
||||
dismissIntent: vi.fn(),
|
||||
setStatus: mockSetStatus,
|
||||
classifyStatus: mockClassify
|
||||
classifyStatus: mockClassify,
|
||||
updateRawText: mockUpdateRawText,
|
||||
listRevisions: vi.fn(async () => []),
|
||||
restoreRevision: vi.fn(async () => ({ ok: true as const }))
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -154,3 +158,31 @@ describe('NoteCard — 이동 메뉴 (v0.2.9 Cut B Task 6)', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteCard — raw_text editing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('원문 편집: textarea 저장 → updateRawText 호출 + 로컬 raw 갱신', async () => {
|
||||
const onUpdated = vi.fn();
|
||||
render(<NoteCard note={{ ...baseNote, rawText: 'old' }} onUpdated={onUpdated} mode="inbox" />);
|
||||
// 원문 펼침
|
||||
fireEvent.click(screen.getByRole('button', { name: /원문/ }));
|
||||
// 편집 진입
|
||||
fireEvent.click(screen.getByRole('button', { name: '편집' }));
|
||||
const ta = screen.getByRole('textbox', { name: /원문 편집/ }) as HTMLTextAreaElement;
|
||||
fireEvent.change(ta, { target: { value: 'new' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '저장' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateRawText).toHaveBeenCalledWith('n1', 'new');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(onUpdated).toHaveBeenCalled();
|
||||
});
|
||||
const last = onUpdated.mock.calls.at(-1)![0] as { rawText: string };
|
||||
expect(last.rawText).toBe('new');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user