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 {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 120 }}
|
|
||||||
onClick={onClose}
|
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 }}>
|
<div
|
||||||
(NotebookCreateModal — Task 13 에서 구현)
|
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>
|
||||||
</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