feat: add filter pills (Open/Archived/Trashed) to All Notes view
Reuses the existing FilterPills component and sub-filter mechanism from sectionGroup views. Adds countAllByFilter helper for counting across all entry types and extends filterByKind to support subFilter for the 'all' filter. Bulk actions automatically adapt based on active pill. Switching pills resets multi-selection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NoteList } from './NoteList'
|
||||
import { getSortComparator, filterEntries, countByFilter } from '../utils/noteListHelpers'
|
||||
import { getSortComparator, filterEntries, countByFilter, countAllByFilter } from '../utils/noteListHelpers'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
|
||||
@@ -1293,11 +1293,44 @@ describe('NoteList — filter pills', () => {
|
||||
expect(screen.getByTestId('filter-pill-trashed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show filter pills in All Notes view', () => {
|
||||
it('shows filter pills in All Notes view', () => {
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={projectEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('filter-pills')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-pills')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-pill-open')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-pill-archived')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-pill-trashed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows correct All Notes count badges across all types', () => {
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={projectEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// projectEntries: 2 open Projects + 1 open Note = 3 open, 1 archived, 1 trashed
|
||||
const openPill = screen.getByTestId('filter-pill-open')
|
||||
const archivedPill = screen.getByTestId('filter-pill-archived')
|
||||
const trashedPill = screen.getByTestId('filter-pill-trashed')
|
||||
expect(openPill).toHaveTextContent('3')
|
||||
expect(archivedPill).toHaveTextContent('1')
|
||||
expect(trashedPill).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('shows archived notes in All Notes when filter is archived', () => {
|
||||
render(
|
||||
<NoteList noteListFilter="archived" onNoteListFilterChange={noopFilterChange} entries={projectEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Archived Project')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Some Note')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows trashed notes in All Notes when filter is trashed', () => {
|
||||
render(
|
||||
<NoteList noteListFilter="trashed" onNoteListFilterChange={noopFilterChange} entries={projectEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Trashed Project')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows correct count badges for each filter', () => {
|
||||
@@ -1377,4 +1410,33 @@ describe('NoteList — filterEntries with subFilter', () => {
|
||||
const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' })
|
||||
expect(result.map(e => e.title)).toEqual(['Active'])
|
||||
})
|
||||
|
||||
it('filters all notes by open sub-filter', () => {
|
||||
const result = filterEntries(entries, { kind: 'filter', filter: 'all' }, 'open')
|
||||
expect(result.map(e => e.title)).toEqual(['Active', 'Other'])
|
||||
})
|
||||
|
||||
it('filters all notes by archived sub-filter', () => {
|
||||
const result = filterEntries(entries, { kind: 'filter', filter: 'all' }, 'archived')
|
||||
expect(result.map(e => e.title)).toEqual(['Archived'])
|
||||
})
|
||||
|
||||
it('filters all notes by trashed sub-filter', () => {
|
||||
const result = filterEntries(entries, { kind: 'filter', filter: 'all' }, 'trashed')
|
||||
expect(result.map(e => e.title)).toEqual(['Trashed'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('countAllByFilter', () => {
|
||||
it('counts all entries by filter status', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/1.md', isA: 'Project' }),
|
||||
makeEntry({ path: '/2.md', isA: 'Note' }),
|
||||
makeEntry({ path: '/3.md', isA: 'Project', archived: true }),
|
||||
makeEntry({ path: '/4.md', isA: 'Note', trashed: true }),
|
||||
makeEntry({ path: '/5.md', isA: 'Person', archived: true, trashed: true }),
|
||||
]
|
||||
const counts = countAllByFilter(entries)
|
||||
expect(counts).toEqual({ open: 2, archived: 1, trashed: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useCallback, useEffect, memo } from 'react'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPeriod } from '../types'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import { countByFilter, countInboxByPeriod } from '../utils/noteListHelpers'
|
||||
import { countByFilter, countAllByFilter, countInboxByPeriod } from '../utils/noteListHelpers'
|
||||
import { NoteItem } from './NoteItem'
|
||||
import { prefetchNoteContent } from '../hooks/useTabManagement'
|
||||
import { BulkActionBar } from './BulkActionBar'
|
||||
@@ -48,11 +48,13 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
|
||||
const isSectionGroup = selection.kind === 'sectionGroup'
|
||||
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
|
||||
const subFilter = isSectionGroup ? noteListFilter : undefined
|
||||
const isAllNotesView = selection.kind === 'filter' && selection.filter === 'all'
|
||||
const showFilterPills = isSectionGroup || isAllNotesView
|
||||
const subFilter = showFilterPills ? noteListFilter : undefined
|
||||
|
||||
const filterCounts = useMemo(
|
||||
() => isSectionGroup ? countByFilter(entries, selection.type) : { open: 0, archived: 0, trashed: 0 },
|
||||
[entries, isSectionGroup, selection],
|
||||
() => isSectionGroup ? countByFilter(entries, selection.type) : isAllNotesView ? countAllByFilter(entries) : { open: 0, archived: 0, trashed: 0 },
|
||||
[entries, isSectionGroup, isAllNotesView, selection],
|
||||
)
|
||||
|
||||
const inboxCounts = useMemo(
|
||||
@@ -75,7 +77,7 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
|
||||
const noteListKeyboard = useNoteListKeyboard({ items: searched, selectedNotePath: selectedNote?.path ?? null, onOpen: onReplaceActiveTab, enabled: !isEntityView })
|
||||
const multiSelect = useMultiSelect(searched, selectedNote?.path ?? null)
|
||||
useEffect(() => { multiSelect.clear() }, [selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection change only
|
||||
useEffect(() => { multiSelect.clear() }, [selection, noteListFilter]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection/filter change
|
||||
|
||||
const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
|
||||
routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, onOpenInNewWindow, multiSelect })
|
||||
@@ -100,7 +102,7 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
return (
|
||||
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
|
||||
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} isTrashView={isTrashView} trashCount={searched.length} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={onCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} />
|
||||
{isSectionGroup && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} />}
|
||||
{showFilterPills && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} />}
|
||||
{isInboxView && onInboxPeriodChange && <InboxFilterPills active={inboxPeriod} counts={inboxCounts} onChange={onInboxPeriodChange} />}
|
||||
<div className="flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
|
||||
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
|
||||
|
||||
@@ -315,6 +315,7 @@ function filterByKind(entries: VaultEntry[], selection: SidebarSelection, subFil
|
||||
const typeEntries = entries.filter((e) => e.isA === selection.type)
|
||||
return subFilter ? applySubFilter(typeEntries, subFilter) : typeEntries.filter(isActive)
|
||||
}
|
||||
if (selection.filter === 'all' && subFilter) return applySubFilter(entries, subFilter)
|
||||
return filterByFilterType(entries, selection.filter)
|
||||
}
|
||||
|
||||
@@ -342,6 +343,17 @@ export function countByFilter(entries: VaultEntry[], type: string): Record<NoteL
|
||||
return { open, archived, trashed }
|
||||
}
|
||||
|
||||
/** Count notes per sub-filter across all entries (no type filter). */
|
||||
export function countAllByFilter(entries: VaultEntry[]): Record<NoteListFilter, number> {
|
||||
let open = 0, archived = 0, trashed = 0
|
||||
for (const e of entries) {
|
||||
if (e.trashed) trashed++
|
||||
else if (e.archived) archived++
|
||||
else open++
|
||||
}
|
||||
return { open, archived, trashed }
|
||||
}
|
||||
|
||||
// --- Inbox ---
|
||||
|
||||
/** Build a set of all valid link targets (titles, aliases, filename stems, path stems). */
|
||||
|
||||
Reference in New Issue
Block a user