feat(ui): NotebookCreateModal — 이름 + 색 + 중복 검증 + esc/overlay 닫기

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-15 10:49:05 +09:00
parent 7b3450d0d5
commit 8ffb2408e4
2 changed files with 118 additions and 5 deletions

View File

@@ -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<string>(COLOR_PALETTE[0]!);
const [err, setErr] = useState<string | null>(null);
async function onSubmit() {
const r = await createNotebook(name.trim(), color);
if (r.ok) onClose();
else setErr(r.reason === 'duplicate_name' ? '같은 이름의 노트북이 이미 있어요.' : (r.reason ?? '저장 실패'));
}
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 120 }}
onClick={onClose}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 120 }}
>
<div style={{ background: '#fff', padding: 20, margin: '20% auto', width: 320 }}>
(NotebookCreateModal Task 13 )
<div
onClick={(e) => e.stopPropagation()}
style={{ background: '#fff', padding: 20, borderRadius: 8, width: 360 }}
>
<h3 style={{ margin: '0 0 12px 0', fontSize: 15 }}> </h3>
<input
aria-label="노트북 이름"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="예: 회사, 학습"
autoFocus
style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4, boxSizing: 'border-box' }}
/>
<div style={{ display: 'flex', gap: 6, marginTop: 10 }}>
{COLOR_PALETTE.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
aria-label={`${c}`}
style={{
width: 24, height: 24, borderRadius: '50%',
background: c, border: c === color ? '2px solid #333' : '1px solid #ccc',
cursor: 'pointer', padding: 0
}}
/>
))}
</div>
{err && <div style={{ color: '#c33', fontSize: 12, marginTop: 8 }}>{err}</div>}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 14 }}>
<button onClick={onClose} style={{ padding: '5px 12px', fontSize: 12 }}></button>
<button
onClick={() => { void onSubmit(); }}
disabled={name.trim().length === 0}
style={{ padding: '5px 12px', fontSize: 12, background: '#0a4b80', color: '#fff', border: 'none', borderRadius: 4, cursor: name.trim().length === 0 ? 'not-allowed' : 'pointer', opacity: name.trim().length === 0 ? 0.5 : 1 }}
>
</button>
</div>
</div>
</div>
);

View File

@@ -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(<NotebookCreateModal onClose={() => {}} />);
const btn = screen.getByRole('button', { name: '만들기' });
expect(btn).toBeDisabled();
});
it('이름 입력 후 만들기 클릭 → createNotebook 호출 + onClose', async () => {
const onClose = vi.fn();
createNotebook.mockResolvedValueOnce({ ok: true });
render(<NotebookCreateModal onClose={onClose} />);
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(<NotebookCreateModal onClose={onClose} />);
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(<NotebookCreateModal onClose={onClose} />);
fireEvent.click(container.firstChild as HTMLElement);
expect(onClose).toHaveBeenCalled();
});
it('color palette 클릭 시 선택 색 변경 (border 확인)', () => {
render(<NotebookCreateModal onClose={() => {}} />);
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');
});
});