From 8ffb2408e47a84cfa4b8518fde8be1d73a5c57c7 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Fri, 15 May 2026 10:49:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20NotebookCreateModal=20=E2=80=94=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20+=20=EC=83=89=20+=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20+=20esc/overlay=20=EB=8B=AB=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../inbox/components/NotebookCreateModal.tsx | 59 +++++++++++++++-- tests/unit/NotebookCreateModal.test.tsx | 64 +++++++++++++++++++ 2 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 tests/unit/NotebookCreateModal.test.tsx diff --git a/src/renderer/inbox/components/NotebookCreateModal.tsx b/src/renderer/inbox/components/NotebookCreateModal.tsx index 3c85ce7..30d809d 100644 --- a/src/renderer/inbox/components/NotebookCreateModal.tsx +++ b/src/renderer/inbox/components/NotebookCreateModal.tsx @@ -1,14 +1,63 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { useInbox } from '../store.js'; + +const COLOR_PALETTE = ['#0a4b80', '#236b1a', '#946100', '#a55', '#5a3a8c', '#1a5b6e']; -// Stub for Task 12 — full implementation in Task 13. export function NotebookCreateModal({ onClose }: { onClose: () => void }): React.ReactElement { + const createNotebook = useInbox((s) => s.createNotebook); + const [name, setName] = useState(''); + const [color, setColor] = useState(COLOR_PALETTE[0]!); + const [err, setErr] = useState(null); + + async function onSubmit() { + const r = await createNotebook(name.trim(), color); + if (r.ok) onClose(); + else setErr(r.reason === 'duplicate_name' ? '같은 이름의 노트북이 이미 있어요.' : (r.reason ?? '저장 실패')); + } + return (
-
- (NotebookCreateModal — Task 13 에서 구현) +
e.stopPropagation()} + style={{ background: '#fff', padding: 20, borderRadius: 8, width: 360 }} + > +

새 노트북

+ setName(e.target.value)} + placeholder="예: 회사, 학습" + autoFocus + style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4, boxSizing: 'border-box' }} + /> +
+ {COLOR_PALETTE.map((c) => ( +
+ {err &&
{err}
} +
+ + +
); diff --git a/tests/unit/NotebookCreateModal.test.tsx b/tests/unit/NotebookCreateModal.test.tsx new file mode 100644 index 0000000..1397c3c --- /dev/null +++ b/tests/unit/NotebookCreateModal.test.tsx @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; + +const createNotebook = vi.fn(async (): Promise<{ ok: boolean; reason?: string }> => ({ ok: true })); + +vi.mock('../../src/renderer/inbox/store.js', () => ({ + useInbox: (selector?: (s: { createNotebook: typeof createNotebook }) => unknown) => { + const state = { createNotebook }; + return selector ? selector(state) : state; + } +})); + +import { NotebookCreateModal } from '../../src/renderer/inbox/components/NotebookCreateModal'; + +describe('NotebookCreateModal', () => { + beforeEach(() => { + cleanup(); + createNotebook.mockClear(); + }); + + it('이름 빈 상태에서 "만들기" disabled', () => { + render( {}} />); + const btn = screen.getByRole('button', { name: '만들기' }); + expect(btn).toBeDisabled(); + }); + + it('이름 입력 후 만들기 클릭 → createNotebook 호출 + onClose', async () => { + const onClose = vi.fn(); + createNotebook.mockResolvedValueOnce({ ok: true }); + render(); + fireEvent.change(screen.getByLabelText('노트북 이름'), { target: { value: '회사' } }); + fireEvent.click(screen.getByRole('button', { name: '만들기' })); + await waitFor(() => expect(createNotebook).toHaveBeenCalledWith('회사', expect.any(String))); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); + + it('duplicate_name reason 시 에러 표시 + onClose 안 됨', async () => { + const onClose = vi.fn(); + createNotebook.mockResolvedValueOnce({ ok: false, reason: 'duplicate_name' }); + render(); + fireEvent.change(screen.getByLabelText('노트북 이름'), { target: { value: '기본' } }); + fireEvent.click(screen.getByRole('button', { name: '만들기' })); + await waitFor(() => expect(screen.getByText(/이미 있어요/)).toBeInTheDocument()); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('overlay 클릭 → onClose', () => { + const onClose = vi.fn(); + const { container } = render(); + fireEvent.click(container.firstChild as HTMLElement); + expect(onClose).toHaveBeenCalled(); + }); + + it('color palette 클릭 시 선택 색 변경 (border 확인)', () => { + render( {}} />); + const colorBtns = screen.getAllByRole('button').filter((b) => b.getAttribute('aria-label')?.startsWith('색 ')); + expect(colorBtns).toHaveLength(6); + fireEvent.click(colorBtns[2]!); + // 선택 색의 border 가 '2px solid #333' 인지 확인 + expect(colorBtns[2]!.style.border).toContain('2px'); + }); +});