From 085b9089f3f82bc299b476ec7ff581fc791172e7 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 14 Feb 2026 19:44:39 +0100 Subject: [PATCH] Add note list filtering by sidebar selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- e2e/filtering.spec.ts | 56 ++++++++++++ src/App.tsx | 2 +- src/components/NoteList.css | 6 ++ src/components/NoteList.test.tsx | 149 +++++++++++++++++++++++++------ src/components/NoteList.tsx | 74 +++++++++++---- 5 files changed, 245 insertions(+), 42 deletions(-) create mode 100644 e2e/filtering.spec.ts diff --git a/e2e/filtering.spec.ts b/e2e/filtering.spec.ts new file mode 100644 index 00000000..aa1fe65e --- /dev/null +++ b/e2e/filtering.spec.ts @@ -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' }) +}) diff --git a/src/App.tsx b/src/App.tsx index fd360e53..bc1a954c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -63,7 +63,7 @@ function App() {
- +
diff --git a/src/components/NoteList.css b/src/components/NoteList.css index c3af8a88..76102c8b 100644 --- a/src/components/NoteList.css +++ b/src/components/NoteList.css @@ -72,3 +72,9 @@ .note-list__status { color: #6a6; } + +.note-list__item--pinned { + background: #1e1e3a; + border-left: 3px solid #6a6; + padding-left: 13px; +} diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx index fcc0d852..b23ed01d 100644 --- a/src/components/NoteList.test.tsx +++ b/src/components/NoteList.test.tsx @@ -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() - expect(screen.getByText('No notes loaded')).toBeInTheDocument() + render() + 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() - 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() + 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() + expect(screen.getByText('Matteo Cellini')).toBeInTheDocument() + expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() + expect(screen.getByText('1')).toBeInTheDocument() + }) + + it('filters by Events', () => { + render() + expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument() + expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() + }) + + it('filters by section group type', () => { + render() + 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( + + ) + // 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( + + ) + // Build Laputa App has relatedTo: [[topic/software-development]] + expect(screen.getByText('Build Laputa App')).toBeInTheDocument() + expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument() + }) }) diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 00cf9c63..6e168d0b 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -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 (

Notes

- {entries.length} + {filtered.length}
- {entries.length === 0 ? ( -
No notes loaded
+ {filtered.length === 0 ? ( +
No notes found
) : ( - entries.map((entry) => ( -
+ filtered.map((entry, i) => ( +
{entry.title}
{entry.isA && {entry.isA}}