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';
|
import type { Notebook } from '@shared/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -6,33 +6,63 @@ interface Props {
|
|||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onCreate: () => 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 (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
{notebooks.map((nb) => {
|
{notebooks.map((nb, idx) => {
|
||||||
const active = nb.id === selectedId;
|
const active = nb.id === selectedId;
|
||||||
|
const hover = nb.id === hoverId;
|
||||||
|
const isFirst = idx === 0;
|
||||||
|
const isLast = idx === notebooks.length - 1;
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={nb.id}
|
key={nb.id}
|
||||||
onClick={() => onSelect(nb.id)}
|
onMouseEnter={() => setHoverId(nb.id)}
|
||||||
style={{
|
onMouseLeave={() => setHoverId(null)}
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
style={{ position: 'relative', display: 'flex' }}
|
||||||
padding: '6px 12px', background: active ? '#eaf3ff' : 'transparent',
|
|
||||||
border: 'none', cursor: 'pointer', textAlign: 'left',
|
|
||||||
color: active ? '#0a4b80' : '#333', fontSize: 13
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span style={{
|
<button
|
||||||
width: 8, height: 8, borderRadius: '50%',
|
onClick={() => onSelect(nb.id)}
|
||||||
background: nb.color ?? '#bbb', flexShrink: 0
|
style={{
|
||||||
}} />
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
padding: '6px 12px', background: active ? '#eaf3ff' : 'transparent',
|
||||||
{nb.name}
|
border: 'none', cursor: 'pointer', textAlign: 'left',
|
||||||
</span>
|
color: active ? '#0a4b80' : '#333', fontSize: 13, flex: 1
|
||||||
<span style={{ fontSize: 11, color: '#888' }}>{nb.noteCount}</span>
|
}}
|
||||||
</button>
|
>
|
||||||
|
<span style={{
|
||||||
|
width: 8, height: 8, borderRadius: '50%',
|
||||||
|
background: nb.color ?? '#bbb', flexShrink: 0
|
||||||
|
}} />
|
||||||
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{nb.name}
|
||||||
|
</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
|
<button
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export function Sidebar(): React.ReactElement | null {
|
|||||||
const notebooks = useInbox((s) => s.notebooks);
|
const notebooks = useInbox((s) => s.notebooks);
|
||||||
const selectedId = useInbox((s) => s.selectedNotebookId);
|
const selectedId = useInbox((s) => s.selectedNotebookId);
|
||||||
const selectNotebook = useInbox((s) => s.selectNotebook);
|
const selectNotebook = useInbox((s) => s.selectNotebook);
|
||||||
|
const reorderNotebook = useInbox((s) => s.reorderNotebook);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
@@ -24,6 +25,7 @@ export function Sidebar(): React.ReactElement | null {
|
|||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onSelect={selectNotebook}
|
onSelect={selectNotebook}
|
||||||
onCreate={() => setCreateOpen(true)}
|
onCreate={() => setCreateOpen(true)}
|
||||||
|
onReorder={reorderNotebook}
|
||||||
/>
|
/>
|
||||||
{createOpen && <NotebookCreateModal onClose={() => setCreateOpen(false)} />}
|
{createOpen && <NotebookCreateModal onClose={() => setCreateOpen(false)} />}
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ interface InboxState {
|
|||||||
setNotebookColor: (id: string, color: string | null) => Promise<void>;
|
setNotebookColor: (id: string, color: string | null) => Promise<void>;
|
||||||
deleteNotebook: (id: string) => Promise<{ ok: boolean; reason?: string }>;
|
deleteNotebook: (id: string) => Promise<{ ok: boolean; reason?: string }>;
|
||||||
moveNoteToNotebook: (noteId: string, notebookId: string) => Promise<void>;
|
moveNoteToNotebook: (noteId: string, notebookId: string) => Promise<void>;
|
||||||
|
reorderNotebook: (id: string, direction: 'up' | 'down') => Promise<void>;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
// v0.4 Task 11 — promotion candidate actions.
|
// v0.4 Task 11 — promotion candidate actions.
|
||||||
loadPromotionCandidates: () => Promise<void>;
|
loadPromotionCandidates: () => Promise<void>;
|
||||||
@@ -486,6 +487,12 @@ export const useInbox = create<InboxState>((set, get) => ({
|
|||||||
await notebookApi.moveNote(noteId, notebookId);
|
await notebookApi.moveNote(noteId, notebookId);
|
||||||
await get().refreshMeta();
|
await get().refreshMeta();
|
||||||
},
|
},
|
||||||
|
async reorderNotebook(id, direction) {
|
||||||
|
const r = await notebookApi.reorder(id, direction);
|
||||||
|
if (r.ok) {
|
||||||
|
await get().loadNotebooks();
|
||||||
|
}
|
||||||
|
},
|
||||||
toggleSidebar() {
|
toggleSidebar() {
|
||||||
const next = !get().sidebarVisible;
|
const next = !get().sidebarVisible;
|
||||||
set({ sidebarVisible: next });
|
set({ sidebarVisible: next });
|
||||||
|
|||||||
@@ -41,4 +41,32 @@ describe('NotebookList', () => {
|
|||||||
expect(btn1.style.background).not.toBe('transparent');
|
expect(btn1.style.background).not.toBe('transparent');
|
||||||
expect(btn2.style.background).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';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
const { mockListByStatus, mockCountsByStatus } = vi.hoisted(() => ({
|
const { mockListByStatus, mockCountsByStatus, mockReorder } = vi.hoisted(() => ({
|
||||||
mockListByStatus: vi.fn(async () => []),
|
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', () => ({
|
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 })),
|
delete: vi.fn(async () => ({ ok: true as const })),
|
||||||
rename: vi.fn(async () => ({ ok: true as const })),
|
rename: vi.fn(async () => ({ ok: true as const })),
|
||||||
setColor: 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(() => {
|
beforeEach(() => {
|
||||||
mockListByStatus.mockClear();
|
mockListByStatus.mockClear();
|
||||||
mockCountsByStatus.mockClear();
|
mockCountsByStatus.mockClear();
|
||||||
|
mockReorder.mockClear();
|
||||||
useInbox.setState({ notebooks: [], selectedNotebookId: null, sidebarVisible: false, sidebarWidth: 240, view: 'inbox' } as never);
|
useInbox.setState({ notebooks: [], selectedNotebookId: null, sidebarVisible: false, sidebarWidth: 240, view: 'inbox' } as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,4 +119,11 @@ describe('store notebooks', () => {
|
|||||||
// countsByStatus 는 refreshMeta 경로로 호출됨
|
// countsByStatus 는 refreshMeta 경로로 호출됨
|
||||||
expect(mockCountsByStatus).toHaveBeenCalled();
|
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