feat(ui): NotebookCreateModal — 이름 + 색 + 중복 검증 + esc/overlay 닫기
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
64
tests/unit/NotebookCreateModal.test.tsx
Normal file
64
tests/unit/NotebookCreateModal.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user