Add note list filtering by sidebar selection

- All Notes → show everything
- People/Events filters → show entities of that type
- Section group header → show all entities of that type
- Specific entity → pinned at top with green border + children
- Topic → show entries whose relatedTo references that topic
- Wikilink reference matching (belongsTo/relatedTo → path stem)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-14 19:44:39 +01:00
parent 97499088b3
commit 085b9089f3
5 changed files with 245 additions and 42 deletions

56
e2e/filtering.spec.ts Normal file
View File

@@ -0,0 +1,56 @@
import { test, expect } from '@playwright/test'
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500) // Wait for mock data
})
test('All Notes shows all entries', async ({ page }) => {
const count = page.locator('.note-list__count')
await expect(count).toHaveText('12')
})
test('clicking People filter shows only people', async ({ page }) => {
await page.click('text=People')
await page.waitForTimeout(100)
const count = page.locator('.note-list__count')
await expect(count).toHaveText('1')
await expect(page.locator('.note-list__title', { hasText: 'Matteo Cellini' })).toBeVisible()
await page.screenshot({ path: 'test-results/filter-people.png' })
})
test('clicking Events filter shows only events', async ({ page }) => {
await page.click('text=Events')
await page.waitForTimeout(100)
const count = page.locator('.note-list__count')
await expect(count).toHaveText('1')
await expect(page.locator('.note-list__title', { hasText: 'Laputa App Design Session' })).toBeVisible()
await page.screenshot({ path: 'test-results/filter-events.png' })
})
test('clicking PROJECTS header shows all projects', async ({ page }) => {
await page.click('text=PROJECTS')
await page.waitForTimeout(100)
const count = page.locator('.note-list__count')
await expect(count).toHaveText('1')
await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).toBeVisible()
await page.screenshot({ path: 'test-results/filter-projects.png' })
})
test('clicking specific entity shows it pinned with children', async ({ page }) => {
await page.locator('.sidebar__item', { hasText: 'Build Laputa App' }).click()
await page.waitForTimeout(100)
// Pinned entity + children that belongTo this project
await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).toBeVisible()
await expect(page.locator('.note-list__title', { hasText: 'Facebook Ads Strategy' })).toBeVisible()
await expect(page.locator('.note-list__title', { hasText: 'Budget Allocation' })).toBeVisible()
await expect(page.locator('.note-list__item--pinned')).toBeVisible()
await page.screenshot({ path: 'test-results/filter-entity.png' })
})
test('clicking topic shows entries related to that topic', async ({ page }) => {
await page.locator('.sidebar__topic-item', { hasText: 'Software Development' }).click()
await page.waitForTimeout(100)
await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).toBeVisible()
await page.screenshot({ path: 'test-results/filter-topic.png' })
})

View File

@@ -63,7 +63,7 @@ function App() {
</div>
<ResizeHandle onResize={handleSidebarResize} />
<div className="app__note-list" style={{ width: noteListWidth }}>
<NoteList entries={entries} />
<NoteList entries={entries} selection={selection} />
</div>
<ResizeHandle onResize={handleNoteListResize} />
<div className="app__editor">

View File

@@ -72,3 +72,9 @@
.note-list__status {
color: #6a6;
}
.note-list__item--pinned {
background: #1e1e3a;
border-left: 3px solid #6a6;
padding-left: 13px;
}

View File

@@ -1,38 +1,135 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { NoteList } from './NoteList'
import type { VaultEntry, SidebarSelection } from '../types'
const allSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
const mockEntries: VaultEntry[] = [
{
path: '/Users/luca/Laputa/project/26q1-laputa-app.md',
filename: '26q1-laputa-app.md',
title: 'Build Laputa App',
isA: 'Project',
aliases: [],
belongsTo: [],
relatedTo: ['[[topic/software-development]]'],
status: 'Active',
owner: 'Luca',
cadence: null,
modifiedAt: 1700000000,
fileSize: 1024,
},
{
path: '/Users/luca/Laputa/note/facebook-ads-strategy.md',
filename: 'facebook-ads-strategy.md',
title: 'Facebook Ads Strategy',
isA: 'Note',
aliases: [],
belongsTo: ['[[project/26q1-laputa-app]]'],
relatedTo: ['[[topic/growth]]'],
status: null,
owner: null,
cadence: null,
modifiedAt: 1700000000,
fileSize: 847,
},
{
path: '/Users/luca/Laputa/person/matteo-cellini.md',
filename: 'matteo-cellini.md',
title: 'Matteo Cellini',
isA: 'Person',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
modifiedAt: 1700000000,
fileSize: 320,
},
{
path: '/Users/luca/Laputa/event/2026-02-14-kickoff.md',
filename: '2026-02-14-kickoff.md',
title: 'Kickoff Meeting',
isA: 'Event',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
modifiedAt: 1700000000,
fileSize: 512,
},
{
path: '/Users/luca/Laputa/topic/software-development.md',
filename: 'software-development.md',
title: 'Software Development',
isA: 'Topic',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
modifiedAt: 1700000000,
fileSize: 256,
},
]
describe('NoteList', () => {
it('shows empty state when no entries', () => {
render(<NoteList entries={[]} />)
expect(screen.getByText('No notes loaded')).toBeInTheDocument()
render(<NoteList entries={[]} selection={allSelection} />)
expect(screen.getByText('No notes found')).toBeInTheDocument()
expect(screen.getByText('0')).toBeInTheDocument()
})
it('renders note entries', () => {
const entries = [
{
path: '/vault/note1.md',
filename: 'note1.md',
title: 'First Note',
isA: 'Project',
status: 'Active',
modifiedAt: 1700000000,
},
{
path: '/vault/note2.md',
filename: 'note2.md',
title: 'Second Note',
isA: null,
status: null,
modifiedAt: null,
},
]
render(<NoteList entries={entries} />)
expect(screen.getByText('First Note')).toBeInTheDocument()
expect(screen.getByText('Second Note')).toBeInTheDocument()
expect(screen.getByText('Project')).toBeInTheDocument()
expect(screen.getByText('Active')).toBeInTheDocument()
it('renders all entries with All Notes filter', () => {
render(<NoteList entries={mockEntries} selection={allSelection} />)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
expect(screen.getByText('5')).toBeInTheDocument()
})
it('filters by People', () => {
render(<NoteList entries={mockEntries} selection={{ kind: 'filter', filter: 'people' }} />)
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
expect(screen.getByText('1')).toBeInTheDocument()
})
it('filters by Events', () => {
render(<NoteList entries={mockEntries} selection={{ kind: 'filter', filter: 'events' }} />)
expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument()
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
})
it('filters by section group type', () => {
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} />)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
expect(screen.getByText('1')).toBeInTheDocument()
})
it('shows entity pinned at top with children', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} />
)
// Pinned entity + child (Facebook Ads Strategy belongsTo this project)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
})
it('filters by topic (relatedTo references)', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'topic', entry: mockEntries[4] }} />
)
// Build Laputa App has relatedTo: [[topic/software-development]]
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
})
})

View File

@@ -1,31 +1,75 @@
import type { VaultEntry, SidebarSelection } from '../types'
import './NoteList.css'
interface VaultEntry {
path: string
filename: string
title: string
isA: string | null
status: string | null
modifiedAt: number | null
}
interface NoteListProps {
entries: VaultEntry[]
selection: SidebarSelection
}
export function NoteList({ entries }: NoteListProps) {
/** Check if a wikilink array (e.g. belongsTo) references a given entry by path stem */
function refsMatch(refs: string[], entry: VaultEntry): boolean {
// Extract the path stem: /Users/luca/Laputa/project/26q1-laputa-app.md → project/26q1-laputa-app
const stem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
return refs.some((ref) => {
const inner = ref.replace(/^\[\[/, '').replace(/\]\]$/, '')
return inner === stem
})
}
function filterEntries(entries: VaultEntry[], selection: SidebarSelection): VaultEntry[] {
switch (selection.kind) {
case 'filter':
switch (selection.filter) {
case 'all':
return entries
case 'people':
return entries.filter((e) => e.isA === 'Person')
case 'events':
return entries.filter((e) => e.isA === 'Event')
case 'favorites':
// TODO: Implement favorites (needs a "favorite" field in frontmatter)
return []
case 'trash':
// TODO: Implement trash (needs deleted/archived status)
return []
}
break
case 'sectionGroup':
return entries.filter((e) => e.isA === selection.type)
case 'entity': {
const pinned = selection.entry
const children = entries.filter(
(e) => e.path !== pinned.path && refsMatch(e.belongsTo, pinned)
)
return [pinned, ...children]
}
case 'topic': {
const topic = selection.entry
return entries.filter((e) => refsMatch(e.relatedTo, topic))
}
}
}
export function NoteList({ entries, selection }: NoteListProps) {
const filtered = filterEntries(entries, selection)
return (
<div className="note-list">
<div className="note-list__header">
<h3>Notes</h3>
<span className="note-list__count">{entries.length}</span>
<span className="note-list__count">{filtered.length}</span>
</div>
<div className="note-list__items">
{entries.length === 0 ? (
<div className="note-list__empty">No notes loaded</div>
{filtered.length === 0 ? (
<div className="note-list__empty">No notes found</div>
) : (
entries.map((entry) => (
<div key={entry.path} className="note-list__item">
filtered.map((entry, i) => (
<div
key={entry.path}
className={`note-list__item${
selection.kind === 'entity' && i === 0 ? ' note-list__item--pinned' : ''
}`}
>
<div className="note-list__title">{entry.title}</div>
<div className="note-list__meta">
{entry.isA && <span className="note-list__type">{entry.isA}</span>}