diff --git a/src/App.tsx b/src/App.tsx index 1835da1a..fa9b6b45 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,6 +38,7 @@ import { useThemeManager } from './hooks/useThemeManager' import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks' import { useNavigationGestures } from './hooks/useNavigationGestures' import { useAiActivity } from './hooks/useAiActivity' +import { useBulkActions } from './hooks/useBulkActions' import { ConflictResolverModal } from './components/ConflictResolverModal' import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog' import { UpdateBanner } from './components/UpdateBanner' @@ -61,40 +62,6 @@ declare global { const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' } -function useBulkActions( - entryActions: { handleArchiveNote: (path: string) => Promise; handleTrashNote: (path: string) => Promise; handleRestoreNote: (path: string) => Promise }, - setToastMessage: (msg: string | null) => void, -) { - const handleBulkArchive = useCallback(async (paths: string[]) => { - let ok = 0 - for (const path of paths) { - try { await entryActions.handleArchiveNote(path); ok++ } - catch { /* error toast already shown by flushBeforeAction */ } - } - if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} archived`) - }, [entryActions, setToastMessage]) - - const handleBulkTrash = useCallback(async (paths: string[]) => { - let ok = 0 - for (const path of paths) { - try { await entryActions.handleTrashNote(path); ok++ } - catch { /* error toast already shown by flushBeforeAction */ } - } - if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} moved to trash`) - }, [entryActions, setToastMessage]) - - const handleBulkRestore = useCallback(async (paths: string[]) => { - let ok = 0 - for (const path of paths) { - try { await entryActions.handleRestoreNote(path); ok++ } - catch { /* skip — error toast already shown */ } - } - if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} restored`) - }, [entryActions, setToastMessage]) - - return { handleBulkArchive, handleBulkTrash, handleBulkRestore } -} - function useLayoutPanels() { const [sidebarWidth, setSidebarWidth] = useState(250) const [noteListWidth, setNoteListWidth] = useState(300) diff --git a/src/hooks/useBulkActions.test.ts b/src/hooks/useBulkActions.test.ts new file mode 100644 index 00000000..61e0550a --- /dev/null +++ b/src/hooks/useBulkActions.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useBulkActions } from './useBulkActions' + +describe('useBulkActions', () => { + let handleArchiveNote: ReturnType + let handleTrashNote: ReturnType + let handleRestoreNote: ReturnType + let setToastMessage: ReturnType + + beforeEach(() => { + handleArchiveNote = vi.fn().mockResolvedValue(undefined) + handleTrashNote = vi.fn().mockResolvedValue(undefined) + handleRestoreNote = vi.fn().mockResolvedValue(undefined) + setToastMessage = vi.fn() + }) + + function renderBulkActions() { + return renderHook(() => + useBulkActions( + { handleArchiveNote, handleTrashNote, handleRestoreNote }, + setToastMessage, + ), + ) + } + + // --- handleBulkArchive --- + + describe('handleBulkArchive', () => { + it('archives each path and shows plural toast for multiple notes', async () => { + const { result } = renderBulkActions() + await act(async () => { + await result.current.handleBulkArchive(['/vault/a.md', '/vault/b.md']) + }) + expect(handleArchiveNote).toHaveBeenCalledTimes(2) + expect(handleArchiveNote).toHaveBeenCalledWith('/vault/a.md') + expect(handleArchiveNote).toHaveBeenCalledWith('/vault/b.md') + expect(setToastMessage).toHaveBeenCalledWith('2 notes archived') + }) + + it('shows singular toast when one note archived', async () => { + const { result } = renderBulkActions() + await act(async () => { + await result.current.handleBulkArchive(['/vault/a.md']) + }) + expect(setToastMessage).toHaveBeenCalledWith('1 note archived') + }) + + it('does not show toast when empty array given', async () => { + const { result } = renderBulkActions() + await act(async () => { + await result.current.handleBulkArchive([]) + }) + expect(handleArchiveNote).not.toHaveBeenCalled() + expect(setToastMessage).not.toHaveBeenCalled() + }) + + it('skips failed paths and only counts successes in toast', async () => { + handleArchiveNote + .mockResolvedValueOnce(undefined) // /vault/a.md succeeds + .mockRejectedValueOnce(new Error('fail')) // /vault/b.md fails + .mockResolvedValueOnce(undefined) // /vault/c.md succeeds + const { result } = renderBulkActions() + await act(async () => { + await result.current.handleBulkArchive(['/vault/a.md', '/vault/b.md', '/vault/c.md']) + }) + expect(handleArchiveNote).toHaveBeenCalledTimes(3) + expect(setToastMessage).toHaveBeenCalledWith('2 notes archived') + }) + + it('shows no toast when all paths fail', async () => { + handleArchiveNote.mockRejectedValue(new Error('fail')) + const { result } = renderBulkActions() + await act(async () => { + await result.current.handleBulkArchive(['/vault/a.md', '/vault/b.md']) + }) + expect(setToastMessage).not.toHaveBeenCalled() + }) + }) + + // --- handleBulkTrash --- + + describe('handleBulkTrash', () => { + it('trashes each path and shows plural toast', async () => { + const { result } = renderBulkActions() + await act(async () => { + await result.current.handleBulkTrash(['/vault/a.md', '/vault/b.md']) + }) + expect(handleTrashNote).toHaveBeenCalledTimes(2) + expect(setToastMessage).toHaveBeenCalledWith('2 notes moved to trash') + }) + + it('shows singular toast when one note trashed', async () => { + const { result } = renderBulkActions() + await act(async () => { + await result.current.handleBulkTrash(['/vault/a.md']) + }) + expect(setToastMessage).toHaveBeenCalledWith('1 note moved to trash') + }) + + it('does not show toast when empty array given', async () => { + const { result } = renderBulkActions() + await act(async () => { + await result.current.handleBulkTrash([]) + }) + expect(setToastMessage).not.toHaveBeenCalled() + }) + + it('skips failed paths and counts only successes', async () => { + handleTrashNote + .mockRejectedValueOnce(new Error('fail')) + .mockResolvedValueOnce(undefined) + const { result } = renderBulkActions() + await act(async () => { + await result.current.handleBulkTrash(['/vault/a.md', '/vault/b.md']) + }) + expect(setToastMessage).toHaveBeenCalledWith('1 note moved to trash') + }) + + it('shows no toast when all fail', async () => { + handleTrashNote.mockRejectedValue(new Error('fail')) + const { result } = renderBulkActions() + await act(async () => { + await result.current.handleBulkTrash(['/vault/a.md']) + }) + expect(setToastMessage).not.toHaveBeenCalled() + }) + }) + + // --- handleBulkRestore --- + + describe('handleBulkRestore', () => { + it('restores each path and shows plural toast', async () => { + const { result } = renderBulkActions() + await act(async () => { + await result.current.handleBulkRestore(['/vault/a.md', '/vault/b.md', '/vault/c.md']) + }) + expect(handleRestoreNote).toHaveBeenCalledTimes(3) + expect(setToastMessage).toHaveBeenCalledWith('3 notes restored') + }) + + it('shows singular toast when one note restored', async () => { + const { result } = renderBulkActions() + await act(async () => { + await result.current.handleBulkRestore(['/vault/only.md']) + }) + expect(setToastMessage).toHaveBeenCalledWith('1 note restored') + }) + + it('does not show toast when empty array given', async () => { + const { result } = renderBulkActions() + await act(async () => { + await result.current.handleBulkRestore([]) + }) + expect(setToastMessage).not.toHaveBeenCalled() + }) + + it('partial failure: counts only successful restores in toast', async () => { + handleRestoreNote + .mockResolvedValueOnce(undefined) // /vault/a.md ok + .mockRejectedValueOnce(new Error('not found')) // /vault/b.md fails + .mockResolvedValueOnce(undefined) // /vault/c.md ok + const { result } = renderBulkActions() + await act(async () => { + await result.current.handleBulkRestore(['/vault/a.md', '/vault/b.md', '/vault/c.md']) + }) + expect(handleRestoreNote).toHaveBeenCalledTimes(3) + expect(setToastMessage).toHaveBeenCalledWith('2 notes restored') + }) + + it('shows no toast when all restores fail', async () => { + handleRestoreNote.mockRejectedValue(new Error('fail')) + const { result } = renderBulkActions() + await act(async () => { + await result.current.handleBulkRestore(['/vault/a.md', '/vault/b.md']) + }) + expect(setToastMessage).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/hooks/useBulkActions.ts b/src/hooks/useBulkActions.ts new file mode 100644 index 00000000..ff28bfcc --- /dev/null +++ b/src/hooks/useBulkActions.ts @@ -0,0 +1,41 @@ +import { useCallback } from 'react' + +interface BulkEntryActions { + handleArchiveNote: (path: string) => Promise + handleTrashNote: (path: string) => Promise + handleRestoreNote: (path: string) => Promise +} + +export function useBulkActions( + entryActions: BulkEntryActions, + setToastMessage: (msg: string | null) => void, +) { + const handleBulkArchive = useCallback(async (paths: string[]) => { + let ok = 0 + for (const path of paths) { + try { await entryActions.handleArchiveNote(path); ok++ } + catch { /* error toast already shown by flushBeforeAction */ } + } + if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} archived`) + }, [entryActions, setToastMessage]) + + const handleBulkTrash = useCallback(async (paths: string[]) => { + let ok = 0 + for (const path of paths) { + try { await entryActions.handleTrashNote(path); ok++ } + catch { /* error toast already shown by flushBeforeAction */ } + } + if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} moved to trash`) + }, [entryActions, setToastMessage]) + + const handleBulkRestore = useCallback(async (paths: string[]) => { + let ok = 0 + for (const path of paths) { + try { await entryActions.handleRestoreNote(path); ok++ } + catch { /* skip — error toast already shown */ } + } + if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} restored`) + }, [entryActions, setToastMessage]) + + return { handleBulkArchive, handleBulkTrash, handleBulkRestore } +}