feat(promotion): PromotionBanner — 수락/나중에/숨기기 + inline 이름·색 수정
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
78
src/renderer/inbox/components/PromotionBanner.tsx
Normal file
78
src/renderer/inbox/components/PromotionBanner.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
import { Banner } from './Banner.js';
|
||||
|
||||
const COLOR_PALETTE = ['#0a4b80', '#236b1a', '#946100', '#a55', '#5a3a8c'];
|
||||
|
||||
export function PromotionBanner(): React.ReactElement | null {
|
||||
const candidates = useInbox((s) => s.promotionCandidates);
|
||||
const accept = useInbox((s) => s.acceptPromotion);
|
||||
const snooze = useInbox((s) => s.snoozePromotion);
|
||||
const dismiss = useInbox((s) => s.dismissPromotion);
|
||||
const [editing, setEditing] = useState<{ tag: string; name: string; color: string } | null>(null);
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
const c = candidates[0]!;
|
||||
|
||||
return (
|
||||
<Banner severity="info">
|
||||
{editing === null ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>💡 <code>{c.tag}</code> 관련 노트 {c.noteIds.length}개가 모였어요. 새 노트북 <b>{c.suggestedName}</b> 로 분리할까요?</span>
|
||||
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto' }}>
|
||||
<button
|
||||
onClick={() => setEditing({ tag: c.tag, name: c.suggestedName, color: COLOR_PALETTE[0]! })}
|
||||
style={{ background: '#0a4b80', color: '#fff', border: 'none', borderRadius: 4, padding: '4px 12px', fontSize: 12, cursor: 'pointer' }}
|
||||
>
|
||||
수락
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { void snooze(); }}
|
||||
style={{ background: 'transparent', color: '#234', border: '1px solid #ccc', borderRadius: 4, padding: '4px 12px', fontSize: 12, cursor: 'pointer' }}
|
||||
>
|
||||
나중에
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { void dismiss(c.tag); }}
|
||||
style={{ background: 'transparent', color: '#888', border: 'none', cursor: 'pointer', fontSize: 12 }}
|
||||
>
|
||||
숨기기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
aria-label="노트북 이름"
|
||||
value={editing.name}
|
||||
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
||||
style={{ flex: 1, fontSize: 13, padding: '4px 8px', border: '1px solid #ccc', borderRadius: 4, minWidth: 120 }}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{COLOR_PALETTE.map((col) => (
|
||||
<button
|
||||
key={col}
|
||||
onClick={() => setEditing({ ...editing, color: col })}
|
||||
aria-label={`색 ${col}`}
|
||||
style={{
|
||||
width: 20, height: 20, borderRadius: '50%', background: col,
|
||||
border: col === editing.color ? '2px solid #333' : '1px solid #ccc',
|
||||
cursor: 'pointer', padding: 0
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { void accept(editing.tag, editing.name.trim(), editing.color); setEditing(null); }}
|
||||
disabled={editing.name.trim().length === 0}
|
||||
style={{ background: '#0a4b80', color: '#fff', border: 'none', borderRadius: 4, padding: '4px 12px', fontSize: 12, cursor: 'pointer' }}
|
||||
>
|
||||
만들기
|
||||
</button>
|
||||
<button onClick={() => setEditing(null)} style={{ fontSize: 12, padding: '4px 10px' }}>취소</button>
|
||||
</div>
|
||||
)}
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
81
tests/unit/PromotionBanner.test.tsx
Normal file
81
tests/unit/PromotionBanner.test.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
|
||||
const accept = vi.fn(async () => {});
|
||||
const snooze = vi.fn(async () => {});
|
||||
const dismiss = vi.fn(async () => {});
|
||||
|
||||
let mockState: {
|
||||
promotionCandidates: Array<{ tag: string; noteIds: string[]; suggestedName: string }>;
|
||||
acceptPromotion: typeof accept;
|
||||
snoozePromotion: typeof snooze;
|
||||
dismissPromotion: typeof dismiss;
|
||||
} = {
|
||||
promotionCandidates: [],
|
||||
acceptPromotion: accept,
|
||||
snoozePromotion: snooze,
|
||||
dismissPromotion: dismiss
|
||||
};
|
||||
|
||||
vi.mock('../../src/renderer/inbox/store.js', () => ({
|
||||
useInbox: (selector?: (s: typeof mockState) => unknown) => selector ? selector(mockState) : mockState
|
||||
}));
|
||||
|
||||
import { PromotionBanner } from '../../src/renderer/inbox/components/PromotionBanner';
|
||||
|
||||
describe('PromotionBanner', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
accept.mockClear(); snooze.mockClear(); dismiss.mockClear();
|
||||
mockState = {
|
||||
promotionCandidates: [],
|
||||
acceptPromotion: accept,
|
||||
snoozePromotion: snooze,
|
||||
dismissPromotion: dismiss
|
||||
};
|
||||
});
|
||||
|
||||
it('candidates 비어있으면 null', () => {
|
||||
const { container } = render(<PromotionBanner />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('첫 candidate 의 tag/suggestedName 표시', () => {
|
||||
mockState.promotionCandidates = [{ tag: 'mlx-ops', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Mlx Ops' }];
|
||||
render(<PromotionBanner />);
|
||||
expect(screen.getByText('mlx-ops')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mlx Ops')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('수락 클릭 → 편집 모드 → 만들기 → acceptPromotion 호출', () => {
|
||||
mockState.promotionCandidates = [{ tag: 'mlx-ops', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Mlx Ops' }];
|
||||
render(<PromotionBanner />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '수락' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '만들기' }));
|
||||
expect(accept).toHaveBeenCalledWith('mlx-ops', 'Mlx Ops', expect.any(String));
|
||||
});
|
||||
|
||||
it('나중에 클릭 → snoozePromotion 호출', () => {
|
||||
mockState.promotionCandidates = [{ tag: 'mlx-ops', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Mlx Ops' }];
|
||||
render(<PromotionBanner />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '나중에' }));
|
||||
expect(snooze).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('숨기기 클릭 → dismissPromotion(tag) 호출', () => {
|
||||
mockState.promotionCandidates = [{ tag: 'mlx-ops', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Mlx Ops' }];
|
||||
render(<PromotionBanner />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '숨기기' }));
|
||||
expect(dismiss).toHaveBeenCalledWith('mlx-ops');
|
||||
});
|
||||
|
||||
it('편집 모드: 이름 빈 시 만들기 disabled', () => {
|
||||
mockState.promotionCandidates = [{ tag: 'mlx-ops', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Mlx Ops' }];
|
||||
render(<PromotionBanner />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '수락' }));
|
||||
fireEvent.change(screen.getByLabelText('노트북 이름'), { target: { value: '' } });
|
||||
expect(screen.getByRole('button', { name: '만들기' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user