feat: add note count chips to sidebar view entries
Each view in the sidebar now shows a count chip displaying the number of notes matching its filters, consistent with type section count chips. Count updates reactively as vault entries change. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -987,4 +987,67 @@ describe('Sidebar', () => {
|
||||
expect(onCreateNewType).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('view note count chips', () => {
|
||||
const mockViews = [
|
||||
{
|
||||
filename: 'active-projects.yml',
|
||||
definition: {
|
||||
name: 'Active Projects',
|
||||
icon: '🚀',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals' as const, value: 'Project' }] },
|
||||
},
|
||||
},
|
||||
{
|
||||
filename: 'all-topics.yml',
|
||||
definition: {
|
||||
name: 'All Topics',
|
||||
icon: null,
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals' as const, value: 'Topic' }] },
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
it('shows note count chip for each view matching the filter results', () => {
|
||||
render(
|
||||
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={mockViews} />
|
||||
)
|
||||
// 'Active Projects' filters for type=Project -> mockEntries has 1 Project (build-app.md)
|
||||
const projectLabel = screen.getByText('Active Projects')
|
||||
const projectNavItem = projectLabel.closest('[class*="cursor-pointer"]')!
|
||||
// The count chip is a sibling span inside the NavItem
|
||||
const projectCount = projectNavItem.querySelector('span:last-child')
|
||||
expect(projectCount?.textContent).toBe('1')
|
||||
|
||||
// 'All Topics' filters for type=Topic -> mockEntries has 2 Topics
|
||||
const topicLabel = screen.getByText('All Topics')
|
||||
const topicNavItem = topicLabel.closest('[class*="cursor-pointer"]')!
|
||||
const topicCount = topicNavItem.querySelector('span:last-child')
|
||||
expect(topicCount?.textContent).toBe('2')
|
||||
})
|
||||
|
||||
it('does not show count chip for views with 0 matching notes', () => {
|
||||
const emptyView = [{
|
||||
filename: 'empty.yml',
|
||||
definition: {
|
||||
name: 'Empty View',
|
||||
icon: null,
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals' as const, value: 'Nonexistent' }] },
|
||||
},
|
||||
}]
|
||||
render(
|
||||
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={emptyView} />
|
||||
)
|
||||
expect(screen.getByText('Empty View')).toBeInTheDocument()
|
||||
// No count chip rendered for 0 results (NavItem hides count <= 0)
|
||||
const viewContainer = screen.getByText('Empty View').closest('div')
|
||||
expect(viewContainer?.querySelector('span:last-child')?.textContent).not.toBe('0')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
FileText, Trash, Archive, CaretLeft, Tray, CaretRight, CaretDown, Plus, Funnel, PencilSimple,
|
||||
} from '@phosphor-icons/react'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
import { evaluateView } from '../utils/viewFilters'
|
||||
import { arrayMove } from '@dnd-kit/sortable'
|
||||
import { SlidersHorizontal } from 'lucide-react'
|
||||
import {
|
||||
@@ -169,19 +170,22 @@ function SidebarGroupHeader({ label, collapsed, onToggle, count, children }: {
|
||||
)
|
||||
}
|
||||
|
||||
function ViewItem({ view, isActive, onSelect, onEditView, onDeleteView }: {
|
||||
function ViewItem({ view, isActive, onSelect, onEditView, onDeleteView, entries }: {
|
||||
view: ViewFile
|
||||
isActive: boolean
|
||||
onSelect: () => void
|
||||
onEditView?: (filename: string) => void
|
||||
onDeleteView?: (filename: string) => void
|
||||
entries: VaultEntry[]
|
||||
}) {
|
||||
const count = useMemo(() => evaluateView(view.definition, entries).length, [view.definition, entries])
|
||||
return (
|
||||
<div className="group relative">
|
||||
<NavItem
|
||||
icon={Funnel}
|
||||
emoji={view.definition.icon}
|
||||
label={view.definition.name}
|
||||
count={count}
|
||||
isActive={isActive}
|
||||
onClick={onSelect}
|
||||
/>
|
||||
@@ -209,7 +213,7 @@ function ViewItem({ view, isActive, onSelect, onEditView, onDeleteView }: {
|
||||
)
|
||||
}
|
||||
|
||||
function ViewsSection({ views, selection, onSelect, collapsed, onToggle, onCreateView, onEditView, onDeleteView }: {
|
||||
function ViewsSection({ views, selection, onSelect, collapsed, onToggle, onCreateView, onEditView, onDeleteView, entries }: {
|
||||
views: ViewFile[]
|
||||
selection: SidebarSelection
|
||||
onSelect: (sel: SidebarSelection) => void
|
||||
@@ -218,6 +222,7 @@ function ViewsSection({ views, selection, onSelect, collapsed, onToggle, onCreat
|
||||
onCreateView?: () => void
|
||||
onEditView?: (filename: string) => void
|
||||
onDeleteView?: (filename: string) => void
|
||||
entries: VaultEntry[]
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-border" style={{ padding: '0 6px' }}>
|
||||
@@ -240,6 +245,7 @@ function ViewsSection({ views, selection, onSelect, collapsed, onToggle, onCreat
|
||||
onSelect={() => onSelect({ kind: 'view', filename: v.filename })}
|
||||
onEditView={onEditView}
|
||||
onDeleteView={onDeleteView}
|
||||
entries={entries}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -581,7 +587,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
|
||||
{/* Views */}
|
||||
{hasViews && (
|
||||
<ViewsSection views={views} selection={selection} onSelect={onSelect} collapsed={groupCollapsed.views} onToggle={() => toggleGroup('views')} onCreateView={onCreateView} onEditView={onEditView} onDeleteView={onDeleteView} />
|
||||
<ViewsSection views={views} selection={selection} onSelect={onSelect} collapsed={groupCollapsed.views} onToggle={() => toggleGroup('views')} onCreateView={onCreateView} onEditView={onEditView} onDeleteView={onDeleteView} entries={entries} />
|
||||
)}
|
||||
|
||||
{/* Types */}
|
||||
|
||||
Reference in New Issue
Block a user