feat(notebook): NotebookList 의 hover ↑↓ 버튼 + reorder action
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import type { Notebook } from '@shared/types';
|
||||
|
||||
interface Props {
|
||||
@@ -6,22 +6,33 @@ interface Props {
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
onReorder?: (id: string, direction: 'up' | 'down') => Promise<void>;
|
||||
}
|
||||
|
||||
export function NotebookList({ notebooks, selectedId, onSelect, onCreate }: Props): React.ReactElement {
|
||||
export function NotebookList({ notebooks, selectedId, onSelect, onCreate, onReorder }: Props): React.ReactElement {
|
||||
const [hoverId, setHoverId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{notebooks.map((nb) => {
|
||||
{notebooks.map((nb, idx) => {
|
||||
const active = nb.id === selectedId;
|
||||
const hover = nb.id === hoverId;
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === notebooks.length - 1;
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={nb.id}
|
||||
onMouseEnter={() => setHoverId(nb.id)}
|
||||
onMouseLeave={() => setHoverId(null)}
|
||||
style={{ position: 'relative', display: 'flex' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => onSelect(nb.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '6px 12px', background: active ? '#eaf3ff' : 'transparent',
|
||||
border: 'none', cursor: 'pointer', textAlign: 'left',
|
||||
color: active ? '#0a4b80' : '#333', fontSize: 13
|
||||
color: active ? '#0a4b80' : '#333', fontSize: 13, flex: 1
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
@@ -33,6 +44,25 @@ export function NotebookList({ notebooks, selectedId, onSelect, onCreate }: Prop
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: '#888' }}>{nb.noteCount}</span>
|
||||
</button>
|
||||
{hover && onReorder && notebooks.length > 1 && (
|
||||
<div style={{ position: 'absolute', right: 4, top: 2, display: 'flex', gap: 2 }}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); if (!isFirst) void onReorder(nb.id, 'up'); }}
|
||||
disabled={isFirst}
|
||||
aria-label={`${nb.name} 위로`}
|
||||
title="위로"
|
||||
style={{ background: 'rgba(255,255,255,0.9)', border: '1px solid #ccc', borderRadius: 3, fontSize: 10, padding: '0 4px', cursor: isFirst ? 'not-allowed' : 'pointer', opacity: isFirst ? 0.3 : 1 }}
|
||||
>▲</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); if (!isLast) void onReorder(nb.id, 'down'); }}
|
||||
disabled={isLast}
|
||||
aria-label={`${nb.name} 아래로`}
|
||||
title="아래로"
|
||||
style={{ background: 'rgba(255,255,255,0.9)', border: '1px solid #ccc', borderRadius: 3, fontSize: 10, padding: '0 4px', cursor: isLast ? 'not-allowed' : 'pointer', opacity: isLast ? 0.3 : 1 }}
|
||||
>▼</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
|
||||
@@ -9,6 +9,7 @@ export function Sidebar(): React.ReactElement | null {
|
||||
const notebooks = useInbox((s) => s.notebooks);
|
||||
const selectedId = useInbox((s) => s.selectedNotebookId);
|
||||
const selectNotebook = useInbox((s) => s.selectNotebook);
|
||||
const reorderNotebook = useInbox((s) => s.reorderNotebook);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
if (!visible) return null;
|
||||
@@ -24,6 +25,7 @@ export function Sidebar(): React.ReactElement | null {
|
||||
selectedId={selectedId}
|
||||
onSelect={selectNotebook}
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
onReorder={reorderNotebook}
|
||||
/>
|
||||
{createOpen && <NotebookCreateModal onClose={() => setCreateOpen(false)} />}
|
||||
</aside>
|
||||
|
||||
@@ -91,6 +91,7 @@ interface InboxState {
|
||||
setNotebookColor: (id: string, color: string | null) => Promise<void>;
|
||||
deleteNotebook: (id: string) => Promise<{ ok: boolean; reason?: string }>;
|
||||
moveNoteToNotebook: (noteId: string, notebookId: string) => Promise<void>;
|
||||
reorderNotebook: (id: string, direction: 'up' | 'down') => Promise<void>;
|
||||
toggleSidebar: () => void;
|
||||
// v0.4 Task 11 — promotion candidate actions.
|
||||
loadPromotionCandidates: () => Promise<void>;
|
||||
@@ -486,6 +487,12 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
await notebookApi.moveNote(noteId, notebookId);
|
||||
await get().refreshMeta();
|
||||
},
|
||||
async reorderNotebook(id, direction) {
|
||||
const r = await notebookApi.reorder(id, direction);
|
||||
if (r.ok) {
|
||||
await get().loadNotebooks();
|
||||
}
|
||||
},
|
||||
toggleSidebar() {
|
||||
const next = !get().sidebarVisible;
|
||||
set({ sidebarVisible: next });
|
||||
|
||||
@@ -41,4 +41,32 @@ describe('NotebookList', () => {
|
||||
expect(btn1.style.background).not.toBe('transparent');
|
||||
expect(btn2.style.background).toBe('transparent');
|
||||
});
|
||||
|
||||
it('hover 시 ↑↓ 버튼 노출', () => {
|
||||
const { container } = render(<NotebookList notebooks={notebooks} selectedId="nb-1" onSelect={() => {}} onCreate={() => {}} onReorder={async () => {}} />);
|
||||
// 기본 상태: ↑↓ 미노출
|
||||
expect(screen.queryByLabelText(/위로/)).not.toBeInTheDocument();
|
||||
// hover 후 보임 — position:relative 인 row div 선택
|
||||
const row = container.querySelector('div[style*="position: relative"]') as HTMLElement;
|
||||
fireEvent.mouseEnter(row);
|
||||
expect(screen.getAllByLabelText(/위로/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('↑ 클릭 시 onReorder up 호출', () => {
|
||||
const onReorder = vi.fn();
|
||||
const { container } = render(<NotebookList notebooks={notebooks} selectedId="nb-2" onSelect={() => {}} onCreate={() => {}} onReorder={onReorder} />);
|
||||
const rows = container.querySelectorAll('div[style*="position: relative"]');
|
||||
// 두번째 row (nb-2) hover → 위로 클릭
|
||||
fireEvent.mouseEnter(rows[1] as Element);
|
||||
fireEvent.click(screen.getByLabelText('회사 위로'));
|
||||
expect(onReorder).toHaveBeenCalledWith('nb-2', 'up');
|
||||
});
|
||||
|
||||
it('첫 row 의 ↑ 는 disabled', () => {
|
||||
const { container } = render(<NotebookList notebooks={notebooks} selectedId="nb-1" onSelect={() => {}} onCreate={() => {}} onReorder={async () => {}} />);
|
||||
const rows = container.querySelectorAll('div[style*="position: relative"]');
|
||||
fireEvent.mouseEnter(rows[0] as Element);
|
||||
const upBtn = screen.getByLabelText('기본 위로');
|
||||
expect(upBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockListByStatus, mockCountsByStatus } = vi.hoisted(() => ({
|
||||
const { mockListByStatus, mockCountsByStatus, mockReorder } = vi.hoisted(() => ({
|
||||
mockListByStatus: vi.fn(async () => []),
|
||||
mockCountsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 }))
|
||||
mockCountsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 })),
|
||||
mockReorder: vi.fn(async () => ({ ok: true as const }))
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
@@ -32,7 +33,8 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
delete: vi.fn(async () => ({ ok: true as const })),
|
||||
rename: vi.fn(async () => ({ ok: true as const })),
|
||||
setColor: vi.fn(async () => ({ ok: true as const })),
|
||||
moveNote: vi.fn(async () => ({ ok: true as const }))
|
||||
moveNote: vi.fn(async () => ({ ok: true as const })),
|
||||
reorder: mockReorder
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -42,6 +44,7 @@ describe('store notebooks', () => {
|
||||
beforeEach(() => {
|
||||
mockListByStatus.mockClear();
|
||||
mockCountsByStatus.mockClear();
|
||||
mockReorder.mockClear();
|
||||
useInbox.setState({ notebooks: [], selectedNotebookId: null, sidebarVisible: false, sidebarWidth: 240, view: 'inbox' } as never);
|
||||
});
|
||||
|
||||
@@ -116,4 +119,11 @@ describe('store notebooks', () => {
|
||||
// countsByStatus 는 refreshMeta 경로로 호출됨
|
||||
expect(mockCountsByStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reorderNotebook 성공 시 notebookApi.reorder 호출 + loadNotebooks 재로드', async () => {
|
||||
await useInbox.getState().reorderNotebook('nb-1', 'down');
|
||||
expect(mockReorder).toHaveBeenCalledWith('nb-1', 'down');
|
||||
// loadNotebooks 가 호출되어 notebooks 가 갱신됨
|
||||
expect(useInbox.getState().notebooks).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user