feat: simplify bulk action bar
This commit is contained in:
@@ -666,7 +666,7 @@ function App() {
|
||||
return [...builtIn, ...Array.from(customFields).sort()]
|
||||
}, [vault.entries])
|
||||
|
||||
const bulkActions = useBulkActions(entryActions, setToastMessage)
|
||||
const bulkActions = useBulkActions(entryActions, vault.entries, setToastMessage)
|
||||
|
||||
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
|
||||
const rawToggleRef = useRef<() => void>(() => {})
|
||||
@@ -888,7 +888,7 @@ function App() {
|
||||
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} />
|
||||
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
|
||||
@@ -5,15 +5,20 @@ import { BulkActionBar } from './BulkActionBar'
|
||||
describe('BulkActionBar', () => {
|
||||
const defaultProps = {
|
||||
count: 3,
|
||||
onOrganize: vi.fn(),
|
||||
onArchive: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onClear: vi.fn(),
|
||||
}
|
||||
|
||||
it('shows Archive and Delete buttons in normal view', () => {
|
||||
it('shows icon-only organize, archive, and delete buttons in normal view', () => {
|
||||
render(<BulkActionBar {...defaultProps} />)
|
||||
expect(screen.getByTestId('bulk-organize-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-archive-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-delete-btn')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Organize')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Archive')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Delete')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows selected count', () => {
|
||||
@@ -21,13 +26,64 @@ describe('BulkActionBar', () => {
|
||||
expect(screen.getByText('5 selected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Unarchive and Delete buttons in archived view', () => {
|
||||
it('shows organize, unarchive, and delete buttons in archived view', () => {
|
||||
render(<BulkActionBar {...defaultProps} isArchivedView={true} />)
|
||||
expect(screen.getByTestId('bulk-organize-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-unarchive-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-delete-btn')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-archive-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('exposes accessible names and a destructive variant for icon-only actions', () => {
|
||||
render(<BulkActionBar {...defaultProps} />)
|
||||
expect(screen.getByRole('button', { name: 'Organize selected notes' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Archive selected notes' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Permanently delete selected notes' })).toHaveAttribute('data-variant', 'destructive')
|
||||
expect(screen.getByRole('button', { name: 'Clear selection' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the icon-only controls focusable and activatable', () => {
|
||||
const onOrganize = vi.fn()
|
||||
const onArchive = vi.fn()
|
||||
const onDelete = vi.fn()
|
||||
const onClear = vi.fn()
|
||||
|
||||
render(
|
||||
<BulkActionBar
|
||||
{...defaultProps}
|
||||
onOrganize={onOrganize}
|
||||
onArchive={onArchive}
|
||||
onDelete={onDelete}
|
||||
onClear={onClear}
|
||||
/>,
|
||||
)
|
||||
|
||||
const organizeButton = screen.getByRole('button', { name: 'Organize selected notes' })
|
||||
const archiveButton = screen.getByRole('button', { name: 'Archive selected notes' })
|
||||
const deleteButton = screen.getByRole('button', { name: 'Permanently delete selected notes' })
|
||||
const clearButton = screen.getByRole('button', { name: 'Clear selection' })
|
||||
|
||||
organizeButton.focus()
|
||||
expect(organizeButton).toHaveFocus()
|
||||
fireEvent.click(organizeButton)
|
||||
expect(onOrganize).toHaveBeenCalledTimes(1)
|
||||
|
||||
archiveButton.focus()
|
||||
expect(archiveButton).toHaveFocus()
|
||||
fireEvent.click(archiveButton)
|
||||
expect(onArchive).toHaveBeenCalledTimes(1)
|
||||
|
||||
deleteButton.focus()
|
||||
expect(deleteButton).toHaveFocus()
|
||||
fireEvent.click(deleteButton)
|
||||
expect(onDelete).toHaveBeenCalledTimes(1)
|
||||
|
||||
clearButton.focus()
|
||||
expect(clearButton).toHaveFocus()
|
||||
fireEvent.click(clearButton)
|
||||
expect(onClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onUnarchive when Unarchive button clicked in archived view', () => {
|
||||
const onUnarchive = vi.fn()
|
||||
render(<BulkActionBar {...defaultProps} isArchivedView={true} onUnarchive={onUnarchive} />)
|
||||
|
||||
@@ -1,45 +1,74 @@
|
||||
import { memo } from 'react'
|
||||
import { Archive, ArrowCounterClockwise, Trash, X } from '@phosphor-icons/react'
|
||||
import { Archive, ArrowCounterClockwise, CheckCircle, Trash, X } from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface BulkActionBarProps {
|
||||
count: number
|
||||
isArchivedView?: boolean
|
||||
onOrganize?: () => void
|
||||
onArchive: () => void
|
||||
onDelete: () => void
|
||||
onUnarchive?: () => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
const actionBtnStyle = { padding: '5px 10px', borderRadius: 6, background: 'rgba(255,255,255,0.12)', color: 'inherit', fontSize: 12, fontWeight: 500 } as const
|
||||
const destructiveBtnStyle = { padding: '5px 10px', borderRadius: 6, background: 'rgba(224,62,62,0.2)', color: 'var(--destructive)', fontSize: 12, fontWeight: 500 } as const
|
||||
interface BulkActionButtonProps {
|
||||
ariaLabel: string
|
||||
children: React.ReactNode
|
||||
destructive?: boolean
|
||||
onClick?: () => void
|
||||
testId: string
|
||||
}
|
||||
|
||||
function BulkActionButton({ ariaLabel, children, destructive = false, onClick, testId }: BulkActionButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant={destructive ? 'destructive' : 'ghost'}
|
||||
className={
|
||||
destructive
|
||||
? 'h-8 w-8 rounded-lg bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/30'
|
||||
: 'h-8 w-8 rounded-lg bg-white/10 text-background hover:bg-white/20 focus-visible:ring-white/35 disabled:bg-white/5 disabled:text-white/35'
|
||||
}
|
||||
onClick={onClick}
|
||||
disabled={!onClick}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function renderPrimaryActions(
|
||||
isArchivedView: boolean,
|
||||
onOrganize: (() => void) | undefined,
|
||||
onArchive: () => void,
|
||||
onDelete: () => void,
|
||||
onUnarchive: (() => void) | undefined,
|
||||
) {
|
||||
const archiveLabel = isArchivedView ? 'Unarchive selected notes' : 'Archive selected notes'
|
||||
const archiveIcon = isArchivedView ? <ArrowCounterClockwise size={16} /> : <Archive size={16} />
|
||||
const archiveHandler = isArchivedView ? onUnarchive : onArchive
|
||||
|
||||
function renderArchivedActions(onUnarchive: (() => void) | undefined, onDelete: () => void) {
|
||||
return (
|
||||
<>
|
||||
<button className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer" style={actionBtnStyle} onClick={onUnarchive} title="Unarchive selected notes" data-testid="bulk-unarchive-btn">
|
||||
<ArrowCounterClockwise size={14} /> Unarchive
|
||||
</button>
|
||||
<button className="flex items-center gap-1.5 border-none cursor-pointer" style={destructiveBtnStyle} onClick={onDelete} title="Permanently delete selected notes" data-testid="bulk-delete-btn">
|
||||
<Trash size={14} /> Delete
|
||||
</button>
|
||||
<BulkActionButton ariaLabel="Organize selected notes" onClick={onOrganize} testId="bulk-organize-btn">
|
||||
<CheckCircle size={16} weight="fill" />
|
||||
</BulkActionButton>
|
||||
<BulkActionButton ariaLabel={archiveLabel} onClick={archiveHandler} testId={isArchivedView ? 'bulk-unarchive-btn' : 'bulk-archive-btn'}>
|
||||
{archiveIcon}
|
||||
</BulkActionButton>
|
||||
<BulkActionButton ariaLabel="Permanently delete selected notes" destructive onClick={onDelete} testId="bulk-delete-btn">
|
||||
<Trash size={16} />
|
||||
</BulkActionButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderDefaultActions(onArchive: () => void, onDelete: () => void) {
|
||||
return (
|
||||
<>
|
||||
<button className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer" style={actionBtnStyle} onClick={onArchive} title="Archive selected notes" data-testid="bulk-archive-btn">
|
||||
<Archive size={14} /> Archive
|
||||
</button>
|
||||
<button className="flex items-center gap-1.5 border-none cursor-pointer" style={destructiveBtnStyle} onClick={onDelete} title="Permanently delete selected notes" data-testid="bulk-delete-btn">
|
||||
<Trash size={14} /> Delete
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BulkActionBarInner({ count, isArchivedView, onArchive, onDelete, onUnarchive, onClear }: BulkActionBarProps) {
|
||||
function BulkActionBarInner({ count, isArchivedView, onOrganize, onArchive, onDelete, onUnarchive, onClear }: BulkActionBarProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between"
|
||||
@@ -54,18 +83,20 @@ function BulkActionBarInner({ count, isArchivedView, onArchive, onDelete, onUnar
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{count} selected
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{isArchivedView ? renderArchivedActions(onUnarchive, onDelete)
|
||||
: renderDefaultActions(onArchive, onDelete)}
|
||||
<button
|
||||
className="flex items-center border-none bg-transparent cursor-pointer"
|
||||
style={{ padding: '5px 6px', color: 'rgba(255,255,255,0.5)' }}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{renderPrimaryActions(Boolean(isArchivedView), onOrganize, onArchive, onDelete, onUnarchive)}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 rounded-lg text-white/55 hover:bg-white/10 hover:text-background focus-visible:ring-white/30"
|
||||
onClick={onClear}
|
||||
aria-label="Clear selection"
|
||||
title="Clear selection"
|
||||
data-testid="bulk-clear-btn"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -150,6 +150,7 @@ describe('NoteList multi-select', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'organizes via button', prop: 'onBulkOrganize', trigger: () => fireEvent.click(screen.getByTestId('bulk-organize-btn')) },
|
||||
{ label: 'archives via button', prop: 'onBulkArchive', trigger: () => fireEvent.click(screen.getByTestId('bulk-archive-btn')) },
|
||||
{ label: 'deletes via button', prop: 'onBulkDeletePermanently', trigger: () => fireEvent.click(screen.getByTestId('bulk-delete-btn')) },
|
||||
{ label: 'archives via Cmd+E', prop: 'onBulkArchive', trigger: () => fireEvent.keyDown(window, { key: 'e', metaKey: true }) },
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { memo } from 'react'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { NoteListLayout } from './note-list/NoteListLayout'
|
||||
import { useNoteListModel, type NoteListProps } from './note-list/useNoteListModel'
|
||||
|
||||
function NoteListInner(props: NoteListProps) {
|
||||
type NoteListInnerProps = NoteListProps & {
|
||||
onBulkOrganize?: (paths: string[]) => void
|
||||
}
|
||||
|
||||
function NoteListInner({ onBulkOrganize, ...props }: NoteListInnerProps) {
|
||||
const model = useNoteListModel(props)
|
||||
return <NoteListLayout {...model} />
|
||||
const handleBulkOrganize = useCallback(() => {
|
||||
const paths = [...model.multiSelect.selectedPaths]
|
||||
model.multiSelect.clear()
|
||||
onBulkOrganize?.(paths)
|
||||
}, [model.multiSelect, onBulkOrganize])
|
||||
|
||||
return <NoteListLayout {...model} handleBulkOrganize={onBulkOrganize ? handleBulkOrganize : undefined} />
|
||||
}
|
||||
|
||||
export const NoteList = memo(NoteListInner)
|
||||
|
||||
@@ -4,7 +4,32 @@ import { NoteListHeader } from './NoteListHeader'
|
||||
import { EntityView, ListView } from './NoteListViews'
|
||||
import type { useNoteListModel } from './useNoteListModel'
|
||||
|
||||
type NoteListLayoutProps = ReturnType<typeof useNoteListModel>
|
||||
type NoteListLayoutProps = ReturnType<typeof useNoteListModel> & {
|
||||
handleBulkOrganize?: () => void
|
||||
}
|
||||
|
||||
function MultiSelectBar({
|
||||
multiSelect,
|
||||
isArchivedView,
|
||||
handleBulkOrganize,
|
||||
handleBulkArchive,
|
||||
handleBulkDeletePermanently,
|
||||
handleBulkUnarchive,
|
||||
}: Pick<NoteListLayoutProps, 'multiSelect' | 'isArchivedView' | 'handleBulkOrganize' | 'handleBulkArchive' | 'handleBulkDeletePermanently' | 'handleBulkUnarchive'>) {
|
||||
if (!multiSelect.isMultiSelecting) return null
|
||||
|
||||
return (
|
||||
<BulkActionBar
|
||||
count={multiSelect.selectedPaths.size}
|
||||
isArchivedView={isArchivedView}
|
||||
onOrganize={handleBulkOrganize}
|
||||
onArchive={handleBulkArchive}
|
||||
onDelete={handleBulkDeletePermanently}
|
||||
onUnarchive={handleBulkUnarchive}
|
||||
onClear={multiSelect.clear}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NoteListLayout({
|
||||
title,
|
||||
@@ -43,6 +68,7 @@ export function NoteListLayout({
|
||||
filterCounts,
|
||||
onNoteListFilterChange,
|
||||
multiSelect,
|
||||
handleBulkOrganize,
|
||||
handleBulkArchive,
|
||||
handleBulkDeletePermanently,
|
||||
handleBulkUnarchive,
|
||||
@@ -115,16 +141,14 @@ export function NoteListLayout({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{multiSelect.isMultiSelecting && (
|
||||
<BulkActionBar
|
||||
count={multiSelect.selectedPaths.size}
|
||||
isArchivedView={isArchivedView}
|
||||
onArchive={handleBulkArchive}
|
||||
onDelete={handleBulkDeletePermanently}
|
||||
onUnarchive={handleBulkUnarchive}
|
||||
onClear={multiSelect.clear}
|
||||
/>
|
||||
)}
|
||||
<MultiSelectBar
|
||||
multiSelect={multiSelect}
|
||||
isArchivedView={isArchivedView}
|
||||
handleBulkOrganize={handleBulkOrganize}
|
||||
handleBulkArchive={handleBulkArchive}
|
||||
handleBulkDeletePermanently={handleBulkDeletePermanently}
|
||||
handleBulkUnarchive={handleBulkUnarchive}
|
||||
/>
|
||||
{contextMenuNode}
|
||||
{dialogNode}
|
||||
</div>
|
||||
|
||||
@@ -1,52 +1,79 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useBulkActions } from './useBulkActions'
|
||||
import { makeEntry } from '../test-utils/noteListTestUtils'
|
||||
|
||||
describe('useBulkActions', () => {
|
||||
const paths = {
|
||||
a: '/vault/a.md',
|
||||
b: '/vault/b.md',
|
||||
c: '/vault/c.md',
|
||||
} as const
|
||||
|
||||
let handleArchiveNote: ReturnType<typeof vi.fn>
|
||||
let handleToggleOrganized: ReturnType<typeof vi.fn>
|
||||
let setToastMessage: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
handleArchiveNote = vi.fn().mockResolvedValue(undefined)
|
||||
handleToggleOrganized = vi.fn().mockResolvedValue(undefined)
|
||||
setToastMessage = vi.fn()
|
||||
})
|
||||
|
||||
function renderBulkActions() {
|
||||
function renderBulkActions(organizedPaths: string[] = []) {
|
||||
const entries = [
|
||||
makeEntry({ path: paths.a, organized: organizedPaths.includes(paths.a) }),
|
||||
makeEntry({ path: paths.b, organized: organizedPaths.includes(paths.b) }),
|
||||
makeEntry({ path: paths.c, organized: organizedPaths.includes(paths.c) }),
|
||||
]
|
||||
|
||||
return renderHook(() =>
|
||||
useBulkActions(
|
||||
{ handleArchiveNote },
|
||||
{ handleArchiveNote, handleToggleOrganized },
|
||||
entries,
|
||||
setToastMessage,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async function runAction(
|
||||
action: 'handleBulkArchive' | 'handleBulkOrganize',
|
||||
selectedPaths: string[],
|
||||
organizedPaths: string[] = [],
|
||||
) {
|
||||
const { result } = renderBulkActions(organizedPaths)
|
||||
|
||||
await act(async () => {
|
||||
await result.current[action](selectedPaths)
|
||||
})
|
||||
}
|
||||
|
||||
function expectSuccessfulCalls(handler: ReturnType<typeof vi.fn>, successfulPaths: string[]) {
|
||||
expect(handler).toHaveBeenCalledTimes(successfulPaths.length)
|
||||
for (const path of successfulPaths) {
|
||||
expect(handler).toHaveBeenCalledWith(path)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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')
|
||||
await runAction('handleBulkArchive', [paths.a, paths.b])
|
||||
|
||||
expectSuccessfulCalls(handleArchiveNote, [paths.a, paths.b])
|
||||
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'])
|
||||
})
|
||||
await runAction('handleBulkArchive', [paths.a])
|
||||
|
||||
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([])
|
||||
})
|
||||
await runAction('handleBulkArchive', [])
|
||||
|
||||
expect(handleArchiveNote).not.toHaveBeenCalled()
|
||||
expect(setToastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -56,21 +83,46 @@ describe('useBulkActions', () => {
|
||||
.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'])
|
||||
})
|
||||
|
||||
await runAction('handleBulkArchive', [paths.a, paths.b, paths.c])
|
||||
|
||||
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'])
|
||||
})
|
||||
|
||||
await runAction('handleBulkArchive', [paths.a, paths.b])
|
||||
|
||||
expect(setToastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleBulkOrganize', () => {
|
||||
it('organizes only notes that are not already organized', async () => {
|
||||
await runAction('handleBulkOrganize', [paths.a, paths.b, paths.c], [paths.b])
|
||||
|
||||
expectSuccessfulCalls(handleToggleOrganized, [paths.a, paths.c])
|
||||
expect(setToastMessage).toHaveBeenCalledWith('2 notes organized')
|
||||
})
|
||||
|
||||
it('shows no toast when all selected notes are already organized', async () => {
|
||||
await runAction('handleBulkOrganize', [paths.a, paths.b], [paths.a, paths.b])
|
||||
|
||||
expect(handleToggleOrganized).not.toHaveBeenCalled()
|
||||
expect(setToastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips failed organize actions and reports only successes', async () => {
|
||||
handleToggleOrganized
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('fail'))
|
||||
|
||||
await runAction('handleBulkOrganize', [paths.a, paths.b])
|
||||
|
||||
expect(handleToggleOrganized).toHaveBeenCalledTimes(2)
|
||||
expect(setToastMessage).toHaveBeenCalledWith('1 note organized')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,21 +1,53 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
interface BulkEntryActions {
|
||||
handleArchiveNote: (path: string) => Promise<void>
|
||||
handleToggleOrganized: (path: string) => Promise<void>
|
||||
}
|
||||
|
||||
function formatBulkToast(count: number, label: string) {
|
||||
return `${count} note${count > 1 ? 's' : ''} ${label}`
|
||||
}
|
||||
|
||||
async function runBulkAction(paths: string[], action: (path: string) => Promise<boolean>) {
|
||||
let ok = 0
|
||||
for (const path of paths) {
|
||||
try {
|
||||
if (await action(path)) ok++
|
||||
} catch {
|
||||
// Error toast already shown by the underlying action.
|
||||
}
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
export function useBulkActions(
|
||||
entryActions: BulkEntryActions,
|
||||
entries: VaultEntry[],
|
||||
setToastMessage: (msg: string | null) => void,
|
||||
) {
|
||||
const organizedPathSet = useMemo(
|
||||
() => new Set(entries.filter((entry) => entry.organized).map((entry) => entry.path)),
|
||||
[entries],
|
||||
)
|
||||
|
||||
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`)
|
||||
const ok = await runBulkAction(paths, async (path) => {
|
||||
await entryActions.handleArchiveNote(path)
|
||||
return true
|
||||
})
|
||||
if (ok > 0) setToastMessage(formatBulkToast(ok, 'archived'))
|
||||
}, [entryActions, setToastMessage])
|
||||
|
||||
return { handleBulkArchive }
|
||||
const handleBulkOrganize = useCallback(async (paths: string[]) => {
|
||||
const ok = await runBulkAction(paths, async (path) => {
|
||||
if (organizedPathSet.has(path)) return false
|
||||
await entryActions.handleToggleOrganized(path)
|
||||
return true
|
||||
})
|
||||
if (ok > 0) setToastMessage(formatBulkToast(ok, 'organized'))
|
||||
}, [entryActions, organizedPathSet, setToastMessage])
|
||||
|
||||
return { handleBulkArchive, handleBulkOrganize }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user