From c6fa1f48cb8be081c0f24fda143567d2834fcd04 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 20 Mar 2026 23:03:17 +0100 Subject: [PATCH] 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) --- src/components/NoteList.test.tsx | 68 ++++++++++++++++++++++++++++++-- src/components/NoteList.tsx | 14 ++++--- src/utils/noteListHelpers.ts | 12 ++++++ 3 files changed, 85 insertions(+), 9 deletions(-) diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx index 480ebc3e..c975b2b8 100644 --- a/src/components/NoteList.test.tsx +++ b/src/components/NoteList.test.tsx @@ -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( ) - 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( + + ) + // 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( + + ) + 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( + + ) + 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 }) + }) }) diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 73d791e8..e9cecfa8 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -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 (
- {isSectionGroup && } + {showFilterPills && } {isInboxView && onInboxPeriodChange && }
diff --git a/src/utils/noteListHelpers.ts b/src/utils/noteListHelpers.ts index 23202b55..aeaa8c26 100644 --- a/src/utils/noteListHelpers.ts +++ b/src/utils/noteListHelpers.ts @@ -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 { + 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). */