refactor: split note list hotspots
This commit is contained in:
@@ -1,307 +1,76 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { NoteItem } from './NoteItem'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { makeEntry } from '../test-utils/noteListTestUtils'
|
||||
|
||||
vi.mock('../mock-tauri', () => ({ isTauri: () => false, mockInvoke: vi.fn() }))
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return {
|
||||
path: '/vault/test.md', filename: 'test.md', title: 'Test Note',
|
||||
isA: 'Movie', aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, archived: false,
|
||||
modifiedAt: 1700000000, createdAt: null, fileSize: 100,
|
||||
snippet: 'A snippet', wordCount: 50,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, sort: null, view: null,
|
||||
visible: null, favorite: false, favoriteIndex: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
listPropertiesDisplay: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeTypeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return makeEntry({
|
||||
path: '/vault/movie.md', filename: 'movie.md', title: 'Movie',
|
||||
isA: 'Type', listPropertiesDisplay: [],
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
const noop = vi.fn()
|
||||
|
||||
describe('NoteItem property chips', () => {
|
||||
it('renders property chips when type has listPropertiesDisplay', () => {
|
||||
const entry = makeEntry({
|
||||
properties: { rating: 4, genre: 'Drama' },
|
||||
})
|
||||
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating', 'genre'] })
|
||||
|
||||
render(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('property-chips')).toBeInTheDocument()
|
||||
expect(screen.getByText('4')).toBeInTheDocument()
|
||||
expect(screen.getByText('Drama')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render chips when listPropertiesDisplay is empty', () => {
|
||||
const entry = makeEntry({ properties: { rating: 4 } })
|
||||
const typeEntry = makeTypeEntry({ listPropertiesDisplay: [] })
|
||||
|
||||
render(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('property-chips')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('skips chips for missing properties', () => {
|
||||
const entry = makeEntry({ properties: { rating: 4 } })
|
||||
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating', 'genre'] })
|
||||
|
||||
render(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('4')).toBeInTheDocument()
|
||||
expect(screen.queryByText('genre')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders relationship values as display labels', () => {
|
||||
const entry = makeEntry({
|
||||
relationships: { Director: ['[[spielberg|Steven Spielberg]]'] },
|
||||
})
|
||||
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['Director'] })
|
||||
|
||||
render(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Steven Spielberg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the target note icon as a prefix on relationship chips', () => {
|
||||
const entry = makeEntry({
|
||||
relationships: { Director: ['[[spielberg|Steven Spielberg]]'] },
|
||||
})
|
||||
const director = makeEntry({
|
||||
path: '/vault/spielberg.md',
|
||||
filename: 'spielberg.md',
|
||||
title: 'Steven Spielberg',
|
||||
isA: 'Person',
|
||||
icon: 'star',
|
||||
})
|
||||
const movieType = makeTypeEntry({ listPropertiesDisplay: ['Director'] })
|
||||
const personType = makeTypeEntry({
|
||||
path: '/vault/person.md',
|
||||
filename: 'person.md',
|
||||
title: 'Person',
|
||||
icon: 'users',
|
||||
})
|
||||
|
||||
render(
|
||||
<NoteItem
|
||||
entry={entry}
|
||||
isSelected={false}
|
||||
noteStatus="clean"
|
||||
typeEntryMap={{ Movie: movieType, Person: personType }}
|
||||
allEntries={[entry, director, movieType, personType]}
|
||||
onClickNote={noop}
|
||||
/>
|
||||
)
|
||||
|
||||
const chip = screen.getByText('Steven Spielberg').parentElement
|
||||
expect(chip?.querySelector('svg')).toBeInTheDocument()
|
||||
expect(chip?.querySelector('svg')).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
|
||||
it('falls back to the target type icon when the target note has no icon', () => {
|
||||
const entry = makeEntry({
|
||||
relationships: { Director: ['[[spielberg|Steven Spielberg]]'] },
|
||||
})
|
||||
const director = makeEntry({
|
||||
path: '/vault/spielberg.md',
|
||||
filename: 'spielberg.md',
|
||||
title: 'Steven Spielberg',
|
||||
isA: 'Person',
|
||||
icon: null,
|
||||
})
|
||||
const movieType = makeTypeEntry({ listPropertiesDisplay: ['Director'] })
|
||||
const personType = makeTypeEntry({
|
||||
path: '/vault/person.md',
|
||||
filename: 'person.md',
|
||||
title: 'Person',
|
||||
icon: 'users',
|
||||
})
|
||||
|
||||
render(
|
||||
<NoteItem
|
||||
entry={entry}
|
||||
isSelected={false}
|
||||
noteStatus="clean"
|
||||
typeEntryMap={{ Movie: movieType, Person: personType }}
|
||||
allEntries={[entry, director, movieType, personType]}
|
||||
onClickNote={noop}
|
||||
/>
|
||||
)
|
||||
|
||||
const chip = screen.getByText('Steven Spielberg').parentElement
|
||||
expect(chip?.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders relationship chips without a prefix when neither note nor type has an icon', () => {
|
||||
const entry = makeEntry({
|
||||
relationships: { Director: ['[[spielberg|Steven Spielberg]]'] },
|
||||
})
|
||||
const director = makeEntry({
|
||||
path: '/vault/spielberg.md',
|
||||
filename: 'spielberg.md',
|
||||
title: 'Steven Spielberg',
|
||||
isA: 'Person',
|
||||
icon: null,
|
||||
})
|
||||
const movieType = makeTypeEntry({ listPropertiesDisplay: ['Director'] })
|
||||
const personType = makeTypeEntry({
|
||||
path: '/vault/person.md',
|
||||
filename: 'person.md',
|
||||
title: 'Person',
|
||||
icon: null,
|
||||
})
|
||||
|
||||
render(
|
||||
<NoteItem
|
||||
entry={entry}
|
||||
isSelected={false}
|
||||
noteStatus="clean"
|
||||
typeEntryMap={{ Movie: movieType, Person: personType }}
|
||||
allEntries={[entry, director, movieType, personType]}
|
||||
onClickNote={noop}
|
||||
/>
|
||||
)
|
||||
|
||||
const chip = screen.getByText('Steven Spielberg').parentElement
|
||||
expect(chip?.querySelector('svg')).toBeNull()
|
||||
expect(chip?.querySelector('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to the target type icon when the target note image icon fails to load', () => {
|
||||
const entry = makeEntry({
|
||||
relationships: { Director: ['[[spielberg|Steven Spielberg]]'] },
|
||||
})
|
||||
const director = makeEntry({
|
||||
path: '/vault/spielberg.md',
|
||||
filename: 'spielberg.md',
|
||||
title: 'Steven Spielberg',
|
||||
isA: 'Person',
|
||||
icon: 'https://example.com/director.png',
|
||||
})
|
||||
const movieType = makeTypeEntry({ listPropertiesDisplay: ['Director'] })
|
||||
const personType = makeTypeEntry({
|
||||
path: '/vault/person.md',
|
||||
filename: 'person.md',
|
||||
title: 'Person',
|
||||
icon: 'users',
|
||||
})
|
||||
|
||||
render(
|
||||
<NoteItem
|
||||
entry={entry}
|
||||
isSelected={false}
|
||||
noteStatus="clean"
|
||||
typeEntryMap={{ Movie: movieType, Person: personType }}
|
||||
allEntries={[entry, director, movieType, personType]}
|
||||
onClickNote={noop}
|
||||
/>
|
||||
)
|
||||
|
||||
const chip = screen.getByText('Steven Spielberg').parentElement
|
||||
const image = chip?.querySelector('img')
|
||||
expect(image).toBeInTheDocument()
|
||||
fireEvent.error(image!)
|
||||
expect(chip?.querySelector('img')).toBeNull()
|
||||
expect(chip?.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows hostname for URL properties', () => {
|
||||
const entry = makeEntry({
|
||||
properties: { url: 'https://www.example.com/page/123' },
|
||||
})
|
||||
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['url'] })
|
||||
|
||||
render(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('www.example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('prefers displayPropsOverride over the type defaults', () => {
|
||||
const entry = makeEntry({
|
||||
properties: { rating: 4, Owner: 'Luca' },
|
||||
})
|
||||
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating'] })
|
||||
|
||||
render(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} displayPropsOverride={['Owner']} onClickNote={noop} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Luca')).toBeInTheDocument()
|
||||
expect(screen.queryByText('4')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render chips for binary files', () => {
|
||||
const entry = makeEntry({
|
||||
describe('NoteItem', () => {
|
||||
it('renders binary files as non-clickable muted rows', () => {
|
||||
const binaryEntry = makeEntry({
|
||||
path: '/vault/photo.png',
|
||||
filename: 'photo.png',
|
||||
title: 'photo.png',
|
||||
fileKind: 'binary',
|
||||
properties: { rating: 4 },
|
||||
})
|
||||
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating'] })
|
||||
const onClickNote = vi.fn()
|
||||
|
||||
render(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
render(<NoteItem entry={binaryEntry} isSelected={false} typeEntryMap={{}} onClickNote={onClickNote} />)
|
||||
|
||||
expect(screen.queryByTestId('property-chips')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NoteItem note icons', () => {
|
||||
it('renders a Phosphor note icon in the note row', () => {
|
||||
render(
|
||||
<NoteItem
|
||||
entry={makeEntry({ icon: 'star' })}
|
||||
isSelected={false}
|
||||
noteStatus="clean"
|
||||
typeEntryMap={{ Movie: makeTypeEntry() }}
|
||||
onClickNote={noop}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('note-title-icon').tagName.toLowerCase()).toBe('svg')
|
||||
})
|
||||
|
||||
it('renders an image note icon in the note row', () => {
|
||||
render(
|
||||
<NoteItem
|
||||
entry={makeEntry({ icon: 'https://example.com/favicon.png' })}
|
||||
isSelected={false}
|
||||
noteStatus="clean"
|
||||
typeEntryMap={{ Movie: makeTypeEntry() }}
|
||||
onClickNote={noop}
|
||||
/>
|
||||
)
|
||||
|
||||
const icon = screen.getByTestId('note-title-icon')
|
||||
expect(icon.tagName.toLowerCase()).toBe('img')
|
||||
expect(icon).toHaveAttribute('src', 'https://example.com/favicon.png')
|
||||
const item = screen.getByTestId('binary-file-item')
|
||||
expect(item.className).toContain('opacity-50')
|
||||
expect(item).toHaveAttribute('title', 'Cannot open this file type')
|
||||
|
||||
fireEvent.click(item)
|
||||
expect(onClickNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders text files as clickable rows', () => {
|
||||
const textEntry = makeEntry({
|
||||
path: '/vault/config.yml',
|
||||
filename: 'config.yml',
|
||||
title: 'config.yml',
|
||||
fileKind: 'text',
|
||||
})
|
||||
const onClickNote = vi.fn()
|
||||
|
||||
render(<NoteItem entry={textEntry} isSelected={false} typeEntryMap={{}} onClickNote={onClickNote} />)
|
||||
|
||||
const item = screen.getByText('config.yml').closest('div')!
|
||||
fireEvent.click(item)
|
||||
expect(onClickNote).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows filenames instead of titles when a change status is present', () => {
|
||||
const entry = makeEntry({ filename: 'my-note.md', title: 'My Note Title' })
|
||||
|
||||
render(<NoteItem entry={entry} isSelected={false} typeEntryMap={{}} onClickNote={vi.fn()} changeStatus="modified" />)
|
||||
|
||||
expect(screen.getByText('my-note.md')).toBeInTheDocument()
|
||||
expect(screen.queryByText('My Note Title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the correct symbol for modified files', () => {
|
||||
const entry = makeEntry({ filename: 'note.md' })
|
||||
|
||||
render(<NoteItem entry={entry} isSelected={false} typeEntryMap={{}} onClickNote={vi.fn()} changeStatus="modified" />)
|
||||
|
||||
expect(screen.getByTestId('change-status-icon').textContent).toBe('·')
|
||||
})
|
||||
|
||||
it('renders the correct symbol for added files', () => {
|
||||
const entry = makeEntry({ filename: 'new-note.md' })
|
||||
|
||||
render(<NoteItem entry={entry} isSelected={false} typeEntryMap={{}} onClickNote={vi.fn()} changeStatus="added" />)
|
||||
|
||||
expect(screen.getByTestId('change-status-icon').textContent).toBe('+')
|
||||
})
|
||||
|
||||
it('renders the regular title when no change status is set', () => {
|
||||
const entry = makeEntry({ filename: 'note.md', title: 'My Note' })
|
||||
|
||||
render(<NoteItem entry={entry} isSelected={false} typeEntryMap={{}} onClickNote={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('My Note')).toBeInTheDocument()
|
||||
expect(screen.queryByText('note.md')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('change-status-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
257
src/components/NoteList.behavior.test.tsx
Normal file
257
src/components/NoteList.behavior.test.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makeEntry, makeIndexedEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
|
||||
|
||||
describe('NoteList status indicators', () => {
|
||||
it('shows a modified indicator for modified notes', () => {
|
||||
const getNoteStatus = (path: string) => path === mockEntries[0].path ? 'modified' as const : 'clean' as const
|
||||
renderNoteList({ getNoteStatus })
|
||||
|
||||
const indicators = screen.getAllByTestId('modified-indicator')
|
||||
expect(indicators).toHaveLength(1)
|
||||
const noteRow = indicators[0].closest('[data-testid="modified-indicator"]')!.parentElement!.parentElement!
|
||||
expect(noteRow.textContent).toContain('Build Laputa App')
|
||||
})
|
||||
|
||||
it('does not show indicators when everything is clean', () => {
|
||||
renderNoteList({ getNoteStatus: () => 'clean' as const })
|
||||
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('new-indicator')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows multiple modified indicators when multiple notes are dirty', () => {
|
||||
const modifiedPaths = new Set([mockEntries[0].path, mockEntries[1].path])
|
||||
const getNoteStatus = (path: string) => modifiedPaths.has(path) ? 'modified' as const : 'clean' as const
|
||||
|
||||
renderNoteList({ getNoteStatus })
|
||||
expect(screen.getAllByTestId('modified-indicator')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not show indicators when getNoteStatus is undefined', () => {
|
||||
renderNoteList()
|
||||
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('new-indicator')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the green new indicator for new notes', () => {
|
||||
const getNoteStatus = (path: string) => path === mockEntries[0].path ? 'new' as const : 'clean' as const
|
||||
renderNoteList({ getNoteStatus })
|
||||
expect(screen.getAllByTestId('new-indicator')).toHaveLength(1)
|
||||
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NoteList virtualized datasets', () => {
|
||||
it('renders 9000 entries without crashing', { timeout: 30000 }, () => {
|
||||
const largeDataset = Array.from({ length: 9000 }, (_, index) => makeIndexedEntry(index))
|
||||
const { container } = renderNoteList({ entries: largeDataset })
|
||||
expect(container.querySelector('[data-testid="virtuoso-mock"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders both ends of a large dataset through the Virtuoso mock', () => {
|
||||
const largeDataset = Array.from({ length: 500 }, (_, index) => makeIndexedEntry(index))
|
||||
renderNoteList({ entries: largeDataset })
|
||||
expect(screen.getByText('Note 0')).toBeInTheDocument()
|
||||
expect(screen.getByText('Note 499')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters large datasets by search query', () => {
|
||||
const entries = [
|
||||
makeIndexedEntry(0, { title: 'Alpha Strategy' }),
|
||||
...Array.from({ length: 998 }, (_, index) => makeIndexedEntry(index + 1, { title: `Filler Note ${index + 1}` })),
|
||||
makeIndexedEntry(999, { title: 'Beta Strategy' }),
|
||||
]
|
||||
|
||||
renderNoteList({ entries })
|
||||
fireEvent.click(screen.getByTitle('Search notes'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'Strategy' } })
|
||||
|
||||
expect(screen.getByText('Alpha Strategy')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta Strategy')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Filler Note 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sorts large datasets correctly', () => {
|
||||
const entries = [
|
||||
makeIndexedEntry(0, { title: 'Zebra', modifiedAt: 1000 }),
|
||||
makeIndexedEntry(1, { title: 'Alpha', modifiedAt: 3000 }),
|
||||
...Array.from({ length: 100 }, (_, index) => makeIndexedEntry(index + 2, { title: `Mid ${index}`, modifiedAt: 2000 - index })),
|
||||
]
|
||||
|
||||
renderNoteList({ entries })
|
||||
expect(screen.getAllByText(/^Alpha$|^Zebra$/)[0].textContent).toBe('Alpha')
|
||||
})
|
||||
|
||||
it('filters section groups inside large mixed datasets', () => {
|
||||
const entries = [
|
||||
...Array.from({ length: 100 }, (_, index) => makeIndexedEntry(index, { isA: 'Project', title: `Project ${index}` })),
|
||||
...Array.from({ length: 200 }, (_, index) => makeIndexedEntry(100 + index, { isA: 'Note', title: `Note ${index}` })),
|
||||
]
|
||||
|
||||
renderNoteList({ entries, selection: { kind: 'sectionGroup', type: 'Project' } })
|
||||
expect(screen.getByText('Project 0')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Note 0')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps selection highlighting in virtualized lists', () => {
|
||||
const entries = Array.from({ length: 100 }, (_, index) => makeIndexedEntry(index))
|
||||
renderNoteList({ entries, selectedNote: entries[5] })
|
||||
expect(screen.getByText('Note 5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps click behavior working on virtualized items', () => {
|
||||
const entries = Array.from({ length: 100 }, (_, index) => makeIndexedEntry(index))
|
||||
const { onReplaceActiveTab } = renderNoteList({ entries })
|
||||
fireEvent.click(screen.getByText('Note 50'))
|
||||
expect(onReplaceActiveTab).toHaveBeenCalledWith(entries[50])
|
||||
})
|
||||
})
|
||||
|
||||
describe('NoteList multi-select', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function selectTwoNotes(extraProps: Record<string, unknown> = {}) {
|
||||
renderNoteList(extraProps)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
}
|
||||
|
||||
it('selects a range on Shift+Click', () => {
|
||||
selectTwoNotes()
|
||||
expect(screen.getAllByTestId('multi-selected-item').length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('clears multi-select and opens the note on regular click', () => {
|
||||
const { onReplaceActiveTab } = renderNoteList()
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
fireEvent.click(screen.getByText('Matteo Cellini'))
|
||||
|
||||
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
|
||||
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[2])
|
||||
})
|
||||
|
||||
it('clears multi-select and opens a new tab on Cmd+Click', () => {
|
||||
const { onSelectNote } = renderNoteList()
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
fireEvent.click(screen.getByText('Matteo Cellini'), { metaKey: true })
|
||||
|
||||
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
|
||||
expect(onSelectNote).toHaveBeenCalledWith(mockEntries[2])
|
||||
})
|
||||
|
||||
it('shows the bulk action bar with the selected count', () => {
|
||||
selectTwoNotes()
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 selected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'archives via button', prop: 'onBulkArchive', trigger: () => fireEvent.click(screen.getByTestId('bulk-archive-btn')) },
|
||||
{ label: 'deletes via button', prop: 'onBulkDeletePermanently', trigger: () => fireEvent.click(screen.getByTestId('bulk-delete-btn')) },
|
||||
{ label: 'archives via Cmd+E', prop: 'onBulkArchive', trigger: () => fireEvent.keyDown(window, { key: 'e', metaKey: true }) },
|
||||
{ label: 'deletes via Cmd+Backspace', prop: 'onBulkDeletePermanently', trigger: () => fireEvent.keyDown(window, { key: 'Backspace', metaKey: true }) },
|
||||
{ label: 'deletes via Cmd+Delete', prop: 'onBulkDeletePermanently', trigger: () => fireEvent.keyDown(window, { key: 'Delete', metaKey: true }) },
|
||||
])('bulk-select $label and clears the selection', ({ prop, trigger }) => {
|
||||
const handler = vi.fn()
|
||||
selectTwoNotes({ [prop]: handler })
|
||||
trigger()
|
||||
expect(handler).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clears the selection from the bulk action bar', () => {
|
||||
selectTwoNotes()
|
||||
fireEvent.click(screen.getByTestId('bulk-clear-btn'))
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show a bulk action bar when nothing is selected', () => {
|
||||
renderNoteList()
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NoteList filter pills', () => {
|
||||
const projectEntries = [
|
||||
makeEntry({ path: '/p1.md', title: 'Open Project 1', isA: 'Project' }),
|
||||
makeEntry({ path: '/p2.md', title: 'Open Project 2', isA: 'Project' }),
|
||||
makeEntry({ path: '/p3.md', title: 'Archived Project', isA: 'Project', archived: true }),
|
||||
makeEntry({ path: '/n1.md', title: 'Some Note', isA: 'Note' }),
|
||||
]
|
||||
|
||||
it('shows filter pills for type sections', () => {
|
||||
renderNoteList({ entries: projectEntries, selection: { kind: 'sectionGroup', type: 'Project' } })
|
||||
expect(screen.getByTestId('filter-pills')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-pill-open')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-pill-archived')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows filter pills in all-notes view', () => {
|
||||
renderNoteList({ entries: projectEntries })
|
||||
expect(screen.getByTestId('filter-pills')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-pill-open')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-pill-archived')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the correct all-notes count badges', () => {
|
||||
renderNoteList({ entries: projectEntries })
|
||||
expect(screen.getByTestId('filter-pill-open')).toHaveTextContent('3')
|
||||
expect(screen.getByTestId('filter-pill-archived')).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('shows archived notes in all-notes archived mode', () => {
|
||||
renderNoteList({ entries: projectEntries, noteListFilter: 'archived' })
|
||||
expect(screen.getByText('Archived Project')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Some Note')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the correct counts for a type filter', () => {
|
||||
renderNoteList({ entries: projectEntries, selection: { kind: 'sectionGroup', type: 'Project' } })
|
||||
const openPill = screen.getByTestId('filter-pill-open')
|
||||
const archivedPill = screen.getByTestId('filter-pill-archived')
|
||||
|
||||
expect(openPill).toHaveTextContent('Open')
|
||||
expect(openPill).toHaveTextContent('2')
|
||||
expect(archivedPill).toHaveTextContent('Archived')
|
||||
expect(archivedPill).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('calls onNoteListFilterChange when a filter pill is clicked', () => {
|
||||
const onNoteListFilterChange = vi.fn()
|
||||
renderNoteList({
|
||||
entries: projectEntries,
|
||||
selection: { kind: 'sectionGroup', type: 'Project' },
|
||||
onNoteListFilterChange,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('filter-pill-archived'))
|
||||
expect(onNoteListFilterChange).toHaveBeenCalledWith('archived')
|
||||
})
|
||||
|
||||
it('shows archived notes when the type filter switches to archived', () => {
|
||||
renderNoteList({
|
||||
entries: projectEntries,
|
||||
selection: { kind: 'sectionGroup', type: 'Project' },
|
||||
noteListFilter: 'archived',
|
||||
})
|
||||
|
||||
expect(screen.getByText('Archived Project')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the archived empty state when a section has no archived notes', () => {
|
||||
renderNoteList({
|
||||
entries: projectEntries.filter((entry) => !entry.archived),
|
||||
selection: { kind: 'sectionGroup', type: 'Project' },
|
||||
noteListFilter: 'archived',
|
||||
})
|
||||
|
||||
expect(screen.getByText('No archived notes')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
236
src/components/NoteList.changes.test.tsx
Normal file
236
src/components/NoteList.changes.test.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { act, fireEvent, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { NoteList } from './NoteList'
|
||||
import type { SidebarSelection, VaultEntry } from '../types'
|
||||
import { allSelection, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
|
||||
|
||||
const changesSelection: SidebarSelection = { kind: 'filter', filter: 'changes' }
|
||||
const modifiedFiles = [
|
||||
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
|
||||
{ path: mockEntries[1].path, relativePath: 'note/facebook-ads-strategy.md', status: 'modified' as const },
|
||||
]
|
||||
|
||||
describe('NoteList changes view', () => {
|
||||
it('shows only modified notes in changes view with filenames', () => {
|
||||
renderNoteList({ selection: changesSelection, modifiedFiles })
|
||||
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
|
||||
expect(screen.getByText('facebook-ads-strategy.md')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Kickoff Meeting')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the changes header title', () => {
|
||||
renderNoteList({ selection: changesSelection, modifiedFiles })
|
||||
expect(screen.getByText('Changes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an empty state when no modified files exist', () => {
|
||||
renderNoteList({ selection: changesSelection, modifiedFiles: [] })
|
||||
expect(screen.getByText('No pending changes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates the list when modifiedFiles changes', () => {
|
||||
const { rerender, props } = renderNoteList({ selection: changesSelection, modifiedFiles })
|
||||
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
|
||||
expect(screen.getByText('facebook-ads-strategy.md')).toBeInTheDocument()
|
||||
|
||||
rerender(<NoteList {...props} modifiedFiles={[modifiedFiles[0]]} />)
|
||||
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
|
||||
expect(screen.queryByText('facebook-ads-strategy.md')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses modifiedFiles for filtering even when getNoteStatus is also provided', () => {
|
||||
const getNoteStatus = (path: string) => modifiedFiles.some((file) => file.path === path) ? 'modified' as const : 'clean' as const
|
||||
renderNoteList({ selection: changesSelection, modifiedFiles, getNoteStatus })
|
||||
|
||||
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
|
||||
expect(screen.getByText('facebook-ads-strategy.md')).toBeInTheDocument()
|
||||
expect(screen.queryByText('matteo-cellini.md')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('matches entries by relative path suffix across machines', () => {
|
||||
const crossMachineEntries: VaultEntry[] = mockEntries.map((entry) => ({
|
||||
...entry,
|
||||
path: entry.path.replace('/Users/luca/Laputa', '/Users/other-machine/OtherVault'),
|
||||
}))
|
||||
|
||||
renderNoteList({
|
||||
entries: crossMachineEntries,
|
||||
selection: changesSelection,
|
||||
modifiedFiles,
|
||||
})
|
||||
|
||||
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
|
||||
expect(screen.getByText('facebook-ads-strategy.md')).toBeInTheDocument()
|
||||
expect(screen.queryByText('matteo-cellini.md')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the load error when modifiedFilesError is set', () => {
|
||||
renderNoteList({
|
||||
selection: changesSelection,
|
||||
modifiedFiles: [],
|
||||
modifiedFilesError: 'git status failed: not a git repository',
|
||||
})
|
||||
|
||||
expect(screen.getByText(/Failed to load changes/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/git status failed/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows untracked notes alongside modified notes', () => {
|
||||
const mixedFiles = [
|
||||
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
|
||||
{ path: mockEntries[2].path, relativePath: 'person/matteo-cellini.md', status: 'untracked' as const },
|
||||
]
|
||||
|
||||
renderNoteList({ selection: changesSelection, modifiedFiles: mixedFiles })
|
||||
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
|
||||
expect(screen.getByText('matteo-cellini.md')).toBeInTheDocument()
|
||||
expect(screen.queryByText('facebook-ads-strategy.md')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows change-status icons for each modified file', () => {
|
||||
const mixedFiles = [
|
||||
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
|
||||
{ path: mockEntries[2].path, relativePath: 'person/matteo-cellini.md', status: 'untracked' as const },
|
||||
]
|
||||
|
||||
renderNoteList({ selection: changesSelection, modifiedFiles: mixedFiles })
|
||||
expect(screen.getAllByTestId('change-status-icon')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows deleted notes as rows when files are deleted', () => {
|
||||
const filesWithDeleted = [
|
||||
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
|
||||
{ path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const },
|
||||
{ path: '/Users/luca/Laputa/note/also-gone.md', relativePath: 'note/also-gone.md', status: 'deleted' as const },
|
||||
]
|
||||
|
||||
renderNoteList({ selection: changesSelection, modifiedFiles: filesWithDeleted })
|
||||
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
|
||||
expect(screen.getByText('gone.md')).toBeInTheDocument()
|
||||
expect(screen.getByText('also-gone.md')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/notes? deleted/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders deleted rows with dimmed strikethrough styling', () => {
|
||||
renderNoteList({
|
||||
selection: changesSelection,
|
||||
modifiedFiles: [
|
||||
{ path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const },
|
||||
],
|
||||
})
|
||||
|
||||
expect(screen.getByText('gone.md')).toHaveClass('line-through')
|
||||
expect(screen.getByText('gone.md')).toHaveClass('opacity-70')
|
||||
})
|
||||
|
||||
it('does not show a deleted banner in or outside changes view', () => {
|
||||
renderNoteList({ selection: changesSelection, modifiedFiles })
|
||||
expect(screen.queryByText(/notes? deleted/)).not.toBeInTheDocument()
|
||||
|
||||
renderNoteList({
|
||||
selection: allSelection,
|
||||
modifiedFiles: [
|
||||
{ path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const },
|
||||
],
|
||||
})
|
||||
expect(screen.queryByText(/notes? deleted/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the discard context menu when onDiscardFile is provided', () => {
|
||||
renderNoteList({ selection: changesSelection, modifiedFiles, onDiscardFile: vi.fn() })
|
||||
const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')!
|
||||
fireEvent.contextMenu(noteItem)
|
||||
|
||||
expect(screen.getByTestId('changes-context-menu')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('discard-changes-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the restore action for deleted rows in the context menu', () => {
|
||||
renderNoteList({
|
||||
selection: changesSelection,
|
||||
modifiedFiles: [
|
||||
{ path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const },
|
||||
],
|
||||
onDiscardFile: vi.fn(),
|
||||
})
|
||||
|
||||
const noteItem = screen.getByText('gone.md').closest('[class*="border-b"]')!
|
||||
fireEvent.contextMenu(noteItem)
|
||||
|
||||
expect(screen.getByTestId('changes-context-menu')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('restore-note-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show a context menu when discard is unavailable', () => {
|
||||
renderNoteList({ selection: changesSelection, modifiedFiles })
|
||||
const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')!
|
||||
fireEvent.contextMenu(noteItem)
|
||||
|
||||
expect(screen.queryByTestId('changes-context-menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the confirmation dialog after clicking discard changes', () => {
|
||||
renderNoteList({ selection: changesSelection, modifiedFiles, onDiscardFile: vi.fn() })
|
||||
const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')!
|
||||
|
||||
fireEvent.contextMenu(noteItem)
|
||||
fireEvent.click(screen.getByTestId('discard-changes-button'))
|
||||
|
||||
const dialog = screen.getByTestId('discard-confirm-dialog')
|
||||
expect(dialog).toBeInTheDocument()
|
||||
expect(dialog.textContent).toContain('Build Laputa App')
|
||||
})
|
||||
|
||||
it('opens the restore context-menu action from Shift+F10 on highlighted deleted rows', () => {
|
||||
renderNoteList({
|
||||
selection: changesSelection,
|
||||
modifiedFiles: [
|
||||
{ path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const },
|
||||
],
|
||||
onDiscardFile: vi.fn(),
|
||||
})
|
||||
|
||||
const container = screen.getByTestId('note-list-container')
|
||||
act(() => {
|
||||
fireEvent.focus(container)
|
||||
})
|
||||
act(() => {
|
||||
fireEvent.keyDown(container, { key: 'F10', shiftKey: true })
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('changes-context-menu')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('restore-note-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onDiscardFile with the relative path when discard is confirmed', async () => {
|
||||
const onDiscardFile = vi.fn().mockResolvedValue(undefined)
|
||||
renderNoteList({ selection: changesSelection, modifiedFiles, onDiscardFile })
|
||||
|
||||
const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')!
|
||||
act(() => {
|
||||
fireEvent.contextMenu(noteItem)
|
||||
})
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByTestId('discard-changes-button'))
|
||||
})
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('discard-confirm-button'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(onDiscardFile).toHaveBeenCalledWith('project/26q1-laputa-app.md')
|
||||
})
|
||||
|
||||
it('does not call onDiscardFile when cancel is clicked', () => {
|
||||
const onDiscardFile = vi.fn()
|
||||
renderNoteList({ selection: changesSelection, modifiedFiles, onDiscardFile })
|
||||
|
||||
const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')!
|
||||
fireEvent.contextMenu(noteItem)
|
||||
fireEvent.click(screen.getByTestId('discard-changes-button'))
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
|
||||
expect(onDiscardFile).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
328
src/components/NoteList.rendering.test.tsx
Normal file
328
src/components/NoteList.rendering.test.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import { act, fireEvent, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { openNoteListPropertiesPicker } from './note-list/noteListPropertiesEvents'
|
||||
import {
|
||||
allSelection,
|
||||
makeEntry,
|
||||
makeTypeDefinition,
|
||||
mockEntries,
|
||||
renderNoteList,
|
||||
} from '../test-utils/noteListTestUtils'
|
||||
|
||||
describe('NoteList rendering', () => {
|
||||
it('shows an empty state when there are no entries', () => {
|
||||
renderNoteList({ entries: [] })
|
||||
expect(screen.getByText('No notes found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all entries in the all-notes view', () => {
|
||||
renderNoteList()
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters section groups by type', () => {
|
||||
renderNoteList({ selection: { kind: 'sectionGroup', type: 'Person' } })
|
||||
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('supports event sections', () => {
|
||||
renderNoteList({ selection: { kind: 'sectionGroup', type: 'Event' } })
|
||||
expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('supports project sections', () => {
|
||||
renderNoteList({ selection: { kind: 'sectionGroup', type: 'Project' } })
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes the selected type when creating a note from a type section', () => {
|
||||
const { onCreateNote } = renderNoteList({ selection: { kind: 'sectionGroup', type: 'Project' } })
|
||||
fireEvent.click(screen.getByTitle('Create new note'))
|
||||
expect(onCreateNote).toHaveBeenCalledWith('Project')
|
||||
})
|
||||
|
||||
it('creates an untyped note from all notes', () => {
|
||||
const { onCreateNote } = renderNoteList()
|
||||
fireEvent.click(screen.getByTitle('Create new note'))
|
||||
expect(onCreateNote).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('pins the current entity and shows grouped children', () => {
|
||||
renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
|
||||
expect(screen.getAllByText('Build Laputa App').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Children')).toBeInTheDocument()
|
||||
expect(screen.getByText('Related to')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows referenced-by groups for topic entities', () => {
|
||||
renderNoteList({ selection: { kind: 'entity', entry: mockEntries[4] } })
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Referenced By')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles the search input from the header action', () => {
|
||||
renderNoteList()
|
||||
expect(screen.queryByPlaceholderText('Search notes...')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTitle('Search notes'))
|
||||
expect(screen.getByPlaceholderText('Search notes...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by a case-insensitive search query', () => {
|
||||
renderNoteList()
|
||||
fireEvent.click(screen.getByTitle('Search notes'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { 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 by default', () => {
|
||||
renderNoteList({
|
||||
entries: [
|
||||
{ ...mockEntries[0], modifiedAt: 1000, title: 'Oldest' },
|
||||
{ ...mockEntries[1], modifiedAt: 3000, title: 'Newest', path: '/p2' },
|
||||
{ ...mockEntries[2], modifiedAt: 2000, title: 'Middle', path: '/p3' },
|
||||
],
|
||||
})
|
||||
|
||||
const titles = screen.getAllByText(/Oldest|Newest|Middle/).map((element) => element.textContent)
|
||||
expect(titles).toEqual(['Newest', 'Middle', 'Oldest'])
|
||||
})
|
||||
|
||||
it('hides standalone status badges inside note rows', () => {
|
||||
renderNoteList()
|
||||
expect(screen.queryByText('Active')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows search and create actions in the header instead of a count badge', () => {
|
||||
renderNoteList()
|
||||
expect(screen.getByTitle('Search notes')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('Create new note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows backlinks from outgoing links in entity view', () => {
|
||||
const entriesWithBacklink = mockEntries.map((entry) =>
|
||||
entry.path === mockEntries[2].path ? { ...entry, outgoingLinks: ['Build Laputa App'] } : entry,
|
||||
)
|
||||
|
||||
renderNoteList({
|
||||
entries: entriesWithBacklink,
|
||||
selection: { kind: 'entity', entry: mockEntries[0] },
|
||||
})
|
||||
|
||||
expect(screen.getByText('Backlinks')).toBeInTheDocument()
|
||||
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses and expands entity groups', () => {
|
||||
renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Children'))
|
||||
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Children'))
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the entity snippet in the prominent card', () => {
|
||||
renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
|
||||
expect(screen.getByText('Build a personal knowledge management app.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the inbox customize-columns action and falls back to type-defined chips', () => {
|
||||
const entries = [
|
||||
makeTypeDefinition('Book', ['Priority']),
|
||||
makeEntry({
|
||||
path: '/vault/book.md',
|
||||
filename: 'book.md',
|
||||
title: 'Book Note',
|
||||
isA: 'Book',
|
||||
properties: { Priority: 'High', Owner: 'Luca' },
|
||||
createdAt: 1700000000,
|
||||
}),
|
||||
]
|
||||
|
||||
renderNoteList({
|
||||
entries,
|
||||
selection: { kind: 'filter', filter: 'inbox' },
|
||||
inboxNoteListProperties: null,
|
||||
onUpdateInboxNoteListProperties: () => undefined,
|
||||
})
|
||||
|
||||
expect(screen.getByTitle('Customize Inbox columns')).toBeInTheDocument()
|
||||
expect(screen.getByText('High')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Luca')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the inbox column picker from the global event and saves new columns', () => {
|
||||
const onUpdateInboxNoteListProperties = vi.fn()
|
||||
const entries = [
|
||||
makeTypeDefinition('Book', ['Priority']),
|
||||
makeEntry({
|
||||
path: '/vault/book.md',
|
||||
filename: 'book.md',
|
||||
title: 'Book Note',
|
||||
isA: 'Book',
|
||||
properties: { Priority: 'High', Owner: 'Luca' },
|
||||
createdAt: 1700000000,
|
||||
}),
|
||||
]
|
||||
|
||||
renderNoteList({
|
||||
entries,
|
||||
selection: { kind: 'filter', filter: 'inbox' },
|
||||
inboxNoteListProperties: null,
|
||||
onUpdateInboxNoteListProperties,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
openNoteListPropertiesPicker('inbox')
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument()
|
||||
expect(screen.getByRole('checkbox', { name: 'Priority' })).toBeChecked()
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Owner' }))
|
||||
expect(onUpdateInboxNoteListProperties).toHaveBeenCalledWith(['Priority', 'Owner'])
|
||||
})
|
||||
|
||||
it('uses inbox overrides when configured', () => {
|
||||
const entries = [
|
||||
makeTypeDefinition('Book', ['Priority']),
|
||||
makeEntry({
|
||||
path: '/vault/book.md',
|
||||
filename: 'book.md',
|
||||
title: 'Book Note',
|
||||
isA: 'Book',
|
||||
properties: { Priority: 'High', Owner: 'Luca' },
|
||||
createdAt: 1700000000,
|
||||
}),
|
||||
]
|
||||
|
||||
renderNoteList({
|
||||
entries,
|
||||
selection: { kind: 'filter', filter: 'inbox' },
|
||||
inboxNoteListProperties: ['Owner'],
|
||||
onUpdateInboxNoteListProperties: () => undefined,
|
||||
})
|
||||
|
||||
expect(screen.getByText('Luca')).toBeInTheDocument()
|
||||
expect(screen.queryByText('High')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NoteList click behavior', () => {
|
||||
it('opens the current tab on a regular click', () => {
|
||||
const { onReplaceActiveTab, onSelectNote } = renderNoteList()
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(onSelectNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens a new tab on Cmd+Click', () => {
|
||||
const { onReplaceActiveTab, onSelectNote } = renderNoteList()
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
expect(onSelectNote).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(onReplaceActiveTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens a new tab on Ctrl+Click', () => {
|
||||
const { onReplaceActiveTab, onSelectNote } = renderNoteList()
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { ctrlKey: true })
|
||||
expect(onSelectNote).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(onReplaceActiveTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('supports Cmd+Click on the entity pinned card', () => {
|
||||
const { onReplaceActiveTab, onSelectNote } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
|
||||
const titles = screen.getAllByText('Build Laputa App')
|
||||
fireEvent.click(titles[titles.length - 1], { metaKey: true })
|
||||
expect(onSelectNote).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(onReplaceActiveTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the current tab from the entity pinned card on regular click', () => {
|
||||
const { onReplaceActiveTab, onSelectNote } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
|
||||
const titles = screen.getAllByText('Build Laputa App')
|
||||
fireEvent.click(titles[titles.length - 1])
|
||||
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(onSelectNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens child notes from entity view in the current tab', () => {
|
||||
const { onReplaceActiveTab, onSelectNote } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'))
|
||||
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[1])
|
||||
expect(onSelectNote).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NoteList type sections', () => {
|
||||
const typeEntry = {
|
||||
...makeEntry({
|
||||
path: '/Users/luca/Laputa/types/project.md',
|
||||
filename: 'project.md',
|
||||
title: 'Project',
|
||||
isA: 'Type',
|
||||
snippet: 'Defines the Project type.',
|
||||
modifiedAt: 1700000000,
|
||||
fileSize: 200,
|
||||
wordCount: 50,
|
||||
}),
|
||||
}
|
||||
const entriesWithType = [...mockEntries, typeEntry]
|
||||
|
||||
it('does not show a type note pinned card while browsing the section', () => {
|
||||
renderNoteList({
|
||||
entries: entriesWithType,
|
||||
selection: { kind: 'sectionGroup', type: 'Project' },
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Defines the Project type.')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a clickable type header that opens the type note', () => {
|
||||
const { onReplaceActiveTab } = renderNoteList({
|
||||
entries: entriesWithType,
|
||||
selection: { kind: 'sectionGroup', type: 'Project' },
|
||||
})
|
||||
|
||||
const headerLink = screen.getByTestId('type-header-link')
|
||||
expect(headerLink).toHaveTextContent('Project')
|
||||
fireEvent.click(headerLink)
|
||||
expect(onReplaceActiveTab).toHaveBeenCalledWith(typeEntry)
|
||||
})
|
||||
|
||||
it('does not render a type header outside type sections', () => {
|
||||
renderNoteList({ entries: entriesWithType, selection: allSelection })
|
||||
expect(screen.queryByTestId('type-header-link')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NoteList traffic-light padding', () => {
|
||||
it('adds left padding when the sidebar is collapsed', () => {
|
||||
const { container } = renderNoteList({ sidebarCollapsed: true })
|
||||
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
|
||||
expect(header.style.paddingLeft).toBe('80px')
|
||||
})
|
||||
|
||||
it('does not add extra left padding when the sidebar is expanded', () => {
|
||||
const { container } = renderNoteList({ sidebarCollapsed: false })
|
||||
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
|
||||
expect(header.style.paddingLeft).toBe('')
|
||||
})
|
||||
|
||||
it('defaults to no extra padding when sidebarCollapsed is omitted', () => {
|
||||
const { container } = renderNoteList()
|
||||
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
|
||||
expect(header.style.paddingLeft).toBe('')
|
||||
})
|
||||
})
|
||||
295
src/components/NoteList.sorting.test.tsx
Normal file
295
src/components/NoteList.sorting.test.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { getSortComparator } from '../utils/noteListHelpers'
|
||||
import { makeEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
|
||||
|
||||
describe('getSortComparator', () => {
|
||||
it('sorts by modified date descending', () => {
|
||||
const entries = [
|
||||
makeEntry({ title: 'A', modifiedAt: 1000 }),
|
||||
makeEntry({ title: 'B', modifiedAt: 3000 }),
|
||||
makeEntry({ title: 'C', modifiedAt: 2000 }),
|
||||
]
|
||||
|
||||
expect(entries.sort(getSortComparator('modified')).map((entry) => entry.title)).toEqual(['B', 'C', 'A'])
|
||||
})
|
||||
|
||||
it('sorts by created date descending', () => {
|
||||
const entries = [
|
||||
makeEntry({ title: 'A', createdAt: 3000, modifiedAt: 1000 }),
|
||||
makeEntry({ title: 'B', createdAt: 1000, modifiedAt: 3000 }),
|
||||
makeEntry({ title: 'C', createdAt: 2000, modifiedAt: 2000 }),
|
||||
]
|
||||
|
||||
expect(entries.sort(getSortComparator('created')).map((entry) => entry.title)).toEqual(['A', 'C', 'B'])
|
||||
})
|
||||
|
||||
it('falls back to modifiedAt when createdAt is null', () => {
|
||||
const entries = [
|
||||
makeEntry({ title: 'A', createdAt: null, modifiedAt: 5000 }),
|
||||
makeEntry({ title: 'B', createdAt: 2000, modifiedAt: 1000 }),
|
||||
]
|
||||
|
||||
expect(entries.sort(getSortComparator('created')).map((entry) => entry.title)).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('sorts by title alphabetically', () => {
|
||||
const entries = [
|
||||
makeEntry({ title: 'Zebra' }),
|
||||
makeEntry({ title: 'Alpha' }),
|
||||
makeEntry({ title: 'Middle' }),
|
||||
]
|
||||
|
||||
expect(entries.sort(getSortComparator('title')).map((entry) => entry.title)).toEqual(['Alpha', 'Middle', 'Zebra'])
|
||||
})
|
||||
|
||||
it('sorts by status with the expected priority', () => {
|
||||
const entries = [
|
||||
makeEntry({ title: 'Done', status: 'Done', modifiedAt: 1000 }),
|
||||
makeEntry({ title: 'Active', status: 'Active', modifiedAt: 1000 }),
|
||||
makeEntry({ title: 'NoStatus', status: null, modifiedAt: 1000 }),
|
||||
makeEntry({ title: 'Paused', status: 'Paused', modifiedAt: 1000 }),
|
||||
]
|
||||
|
||||
expect(entries.sort(getSortComparator('status')).map((entry) => entry.title)).toEqual(['Active', 'Paused', 'Done', 'NoStatus'])
|
||||
})
|
||||
|
||||
it('uses modifiedAt as the status tiebreaker', () => {
|
||||
const entries = [
|
||||
makeEntry({ title: 'OlderActive', status: 'Active', modifiedAt: 1000 }),
|
||||
makeEntry({ title: 'NewerActive', status: 'Active', modifiedAt: 3000 }),
|
||||
]
|
||||
|
||||
expect(entries.sort(getSortComparator('status')).map((entry) => entry.title)).toEqual(['NewerActive', 'OlderActive'])
|
||||
})
|
||||
|
||||
it('supports ascending modified sorting', () => {
|
||||
const entries = [
|
||||
makeEntry({ title: 'A', modifiedAt: 1000 }),
|
||||
makeEntry({ title: 'B', modifiedAt: 3000 }),
|
||||
makeEntry({ title: 'C', modifiedAt: 2000 }),
|
||||
]
|
||||
|
||||
expect(entries.sort(getSortComparator('modified', 'asc')).map((entry) => entry.title)).toEqual(['A', 'C', 'B'])
|
||||
})
|
||||
|
||||
it('supports descending title sorting', () => {
|
||||
const entries = [
|
||||
makeEntry({ title: 'Zebra' }),
|
||||
makeEntry({ title: 'Alpha' }),
|
||||
makeEntry({ title: 'Middle' }),
|
||||
]
|
||||
|
||||
expect(entries.sort(getSortComparator('title', 'desc')).map((entry) => entry.title)).toEqual(['Zebra', 'Middle', 'Alpha'])
|
||||
})
|
||||
|
||||
it('supports ascending created sorting', () => {
|
||||
const entries = [
|
||||
makeEntry({ title: 'A', createdAt: 3000, modifiedAt: 1000 }),
|
||||
makeEntry({ title: 'B', createdAt: 1000, modifiedAt: 3000 }),
|
||||
makeEntry({ title: 'C', createdAt: 2000, modifiedAt: 2000 }),
|
||||
]
|
||||
|
||||
expect(entries.sort(getSortComparator('created', 'asc')).map((entry) => entry.title)).toEqual(['B', 'C', 'A'])
|
||||
})
|
||||
|
||||
it('supports descending status sorting', () => {
|
||||
const entries = [
|
||||
makeEntry({ title: 'Done', status: 'Done', modifiedAt: 1000 }),
|
||||
makeEntry({ title: 'Active', status: 'Active', modifiedAt: 1000 }),
|
||||
makeEntry({ title: 'NoStatus', status: null, modifiedAt: 1000 }),
|
||||
]
|
||||
|
||||
expect(entries.sort(getSortComparator('status', 'desc')).map((entry) => entry.title)).toEqual(['NoStatus', 'Done', 'Active'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('NoteList sort controls', () => {
|
||||
beforeEach(() => {
|
||||
try {
|
||||
localStorage.removeItem('laputa-sort-preferences')
|
||||
} catch {
|
||||
// ignore storage failures in tests
|
||||
}
|
||||
})
|
||||
|
||||
const zamEntries = [
|
||||
makeEntry({ path: '/a.md', title: 'Zebra', modifiedAt: 3000 }),
|
||||
makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }),
|
||||
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
|
||||
]
|
||||
|
||||
function openListSortMenu(entries = mockEntries) {
|
||||
renderNoteList({ entries })
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
}
|
||||
|
||||
it('shows the sort button in flat list view', () => {
|
||||
renderNoteList()
|
||||
expect(screen.getByTestId('sort-button-__list__')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a per-group sort button in entity view', () => {
|
||||
renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
|
||||
expect(screen.getByTestId('sort-button-Children')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the sort menu and lists all built-in options', () => {
|
||||
openListSortMenu()
|
||||
expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-option-modified')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-option-created')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-option-title')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-option-status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('changes list order when a different sort option is selected', () => {
|
||||
openListSortMenu(zamEntries)
|
||||
|
||||
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((element) => element.textContent)
|
||||
expect(titles).toEqual(['Zebra', 'Middle', 'Alpha'])
|
||||
|
||||
fireEvent.click(screen.getByTestId('sort-option-title'))
|
||||
|
||||
titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((element) => element.textContent)
|
||||
expect(titles).toEqual(['Alpha', 'Middle', 'Zebra'])
|
||||
})
|
||||
|
||||
it('closes the sort menu after choosing an option', () => {
|
||||
openListSortMenu()
|
||||
fireEvent.click(screen.getByTestId('sort-option-title'))
|
||||
expect(screen.queryByTestId('sort-menu-__list__')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows direction arrows for every sort option', () => {
|
||||
openListSortMenu()
|
||||
expect(screen.getByTestId('sort-dir-asc-modified')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-dir-desc-modified')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-dir-asc-title')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-dir-desc-title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reverses list order when a direction arrow is chosen', () => {
|
||||
openListSortMenu(zamEntries)
|
||||
|
||||
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((element) => element.textContent)
|
||||
expect(titles).toEqual(['Zebra', 'Middle', 'Alpha'])
|
||||
|
||||
fireEvent.click(screen.getByTestId('sort-dir-asc-modified'))
|
||||
|
||||
titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((element) => element.textContent)
|
||||
expect(titles).toEqual(['Alpha', 'Middle', 'Zebra'])
|
||||
})
|
||||
|
||||
it('persists the chosen direction', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/a.md', title: 'Zebra', modifiedAt: 3000 }),
|
||||
makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }),
|
||||
]
|
||||
|
||||
renderNoteList({ entries })
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
fireEvent.click(screen.getByTestId('sort-dir-desc-title'))
|
||||
|
||||
const titles = screen.getAllByText(/Zebra|Alpha/).map((element) => element.textContent)
|
||||
expect(titles).toEqual(['Zebra', 'Alpha'])
|
||||
})
|
||||
|
||||
it('keeps the sort direction icon in sync with the active sort', () => {
|
||||
renderNoteList()
|
||||
expect(screen.getByTestId('sort-direction-icon-__list__')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
fireEvent.click(screen.getByTestId('sort-option-title'))
|
||||
|
||||
expect(screen.getByTestId('sort-direction-icon-__list__')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sorts entity relationship groups independently', () => {
|
||||
const parent = makeEntry({
|
||||
path: '/parent.md',
|
||||
filename: 'parent.md',
|
||||
title: 'Parent',
|
||||
isA: 'Project',
|
||||
})
|
||||
const child1 = makeEntry({
|
||||
path: '/child1.md',
|
||||
filename: 'child1.md',
|
||||
title: 'Zebra Note',
|
||||
belongsTo: ['[[parent]]'],
|
||||
modifiedAt: 3000,
|
||||
})
|
||||
const child2 = makeEntry({
|
||||
path: '/child2.md',
|
||||
filename: 'child2.md',
|
||||
title: 'Alpha Note',
|
||||
belongsTo: ['[[parent]]'],
|
||||
modifiedAt: 1000,
|
||||
})
|
||||
|
||||
renderNoteList({
|
||||
entries: [parent, child1, child2],
|
||||
selection: { kind: 'entity', entry: parent },
|
||||
})
|
||||
|
||||
let titles = screen.getAllByText(/Zebra Note|Alpha Note/).map((element) => element.textContent)
|
||||
expect(titles).toEqual(['Zebra Note', 'Alpha Note'])
|
||||
|
||||
fireEvent.click(screen.getByTestId('sort-button-Children'))
|
||||
fireEvent.click(screen.getByTestId('sort-option-title'))
|
||||
|
||||
titles = screen.getAllByText(/Zebra Note|Alpha Note/).map((element) => element.textContent)
|
||||
expect(titles).toEqual(['Alpha Note', 'Zebra Note'])
|
||||
})
|
||||
|
||||
it('shows custom properties after a separator in the sort menu', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/a.md', title: 'A', properties: { Priority: 'High', Rating: 5 } }),
|
||||
makeEntry({ path: '/b.md', title: 'B', properties: { Priority: 'Low', Company: 'Acme' } }),
|
||||
]
|
||||
|
||||
renderNoteList({ entries })
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
|
||||
expect(screen.getByTestId('sort-separator')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-option-property:Company')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-option-property:Priority')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-option-property:Rating')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the custom-property separator when no properties exist', () => {
|
||||
renderNoteList()
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
expect(screen.queryByTestId('sort-separator')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sorts entries by a custom property', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/a.md', title: 'A', modifiedAt: 3000, properties: { Rating: 3 } }),
|
||||
makeEntry({ path: '/b.md', title: 'B', modifiedAt: 2000, properties: { Rating: 1 } }),
|
||||
makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Rating: 5 } }),
|
||||
]
|
||||
|
||||
renderNoteList({ entries })
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
fireEvent.click(screen.getByTestId('sort-option-property:Rating'))
|
||||
|
||||
const titles = screen.getAllByText(/^[ABC]$/).map((element) => element.textContent)
|
||||
expect(titles).toEqual(['B', 'A', 'C'])
|
||||
})
|
||||
|
||||
it('pushes entries without a custom property to the end', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/a.md', title: 'A', modifiedAt: 3000, properties: { Priority: 'High' } }),
|
||||
makeEntry({ path: '/b.md', title: 'B', modifiedAt: 2000, properties: {} }),
|
||||
makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Priority: 'Low' } }),
|
||||
]
|
||||
|
||||
renderNoteList({ entries })
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
fireEvent.click(screen.getByTestId('sort-option-property:Priority'))
|
||||
|
||||
const titles = screen.getAllByText(/^[ABC]$/).map((element) => element.textContent)
|
||||
expect(titles).toEqual(['A', 'C', 'B'])
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,24 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPeriod, ViewFile } from '../types'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import { countByFilter, countAllByFilter, filterInboxEntries } from '../utils/noteListHelpers'
|
||||
import { countByFilter, countAllByFilter } from '../utils/noteListHelpers'
|
||||
import { NoteItem } from './NoteItem'
|
||||
import { prefetchNoteContent } from '../hooks/useTabManagement'
|
||||
import { BulkActionBar } from './BulkActionBar'
|
||||
import { useMultiSelect } from '../hooks/useMultiSelect'
|
||||
import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard'
|
||||
import { NoteListHeader } from './note-list/NoteListHeader'
|
||||
import { FilterPills } from './note-list/FilterPills'
|
||||
import { EntityView, ListView } from './note-list/NoteListViews'
|
||||
import { type DeletedNoteEntry, isDeletedNoteEntry, routeNoteClick, toggleSetMember, resolveHeaderTitle } from './note-list/noteListUtils'
|
||||
import { type DeletedNoteEntry, resolveHeaderTitle } from './note-list/noteListUtils'
|
||||
import {
|
||||
useTypeEntryMap, useNoteListData, useNoteListSearch,
|
||||
useNoteListSort, useMultiSelectKeyboard, useModifiedFilesState,
|
||||
useChangeStatusResolver,
|
||||
useListPropertyPicker,
|
||||
useModifiedFilesState,
|
||||
useMultiSelectKeyboard,
|
||||
useNoteListData,
|
||||
useNoteListInteractions,
|
||||
useNoteListSearch,
|
||||
useNoteListSort,
|
||||
useTypeEntryMap,
|
||||
useVisibleNotesSync,
|
||||
} from './note-list/noteListHooks'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||
@@ -31,34 +36,6 @@ function useViewFlags(selection: SidebarSelection) {
|
||||
return { isSectionGroup, isFolderView, isInboxView, isAllNotesView, isChangesView, showFilterPills }
|
||||
}
|
||||
|
||||
function collectAvailableProperties(entries: VaultEntry[]): string[] {
|
||||
const keys = new Set<string>()
|
||||
for (const entry of entries) {
|
||||
for (const key of Object.keys(entry.properties ?? {})) keys.add(key)
|
||||
for (const key of Object.keys(entry.relationships ?? {})) keys.add(key)
|
||||
}
|
||||
return [...keys].sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
function collectTypeAvailableProperties(entries: VaultEntry[], typeName: string): string[] {
|
||||
return collectAvailableProperties(entries.filter((entry) => entry.isA === typeName))
|
||||
}
|
||||
|
||||
function deriveInboxDefaultDisplay(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): string[] {
|
||||
const ordered: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const entry of entries) {
|
||||
for (const key of typeEntryMap[entry.isA ?? '']?.listPropertiesDisplay ?? []) {
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
ordered.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
return ordered
|
||||
}
|
||||
|
||||
function useBulkActions(
|
||||
multiSelect: ReturnType<typeof useMultiSelect>,
|
||||
onBulkArchive: NoteListProps['onBulkArchive'],
|
||||
@@ -201,151 +178,54 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
|
||||
const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, onUpdateTypeSort, updateEntry })
|
||||
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
|
||||
const typeEntryMap = useTypeEntryMap(entries)
|
||||
const inboxEntries = useMemo(
|
||||
() => isInboxView ? filterInboxEntries(entries, inboxPeriod) : [],
|
||||
[entries, inboxPeriod, isInboxView],
|
||||
)
|
||||
const typeAvailableProperties = useMemo(
|
||||
() => typeDocument ? collectTypeAvailableProperties(entries, typeDocument.title) : [],
|
||||
[entries, typeDocument],
|
||||
)
|
||||
const inboxAvailableProperties = useMemo(
|
||||
() => collectAvailableProperties(inboxEntries),
|
||||
[inboxEntries],
|
||||
)
|
||||
const inboxDefaultDisplay = useMemo(
|
||||
() => deriveInboxDefaultDisplay(inboxEntries, typeEntryMap),
|
||||
[inboxEntries, typeEntryMap],
|
||||
)
|
||||
const hasCustomInboxProperties = !!(inboxNoteListProperties && inboxNoteListProperties.length > 0)
|
||||
const inboxDisplayOverride = isInboxView && hasCustomInboxProperties ? inboxNoteListProperties : null
|
||||
const propertyPicker = useMemo(() => {
|
||||
if (isInboxView && onUpdateInboxNoteListProperties) {
|
||||
return {
|
||||
scope: 'inbox' as const,
|
||||
availableProperties: inboxAvailableProperties,
|
||||
currentDisplay: hasCustomInboxProperties ? inboxNoteListProperties ?? [] : inboxDefaultDisplay,
|
||||
onSave: onUpdateInboxNoteListProperties,
|
||||
triggerTitle: 'Customize Inbox columns',
|
||||
}
|
||||
}
|
||||
|
||||
if (isSectionGroup && typeDocument && onUpdateTypeSort) {
|
||||
return {
|
||||
scope: 'type' as const,
|
||||
availableProperties: typeAvailableProperties,
|
||||
currentDisplay: typeDocument.listPropertiesDisplay ?? [],
|
||||
onSave: (value: string[] | null) => onUpdateTypeSort(typeDocument.path, '_list_properties_display', value),
|
||||
triggerTitle: 'Customize columns',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}, [
|
||||
hasCustomInboxProperties,
|
||||
inboxAvailableProperties,
|
||||
inboxDefaultDisplay,
|
||||
const { inboxDisplayOverride, propertyPicker } = useListPropertyPicker({
|
||||
entries,
|
||||
selection,
|
||||
inboxPeriod,
|
||||
typeDocument,
|
||||
typeEntryMap,
|
||||
inboxNoteListProperties,
|
||||
isInboxView,
|
||||
isSectionGroup,
|
||||
onUpdateInboxNoteListProperties,
|
||||
onUpdateTypeSort,
|
||||
typeAvailableProperties,
|
||||
typeDocument,
|
||||
])
|
||||
const changeStatusMap = useMemo(() => {
|
||||
if (!isChangesView || !modifiedFiles) return undefined
|
||||
const map = new Map<string, ModifiedFile['status']>()
|
||||
for (const mf of modifiedFiles) {
|
||||
map.set(mf.path, mf.status)
|
||||
// Also index by suffix for matching (vault entries may use different path formats)
|
||||
map.set('/' + mf.relativePath, mf.status)
|
||||
}
|
||||
return map
|
||||
}, [isChangesView, modifiedFiles])
|
||||
})
|
||||
const { isEntityView, isArchivedView, searched, searchedGroups } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, modifiedFiles, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, views })
|
||||
// Keep the visible notes ref in sync for keyboard navigation (Cmd+Option+Arrow)
|
||||
if (visibleNotesRef) {
|
||||
visibleNotesRef.current = isEntityView
|
||||
? searchedGroups.flatMap((g) => g.entries).filter((entry) => !isDeletedNoteEntry(entry))
|
||||
: searched.filter((entry) => !isDeletedNoteEntry(entry))
|
||||
}
|
||||
useVisibleNotesSync({ visibleNotesRef, isEntityView, searched, searchedGroups })
|
||||
const entitySelection = isEntityView && selection.kind === 'entity' ? selection : null
|
||||
|
||||
const handleKeyboardOpen = useCallback((entry: VaultEntry) => {
|
||||
if (isDeletedNoteEntry(entry)) {
|
||||
onOpenDeletedNote?.(entry)
|
||||
return
|
||||
}
|
||||
onReplaceActiveTab(entry)
|
||||
}, [onOpenDeletedNote, onReplaceActiveTab])
|
||||
const handleKeyboardPrefetch = useCallback((entry: VaultEntry) => {
|
||||
if (!isDeletedNoteEntry(entry)) prefetchNoteContent(entry.path)
|
||||
}, [])
|
||||
const noteListKeyboard = useNoteListKeyboard({ items: searched, selectedNotePath: selectedNote?.path ?? null, onOpen: handleKeyboardOpen, onPrefetch: handleKeyboardPrefetch, enabled: !isEntityView })
|
||||
const multiSelect = useMultiSelect(searched, selectedNote?.path ?? null)
|
||||
useEffect(() => { multiSelect.clear() }, [selection, noteListFilter]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection/filter change
|
||||
|
||||
const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
|
||||
if (isDeletedNoteEntry(entry)) {
|
||||
routeNoteClick(entry, e, {
|
||||
onReplace: () => onOpenDeletedNote?.(entry),
|
||||
onSelect: () => onOpenDeletedNote?.(entry),
|
||||
multiSelect,
|
||||
})
|
||||
return
|
||||
}
|
||||
routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, onOpenInNewWindow, multiSelect })
|
||||
if (isChangesView && onAutoTriggerDiff) {
|
||||
// Small delay to let the tab open before triggering diff
|
||||
setTimeout(onAutoTriggerDiff, 50)
|
||||
}
|
||||
}, [onOpenDeletedNote, onReplaceActiveTab, onSelectNote, onOpenInNewWindow, multiSelect, isChangesView, onAutoTriggerDiff])
|
||||
const { handleNoteContextMenu, openContextMenuForEntry, contextMenuNode, dialogNode } = ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles })
|
||||
const {
|
||||
collapsedGroups,
|
||||
handleClickNote,
|
||||
handleCreateNote,
|
||||
handleListKeyDown,
|
||||
multiSelect,
|
||||
noteListKeyboard,
|
||||
toggleGroup,
|
||||
} = useNoteListInteractions({
|
||||
searched,
|
||||
selectedNotePath: selectedNote?.path ?? null,
|
||||
selection,
|
||||
noteListFilter,
|
||||
isEntityView,
|
||||
isChangesView,
|
||||
onReplaceActiveTab,
|
||||
onSelectNote,
|
||||
onOpenDeletedNote,
|
||||
onOpenInNewWindow,
|
||||
onAutoTriggerDiff,
|
||||
onDiscardFile,
|
||||
openContextMenuForEntry,
|
||||
onCreateNote,
|
||||
})
|
||||
const getChangeStatus = useChangeStatusResolver(isChangesView, modifiedFiles)
|
||||
|
||||
const { handleBulkArchive, handleBulkDeletePermanently, handleBulkUnarchive, bulkArchiveOrUnarchive } = useBulkActions(multiSelect, onBulkArchive, onBulkDeletePermanently, isArchivedView)
|
||||
useMultiSelectKeyboard(multiSelect, isEntityView, bulkArchiveOrUnarchive, handleBulkDeletePermanently)
|
||||
|
||||
const { handleNoteContextMenu, openContextMenuForEntry, contextMenuNode, dialogNode } = ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles })
|
||||
|
||||
const getChangeStatus = useCallback((path: string) => {
|
||||
if (!changeStatusMap) return undefined
|
||||
const direct = changeStatusMap.get(path)
|
||||
if (direct) return direct
|
||||
// Try suffix match
|
||||
for (const [key, status] of changeStatusMap) {
|
||||
if (path.endsWith(key) || key.endsWith(path.split('/').slice(-1)[0])) return status
|
||||
}
|
||||
return undefined
|
||||
}, [changeStatusMap])
|
||||
|
||||
const handleListKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (isChangesView && onDiscardFile && e.shiftKey && e.key === 'F10' && noteListKeyboard.highlightedPath) {
|
||||
const entry = searched.find((candidate) => candidate.path === noteListKeyboard.highlightedPath)
|
||||
if (!entry) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const row = document.querySelector<HTMLElement>(`[data-note-path="${entry.path}"]`)
|
||||
const rect = row?.getBoundingClientRect()
|
||||
openContextMenuForEntry(entry, {
|
||||
x: rect ? rect.left + 24 : 160,
|
||||
y: rect ? rect.bottom - 8 : 160,
|
||||
})
|
||||
return
|
||||
}
|
||||
noteListKeyboard.handleKeyDown(e)
|
||||
}, [isChangesView, onDiscardFile, noteListKeyboard, searched, openContextMenuForEntry])
|
||||
|
||||
const renderItem = useCallback((entry: VaultEntry) => (
|
||||
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} changeStatus={getChangeStatus(entry.path)} typeEntryMap={typeEntryMap} allEntries={entries} displayPropsOverride={inboxDisplayOverride} onClickNote={handleClickNote} onPrefetch={isDeletedNoteEntry(entry) ? undefined : prefetchNoteContent} onContextMenu={isChangesView && onDiscardFile ? handleNoteContextMenu : undefined} />
|
||||
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} changeStatus={getChangeStatus(entry.path)} typeEntryMap={typeEntryMap} allEntries={entries} displayPropsOverride={inboxDisplayOverride} onClickNote={handleClickNote} onContextMenu={isChangesView && onDiscardFile ? handleNoteContextMenu : undefined} />
|
||||
), [entries, selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, getChangeStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath, isChangesView, onDiscardFile, handleNoteContextMenu, inboxDisplayOverride])
|
||||
|
||||
const handleCreateNote = useCallback(() => {
|
||||
onCreateNote(selection.kind === 'sectionGroup' ? selection.type : undefined)
|
||||
}, [onCreateNote, selection])
|
||||
const toggleGroup = useCallback((label: string) => { setCollapsedGroups((prev) => toggleSetMember(prev, label)) }, [])
|
||||
const title = resolveHeaderTitle(selection, typeDocument, views)
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,8 +9,13 @@ import {
|
||||
} from '../../utils/noteListHelpers'
|
||||
import type { InboxPeriod } from '../../types'
|
||||
import { buildTypeEntryMap } from '../../utils/typeColors'
|
||||
import { buildChangesEntries, filterByQuery, filterGroupsByQuery, createNoteStatusResolver, isModifiedEntry } from './noteListUtils'
|
||||
import type { MultiSelectState } from '../../hooks/useMultiSelect'
|
||||
import {
|
||||
buildChangesEntries, filterByQuery, filterGroupsByQuery, createNoteStatusResolver,
|
||||
isDeletedNoteEntry, isModifiedEntry, routeNoteClick, toggleSetMember,
|
||||
} from './noteListUtils'
|
||||
import { useMultiSelect, type MultiSelectState } from '../../hooks/useMultiSelect'
|
||||
import { useNoteListKeyboard } from '../../hooks/useNoteListKeyboard'
|
||||
import { prefetchNoteContent } from '../../hooks/useTabManagement'
|
||||
|
||||
// --- useTypeEntryMap ---
|
||||
|
||||
@@ -220,3 +225,312 @@ export function useModifiedFilesState(modifiedFiles: ModifiedFile[] | undefined,
|
||||
)
|
||||
return { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus }
|
||||
}
|
||||
|
||||
// --- useChangeStatusResolver ---
|
||||
|
||||
function buildChangeStatusMap(isChangesView: boolean, modifiedFiles?: ModifiedFile[]) {
|
||||
if (!isChangesView || !modifiedFiles) return undefined
|
||||
|
||||
const map = new Map<string, ModifiedFile['status']>()
|
||||
for (const file of modifiedFiles) {
|
||||
map.set(file.path, file.status)
|
||||
map.set('/' + file.relativePath, file.status)
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
function resolveChangeStatus(path: string, changeStatusMap?: Map<string, ModifiedFile['status']>) {
|
||||
if (!changeStatusMap) return undefined
|
||||
|
||||
const direct = changeStatusMap.get(path)
|
||||
if (direct) return direct
|
||||
|
||||
const filename = path.split('/').slice(-1)[0]
|
||||
for (const [key, status] of changeStatusMap) {
|
||||
if (path.endsWith(key) || key.endsWith(filename)) return status
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function useChangeStatusResolver(isChangesView: boolean, modifiedFiles?: ModifiedFile[]) {
|
||||
const changeStatusMap = useMemo(
|
||||
() => buildChangeStatusMap(isChangesView, modifiedFiles),
|
||||
[isChangesView, modifiedFiles],
|
||||
)
|
||||
|
||||
return useCallback(
|
||||
(path: string) => resolveChangeStatus(path, changeStatusMap),
|
||||
[changeStatusMap],
|
||||
)
|
||||
}
|
||||
|
||||
// --- useVisibleNotesSync ---
|
||||
|
||||
interface VisibleNotesSyncParams {
|
||||
visibleNotesRef?: React.MutableRefObject<VaultEntry[]>
|
||||
isEntityView: boolean
|
||||
searched: VaultEntry[]
|
||||
searchedGroups: Array<{ entries: VaultEntry[] }>
|
||||
}
|
||||
|
||||
export function useVisibleNotesSync({ visibleNotesRef, isEntityView, searched, searchedGroups }: VisibleNotesSyncParams) {
|
||||
useEffect(() => {
|
||||
if (!visibleNotesRef) return
|
||||
|
||||
visibleNotesRef.current = isEntityView
|
||||
? searchedGroups.flatMap((group) => group.entries).filter((entry) => !isDeletedNoteEntry(entry))
|
||||
: searched.filter((entry) => !isDeletedNoteEntry(entry))
|
||||
}, [visibleNotesRef, isEntityView, searched, searchedGroups])
|
||||
}
|
||||
|
||||
// --- useListPropertyPicker ---
|
||||
|
||||
function collectAvailableProperties(entries: VaultEntry[]): string[] {
|
||||
const keys = new Set<string>()
|
||||
for (const entry of entries) {
|
||||
for (const key of Object.keys(entry.properties ?? {})) keys.add(key)
|
||||
for (const key of Object.keys(entry.relationships ?? {})) keys.add(key)
|
||||
}
|
||||
return [...keys].sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
function collectTypeAvailableProperties(entries: VaultEntry[], typeName: string): string[] {
|
||||
return collectAvailableProperties(entries.filter((entry) => entry.isA === typeName))
|
||||
}
|
||||
|
||||
function deriveInboxDefaultDisplay(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): string[] {
|
||||
const ordered: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const entry of entries) {
|
||||
for (const key of typeEntryMap[entry.isA ?? '']?.listPropertiesDisplay ?? []) {
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
ordered.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
return ordered
|
||||
}
|
||||
|
||||
export interface NoteListPropertyPicker {
|
||||
scope: 'inbox' | 'type'
|
||||
availableProperties: string[]
|
||||
currentDisplay: string[]
|
||||
onSave: (value: string[] | null) => void
|
||||
triggerTitle: string
|
||||
}
|
||||
|
||||
interface UseListPropertyPickerParams {
|
||||
entries: VaultEntry[]
|
||||
selection: SidebarSelection
|
||||
inboxPeriod: InboxPeriod
|
||||
typeDocument: VaultEntry | null
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
inboxNoteListProperties?: string[] | null
|
||||
onUpdateInboxNoteListProperties?: (value: string[] | null) => void
|
||||
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
|
||||
}
|
||||
|
||||
export function useListPropertyPicker({
|
||||
entries,
|
||||
selection,
|
||||
inboxPeriod,
|
||||
typeDocument,
|
||||
typeEntryMap,
|
||||
inboxNoteListProperties,
|
||||
onUpdateInboxNoteListProperties,
|
||||
onUpdateTypeSort,
|
||||
}: UseListPropertyPickerParams) {
|
||||
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
|
||||
const isSectionGroup = selection.kind === 'sectionGroup'
|
||||
|
||||
const inboxEntries = useMemo(
|
||||
() => isInboxView ? filterInboxEntries(entries, inboxPeriod) : [],
|
||||
[entries, inboxPeriod, isInboxView],
|
||||
)
|
||||
const typeAvailableProperties = useMemo(
|
||||
() => typeDocument ? collectTypeAvailableProperties(entries, typeDocument.title) : [],
|
||||
[entries, typeDocument],
|
||||
)
|
||||
const inboxAvailableProperties = useMemo(
|
||||
() => collectAvailableProperties(inboxEntries),
|
||||
[inboxEntries],
|
||||
)
|
||||
const inboxDefaultDisplay = useMemo(
|
||||
() => deriveInboxDefaultDisplay(inboxEntries, typeEntryMap),
|
||||
[inboxEntries, typeEntryMap],
|
||||
)
|
||||
const hasCustomInboxProperties = !!(inboxNoteListProperties && inboxNoteListProperties.length > 0)
|
||||
const inboxDisplayOverride = isInboxView && hasCustomInboxProperties ? inboxNoteListProperties : null
|
||||
|
||||
const propertyPicker = useMemo<NoteListPropertyPicker | null>(() => {
|
||||
if (isInboxView && onUpdateInboxNoteListProperties) {
|
||||
return {
|
||||
scope: 'inbox',
|
||||
availableProperties: inboxAvailableProperties,
|
||||
currentDisplay: hasCustomInboxProperties ? inboxNoteListProperties ?? [] : inboxDefaultDisplay,
|
||||
onSave: onUpdateInboxNoteListProperties,
|
||||
triggerTitle: 'Customize Inbox columns',
|
||||
}
|
||||
}
|
||||
|
||||
if (isSectionGroup && typeDocument && onUpdateTypeSort) {
|
||||
return {
|
||||
scope: 'type',
|
||||
availableProperties: typeAvailableProperties,
|
||||
currentDisplay: typeDocument.listPropertiesDisplay ?? [],
|
||||
onSave: (value: string[] | null) => onUpdateTypeSort(typeDocument.path, '_list_properties_display', value),
|
||||
triggerTitle: 'Customize columns',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}, [
|
||||
hasCustomInboxProperties,
|
||||
inboxAvailableProperties,
|
||||
inboxDefaultDisplay,
|
||||
inboxNoteListProperties,
|
||||
isInboxView,
|
||||
isSectionGroup,
|
||||
onUpdateInboxNoteListProperties,
|
||||
onUpdateTypeSort,
|
||||
typeAvailableProperties,
|
||||
typeDocument,
|
||||
])
|
||||
|
||||
return { inboxDisplayOverride, propertyPicker }
|
||||
}
|
||||
|
||||
// --- useNoteListInteractions ---
|
||||
|
||||
interface UseNoteListInteractionsParams {
|
||||
searched: VaultEntry[]
|
||||
selectedNotePath: string | null
|
||||
selection: SidebarSelection
|
||||
noteListFilter: NoteListFilter
|
||||
isEntityView: boolean
|
||||
isChangesView: boolean
|
||||
onReplaceActiveTab: (entry: VaultEntry) => void
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
onOpenDeletedNote?: (entry: VaultEntry) => void
|
||||
onOpenInNewWindow?: (entry: VaultEntry) => void
|
||||
onAutoTriggerDiff?: () => void
|
||||
onDiscardFile?: (relativePath: string) => Promise<void>
|
||||
openContextMenuForEntry: (entry: VaultEntry, point: { x: number; y: number }) => void
|
||||
onCreateNote: (type?: string) => void
|
||||
}
|
||||
|
||||
export function useNoteListInteractions({
|
||||
searched,
|
||||
selectedNotePath,
|
||||
selection,
|
||||
noteListFilter,
|
||||
isEntityView,
|
||||
isChangesView,
|
||||
onReplaceActiveTab,
|
||||
onSelectNote,
|
||||
onOpenDeletedNote,
|
||||
onOpenInNewWindow,
|
||||
onAutoTriggerDiff,
|
||||
onDiscardFile,
|
||||
openContextMenuForEntry,
|
||||
onCreateNote,
|
||||
}: UseNoteListInteractionsParams) {
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
|
||||
const handleKeyboardOpen = useCallback((entry: VaultEntry) => {
|
||||
if (isDeletedNoteEntry(entry)) {
|
||||
onOpenDeletedNote?.(entry)
|
||||
return
|
||||
}
|
||||
onReplaceActiveTab(entry)
|
||||
}, [onOpenDeletedNote, onReplaceActiveTab])
|
||||
|
||||
const handleKeyboardPrefetch = useCallback((entry: VaultEntry) => {
|
||||
if (!isDeletedNoteEntry(entry)) prefetchNoteContent(entry.path)
|
||||
}, [])
|
||||
|
||||
const noteListKeyboard = useNoteListKeyboard({
|
||||
items: searched,
|
||||
selectedNotePath,
|
||||
onOpen: handleKeyboardOpen,
|
||||
onPrefetch: handleKeyboardPrefetch,
|
||||
enabled: !isEntityView,
|
||||
})
|
||||
const multiSelect = useMultiSelect(searched, selectedNotePath)
|
||||
|
||||
useEffect(() => {
|
||||
multiSelect.clear()
|
||||
}, [noteListFilter, selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear only when selection/filter changes
|
||||
|
||||
const handleClickNote = useCallback((entry: VaultEntry, event: React.MouseEvent) => {
|
||||
if (isDeletedNoteEntry(entry)) {
|
||||
routeNoteClick(entry, event, {
|
||||
onReplace: () => onOpenDeletedNote?.(entry),
|
||||
onSelect: () => onOpenDeletedNote?.(entry),
|
||||
multiSelect,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
routeNoteClick(entry, event, {
|
||||
onReplace: onReplaceActiveTab,
|
||||
onSelect: onSelectNote,
|
||||
onOpenInNewWindow,
|
||||
multiSelect,
|
||||
})
|
||||
|
||||
if (isChangesView && onAutoTriggerDiff) {
|
||||
setTimeout(onAutoTriggerDiff, 50)
|
||||
}
|
||||
}, [
|
||||
isChangesView,
|
||||
multiSelect,
|
||||
onAutoTriggerDiff,
|
||||
onOpenDeletedNote,
|
||||
onOpenInNewWindow,
|
||||
onReplaceActiveTab,
|
||||
onSelectNote,
|
||||
])
|
||||
|
||||
const handleListKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (isChangesView && onDiscardFile && event.shiftKey && event.key === 'F10' && noteListKeyboard.highlightedPath) {
|
||||
const entry = searched.find((candidate) => candidate.path === noteListKeyboard.highlightedPath)
|
||||
if (!entry) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const row = document.querySelector<HTMLElement>(`[data-note-path="${entry.path}"]`)
|
||||
const rect = row?.getBoundingClientRect()
|
||||
openContextMenuForEntry(entry, {
|
||||
x: rect ? rect.left + 24 : 160,
|
||||
y: rect ? rect.bottom - 8 : 160,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
noteListKeyboard.handleKeyDown(event)
|
||||
}, [isChangesView, noteListKeyboard, onDiscardFile, openContextMenuForEntry, searched])
|
||||
|
||||
const handleCreateNote = useCallback(() => {
|
||||
onCreateNote(selection.kind === 'sectionGroup' ? selection.type : undefined)
|
||||
}, [onCreateNote, selection])
|
||||
|
||||
const toggleGroup = useCallback((label: string) => {
|
||||
setCollapsedGroups((prev) => toggleSetMember(prev, label))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
collapsedGroups,
|
||||
handleClickNote,
|
||||
handleCreateNote,
|
||||
handleListKeyDown,
|
||||
multiSelect,
|
||||
noteListKeyboard,
|
||||
toggleGroup,
|
||||
}
|
||||
}
|
||||
|
||||
232
src/test-utils/noteListTestUtils.tsx
Normal file
232
src/test-utils/noteListTestUtils.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import { NoteList } from '../components/NoteList'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
|
||||
type NoteListProps = ComponentProps<typeof NoteList>
|
||||
|
||||
export const allSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
|
||||
export 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,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
snippet: 'Build a personal knowledge management app.',
|
||||
wordCount: 0,
|
||||
relationships: {
|
||||
'Related to': ['[[topic/software-development]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
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,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 847,
|
||||
snippet: 'Lookalike audiences convert 3x better.',
|
||||
wordCount: 0,
|
||||
relationships: {
|
||||
'Belongs to': ['[[project/26q1-laputa-app]]'],
|
||||
'Related to': ['[[topic/growth]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
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,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 320,
|
||||
snippet: 'Sponsorship manager.',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
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,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 512,
|
||||
snippet: 'Project kickoff meeting notes.',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
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,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: 'Frontend, backend, and systems programming.',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
]
|
||||
|
||||
export const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/test.md',
|
||||
filename: 'test.md',
|
||||
title: 'Test',
|
||||
isA: null,
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: null,
|
||||
createdAt: null,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
export const makeIndexedEntry = (index: number, overrides?: Partial<VaultEntry>): VaultEntry =>
|
||||
makeEntry({
|
||||
path: `/vault/note/note-${index}.md`,
|
||||
filename: `note-${index}.md`,
|
||||
title: `Note ${index}`,
|
||||
isA: 'Note',
|
||||
modifiedAt: 1700000000 - index * 60,
|
||||
fileSize: 500,
|
||||
snippet: `Content of note ${index}`,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
export const makeTypeDefinition = (title: string, displayProps: string[] = []): VaultEntry =>
|
||||
makeEntry({
|
||||
path: `/vault/type/${title.toLowerCase()}.md`,
|
||||
filename: `${title.toLowerCase()}.md`,
|
||||
title,
|
||||
isA: 'Type',
|
||||
listPropertiesDisplay: displayProps,
|
||||
})
|
||||
|
||||
export function createNoteListSpies() {
|
||||
return {
|
||||
onSelectNote: vi.fn(),
|
||||
onReplaceActiveTab: vi.fn(),
|
||||
onCreateNote: vi.fn(),
|
||||
onNoteListFilterChange: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildNoteListProps(overrides: Partial<NoteListProps> = {}) {
|
||||
const spies = createNoteListSpies()
|
||||
const props: NoteListProps = {
|
||||
entries: mockEntries,
|
||||
selection: allSelection,
|
||||
selectedNote: null,
|
||||
noteListFilter: 'open' as NoteListFilter,
|
||||
onNoteListFilterChange: spies.onNoteListFilterChange,
|
||||
onSelectNote: spies.onSelectNote,
|
||||
onReplaceActiveTab: spies.onReplaceActiveTab,
|
||||
onCreateNote: spies.onCreateNote,
|
||||
...overrides,
|
||||
}
|
||||
|
||||
return { props, ...spies }
|
||||
}
|
||||
|
||||
export function renderNoteList(overrides: Partial<NoteListProps> = {}) {
|
||||
const built = buildNoteListProps(overrides)
|
||||
return {
|
||||
...render(<NoteList {...built.props} />),
|
||||
...built,
|
||||
}
|
||||
}
|
||||
@@ -1,739 +1,104 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups, getSortComparator, extractSortableProperties, getSortOptionLabel, getDefaultDirection, parseSortConfig, serializeSortConfig, isInboxEntry, filterInboxEntries, filterEntries } from './noteListHelpers'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { countAllByFilter, countByFilter, filterEntries } from './noteListHelpers'
|
||||
import { allSelection, makeEntry, mockEntries } from '../test-utils/noteListTestUtils'
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return {
|
||||
path: '/vault/note/test.md', filename: 'test.md', title: 'Test',
|
||||
isA: 'Note', aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, organized: false, archived: false,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0,
|
||||
snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('formatSubtitle', () => {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
it('shows date and word count when both available', () => {
|
||||
const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 342 })
|
||||
const result = formatSubtitle(entry)
|
||||
expect(result).toContain('342 words')
|
||||
expect(result).toContain('\u00b7')
|
||||
describe('filterEntries', () => {
|
||||
it('returns empty for entity selections because entity view uses grouped relationships', () => {
|
||||
const result = filterEntries(mockEntries, { kind: 'entity', entry: mockEntries[4] })
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows "Empty" when word count is 0', () => {
|
||||
const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 0 })
|
||||
const result = formatSubtitle(entry)
|
||||
expect(result).toContain('Empty')
|
||||
expect(result).not.toContain('words')
|
||||
})
|
||||
|
||||
it('shows only word count when no date available', () => {
|
||||
const entry = makeEntry({ wordCount: 100 })
|
||||
expect(formatSubtitle(entry)).toBe('100 words')
|
||||
})
|
||||
|
||||
it('shows only "Empty" when no date and no content', () => {
|
||||
const entry = makeEntry()
|
||||
expect(formatSubtitle(entry)).toBe('Empty')
|
||||
})
|
||||
|
||||
it('falls back to createdAt when modifiedAt is null', () => {
|
||||
const entry = makeEntry({ createdAt: 1700000000, wordCount: 50 })
|
||||
const result = formatSubtitle(entry)
|
||||
expect(result).toContain('50 words')
|
||||
expect(result).toContain('\u00b7')
|
||||
})
|
||||
|
||||
it('includes link count when outgoingLinks is non-empty', () => {
|
||||
const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 200, outgoingLinks: ['a', 'b', 'c'] })
|
||||
const result = formatSubtitle(entry)
|
||||
expect(result).toContain('3 links')
|
||||
})
|
||||
|
||||
it('uses singular "link" when exactly one', () => {
|
||||
const entry = makeEntry({ wordCount: 100, outgoingLinks: ['one'] })
|
||||
expect(formatSubtitle(entry)).toContain('1 link')
|
||||
expect(formatSubtitle(entry)).not.toContain('1 links')
|
||||
})
|
||||
|
||||
it('omits link count when outgoingLinks is empty', () => {
|
||||
const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 50, outgoingLinks: [] })
|
||||
expect(formatSubtitle(entry)).not.toContain('link')
|
||||
})
|
||||
|
||||
it('formats word count with locale separators for large numbers', () => {
|
||||
const entry = makeEntry({ wordCount: 1240 })
|
||||
const result = formatSubtitle(entry)
|
||||
expect(result).toMatch(/1,?240 words/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatSearchSubtitle', () => {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
it('shows modified date, created date, word count, and links', () => {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const entry = makeEntry({
|
||||
modifiedAt: now - 3600,
|
||||
createdAt: now - 86400 * 30,
|
||||
wordCount: 520,
|
||||
outgoingLinks: ['a', 'b', 'c', 'd', 'e'],
|
||||
})
|
||||
const result = formatSearchSubtitle(entry)
|
||||
expect(result).toContain('1h ago')
|
||||
expect(result).toContain('Created')
|
||||
expect(result).toContain('520 words')
|
||||
expect(result).toContain('5 links')
|
||||
})
|
||||
|
||||
it('omits created date when same as modified', () => {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const entry = makeEntry({ modifiedAt: now, createdAt: now, wordCount: 100 })
|
||||
const result = formatSearchSubtitle(entry)
|
||||
expect(result).not.toContain('Created')
|
||||
})
|
||||
|
||||
it('omits created date when createdAt is null', () => {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const entry = makeEntry({ modifiedAt: now, createdAt: null, wordCount: 100 })
|
||||
const result = formatSearchSubtitle(entry)
|
||||
expect(result).not.toContain('Created')
|
||||
})
|
||||
|
||||
it('shows "Empty" for zero word count', () => {
|
||||
const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 0 })
|
||||
expect(formatSearchSubtitle(entry)).toContain('Empty')
|
||||
})
|
||||
|
||||
it('omits link count when no outgoing links', () => {
|
||||
const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 50, outgoingLinks: [] })
|
||||
expect(formatSearchSubtitle(entry)).not.toContain('link')
|
||||
})
|
||||
|
||||
it('falls back to createdAt when modifiedAt is null', () => {
|
||||
const entry = makeEntry({ createdAt: 1700000000, wordCount: 200, outgoingLinks: ['a'] })
|
||||
const result = formatSearchSubtitle(entry)
|
||||
expect(result).toContain('200 words')
|
||||
expect(result).toContain('1 link')
|
||||
expect(result).not.toContain('Created')
|
||||
})
|
||||
})
|
||||
|
||||
describe('relativeDate', () => {
|
||||
it('returns empty string for null', () => {
|
||||
expect(relativeDate(null)).toBe('')
|
||||
})
|
||||
|
||||
it('returns "just now" for recent timestamps', () => {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
expect(relativeDate(now)).toBe('just now')
|
||||
})
|
||||
|
||||
it('returns minutes ago for timestamps within an hour', () => {
|
||||
const fiveMinAgo = Math.floor(Date.now() / 1000) - 300
|
||||
expect(relativeDate(fiveMinAgo)).toBe('5m ago')
|
||||
})
|
||||
|
||||
it('returns hours ago for timestamps within a day', () => {
|
||||
const twoHoursAgo = Math.floor(Date.now() / 1000) - 7200
|
||||
expect(relativeDate(twoHoursAgo)).toBe('2h ago')
|
||||
})
|
||||
|
||||
it('returns days ago for timestamps within a week', () => {
|
||||
const threeDaysAgo = Math.floor(Date.now() / 1000) - 86400 * 3
|
||||
expect(relativeDate(threeDaysAgo)).toBe('3d ago')
|
||||
})
|
||||
|
||||
it('returns formatted date for older timestamps', () => {
|
||||
// Use a fixed timestamp: Nov 14, 2023
|
||||
expect(relativeDate(1700000000)).toMatch(/Nov 14/)
|
||||
})
|
||||
})
|
||||
|
||||
// --- buildRelationshipGroups tests ---
|
||||
|
||||
function makeVault(overrides: Partial<VaultEntry>[]): VaultEntry[] {
|
||||
return overrides.map((o, i) => makeEntry({
|
||||
path: `/Laputa/note/entry-${i}.md`,
|
||||
filename: `entry-${i}.md`,
|
||||
title: `Entry ${i}`,
|
||||
modifiedAt: 1700000000 - i * 100,
|
||||
...o,
|
||||
}))
|
||||
}
|
||||
|
||||
describe('buildRelationshipGroups', () => {
|
||||
it('shows direct relationship properties from entity.relationships', () => {
|
||||
const building = makeEntry({ path: '/Laputa/responsibility/building.md', filename: 'building.md', title: 'Building' })
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: { 'Belongs to': ['[[responsibility/building]]'] },
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, building])
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).toContain('Belongs to')
|
||||
expect(groups.find((g) => g.label === 'Belongs to')!.entries[0].title).toBe('Building')
|
||||
})
|
||||
|
||||
it('shows all direct relationships even when entries also appear as Children', () => {
|
||||
// The entity has "Notes" pointing at note1 and note2.
|
||||
// Those notes also have belongsTo pointing back at the entity.
|
||||
// Previously, Children consumed them via the seen set, suppressing "Notes".
|
||||
const note1 = makeEntry({ path: '/Laputa/note/note1.md', filename: 'note1.md', title: 'Note 1', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 })
|
||||
const note2 = makeEntry({ path: '/Laputa/note/note2.md', filename: 'note2.md', title: 'Note 2', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 })
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: { Notes: ['[[note/note1]]', '[[note/note2]]'] },
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, note1, note2])
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).toContain('Notes')
|
||||
expect(groups.find((g) => g.label === 'Notes')!.entries).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows all 5+ direct relationship properties', () => {
|
||||
const entries = makeVault([
|
||||
{ path: '/Laputa/area/eng.md', filename: 'eng.md', title: 'Engineering' },
|
||||
{ path: '/Laputa/person/alice.md', filename: 'alice.md', title: 'Alice' },
|
||||
{ path: '/Laputa/note/n1.md', filename: 'n1.md', title: 'Note 1' },
|
||||
{ path: '/Laputa/note/n2.md', filename: 'n2.md', title: 'Note 2' },
|
||||
{ path: '/Laputa/topic/rust.md', filename: 'rust.md', title: 'Rust' },
|
||||
{ path: '/Laputa/project/sibling.md', filename: 'sibling.md', title: 'Sibling' },
|
||||
])
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/big.md', filename: 'big.md', title: 'Big Project',
|
||||
relationships: {
|
||||
'Belongs to': ['[[area/eng]]'],
|
||||
Notes: ['[[note/n1]]', '[[note/n2]]'],
|
||||
Owner: ['[[person/alice]]'],
|
||||
'Related to': ['[[project/sibling]]'],
|
||||
Topics: ['[[topic/rust]]'],
|
||||
},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, ...entries])
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).toContain('Belongs to')
|
||||
expect(labels).toContain('Notes')
|
||||
expect(labels).toContain('Owner')
|
||||
expect(labels).toContain('Related to')
|
||||
expect(labels).toContain('Topics')
|
||||
})
|
||||
|
||||
it('shows Children group for reverse belongsTo entries not covered by direct rels', () => {
|
||||
const child = makeEntry({ path: '/Laputa/note/child.md', filename: 'child.md', title: 'Child', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 })
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: {},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, child])
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).toContain('Children')
|
||||
expect(groups.find((g) => g.label === 'Children')!.entries[0].title).toBe('Child')
|
||||
})
|
||||
|
||||
it('excludes Type key from relationship groups', () => {
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: { Type: ['[[project]]'] },
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity])
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).not.toContain('Type')
|
||||
})
|
||||
|
||||
it('returns empty groups for entity with no relationships', () => {
|
||||
const entity = makeEntry({ path: '/Laputa/note/solo.md', filename: 'solo.md', title: 'Solo', relationships: {} })
|
||||
const groups = buildRelationshipGroups(entity, [entity])
|
||||
expect(groups).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows single-item and multi-item relationship properties', () => {
|
||||
const alice = makeEntry({ path: '/Laputa/person/alice.md', filename: 'alice.md', title: 'Alice' })
|
||||
const n1 = makeEntry({ path: '/Laputa/note/n1.md', filename: 'n1.md', title: 'Note 1' })
|
||||
const n2 = makeEntry({ path: '/Laputa/note/n2.md', filename: 'n2.md', title: 'Note 2' })
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/x.md', filename: 'x.md', title: 'X',
|
||||
relationships: {
|
||||
Owner: ['[[person/alice]]'],
|
||||
Notes: ['[[note/n1]]', '[[note/n2]]'],
|
||||
},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, alice, n1, n2])
|
||||
expect(groups.find((g) => g.label === 'Owner')!.entries).toHaveLength(1)
|
||||
expect(groups.find((g) => g.label === 'Notes')!.entries).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows Instances group for Type entities', () => {
|
||||
const instance1 = makeEntry({ path: '/Laputa/project/a.md', filename: 'a.md', title: 'Project A', isA: 'Project', modifiedAt: 1700000000 })
|
||||
const instance2 = makeEntry({ path: '/Laputa/project/b.md', filename: 'b.md', title: 'Project B', isA: 'Project', modifiedAt: 1700000000 })
|
||||
const typeEntity = makeEntry({
|
||||
path: '/Laputa/project.md', filename: 'project.md', title: 'Project',
|
||||
isA: 'Type', relationships: {},
|
||||
})
|
||||
const groups = buildRelationshipGroups(typeEntity, [typeEntity, instance1, instance2])
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).toContain('Instances')
|
||||
expect(groups.find((g) => g.label === 'Instances')!.entries).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('direct relationships are sorted alphabetically', () => {
|
||||
const a = makeEntry({ path: '/Laputa/note/a.md', filename: 'a.md', title: 'A' })
|
||||
const b = makeEntry({ path: '/Laputa/note/b.md', filename: 'b.md', title: 'B' })
|
||||
const c = makeEntry({ path: '/Laputa/note/c.md', filename: 'c.md', title: 'C' })
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/x.md', filename: 'x.md', title: 'X',
|
||||
relationships: {
|
||||
Zebra: ['[[note/c]]'],
|
||||
Alpha: ['[[note/a]]'],
|
||||
Middle: ['[[note/b]]'],
|
||||
},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, a, b, c])
|
||||
const directLabels = groups.map((g) => g.label)
|
||||
expect(directLabels.indexOf('Alpha')).toBeLessThan(directLabels.indexOf('Middle'))
|
||||
expect(directLabels.indexOf('Middle')).toBeLessThan(directLabels.indexOf('Zebra'))
|
||||
})
|
||||
|
||||
it('Referenced By shows entries whose relatedTo matches the entity', () => {
|
||||
const referer = makeEntry({
|
||||
path: '/Laputa/project/ref.md', filename: 'ref.md', title: 'Referer',
|
||||
relatedTo: ['[[project/alpha]]'], modifiedAt: 1700000000,
|
||||
})
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: {},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, referer])
|
||||
expect(groups.find((g) => g.label === 'Referenced By')!.entries[0].title).toBe('Referer')
|
||||
})
|
||||
|
||||
it('Backlinks shows entries that mention the entity via outgoingLinks', () => {
|
||||
const linker = makeEntry({
|
||||
path: '/Laputa/note/linker.md', filename: 'linker.md', title: 'Linker', modifiedAt: 1700000000,
|
||||
outgoingLinks: ['Alpha'],
|
||||
})
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: {},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, linker])
|
||||
expect(groups.find((g) => g.label === 'Backlinks')!.entries[0].title).toBe('Linker')
|
||||
})
|
||||
|
||||
it('resolves all entries in a large Notes relationship (regression: No Code)', () => {
|
||||
// Simulates the No Code topic note with 32 Notes, 2 Referred by Data, 1 Belongs to
|
||||
const noteRefs = [
|
||||
'8020', 'airdev-build-hub', 'airdev-leader', 'budibase', 'bullet-launch',
|
||||
'canvas', 'chameleon', 'felt', 'flutterflow', 'framer-ai',
|
||||
'jumpstart', 'mailparser', 'make', 'michele-sampieri', 'n8n-a',
|
||||
'n8n-ai', 'nocodey', 'outseta', 'lemon-squeezy', 'retool',
|
||||
'rise-no-code', 'scene', 'scrapingbee', 'softr', 'superblocks',
|
||||
'superwall', 'tails', 'supabase', 'varun-anand', 'xano',
|
||||
'directus', 'framer-design',
|
||||
]
|
||||
const noteEntries = noteRefs.map((slug, i) => makeEntry({
|
||||
path: `/Laputa/${slug}.md`, filename: `${slug}.md`, title: `Title ${slug}`,
|
||||
modifiedAt: 1700000000 - i * 100,
|
||||
}))
|
||||
const engineering = makeEntry({
|
||||
path: '/Laputa/engineering.md', filename: 'engineering.md', title: 'Engineering',
|
||||
modifiedAt: 1700000000,
|
||||
})
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/no-code.md', filename: 'no-code.md', title: 'No Code',
|
||||
isA: 'Topic',
|
||||
relationships: {
|
||||
'Belongs to': ['[[engineering|Engineering]]'],
|
||||
Notes: noteRefs.map((slug) => `[[${slug}|Title ${slug}]]`),
|
||||
'Referred by Data': ['[[michele-sampieri|Michele Sampieri]]', '[[varun-anand|Varun Anand]]'],
|
||||
},
|
||||
})
|
||||
const allEntries = [entity, engineering, ...noteEntries]
|
||||
const groups = buildRelationshipGroups(entity, allEntries)
|
||||
|
||||
const belongsGroup = groups.find((g) => g.label === 'Belongs to')
|
||||
expect(belongsGroup).toBeDefined()
|
||||
expect(belongsGroup!.entries).toHaveLength(1)
|
||||
|
||||
const notesGroup = groups.find((g) => g.label === 'Notes')
|
||||
expect(notesGroup).toBeDefined()
|
||||
expect(notesGroup!.entries).toHaveLength(32)
|
||||
|
||||
// michele-sampieri and varun-anand already consumed by Notes → Referred by Data has 0 new
|
||||
const referredGroup = groups.find((g) => g.label === 'Referred by Data')
|
||||
expect(referredGroup).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves refs by title when filename differs from wikilink target', () => {
|
||||
// Wikilink [[Airdev]] but filename is airdev-tool.md, title is "Airdev"
|
||||
const airdev = makeEntry({
|
||||
path: '/vault/airdev-tool.md', filename: 'airdev-tool.md', title: 'Airdev',
|
||||
})
|
||||
const budibase = makeEntry({
|
||||
path: '/vault/budibase-app.md', filename: 'budibase-app.md', title: 'Budibase',
|
||||
aliases: ['Budi'],
|
||||
})
|
||||
const entity = makeEntry({
|
||||
path: '/vault/no-code.md', filename: 'no-code.md', title: 'No Code',
|
||||
relationships: { Notes: ['[[Airdev]]', '[[Budi]]'] },
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, airdev, budibase])
|
||||
const notesGroup = groups.find((g) => g.label === 'Notes')
|
||||
expect(notesGroup).toBeDefined()
|
||||
expect(notesGroup!.entries).toHaveLength(2)
|
||||
expect(notesGroup!.entries.map(e => e.title).sort()).toEqual(['Airdev', 'Budibase'])
|
||||
})
|
||||
|
||||
it('resolves Children via title match when belongsTo target differs from filename', () => {
|
||||
// Child's belongsTo uses [[No Code]] but entity filename is no-code-topic.md
|
||||
const child = makeEntry({
|
||||
path: '/vault/tool.md', filename: 'tool.md', title: 'Tool',
|
||||
belongsTo: ['[[No Code]]'], modifiedAt: 1700000000,
|
||||
})
|
||||
const entity = makeEntry({
|
||||
path: '/vault/no-code-topic.md', filename: 'no-code-topic.md', title: 'No Code',
|
||||
relationships: {},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, child])
|
||||
expect(groups.find((g) => g.label === 'Children')!.entries).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortComparator — custom properties', () => {
|
||||
it('sorts by string property alphabetically', () => {
|
||||
const a = makeEntry({ title: 'A', properties: { Priority: 'High' } })
|
||||
const b = makeEntry({ title: 'B', properties: { Priority: 'Low' } })
|
||||
const c = makeEntry({ title: 'C', properties: { Priority: 'Medium' } })
|
||||
const sorted = [a, b, c].sort(getSortComparator('property:Priority'))
|
||||
expect(sorted.map((e) => e.title)).toEqual(['A', 'B', 'C'])
|
||||
})
|
||||
|
||||
it('sorts by numeric property', () => {
|
||||
const a = makeEntry({ title: 'A', properties: { Rating: 3 } })
|
||||
const b = makeEntry({ title: 'B', properties: { Rating: 5 } })
|
||||
const c = makeEntry({ title: 'C', properties: { Rating: 1 } })
|
||||
const sorted = [a, b, c].sort(getSortComparator('property:Rating'))
|
||||
expect(sorted.map((e) => e.title)).toEqual(['C', 'A', 'B'])
|
||||
})
|
||||
|
||||
it('sorts by date property chronologically', () => {
|
||||
const a = makeEntry({ title: 'A', properties: { 'Due date': '2026-06-15' } })
|
||||
const b = makeEntry({ title: 'B', properties: { 'Due date': '2026-01-01' } })
|
||||
const c = makeEntry({ title: 'C', properties: { 'Due date': '2026-03-10' } })
|
||||
const sorted = [a, b, c].sort(getSortComparator('property:Due date'))
|
||||
expect(sorted.map((e) => e.title)).toEqual(['B', 'C', 'A'])
|
||||
})
|
||||
|
||||
it('pushes null values to end regardless of direction', () => {
|
||||
const a = makeEntry({ title: 'A', properties: { Priority: 'High' } })
|
||||
const b = makeEntry({ title: 'B', properties: {} })
|
||||
const c = makeEntry({ title: 'C', properties: { Priority: 'Low' } })
|
||||
const ascSorted = [a, b, c].sort(getSortComparator('property:Priority', 'asc'))
|
||||
expect(ascSorted.map((e) => e.title)).toEqual(['A', 'C', 'B'])
|
||||
const descSorted = [a, b, c].sort(getSortComparator('property:Priority', 'desc'))
|
||||
expect(descSorted.map((e) => e.title)).toEqual(['C', 'A', 'B'])
|
||||
})
|
||||
|
||||
it('sorts descending when direction is desc', () => {
|
||||
const a = makeEntry({ title: 'A', properties: { Rating: 3 } })
|
||||
const b = makeEntry({ title: 'B', properties: { Rating: 5 } })
|
||||
const c = makeEntry({ title: 'C', properties: { Rating: 1 } })
|
||||
const sorted = [a, b, c].sort(getSortComparator('property:Rating', 'desc'))
|
||||
expect(sorted.map((e) => e.title)).toEqual(['B', 'A', 'C'])
|
||||
})
|
||||
|
||||
it('handles entries with no properties field gracefully', () => {
|
||||
const a = makeEntry({ title: 'A', properties: { Priority: 'High' } })
|
||||
const b = makeEntry({ title: 'B', properties: {} })
|
||||
const sorted = [a, b].sort(getSortComparator('property:Priority'))
|
||||
expect(sorted.map((e) => e.title)).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('handles boolean property sorting', () => {
|
||||
const a = makeEntry({ title: 'A', properties: { Reviewed: true } })
|
||||
const b = makeEntry({ title: 'B', properties: { Reviewed: false } })
|
||||
const sorted = [a, b].sort(getSortComparator('property:Reviewed'))
|
||||
expect(sorted.map((e) => e.title)).toEqual(['B', 'A'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractSortableProperties', () => {
|
||||
it('returns union of all property keys across entries', () => {
|
||||
it('filters section groups by open sub-filter', () => {
|
||||
const entries = [
|
||||
makeEntry({ properties: { Priority: 'High', Rating: 5 } }),
|
||||
makeEntry({ properties: { Priority: 'Low', Company: 'Acme' } }),
|
||||
makeEntry({ path: '/1.md', title: 'Active', isA: 'Project' }),
|
||||
makeEntry({ path: '/2.md', title: 'Archived', isA: 'Project', archived: true }),
|
||||
makeEntry({ path: '/4.md', title: 'Other', isA: 'Note' }),
|
||||
]
|
||||
expect(extractSortableProperties(entries)).toEqual(['Company', 'Priority', 'Rating'])
|
||||
|
||||
const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' }, 'open')
|
||||
expect(result.map((entry) => entry.title)).toEqual(['Active'])
|
||||
})
|
||||
|
||||
it('returns empty array for entries without properties', () => {
|
||||
const entries = [makeEntry(), makeEntry()]
|
||||
expect(extractSortableProperties(entries)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for empty entry list', () => {
|
||||
expect(extractSortableProperties([])).toEqual([])
|
||||
})
|
||||
|
||||
it('deduplicates property keys', () => {
|
||||
it('filters section groups by archived sub-filter', () => {
|
||||
const entries = [
|
||||
makeEntry({ properties: { Priority: 'High' } }),
|
||||
makeEntry({ properties: { Priority: 'Low' } }),
|
||||
makeEntry({ path: '/1.md', title: 'Active', isA: 'Project' }),
|
||||
makeEntry({ path: '/2.md', title: 'Archived', isA: 'Project', archived: true }),
|
||||
makeEntry({ path: '/4.md', title: 'Other', isA: 'Note' }),
|
||||
]
|
||||
expect(extractSortableProperties(entries)).toEqual(['Priority'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortOptionLabel', () => {
|
||||
it('returns label for built-in options', () => {
|
||||
expect(getSortOptionLabel('modified')).toBe('Modified')
|
||||
expect(getSortOptionLabel('title')).toBe('Title')
|
||||
const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' }, 'archived')
|
||||
expect(result.map((entry) => entry.title)).toEqual(['Archived'])
|
||||
})
|
||||
|
||||
it('returns property key for custom properties', () => {
|
||||
expect(getSortOptionLabel('property:Priority')).toBe('Priority')
|
||||
expect(getSortOptionLabel('property:Due date')).toBe('Due date')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDefaultDirection', () => {
|
||||
it('returns desc for time-based sorts', () => {
|
||||
expect(getDefaultDirection('modified')).toBe('desc')
|
||||
expect(getDefaultDirection('created')).toBe('desc')
|
||||
})
|
||||
|
||||
it('returns asc for other sorts', () => {
|
||||
expect(getDefaultDirection('title')).toBe('asc')
|
||||
expect(getDefaultDirection('status')).toBe('asc')
|
||||
expect(getDefaultDirection('property:Priority')).toBe('asc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeSortConfig', () => {
|
||||
it('serializes a built-in sort config', () => {
|
||||
expect(serializeSortConfig({ option: 'modified', direction: 'desc' })).toBe('modified:desc')
|
||||
expect(serializeSortConfig({ option: 'title', direction: 'asc' })).toBe('title:asc')
|
||||
})
|
||||
|
||||
it('serializes a custom property sort config', () => {
|
||||
expect(serializeSortConfig({ option: 'property:Priority', direction: 'asc' })).toBe('property:Priority:asc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseSortConfig', () => {
|
||||
it('parses a built-in sort config', () => {
|
||||
expect(parseSortConfig('modified:desc')).toEqual({ option: 'modified', direction: 'desc' })
|
||||
expect(parseSortConfig('title:asc')).toEqual({ option: 'title', direction: 'asc' })
|
||||
})
|
||||
|
||||
it('parses a custom property sort config with colon in option', () => {
|
||||
expect(parseSortConfig('property:Priority:asc')).toEqual({ option: 'property:Priority', direction: 'asc' })
|
||||
})
|
||||
|
||||
it('returns null for null/undefined input', () => {
|
||||
expect(parseSortConfig(null)).toBeNull()
|
||||
expect(parseSortConfig(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(parseSortConfig('')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for invalid direction', () => {
|
||||
expect(parseSortConfig('modified:up')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for string without colon', () => {
|
||||
expect(parseSortConfig('modified')).toBeNull()
|
||||
})
|
||||
|
||||
it('roundtrips correctly', () => {
|
||||
const configs = [
|
||||
{ option: 'modified' as const, direction: 'desc' as const },
|
||||
{ option: 'title' as const, direction: 'asc' as const },
|
||||
{ option: 'property:Due date' as const, direction: 'desc' as const },
|
||||
]
|
||||
for (const config of configs) {
|
||||
expect(parseSortConfig(serializeSortConfig(config))).toEqual(config)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// --- Inbox ---
|
||||
|
||||
describe('isInboxEntry', () => {
|
||||
it('returns true for a note that is not organized', () => {
|
||||
const note = makeEntry({ organized: false })
|
||||
expect(isInboxEntry(note)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for an organized note', () => {
|
||||
const note = makeEntry({ organized: true })
|
||||
expect(isInboxEntry(note)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for an archived note', () => {
|
||||
const note = makeEntry({ archived: true })
|
||||
expect(isInboxEntry(note)).toBe(false)
|
||||
})
|
||||
|
||||
it('excludes Type entries from inbox', () => {
|
||||
const note = makeEntry({ isA: 'Type' })
|
||||
expect(isInboxEntry(note)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for a note without _organized field (defaults to false)', () => {
|
||||
const note = makeEntry({})
|
||||
expect(isInboxEntry(note)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterInboxEntries', () => {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const DAY = 86400
|
||||
|
||||
const allEntries = [
|
||||
makeEntry({ path: '/vault/a.md', filename: 'a.md', title: 'A', createdAt: now - 2 * DAY }),
|
||||
makeEntry({ path: '/vault/b.md', filename: 'b.md', title: 'B', createdAt: now - 15 * DAY }),
|
||||
makeEntry({ path: '/vault/c.md', filename: 'c.md', title: 'C', createdAt: now - 60 * DAY }),
|
||||
makeEntry({ path: '/vault/d.md', filename: 'd.md', title: 'D', createdAt: now - 120 * DAY }),
|
||||
makeEntry({ path: '/vault/org.md', filename: 'org.md', title: 'Organized', createdAt: now - 1 * DAY, organized: true }),
|
||||
]
|
||||
|
||||
it('filters by "week" period (last 7 days)', () => {
|
||||
const result = filterInboxEntries(allEntries, 'week')
|
||||
expect(result.map(e => e.title)).toEqual(['A'])
|
||||
})
|
||||
|
||||
it('filters by "month" period (last 30 days)', () => {
|
||||
const result = filterInboxEntries(allEntries, 'month')
|
||||
expect(result.map(e => e.title)).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('filters by "quarter" period (last 90 days)', () => {
|
||||
const result = filterInboxEntries(allEntries, 'quarter')
|
||||
expect(result.map(e => e.title)).toEqual(['A', 'B', 'C'])
|
||||
})
|
||||
|
||||
it('filters by "all" period', () => {
|
||||
const result = filterInboxEntries(allEntries, 'all')
|
||||
expect(result.map(e => e.title)).toEqual(['A', 'B', 'C', 'D'])
|
||||
})
|
||||
|
||||
it('sorts by createdAt descending', () => {
|
||||
const result = filterInboxEntries(allEntries, 'all')
|
||||
for (let i = 1; i < result.length; i++) {
|
||||
expect((result[i - 1].createdAt ?? 0)).toBeGreaterThanOrEqual((result[i].createdAt ?? 0))
|
||||
}
|
||||
})
|
||||
|
||||
it('excludes organized notes', () => {
|
||||
const result = filterInboxEntries(allEntries, 'all')
|
||||
expect(result.find(e => e.title === 'Organized')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns empty array when all notes are organized', () => {
|
||||
const organized = [
|
||||
makeEntry({ path: '/vault/x.md', title: 'X', organized: true }),
|
||||
makeEntry({ path: '/vault/y.md', title: 'Y', organized: true }),
|
||||
]
|
||||
const result = filterInboxEntries(organized, 'all')
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterEntries — folder selection', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/vault/projects/laputa/note1.md', title: 'Note 1' }),
|
||||
makeEntry({ path: '/vault/projects/laputa/note2.md', title: 'Note 2' }),
|
||||
makeEntry({ path: '/vault/projects/portfolio/site.md', title: 'Site' }),
|
||||
makeEntry({ path: '/vault/areas/health.md', title: 'Health' }),
|
||||
makeEntry({ path: '/vault/root-note.md', title: 'Root' }),
|
||||
]
|
||||
|
||||
it('filters entries by folder path', () => {
|
||||
const result = filterEntries(entries, { kind: 'folder', path: 'projects/laputa' })
|
||||
expect(result.map(e => e.title)).toEqual(['Note 1', 'Note 2'])
|
||||
})
|
||||
|
||||
it('does not include notes from sibling folders', () => {
|
||||
const result = filterEntries(entries, { kind: 'folder', path: 'projects/laputa' })
|
||||
expect(result.find(e => e.title === 'Site')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('filters recursively — includes notes from subfolders', () => {
|
||||
const result = filterEntries(entries, { kind: 'folder', path: 'projects' })
|
||||
expect(result.map(e => e.title)).toEqual(['Note 1', 'Note 2', 'Site'])
|
||||
})
|
||||
|
||||
it('filters direct children', () => {
|
||||
const result = filterEntries(entries, { kind: 'folder', path: 'areas' })
|
||||
expect(result.map(e => e.title)).toEqual(['Health'])
|
||||
})
|
||||
|
||||
it('returns empty for root when entries are in subfolders', () => {
|
||||
const result = filterEntries(entries, { kind: 'folder', path: 'nonexistent' })
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('excludes archived entries by default', () => {
|
||||
const withArchived = [
|
||||
...entries,
|
||||
makeEntry({ path: '/vault/projects/laputa/archived.md', title: 'Archived', archived: true }),
|
||||
]
|
||||
const result = filterEntries(withArchived, { kind: 'folder', path: 'projects/laputa' })
|
||||
expect(result.find(e => e.title === 'Archived')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterEntries — fileKind filtering', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/vault/note.md', title: 'Note', fileKind: 'markdown' }),
|
||||
makeEntry({ path: '/vault/config.yml', title: 'config.yml', fileKind: 'text' }),
|
||||
makeEntry({ path: '/vault/photo.png', title: 'photo.png', fileKind: 'binary' }),
|
||||
makeEntry({ path: '/vault/projects/readme.md', title: 'README', fileKind: 'markdown' }),
|
||||
makeEntry({ path: '/vault/projects/data.json', title: 'data.json', fileKind: 'text' }),
|
||||
makeEntry({ path: '/vault/projects/image.jpg', title: 'image.jpg', fileKind: 'binary' }),
|
||||
]
|
||||
|
||||
it('all-notes filter only shows markdown files', () => {
|
||||
const result = filterEntries(entries, { kind: 'filter', filter: 'all' })
|
||||
expect(result.map(e => e.title)).toEqual(['Note', 'README'])
|
||||
})
|
||||
|
||||
it('folder view shows all file kinds including binary', () => {
|
||||
const result = filterEntries(entries, { kind: 'folder', path: 'projects' })
|
||||
expect(result.map(e => e.title)).toEqual(['README', 'data.json', 'image.jpg'])
|
||||
})
|
||||
|
||||
it('sectionGroup filter only shows markdown files', () => {
|
||||
const typed = entries.map(e => ({ ...e, isA: 'Note' }))
|
||||
const result = filterEntries(typed, { kind: 'sectionGroup', type: 'Note' })
|
||||
expect(result.map(e => e.title)).toEqual(['Note', 'README'])
|
||||
})
|
||||
|
||||
it('entries without fileKind are treated as markdown', () => {
|
||||
const legacy = [
|
||||
makeEntry({ path: '/vault/old.md', title: 'Old' }),
|
||||
]
|
||||
const result = filterEntries(legacy, { kind: 'filter', filter: 'all' })
|
||||
expect(result.map(e => e.title)).toEqual(['Old'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterInboxEntries — excludes non-markdown files', () => {
|
||||
it('only shows markdown files in inbox', () => {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
it('defaults section groups to active notes when no sub-filter is provided', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/vault/note.md', title: 'Note', fileKind: 'markdown', createdAt: now }),
|
||||
makeEntry({ path: '/vault/config.yml', title: 'config.yml', fileKind: 'text', createdAt: now }),
|
||||
makeEntry({ path: '/vault/photo.png', title: 'photo.png', fileKind: 'binary', createdAt: now }),
|
||||
makeEntry({ path: '/1.md', title: 'Active', isA: 'Project' }),
|
||||
makeEntry({ path: '/2.md', title: 'Archived', isA: 'Project', archived: true }),
|
||||
makeEntry({ path: '/4.md', title: 'Other', isA: 'Note' }),
|
||||
]
|
||||
const result = filterInboxEntries(entries, 'all')
|
||||
expect(result.map(e => e.title)).toEqual(['Note'])
|
||||
|
||||
const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' })
|
||||
expect(result.map((entry) => entry.title)).toEqual(['Active'])
|
||||
})
|
||||
|
||||
it('filters all notes by open sub-filter', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/1.md', title: 'Active', isA: 'Project' }),
|
||||
makeEntry({ path: '/2.md', title: 'Archived', isA: 'Project', archived: true }),
|
||||
makeEntry({ path: '/4.md', title: 'Other', isA: 'Note' }),
|
||||
]
|
||||
|
||||
const result = filterEntries(entries, allSelection, 'open')
|
||||
expect(result.map((entry) => entry.title)).toEqual(['Active', 'Other'])
|
||||
})
|
||||
|
||||
it('filters all notes by archived sub-filter', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/1.md', title: 'Active', isA: 'Project' }),
|
||||
makeEntry({ path: '/2.md', title: 'Archived', isA: 'Project', archived: true }),
|
||||
makeEntry({ path: '/4.md', title: 'Other', isA: 'Note' }),
|
||||
]
|
||||
|
||||
const result = filterEntries(entries, allSelection, 'archived')
|
||||
expect(result.map((entry) => entry.title)).toEqual(['Archived'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('countByFilter', () => {
|
||||
it('counts open and archived entries for a type', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/1.md', isA: 'Project' }),
|
||||
makeEntry({ path: '/2.md', isA: 'Project', archived: true }),
|
||||
makeEntry({ path: '/3.md', isA: 'Project' }),
|
||||
makeEntry({ path: '/4.md', isA: 'Note' }),
|
||||
]
|
||||
|
||||
expect(countByFilter(entries, 'Project')).toEqual({ open: 2, archived: 1 })
|
||||
})
|
||||
|
||||
it('returns zeros when a type has no matching entries', () => {
|
||||
expect(countByFilter([], 'Project')).toEqual({ open: 0, archived: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('countAllByFilter', () => {
|
||||
it('counts all entries by archive status', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/1.md', isA: 'Project' }),
|
||||
makeEntry({ path: '/2.md', isA: 'Note' }),
|
||||
makeEntry({ path: '/3.md', isA: 'Project', archived: true }),
|
||||
]
|
||||
|
||||
expect(countAllByFilter(entries)).toEqual({ open: 2, archived: 1 })
|
||||
})
|
||||
|
||||
it('excludes non-markdown files from counts', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/1.md', isA: 'Note', fileKind: 'markdown' }),
|
||||
makeEntry({ path: '/2.yml', isA: undefined, fileKind: 'text' }),
|
||||
makeEntry({ path: '/3.png', isA: undefined, fileKind: 'binary' }),
|
||||
]
|
||||
|
||||
expect(countAllByFilter(entries)).toEqual({ open: 1, archived: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user