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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user