2026-02-14 19:46:44 +01:00
|
|
|
import { render, screen, fireEvent } from '@testing-library/react'
|
2026-02-14 20:07:23 +01:00
|
|
|
import { describe, it, expect, vi } from 'vitest'
|
2026-02-14 18:25:11 +01:00
|
|
|
import { NoteList } from './NoteList'
|
2026-02-14 19:44:39 +01:00
|
|
|
import type { VaultEntry, SidebarSelection } from '../types'
|
|
|
|
|
|
|
|
|
|
const allSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
|
2026-02-14 20:07:23 +01:00
|
|
|
const noopSelect = vi.fn()
|
2026-02-14 19:44:39 +01:00
|
|
|
|
|
|
|
|
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,
|
2026-02-16 16:56:44 +01:00
|
|
|
createdAt: null,
|
2026-02-14 19:44:39 +01:00
|
|
|
fileSize: 1024,
|
2026-02-17 17:14:29 +01:00
|
|
|
snippet: 'Build a personal knowledge management app.',
|
2026-02-17 19:11:01 +01:00
|
|
|
relationships: {
|
|
|
|
|
'Related to': ['[[topic/software-development]]'],
|
|
|
|
|
},
|
2026-02-14 19:44:39 +01:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
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,
|
2026-02-16 16:56:44 +01:00
|
|
|
createdAt: null,
|
2026-02-14 19:44:39 +01:00
|
|
|
fileSize: 847,
|
2026-02-17 17:14:29 +01:00
|
|
|
snippet: 'Lookalike audiences convert 3x better.',
|
2026-02-17 19:11:01 +01:00
|
|
|
relationships: {
|
|
|
|
|
'Belongs to': ['[[project/26q1-laputa-app]]'],
|
|
|
|
|
'Related to': ['[[topic/growth]]'],
|
|
|
|
|
},
|
2026-02-14 19:44:39 +01:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
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,
|
2026-02-16 16:56:44 +01:00
|
|
|
createdAt: null,
|
2026-02-14 19:44:39 +01:00
|
|
|
fileSize: 320,
|
2026-02-17 17:14:29 +01:00
|
|
|
snippet: 'Sponsorship manager.',
|
2026-02-17 19:11:01 +01:00
|
|
|
relationships: {},
|
2026-02-14 19:44:39 +01:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
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,
|
2026-02-16 16:56:44 +01:00
|
|
|
createdAt: null,
|
2026-02-14 19:44:39 +01:00
|
|
|
fileSize: 512,
|
2026-02-17 17:14:29 +01:00
|
|
|
snippet: 'Project kickoff meeting notes.',
|
2026-02-17 19:11:01 +01:00
|
|
|
relationships: {},
|
2026-02-14 19:44:39 +01:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
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,
|
2026-02-16 16:56:44 +01:00
|
|
|
createdAt: null,
|
2026-02-14 19:44:39 +01:00
|
|
|
fileSize: 256,
|
2026-02-17 17:14:29 +01:00
|
|
|
snippet: 'Frontend, backend, and systems programming.',
|
2026-02-17 19:11:01 +01:00
|
|
|
relationships: {},
|
2026-02-14 19:44:39 +01:00
|
|
|
},
|
|
|
|
|
]
|
2026-02-14 18:25:11 +01:00
|
|
|
|
|
|
|
|
describe('NoteList', () => {
|
|
|
|
|
it('shows empty state when no entries', () => {
|
2026-02-17 11:03:23 +01:00
|
|
|
render(<NoteList entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-14 19:44:39 +01:00
|
|
|
expect(screen.getByText('No notes found')).toBeInTheDocument()
|
2026-02-14 18:25:11 +01:00
|
|
|
})
|
|
|
|
|
|
2026-02-14 19:44:39 +01:00
|
|
|
it('renders all entries with All Notes filter', () => {
|
2026-02-17 11:03:23 +01:00
|
|
|
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-14 19:44:39 +01:00
|
|
|
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-17 11:03:23 +01:00
|
|
|
it('filters by People (section group)', () => {
|
|
|
|
|
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Person' }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-14 19:44:39 +01:00
|
|
|
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-17 11:03:23 +01:00
|
|
|
it('filters by Events (section group)', () => {
|
|
|
|
|
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Event' }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-14 19:44:39 +01:00
|
|
|
expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('filters by section group type', () => {
|
2026-02-17 11:03:23 +01:00
|
|
|
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-14 19:44:39 +01:00
|
|
|
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-15 10:30:13 +01:00
|
|
|
it('shows entity pinned at top with grouped children', () => {
|
2026-02-20 22:02:04 +01:00
|
|
|
render(
|
2026-02-14 21:22:54 +01:00
|
|
|
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
|
2026-02-14 19:44:39 +01:00
|
|
|
)
|
2026-02-15 10:30:13 +01:00
|
|
|
// Entity title appears in header and pinned card
|
|
|
|
|
expect(screen.getAllByText('Build Laputa App').length).toBeGreaterThanOrEqual(1)
|
|
|
|
|
// Child entry in "Children" group
|
2026-02-14 19:44:39 +01:00
|
|
|
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
2026-02-15 10:30:13 +01:00
|
|
|
// Unrelated entries not shown
|
2026-02-14 19:44:39 +01:00
|
|
|
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
2026-02-15 10:30:13 +01:00
|
|
|
// Group headers shown
|
|
|
|
|
expect(screen.getByText('Children')).toBeInTheDocument()
|
2026-02-17 19:11:01 +01:00
|
|
|
expect(screen.getByText('Related to')).toBeInTheDocument()
|
2026-02-14 18:25:11 +01:00
|
|
|
})
|
2026-02-14 19:44:39 +01:00
|
|
|
|
|
|
|
|
it('filters by topic (relatedTo references)', () => {
|
|
|
|
|
render(
|
2026-02-14 21:22:54 +01:00
|
|
|
<NoteList entries={mockEntries} selection={{ kind: 'topic', entry: mockEntries[4] }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
|
2026-02-14 19:44:39 +01:00
|
|
|
)
|
|
|
|
|
// Build Laputa App has relatedTo: [[topic/software-development]]
|
|
|
|
|
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
|
|
|
|
|
})
|
2026-02-14 19:46:44 +01:00
|
|
|
|
2026-02-17 11:03:23 +01:00
|
|
|
it('shows search input when search icon is clicked', () => {
|
2026-02-14 21:22:54 +01:00
|
|
|
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-17 11:03:23 +01:00
|
|
|
// Search is hidden by default
|
|
|
|
|
expect(screen.queryByPlaceholderText('Search notes...')).not.toBeInTheDocument()
|
|
|
|
|
// Click search icon to show it
|
|
|
|
|
fireEvent.click(screen.getByTitle('Search notes'))
|
2026-02-14 19:46:44 +01:00
|
|
|
expect(screen.getByPlaceholderText('Search notes...')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('filters by search query (case-insensitive substring)', () => {
|
2026-02-17 11:03:23 +01:00
|
|
|
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
|
|
|
|
// Open search
|
|
|
|
|
fireEvent.click(screen.getByTitle('Search notes'))
|
2026-02-14 19:46:44 +01:00
|
|
|
const input = screen.getByPlaceholderText('Search notes...')
|
|
|
|
|
fireEvent.change(input, { target: { value: 'facebook' } })
|
|
|
|
|
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('sorts entries by last modified descending', () => {
|
|
|
|
|
const entriesWithDifferentDates: VaultEntry[] = [
|
|
|
|
|
{ ...mockEntries[0], modifiedAt: 1000, title: 'Oldest' },
|
|
|
|
|
{ ...mockEntries[1], modifiedAt: 3000, title: 'Newest', path: '/p2' },
|
|
|
|
|
{ ...mockEntries[2], modifiedAt: 2000, title: 'Middle', path: '/p3' },
|
|
|
|
|
]
|
2026-02-14 21:22:54 +01:00
|
|
|
render(<NoteList entries={entriesWithDifferentDates} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-14 20:07:23 +01:00
|
|
|
const titles = screen.getAllByText(/Oldest|Newest|Middle/)
|
2026-02-14 19:46:44 +01:00
|
|
|
const titleTexts = titles.map((el) => el.textContent)
|
|
|
|
|
expect(titleTexts).toEqual(['Newest', 'Middle', 'Oldest'])
|
|
|
|
|
})
|
2026-02-14 19:48:59 +01:00
|
|
|
|
2026-02-17 11:03:23 +01:00
|
|
|
it('does not render type badge or status on note items', () => {
|
|
|
|
|
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
|
|
|
|
// Type badges like "Project", "Note" etc. should not appear as separate badge elements
|
|
|
|
|
// The word "Project" should only appear in the ALL CAPS pill "PROJECTS 1", not as a standalone badge
|
|
|
|
|
expect(screen.queryByText('Active')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('header shows search and plus icons instead of count badge', () => {
|
|
|
|
|
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
|
|
|
|
expect(screen.getByTitle('Search notes')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByTitle('Create new note')).toBeInTheDocument()
|
|
|
|
|
})
|
feat: implement context view in NoteList
When a note is selected from the sidebar (entity selection), NoteList
now shows a context view instead of a flat list:
- Prominent top card with type-colored background, bold title, snippet,
timestamp, and type icon
- Grouped relationship sections (Children, Events, Referenced By,
Belongs To, Related To, Backlinks) with collapse/expand chevrons
- Backlinks detected by scanning allContent for wikilink references
- Groups show ALL-CAPS headers with count and CaretDown/CaretRight toggle
- Normal flat list shown when sidebar selection is "All Notes"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:47:38 +01:00
|
|
|
|
|
|
|
|
it('context view shows backlinks from allContent', () => {
|
|
|
|
|
const allContent = {
|
|
|
|
|
[mockEntries[2].path]: 'Met with [[project/26q1-laputa-app]] team.',
|
|
|
|
|
}
|
|
|
|
|
render(
|
|
|
|
|
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} allContent={allContent} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
expect(screen.getByText('Backlinks')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('context view collapses and expands groups', () => {
|
|
|
|
|
render(
|
|
|
|
|
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
// Children group is expanded by default
|
|
|
|
|
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
|
|
|
|
// Click the Children header to collapse
|
|
|
|
|
fireEvent.click(screen.getByText('Children'))
|
|
|
|
|
// Items should be hidden
|
|
|
|
|
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
|
|
|
|
|
// Click again to expand
|
|
|
|
|
fireEvent.click(screen.getByText('Children'))
|
|
|
|
|
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('context view shows prominent card with entity snippet', () => {
|
|
|
|
|
render(
|
|
|
|
|
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
// Snippet appears in the prominent card
|
|
|
|
|
expect(screen.getByText('Build a personal knowledge management app.')).toBeInTheDocument()
|
|
|
|
|
})
|
2026-02-14 18:25:11 +01:00
|
|
|
})
|