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:
56
e2e/filtering.spec.ts
Normal file
56
e2e/filtering.spec.ts
Normal 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' })
|
||||
})
|
||||
@@ -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">
|
||||
|
||||
@@ -72,3 +72,9 @@
|
||||
.note-list__status {
|
||||
color: #6a6;
|
||||
}
|
||||
|
||||
.note-list__item--pinned {
|
||||
background: #1e1e3a;
|
||||
border-left: 3px solid #6a6;
|
||||
padding-left: 13px;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>}
|
||||
|
||||
Reference in New Issue
Block a user