2026-04-18 20:31:25 +02:00
|
|
|
import { useState } from 'react'
|
|
|
|
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
2026-04-08 09:03:02 +02:00
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
2026-04-18 20:31:25 +02:00
|
|
|
import { NoteList } from './NoteList'
|
2026-04-08 09:03:02 +02:00
|
|
|
import { openNoteListPropertiesPicker } from './note-list/noteListPropertiesEvents'
|
2026-05-12 06:57:42 +02:00
|
|
|
import { AppPreferencesProvider } from '../hooks/useAppPreferences'
|
2026-04-08 09:03:02 +02:00
|
|
|
import {
|
|
|
|
|
allSelection,
|
2026-04-18 20:31:25 +02:00
|
|
|
buildNoteListProps,
|
2026-04-08 09:03:02 +02:00
|
|
|
makeEntry,
|
|
|
|
|
makeTypeDefinition,
|
|
|
|
|
mockEntries,
|
|
|
|
|
renderNoteList,
|
|
|
|
|
} from '../test-utils/noteListTestUtils'
|
2026-04-18 20:31:25 +02:00
|
|
|
import type { ViewFile } from '../types'
|
2026-04-08 09:03:02 +02:00
|
|
|
|
2026-04-10 18:31:51 +02:00
|
|
|
function makeBookTypeEntries(
|
|
|
|
|
displayProps: string[] = [],
|
|
|
|
|
entryOverrides: Parameters<typeof makeEntry>[0] = {},
|
|
|
|
|
) {
|
|
|
|
|
return [
|
|
|
|
|
makeTypeDefinition('Book', displayProps),
|
|
|
|
|
makeEntry({
|
|
|
|
|
path: '/vault/book.md',
|
|
|
|
|
filename: 'book.md',
|
|
|
|
|
title: 'Book Note',
|
|
|
|
|
isA: 'Book',
|
|
|
|
|
createdAt: 1700000000,
|
|
|
|
|
...entryOverrides,
|
|
|
|
|
}),
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 01:46:04 +02:00
|
|
|
const noop = () => undefined
|
2026-05-23 23:36:22 +02:00
|
|
|
const MAC_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7) AppleWebKit/605.1.15 Safari/605.1.15'
|
|
|
|
|
const WINDOWS_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/125.0.0.0 Safari/537.36'
|
|
|
|
|
|
|
|
|
|
function withUserAgent<T>(userAgent: string, callback: () => T): T {
|
|
|
|
|
const originalUserAgent = navigator.userAgent
|
|
|
|
|
Object.defineProperty(window.navigator, 'userAgent', { value: userAgent, configurable: true })
|
|
|
|
|
try {
|
|
|
|
|
return callback()
|
|
|
|
|
} finally {
|
|
|
|
|
Object.defineProperty(window.navigator, 'userAgent', { value: originalUserAgent, configurable: true })
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-17 01:46:04 +02:00
|
|
|
|
2026-04-18 20:31:25 +02:00
|
|
|
function makeViewDefinition(overrides: Partial<ViewFile> = {}): ViewFile {
|
|
|
|
|
return {
|
|
|
|
|
filename: 'active-books.yml',
|
|
|
|
|
definition: {
|
|
|
|
|
name: 'Active Books',
|
|
|
|
|
icon: null,
|
|
|
|
|
color: null,
|
|
|
|
|
sort: null,
|
|
|
|
|
filters: { all: [{ field: 'type', op: 'equals', value: 'Book' }] },
|
|
|
|
|
...overrides.definition,
|
|
|
|
|
},
|
|
|
|
|
...overrides,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderManagedViewNoteList({
|
|
|
|
|
entries,
|
|
|
|
|
view = makeViewDefinition(),
|
|
|
|
|
}: {
|
|
|
|
|
entries: Parameters<typeof renderNoteList>[0]['entries']
|
|
|
|
|
view?: ViewFile
|
|
|
|
|
}) {
|
|
|
|
|
const built = buildNoteListProps({
|
|
|
|
|
entries,
|
|
|
|
|
selection: { kind: 'view', filename: view.filename },
|
|
|
|
|
views: [view],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
function ManagedViewNoteList() {
|
|
|
|
|
const [views, setViews] = useState([view])
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<NoteList
|
|
|
|
|
{...built.props}
|
|
|
|
|
views={views}
|
|
|
|
|
onUpdateViewDefinition={(filename, patch) => {
|
|
|
|
|
setViews((currentViews) => currentViews.map((currentView) => (
|
|
|
|
|
currentView.filename === filename
|
|
|
|
|
? { ...currentView, definition: { ...currentView.definition, ...patch } }
|
|
|
|
|
: currentView
|
|
|
|
|
)))
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...render(<ManagedViewNoteList />),
|
|
|
|
|
...built,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 00:47:45 +02:00
|
|
|
async function searchNoteList(query: string) {
|
2026-04-17 01:46:04 +02:00
|
|
|
const searchInput = screen.queryByPlaceholderText('Search notes...')
|
|
|
|
|
if (!searchInput) fireEvent.click(screen.getByTitle('Search notes'))
|
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: query } })
|
2026-04-21 00:47:45 +02:00
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId('note-list-search-loading')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.queryByTestId('note-list-search-loading')).not.toBeInTheDocument()
|
|
|
|
|
})
|
2026-04-17 01:46:04 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-28 21:09:05 +02:00
|
|
|
interface NoteListSearchMockResult {
|
|
|
|
|
note_type: string
|
|
|
|
|
path: string
|
|
|
|
|
score: number
|
|
|
|
|
snippet: string
|
|
|
|
|
title: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function installFullTextSearchMocks({
|
|
|
|
|
resultsByVault,
|
|
|
|
|
}: {
|
|
|
|
|
resultsByVault: Record<string, NoteListSearchMockResult[]>
|
|
|
|
|
}) {
|
|
|
|
|
const originalContentHandler = window.__mockHandlers?.get_note_content
|
|
|
|
|
const originalSearchHandler = window.__mockHandlers?.search_vault
|
|
|
|
|
const searchVault = vi.fn((args?: Record<string, unknown>) => ({
|
|
|
|
|
elapsed_ms: 7,
|
|
|
|
|
mode: args?.mode,
|
|
|
|
|
query: args?.query,
|
|
|
|
|
results: resultsByVault[String(args?.vaultPath ?? '')] ?? [],
|
|
|
|
|
}))
|
2026-05-29 03:19:59 +02:00
|
|
|
const getNoteContent = vi.fn(() => {
|
|
|
|
|
throw new Error('Note-list full-text search should not read note content in React')
|
|
|
|
|
})
|
2026-05-28 21:09:05 +02:00
|
|
|
|
|
|
|
|
if (!window.__mockHandlers) window.__mockHandlers = {}
|
|
|
|
|
window.__mockHandlers.search_vault = searchVault
|
|
|
|
|
window.__mockHandlers.get_note_content = getNoteContent
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
getNoteContent,
|
|
|
|
|
restore: () => {
|
|
|
|
|
window.__mockHandlers.search_vault = originalSearchHandler
|
|
|
|
|
window.__mockHandlers.get_note_content = originalContentHandler
|
|
|
|
|
},
|
|
|
|
|
searchVault,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 01:46:04 +02:00
|
|
|
function renderBookNoteList({
|
|
|
|
|
displayProps = ['Priority'],
|
|
|
|
|
entryOverrides = {},
|
|
|
|
|
selection = allSelection,
|
|
|
|
|
allNotesNoteListProperties,
|
|
|
|
|
onUpdateAllNotesNoteListProperties = noop,
|
|
|
|
|
inboxNoteListProperties,
|
|
|
|
|
onUpdateInboxNoteListProperties = noop,
|
|
|
|
|
}: {
|
|
|
|
|
displayProps?: string[]
|
|
|
|
|
entryOverrides?: Parameters<typeof makeEntry>[0]
|
|
|
|
|
selection?: Parameters<typeof renderNoteList>[0]['selection']
|
|
|
|
|
allNotesNoteListProperties?: string[] | null
|
|
|
|
|
onUpdateAllNotesNoteListProperties?: () => void
|
|
|
|
|
inboxNoteListProperties?: string[] | null
|
|
|
|
|
onUpdateInboxNoteListProperties?: () => void
|
|
|
|
|
} = {}) {
|
|
|
|
|
return renderNoteList({
|
|
|
|
|
entries: makeBookTypeEntries(displayProps, entryOverrides),
|
|
|
|
|
selection,
|
|
|
|
|
allNotesNoteListProperties,
|
|
|
|
|
onUpdateAllNotesNoteListProperties,
|
|
|
|
|
inboxNoteListProperties,
|
|
|
|
|
onUpdateInboxNoteListProperties,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 00:47:45 +02:00
|
|
|
async function expectOnlySearchMatch(title: string, matchingQuery: string, hiddenQuery: string) {
|
|
|
|
|
await searchNoteList(matchingQuery)
|
2026-04-17 01:46:04 +02:00
|
|
|
expect(screen.getByText(title)).toBeInTheDocument()
|
|
|
|
|
|
2026-04-21 00:47:45 +02:00
|
|
|
await searchNoteList(hiddenQuery)
|
2026-04-17 01:46:04 +02:00
|
|
|
expect(screen.queryByText(title)).not.toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('No matching notes')).toBeInTheDocument()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 09:03:02 +02:00
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-26 11:42:03 +02:00
|
|
|
it('shows the active folder name and creates notes inside that folder', () => {
|
|
|
|
|
const { onCreateNote } = renderNoteList({
|
|
|
|
|
selection: {
|
|
|
|
|
kind: 'folder',
|
|
|
|
|
path: 'Projects/2026 Planning',
|
|
|
|
|
rootPath: '/Users/luca/Laputa',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(screen.getByRole('heading', { name: '2026 Planning' })).toBeInTheDocument()
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByTitle('Create new note'))
|
|
|
|
|
|
|
|
|
|
expect(onCreateNote).toHaveBeenCalledWith(undefined, {
|
|
|
|
|
creationPath: 'folder_header',
|
|
|
|
|
folderPath: 'Projects/2026 Planning',
|
|
|
|
|
vaultPath: '/Users/luca/Laputa',
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-08 09:03:02 +02:00
|
|
|
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()
|
2026-04-20 12:49:57 +02:00
|
|
|
expect(screen.getByText('Referenced by')).toBeInTheDocument()
|
2026-04-08 09:03:02 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-21 00:47:45 +02:00
|
|
|
it('filters by a case-insensitive search query', async () => {
|
2026-04-08 09:03:02 +02:00
|
|
|
renderNoteList()
|
2026-04-21 00:47:45 +02:00
|
|
|
await searchNoteList('facebook')
|
2026-04-08 09:03:02 +02:00
|
|
|
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-21 00:47:45 +02:00
|
|
|
it('filters by snippet text when the title does not match', async () => {
|
2026-04-17 01:46:04 +02:00
|
|
|
renderNoteList({
|
|
|
|
|
entries: [
|
|
|
|
|
makeEntry({ path: '/vault/a.md', filename: 'a.md', title: 'Alpha Note', snippet: 'Routine body copy.' }),
|
|
|
|
|
makeEntry({ path: '/vault/b.md', filename: 'b.md', title: 'Beta Note', snippet: 'Nebula-only snippet token.' }),
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-21 00:47:45 +02:00
|
|
|
await searchNoteList('nebula-only')
|
2026-04-17 01:46:04 +02:00
|
|
|
|
|
|
|
|
expect(screen.getByText('Beta Note')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Alpha Note')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-28 21:09:05 +02:00
|
|
|
it('filters by full note content when the title and snippet do not match', async () => {
|
2026-05-29 03:19:59 +02:00
|
|
|
const { getNoteContent, restore, searchVault } = installFullTextSearchMocks({
|
2026-05-28 21:09:05 +02:00
|
|
|
resultsByVault: {
|
|
|
|
|
'/vault': [{
|
|
|
|
|
note_type: 'Note',
|
|
|
|
|
path: '/vault/b.md',
|
|
|
|
|
score: 1,
|
|
|
|
|
snippet: 'Private body match is intentionally not rendered here.',
|
|
|
|
|
title: 'Beta Note',
|
|
|
|
|
}],
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
renderNoteList({
|
|
|
|
|
entries: [
|
|
|
|
|
makeEntry({ path: '/vault/a.md', filename: 'a.md', title: 'Alpha Note', snippet: 'Routine body copy.' }),
|
|
|
|
|
makeEntry({ path: '/vault/b.md', filename: 'b.md', title: 'Beta Note', snippet: 'Another public preview.' }),
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await searchNoteList('subterranean-keyword')
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(searchVault).toHaveBeenCalledWith(expect.objectContaining({
|
|
|
|
|
vaultPath: '/vault',
|
|
|
|
|
query: 'subterranean-keyword',
|
|
|
|
|
mode: 'keyword',
|
2026-05-29 03:19:59 +02:00
|
|
|
excludeFrontmatter: true,
|
2026-05-28 21:09:05 +02:00
|
|
|
}))
|
|
|
|
|
})
|
2026-05-29 03:19:59 +02:00
|
|
|
expect(getNoteContent).not.toHaveBeenCalled()
|
2026-05-28 21:09:05 +02:00
|
|
|
expect(screen.getByText('Beta Note')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Alpha Note')).not.toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Private body match is intentionally not rendered here.')).not.toBeInTheDocument()
|
|
|
|
|
} finally {
|
|
|
|
|
restore()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-29 04:06:09 +02:00
|
|
|
it('ignores stale full-content results when the query changes before a slow search returns', async () => {
|
|
|
|
|
const originalContentHandler = window.__mockHandlers?.get_note_content
|
|
|
|
|
const originalSearchHandler = window.__mockHandlers?.search_vault
|
|
|
|
|
let resolveSlowSearch: ((response: {
|
|
|
|
|
elapsed_ms: number
|
|
|
|
|
results: NoteListSearchMockResult[]
|
|
|
|
|
}) => void) | null = null
|
|
|
|
|
const searchVault = vi.fn((args?: Record<string, unknown>) => {
|
|
|
|
|
if (args?.query === 'slow-body') {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
resolveSlowSearch = resolve
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Promise.resolve({
|
|
|
|
|
elapsed_ms: 7,
|
|
|
|
|
results: [],
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
const getNoteContent = vi.fn(() => {
|
|
|
|
|
throw new Error('Note-list full-text search should not read note content in React')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!window.__mockHandlers) window.__mockHandlers = {}
|
|
|
|
|
window.__mockHandlers.search_vault = searchVault
|
|
|
|
|
window.__mockHandlers.get_note_content = getNoteContent
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
renderNoteList({
|
|
|
|
|
entries: [
|
|
|
|
|
makeEntry({ path: '/vault/a.md', filename: 'a.md', title: 'Alpha Note', snippet: 'Routine body copy.' }),
|
|
|
|
|
makeEntry({ path: '/vault/b.md', filename: 'b.md', title: 'Beta Note', snippet: 'Another public preview.' }),
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByTitle('Search notes'))
|
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'slow-body' } })
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(searchVault).toHaveBeenCalledWith(expect.objectContaining({
|
|
|
|
|
query: 'slow-body',
|
|
|
|
|
excludeFrontmatter: true,
|
|
|
|
|
}))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'new-empty-query' } })
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(searchVault).toHaveBeenCalledWith(expect.objectContaining({
|
|
|
|
|
query: 'new-empty-query',
|
|
|
|
|
excludeFrontmatter: true,
|
|
|
|
|
}))
|
|
|
|
|
})
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.queryByTestId('note-list-search-loading')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
resolveSlowSearch?.({
|
|
|
|
|
elapsed_ms: 7,
|
|
|
|
|
results: [{
|
|
|
|
|
note_type: 'Note',
|
|
|
|
|
path: '/vault/b.md',
|
|
|
|
|
score: 1,
|
|
|
|
|
snippet: 'Stale body hit from the previous query.',
|
|
|
|
|
title: 'Beta Note',
|
|
|
|
|
}],
|
|
|
|
|
})
|
|
|
|
|
await Promise.resolve()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(getNoteContent).not.toHaveBeenCalled()
|
|
|
|
|
expect(screen.queryByText('Beta Note')).not.toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('No matching notes')).toBeInTheDocument()
|
|
|
|
|
} finally {
|
|
|
|
|
window.__mockHandlers.search_vault = originalSearchHandler
|
|
|
|
|
window.__mockHandlers.get_note_content = originalContentHandler
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-28 21:09:05 +02:00
|
|
|
it('ignores full-content matches that only appear in hidden frontmatter', async () => {
|
2026-05-29 03:19:59 +02:00
|
|
|
const { getNoteContent, restore, searchVault } = installFullTextSearchMocks({
|
2026-05-28 21:09:05 +02:00
|
|
|
resultsByVault: {
|
2026-05-29 03:19:59 +02:00
|
|
|
'/vault': [],
|
2026-05-28 21:09:05 +02:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
renderNoteList({
|
|
|
|
|
entries: [
|
|
|
|
|
makeEntry({ path: '/vault/a.md', filename: 'a.md', title: 'Alpha Note', snippet: 'Routine body copy.' }),
|
|
|
|
|
makeEntry({ path: '/vault/b.md', filename: 'b.md', title: 'Beta Note', snippet: 'Another public preview.' }),
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await searchNoteList('hidden-frontmatter-keyword')
|
|
|
|
|
|
2026-05-29 03:19:59 +02:00
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(searchVault).toHaveBeenCalledWith(expect.objectContaining({
|
|
|
|
|
excludeFrontmatter: true,
|
|
|
|
|
}))
|
|
|
|
|
})
|
|
|
|
|
expect(getNoteContent).not.toHaveBeenCalled()
|
2026-05-28 21:09:05 +02:00
|
|
|
expect(screen.queryByText('Alpha Note')).not.toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('No matching notes')).toBeInTheDocument()
|
|
|
|
|
} finally {
|
|
|
|
|
restore()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('runs full-content note-list search across visible workspaces', async () => {
|
2026-05-29 03:19:59 +02:00
|
|
|
const { getNoteContent, restore, searchVault } = installFullTextSearchMocks({
|
2026-05-28 21:09:05 +02:00
|
|
|
resultsByVault: {
|
|
|
|
|
'/team': [{
|
|
|
|
|
note_type: 'Note',
|
|
|
|
|
path: '/team/team-body-hit.md',
|
|
|
|
|
score: 1,
|
|
|
|
|
snippet: 'Private workspace body hit.',
|
|
|
|
|
title: 'Team Body Hit',
|
|
|
|
|
}],
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
renderNoteList({
|
|
|
|
|
entries: [
|
|
|
|
|
makeEntry({
|
|
|
|
|
path: '/personal/personal-note.md',
|
|
|
|
|
filename: 'personal-note.md',
|
|
|
|
|
title: 'Personal Note',
|
|
|
|
|
snippet: 'No body token here.',
|
|
|
|
|
workspace: { id: 'personal', label: 'Personal', alias: 'PE', path: '/personal', shortLabel: 'PE', color: null, icon: null, mounted: true, available: true, defaultForNewNotes: true },
|
|
|
|
|
}),
|
|
|
|
|
makeEntry({
|
|
|
|
|
path: '/team/team-body-hit.md',
|
|
|
|
|
filename: 'team-body-hit.md',
|
|
|
|
|
title: 'Team Body Hit',
|
|
|
|
|
snippet: 'No body token here either.',
|
|
|
|
|
workspace: { id: 'team', label: 'Team', alias: 'TE', path: '/team', shortLabel: 'TE', color: null, icon: null, mounted: true, available: true, defaultForNewNotes: false },
|
|
|
|
|
}),
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await searchNoteList('workspace-only-keyword')
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
2026-05-29 03:19:59 +02:00
|
|
|
expect(searchVault).toHaveBeenCalledWith(expect.objectContaining({ vaultPath: '/personal', excludeFrontmatter: true }))
|
|
|
|
|
expect(searchVault).toHaveBeenCalledWith(expect.objectContaining({ vaultPath: '/team', excludeFrontmatter: true }))
|
2026-05-28 21:09:05 +02:00
|
|
|
})
|
2026-05-29 03:19:59 +02:00
|
|
|
expect(getNoteContent).not.toHaveBeenCalled()
|
2026-05-28 21:09:05 +02:00
|
|
|
expect(screen.getByText('Team Body Hit')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Personal Note')).not.toBeInTheDocument()
|
|
|
|
|
} finally {
|
|
|
|
|
restore()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-21 00:47:45 +02:00
|
|
|
it('filters by visible property values and ignores hidden properties', async () => {
|
2026-04-17 01:46:04 +02:00
|
|
|
renderBookNoteList({
|
|
|
|
|
entryOverrides: {
|
|
|
|
|
title: 'Property Search Note',
|
|
|
|
|
properties: { Priority: 'Boarding Window', Owner: 'Hidden Owner Value' },
|
|
|
|
|
},
|
|
|
|
|
allNotesNoteListProperties: null,
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-21 00:47:45 +02:00
|
|
|
await expectOnlySearchMatch('Property Search Note', 'boarding window', 'hidden owner value')
|
2026-04-17 01:46:04 +02:00
|
|
|
})
|
|
|
|
|
|
2026-04-21 00:47:45 +02:00
|
|
|
it('uses the active all-notes columns when filtering by visible property values', async () => {
|
2026-04-17 01:46:04 +02:00
|
|
|
renderBookNoteList({
|
|
|
|
|
entryOverrides: {
|
|
|
|
|
title: 'Override Search Note',
|
|
|
|
|
properties: { Priority: 'Hidden Priority', Owner: 'Visible Owner Value' },
|
|
|
|
|
},
|
|
|
|
|
allNotesNoteListProperties: ['Owner'],
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-21 00:47:45 +02:00
|
|
|
await expectOnlySearchMatch('Override Search Note', 'visible owner value', 'hidden priority')
|
2026-04-17 01:46:04 +02:00
|
|
|
})
|
|
|
|
|
|
2026-04-08 09:03:02 +02:00
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-13 23:33:46 +02:00
|
|
|
it('uses breadcrumbs-like button styling for note-list header actions', () => {
|
2026-04-17 01:46:04 +02:00
|
|
|
renderBookNoteList({
|
|
|
|
|
entryOverrides: { properties: { Priority: 'High' } },
|
2026-04-12 22:29:36 +02:00
|
|
|
selection: { kind: 'filter', filter: 'inbox' },
|
|
|
|
|
inboxNoteListProperties: null,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const buttons = [
|
|
|
|
|
screen.getByTitle('Search notes'),
|
|
|
|
|
screen.getByTitle('Customize Inbox columns'),
|
|
|
|
|
screen.getByTitle('Create new note'),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for (const button of buttons) {
|
|
|
|
|
expect(button).toHaveAttribute('data-variant', 'ghost')
|
2026-04-13 23:33:46 +02:00
|
|
|
expect(button).toHaveClass(
|
|
|
|
|
'!h-auto',
|
|
|
|
|
'!w-auto',
|
|
|
|
|
'!min-w-0',
|
|
|
|
|
'!rounded-none',
|
|
|
|
|
'!p-0',
|
|
|
|
|
'!text-muted-foreground',
|
|
|
|
|
'hover:!bg-transparent',
|
|
|
|
|
'hover:!text-foreground',
|
|
|
|
|
)
|
|
|
|
|
expect(button).not.toHaveAttribute('tabindex', '-1')
|
2026-04-12 22:29:36 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-28 21:09:05 +02:00
|
|
|
it('keeps the note-list search input full width and shows inline search controls while loading', async () => {
|
2026-04-21 19:03:11 +02:00
|
|
|
vi.useFakeTimers()
|
|
|
|
|
try {
|
|
|
|
|
renderNoteList({
|
|
|
|
|
entries: [
|
|
|
|
|
makeEntry({ path: '/vault/a.md', filename: 'a.md', title: 'Alpha Strategy' }),
|
|
|
|
|
makeEntry({ path: '/vault/b.md', filename: 'b.md', title: 'Beta Note' }),
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByTitle('Search notes'))
|
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'strategy' } })
|
|
|
|
|
|
|
|
|
|
const searchInput = screen.getByPlaceholderText('Search notes...')
|
2026-05-16 13:13:16 +02:00
|
|
|
expect(searchInput).toHaveClass('pr-16')
|
2026-04-21 19:03:11 +02:00
|
|
|
expect(searchInput.parentElement).toHaveClass('relative', 'flex-1')
|
2026-05-16 13:13:16 +02:00
|
|
|
expect(screen.getByRole('button', { name: 'Clear search' })).toBeInTheDocument()
|
2026-04-21 19:03:11 +02:00
|
|
|
expect(screen.getByTestId('note-list-search-loading')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Searching...')).not.toBeInTheDocument()
|
|
|
|
|
|
2026-05-28 21:09:05 +02:00
|
|
|
await act(async () => {
|
2026-04-21 19:03:11 +02:00
|
|
|
vi.advanceTimersByTime(180)
|
|
|
|
|
})
|
2026-05-28 21:09:05 +02:00
|
|
|
await act(async () => {
|
|
|
|
|
await vi.runOnlyPendingTimersAsync()
|
|
|
|
|
})
|
2026-04-21 19:03:11 +02:00
|
|
|
|
|
|
|
|
expect(screen.queryByTestId('note-list-search-loading')).not.toBeInTheDocument()
|
|
|
|
|
} finally {
|
|
|
|
|
vi.useRealTimers()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-08 09:03:02 +02:00
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-19 12:26:31 +02:00
|
|
|
it('shows no placeholder neighborhood groups when none exist', () => {
|
2026-04-19 03:37:33 +02:00
|
|
|
const standalone = makeEntry({
|
|
|
|
|
path: '/vault/solo.md',
|
|
|
|
|
filename: 'solo.md',
|
|
|
|
|
title: 'Standalone',
|
|
|
|
|
isA: 'Note',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
renderNoteList({
|
|
|
|
|
entries: [standalone],
|
|
|
|
|
selection: { kind: 'entity', entry: standalone },
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-19 12:26:31 +02:00
|
|
|
expect(screen.queryByRole('button', { name: /Children/i })).not.toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByRole('button', { name: /Events/i })).not.toBeInTheDocument()
|
2026-04-20 12:49:57 +02:00
|
|
|
expect(screen.queryByRole('button', { name: /Referenced by/i })).not.toBeInTheDocument()
|
2026-04-19 12:26:31 +02:00
|
|
|
expect(screen.queryByRole('button', { name: /Backlinks/i })).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-21 00:47:45 +02:00
|
|
|
it('keeps existing neighborhood groups visible at zero after search filters them out', async () => {
|
2026-04-19 12:26:31 +02:00
|
|
|
const parent = makeEntry({
|
|
|
|
|
path: '/vault/parent.md',
|
|
|
|
|
filename: 'parent.md',
|
|
|
|
|
title: 'Parent',
|
|
|
|
|
isA: 'Project',
|
|
|
|
|
})
|
|
|
|
|
const child = makeEntry({
|
|
|
|
|
path: '/vault/child.md',
|
|
|
|
|
filename: 'child.md',
|
|
|
|
|
title: 'Child Note',
|
|
|
|
|
isA: 'Note',
|
|
|
|
|
belongsTo: ['[[parent]]'],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
renderNoteList({
|
|
|
|
|
entries: [parent, child],
|
|
|
|
|
selection: { kind: 'entity', entry: parent },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(screen.getByRole('button', { name: /Children\s*1/i })).toBeInTheDocument()
|
|
|
|
|
|
2026-04-21 00:47:45 +02:00
|
|
|
await searchNoteList('missing-neighborhood-match')
|
2026-04-19 12:26:31 +02:00
|
|
|
|
2026-04-19 03:37:33 +02:00
|
|
|
expect(screen.getByRole('button', { name: /Children\s*0/i })).toBeInTheDocument()
|
2026-04-19 12:26:31 +02:00
|
|
|
expect(screen.queryByRole('button', { name: /Events/i })).not.toBeInTheDocument()
|
2026-04-20 12:49:57 +02:00
|
|
|
expect(screen.queryByRole('button', { name: /Referenced by/i })).not.toBeInTheDocument()
|
2026-04-19 12:26:31 +02:00
|
|
|
expect(screen.queryByRole('button', { name: /Backlinks/i })).not.toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Child Note')).not.toBeInTheDocument()
|
2026-04-19 03:37:33 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows the same note in multiple neighborhood groups when relationships overlap', () => {
|
|
|
|
|
const parent = makeEntry({
|
|
|
|
|
path: '/vault/parent.md',
|
|
|
|
|
filename: 'parent.md',
|
|
|
|
|
title: 'Parent',
|
|
|
|
|
isA: 'Project',
|
|
|
|
|
relationships: { 'Related to': ['[[shared-note]]'] },
|
|
|
|
|
})
|
|
|
|
|
const shared = makeEntry({
|
|
|
|
|
path: '/vault/shared-note.md',
|
|
|
|
|
filename: 'shared-note.md',
|
|
|
|
|
title: 'Shared Note',
|
|
|
|
|
isA: 'Note',
|
|
|
|
|
relatedTo: ['[[parent]]'],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
renderNoteList({
|
|
|
|
|
entries: [parent, shared],
|
|
|
|
|
selection: { kind: 'entity', entry: parent },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText('Related to')).toBeInTheDocument()
|
2026-04-20 12:49:57 +02:00
|
|
|
expect(screen.getByText('Referenced by')).toBeInTheDocument()
|
2026-04-19 03:37:33 +02:00
|
|
|
expect(screen.getAllByText('Shared Note')).toHaveLength(2)
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-19 14:13:10 +02:00
|
|
|
it('shows all real inverse relationship groups for custom relationship keys', () => {
|
|
|
|
|
const parent = makeEntry({
|
|
|
|
|
path: '/vault/parent.md',
|
|
|
|
|
filename: 'parent.md',
|
|
|
|
|
title: 'Parent',
|
|
|
|
|
isA: 'Project',
|
|
|
|
|
})
|
|
|
|
|
const topicNote = makeEntry({
|
|
|
|
|
path: '/vault/topic-note.md',
|
|
|
|
|
filename: 'topic-note.md',
|
|
|
|
|
title: 'Topic Note',
|
|
|
|
|
isA: 'Note',
|
|
|
|
|
relationships: { Topics: ['[[parent]]'] },
|
|
|
|
|
})
|
|
|
|
|
const mentorNote = makeEntry({
|
|
|
|
|
path: '/vault/mentor-note.md',
|
|
|
|
|
filename: 'mentor-note.md',
|
|
|
|
|
title: 'Mentor Note',
|
|
|
|
|
isA: 'Note',
|
|
|
|
|
relationships: { Mentors: ['[[parent]]'] },
|
|
|
|
|
})
|
|
|
|
|
const hostEvent = makeEntry({
|
|
|
|
|
path: '/vault/host-event.md',
|
|
|
|
|
filename: 'host-event.md',
|
|
|
|
|
title: 'Host Event',
|
|
|
|
|
isA: 'Event',
|
|
|
|
|
relationships: { Hosts: ['[[parent]]'] },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
renderNoteList({
|
|
|
|
|
entries: [parent, topicNote, mentorNote, hostEvent],
|
|
|
|
|
selection: { kind: 'entity', entry: parent },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText('← Topics')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('← Mentors')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('← Hosts')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Topic Note')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Mentor Note')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Host Event')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-08 09:03:02 +02:00
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-19 03:37:33 +02:00
|
|
|
it('shows the pinned neighborhood note using the standard row content', () => {
|
2026-04-08 09:03:02 +02:00
|
|
|
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', () => {
|
2026-04-17 01:46:04 +02:00
|
|
|
renderBookNoteList({
|
|
|
|
|
entryOverrides: { properties: { Priority: 'High', Owner: 'Luca' } },
|
2026-04-08 09:03:02 +02:00
|
|
|
selection: { kind: 'filter', filter: 'inbox' },
|
|
|
|
|
inboxNoteListProperties: null,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(screen.getByTitle('Customize Inbox columns')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('High')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Luca')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-16 02:18:43 +02:00
|
|
|
it('shows the all-notes customize-columns action and falls back to type-defined chips', () => {
|
2026-04-17 01:46:04 +02:00
|
|
|
renderBookNoteList({
|
|
|
|
|
entryOverrides: { properties: { Priority: 'High', Owner: 'Luca' } },
|
2026-04-16 02:18:43 +02:00
|
|
|
allNotesNoteListProperties: null,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(screen.getByTitle('Customize All Notes columns')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('High')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Luca')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-16 10:25:56 +02:00
|
|
|
it('opens the all-notes column picker as a searchable combobox and saves new columns', async () => {
|
2026-04-16 02:18:43 +02:00
|
|
|
const onUpdateAllNotesNoteListProperties = vi.fn()
|
|
|
|
|
const archivedOwnerEntry = makeEntry({
|
|
|
|
|
path: '/vault/book-archive.md',
|
|
|
|
|
filename: 'book-archive.md',
|
|
|
|
|
title: 'Archived Book',
|
|
|
|
|
isA: 'Book',
|
|
|
|
|
archived: true,
|
|
|
|
|
properties: { Owner: 'Luca' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
renderNoteList({
|
|
|
|
|
entries: [
|
|
|
|
|
...makeBookTypeEntries(['Priority'], { properties: { Priority: 'High' } }),
|
|
|
|
|
archivedOwnerEntry,
|
|
|
|
|
],
|
|
|
|
|
selection: allSelection,
|
|
|
|
|
allNotesNoteListProperties: null,
|
|
|
|
|
onUpdateAllNotesNoteListProperties,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
openNoteListPropertiesPicker('all')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument()
|
2026-04-16 17:03:50 +02:00
|
|
|
expect(screen.getByTestId('list-properties-popover')).toHaveClass('overflow-hidden')
|
2026-04-16 10:25:56 +02:00
|
|
|
expect(screen.getByTestId('list-properties-scroll-area')).toBeInTheDocument()
|
2026-04-16 17:03:50 +02:00
|
|
|
expect(screen.getByTestId('list-properties-scroll-area')).toHaveClass('overflow-y-auto')
|
2026-04-16 02:18:43 +02:00
|
|
|
expect(screen.getByRole('checkbox', { name: 'Priority' })).toBeChecked()
|
|
|
|
|
expect(screen.getByRole('checkbox', { name: 'Owner' })).toBeInTheDocument()
|
|
|
|
|
|
2026-04-16 10:25:56 +02:00
|
|
|
const combobox = screen.getByRole('combobox', { name: 'Search note-list properties' })
|
|
|
|
|
await waitFor(() => expect(combobox).toHaveFocus())
|
|
|
|
|
|
|
|
|
|
fireEvent.change(combobox, { target: { value: 'Owner' } })
|
|
|
|
|
expect(screen.getByRole('checkbox', { name: 'Owner' })).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByRole('checkbox', { name: 'Priority' })).not.toBeInTheDocument()
|
|
|
|
|
|
2026-04-16 02:18:43 +02:00
|
|
|
fireEvent.click(screen.getByRole('checkbox', { name: 'Owner' }))
|
|
|
|
|
expect(onUpdateAllNotesNoteListProperties).toHaveBeenCalledWith(['Priority', 'Owner'])
|
2026-04-16 10:25:56 +02:00
|
|
|
|
|
|
|
|
fireEvent.keyDown(combobox, { key: 'Escape' })
|
|
|
|
|
await waitFor(() => expect(screen.queryByTestId('list-properties-popover')).not.toBeInTheDocument())
|
2026-04-16 02:18:43 +02:00
|
|
|
})
|
|
|
|
|
|
2026-04-08 09:03:02 +02:00
|
|
|
it('opens the inbox column picker from the global event and saves new columns', () => {
|
|
|
|
|
const onUpdateInboxNoteListProperties = vi.fn()
|
|
|
|
|
|
|
|
|
|
renderNoteList({
|
2026-04-10 18:31:51 +02:00
|
|
|
entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }),
|
2026-04-08 09:03:02 +02:00
|
|
|
selection: { kind: 'filter', filter: 'inbox' },
|
|
|
|
|
inboxNoteListProperties: null,
|
|
|
|
|
onUpdateInboxNoteListProperties,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
openNoteListPropertiesPicker('inbox')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument()
|
2026-04-16 10:25:56 +02:00
|
|
|
expect(screen.getByRole('combobox', { name: 'Search note-list properties' })).toBeInTheDocument()
|
2026-04-08 09:03:02 +02:00
|
|
|
expect(screen.getByRole('checkbox', { name: 'Priority' })).toBeChecked()
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByRole('checkbox', { name: 'Owner' }))
|
|
|
|
|
expect(onUpdateInboxNoteListProperties).toHaveBeenCalledWith(['Priority', 'Owner'])
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-18 20:31:25 +02:00
|
|
|
it('opens the view column picker from the global event and applies the saved columns', () => {
|
|
|
|
|
renderManagedViewNoteList({
|
|
|
|
|
entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText('High')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Luca')).not.toBeInTheDocument()
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
openNoteListPropertiesPicker('view')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument()
|
|
|
|
|
fireEvent.click(screen.getByRole('checkbox', { name: 'Owner' }))
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText('Luca')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows an empty-state picker for views with no matching properties', () => {
|
|
|
|
|
renderManagedViewNoteList({
|
|
|
|
|
entries: makeBookTypeEntries(),
|
|
|
|
|
view: makeViewDefinition({
|
|
|
|
|
filename: 'empty-view.yml',
|
|
|
|
|
definition: {
|
|
|
|
|
name: 'Empty View',
|
|
|
|
|
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
openNoteListPropertiesPicker('view')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('No properties match this search.')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-08 20:56:12 +02:00
|
|
|
it('shows status in the type column picker when at least one note has it set', () => {
|
|
|
|
|
renderNoteList({
|
2026-04-10 18:31:51 +02:00
|
|
|
entries: makeBookTypeEntries([], { status: 'Active' }),
|
2026-04-08 20:56:12 +02:00
|
|
|
selection: { kind: 'sectionGroup', type: 'Book' },
|
|
|
|
|
onUpdateTypeSort: () => undefined,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
openNoteListPropertiesPicker('type')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument()
|
2026-04-16 10:25:56 +02:00
|
|
|
expect(screen.getByRole('combobox', { name: 'Search note-list properties' })).toBeInTheDocument()
|
2026-04-08 20:56:12 +02:00
|
|
|
expect(screen.getByRole('checkbox', { name: 'status' })).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps blank statuses out of the type column picker', () => {
|
|
|
|
|
renderNoteList({
|
2026-04-10 18:31:51 +02:00
|
|
|
entries: makeBookTypeEntries([], { status: '', properties: { Owner: 'Luca' } }),
|
2026-04-08 20:56:12 +02:00
|
|
|
selection: { kind: 'sectionGroup', type: 'Book' },
|
|
|
|
|
onUpdateTypeSort: () => undefined,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
openNoteListPropertiesPicker('type')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByRole('checkbox', { name: 'Owner' })).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByRole('checkbox', { name: 'status' })).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('renders status as a note-list chip when a type displays it', () => {
|
2026-04-10 18:31:51 +02:00
|
|
|
renderNoteList({
|
|
|
|
|
entries: makeBookTypeEntries(['status'], { status: 'Active' }),
|
|
|
|
|
selection: { kind: 'sectionGroup', type: 'Book' },
|
|
|
|
|
})
|
2026-04-08 20:56:12 +02:00
|
|
|
|
2026-04-10 18:31:51 +02:00
|
|
|
const chip = screen.getByTestId('property-chip-status-0')
|
|
|
|
|
expect(chip).toHaveTextContent('• Active')
|
|
|
|
|
expect(chip).toHaveStyle({ backgroundColor: 'var(--accent-green-light)', color: 'var(--accent-green)' })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('auto-detects status-like property values in note-list chips', () => {
|
2026-04-08 20:56:12 +02:00
|
|
|
renderNoteList({
|
2026-04-10 18:31:51 +02:00
|
|
|
entries: makeBookTypeEntries(['Phase'], { properties: { Phase: 'Draft' } }),
|
2026-04-08 20:56:12 +02:00
|
|
|
selection: { kind: 'sectionGroup', type: 'Book' },
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-10 18:31:51 +02:00
|
|
|
const chip = screen.getByTestId('property-chip-phase-0')
|
|
|
|
|
expect(chip).toHaveTextContent('• Draft')
|
|
|
|
|
expect(chip).toHaveStyle({ backgroundColor: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' })
|
2026-04-08 20:56:12 +02:00
|
|
|
})
|
|
|
|
|
|
2026-05-11 13:46:18 +02:00
|
|
|
it('formats date properties in note-list chips with the selected display format', async () => {
|
2026-05-12 06:57:42 +02:00
|
|
|
const built = buildNoteListProps({
|
2026-05-11 13:46:18 +02:00
|
|
|
entries: makeBookTypeEntries(['Due'], { properties: { Due: '2026-05-11' } }),
|
|
|
|
|
selection: { kind: 'sectionGroup', type: 'Book' },
|
|
|
|
|
})
|
2026-05-12 06:57:42 +02:00
|
|
|
render(
|
|
|
|
|
<AppPreferencesProvider dateDisplayFormat="european">
|
|
|
|
|
<NoteList {...built.props} />
|
|
|
|
|
</AppPreferencesProvider>,
|
|
|
|
|
)
|
2026-05-11 13:46:18 +02:00
|
|
|
|
|
|
|
|
expect(screen.getByTestId('property-chip-due-0')).toHaveTextContent('11/5/2026')
|
|
|
|
|
|
|
|
|
|
await searchNoteList('11/5/2026')
|
|
|
|
|
expect(screen.getByText('Book Note')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-10 18:31:51 +02:00
|
|
|
it('keeps unknown status values on neutral note-list chip styling', () => {
|
|
|
|
|
renderNoteList({
|
|
|
|
|
entries: makeBookTypeEntries(['status'], { status: 'Needs Review' }),
|
|
|
|
|
selection: { kind: 'sectionGroup', type: 'Book' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const chip = screen.getByTestId('property-chip-status-0')
|
|
|
|
|
expect(chip).toHaveTextContent('• Needs Review')
|
|
|
|
|
expect(chip.getAttribute('style')).toBeNull()
|
|
|
|
|
})
|
2026-04-08 09:03:02 +02:00
|
|
|
|
2026-04-10 18:31:51 +02:00
|
|
|
it('uses inbox overrides when configured', () => {
|
2026-04-08 09:03:02 +02:00
|
|
|
renderNoteList({
|
2026-04-10 18:31:51 +02:00
|
|
|
entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }),
|
2026-04-08 09:03:02 +02:00
|
|
|
selection: { kind: 'filter', filter: 'inbox' },
|
|
|
|
|
inboxNoteListProperties: ['Owner'],
|
|
|
|
|
onUpdateInboxNoteListProperties: () => undefined,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText('Luca')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('High')).not.toBeInTheDocument()
|
|
|
|
|
})
|
2026-04-08 21:28:28 +02:00
|
|
|
|
2026-04-19 03:37:33 +02:00
|
|
|
it('Cmd+clicks relationship chips through the note list without triggering the row click', async () => {
|
2026-04-08 21:28:28 +02:00
|
|
|
const projectType = makeTypeDefinition('Project')
|
|
|
|
|
const taskType = makeTypeDefinition('Task', ['Belongs to'])
|
|
|
|
|
const projectEntry = makeEntry({
|
|
|
|
|
path: '/vault/project/build-app.md',
|
|
|
|
|
filename: 'build-app.md',
|
|
|
|
|
title: 'Build App',
|
|
|
|
|
isA: 'Project',
|
|
|
|
|
createdAt: 1700000000,
|
|
|
|
|
})
|
|
|
|
|
const taskEntry = makeEntry({
|
|
|
|
|
path: '/vault/task/write-tests.md',
|
|
|
|
|
filename: 'write-tests.md',
|
|
|
|
|
title: 'Write tests',
|
|
|
|
|
isA: 'Task',
|
|
|
|
|
relationships: { 'Belongs to': ['[[project/build-app]]'] },
|
|
|
|
|
createdAt: 1700000001,
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-19 03:37:33 +02:00
|
|
|
const { onReplaceActiveTab, onEnterNeighborhood } = renderNoteList({
|
2026-04-08 21:28:28 +02:00
|
|
|
entries: [projectType, taskType, projectEntry, taskEntry],
|
|
|
|
|
selection: { kind: 'sectionGroup', type: 'Task' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const chip = screen.getByTestId('property-chip-belongs-to-0')
|
|
|
|
|
|
|
|
|
|
fireEvent.click(chip)
|
|
|
|
|
expect(onReplaceActiveTab).not.toHaveBeenCalled()
|
2026-04-19 03:37:33 +02:00
|
|
|
expect(onEnterNeighborhood).not.toHaveBeenCalled()
|
2026-04-08 21:28:28 +02:00
|
|
|
|
|
|
|
|
fireEvent.click(chip, { metaKey: true })
|
2026-04-19 03:37:33 +02:00
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(onReplaceActiveTab).toHaveBeenCalledWith(projectEntry)
|
|
|
|
|
expect(onEnterNeighborhood).toHaveBeenCalledWith(projectEntry)
|
|
|
|
|
})
|
2026-04-08 21:28:28 +02:00
|
|
|
})
|
2026-04-08 09:03:02 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('NoteList click behavior', () => {
|
|
|
|
|
it('opens the current tab on a regular click', () => {
|
2026-04-19 03:37:33 +02:00
|
|
|
const { onReplaceActiveTab, onEnterNeighborhood } = renderNoteList()
|
2026-04-08 09:03:02 +02:00
|
|
|
fireEvent.click(screen.getByText('Build Laputa App'))
|
|
|
|
|
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[0])
|
2026-04-19 03:37:33 +02:00
|
|
|
expect(onEnterNeighborhood).not.toHaveBeenCalled()
|
2026-04-08 09:03:02 +02:00
|
|
|
})
|
|
|
|
|
|
2026-04-19 03:37:33 +02:00
|
|
|
it('enters Neighborhood on Cmd+Click', async () => {
|
|
|
|
|
const { onReplaceActiveTab, onEnterNeighborhood } = renderNoteList()
|
2026-04-08 09:03:02 +02:00
|
|
|
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
2026-04-19 03:37:33 +02:00
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[0])
|
|
|
|
|
expect(onEnterNeighborhood).toHaveBeenCalledWith(mockEntries[0])
|
|
|
|
|
})
|
2026-04-08 09:03:02 +02:00
|
|
|
})
|
|
|
|
|
|
2026-04-19 03:37:33 +02:00
|
|
|
it('enters Neighborhood on Ctrl+Click', async () => {
|
|
|
|
|
const { onReplaceActiveTab, onEnterNeighborhood } = renderNoteList()
|
2026-04-08 09:03:02 +02:00
|
|
|
fireEvent.click(screen.getByText('Build Laputa App'), { ctrlKey: true })
|
2026-04-19 03:37:33 +02:00
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[0])
|
|
|
|
|
expect(onEnterNeighborhood).toHaveBeenCalledWith(mockEntries[0])
|
|
|
|
|
})
|
2026-04-08 09:03:02 +02:00
|
|
|
})
|
|
|
|
|
|
2026-04-19 03:37:33 +02:00
|
|
|
it('supports Cmd+Click on the entity pinned card', async () => {
|
|
|
|
|
const { onReplaceActiveTab, onEnterNeighborhood } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
|
2026-04-08 09:03:02 +02:00
|
|
|
const titles = screen.getAllByText('Build Laputa App')
|
|
|
|
|
fireEvent.click(titles[titles.length - 1], { metaKey: true })
|
2026-04-19 03:37:33 +02:00
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[0])
|
|
|
|
|
expect(onEnterNeighborhood).toHaveBeenCalledWith(mockEntries[0])
|
|
|
|
|
})
|
2026-04-08 09:03:02 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('opens the current tab from the entity pinned card on regular click', () => {
|
2026-04-19 03:37:33 +02:00
|
|
|
const { onReplaceActiveTab, onEnterNeighborhood } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
|
2026-04-08 09:03:02 +02:00
|
|
|
const titles = screen.getAllByText('Build Laputa App')
|
|
|
|
|
fireEvent.click(titles[titles.length - 1])
|
|
|
|
|
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[0])
|
2026-04-19 03:37:33 +02:00
|
|
|
expect(onEnterNeighborhood).not.toHaveBeenCalled()
|
2026-04-08 09:03:02 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('opens child notes from entity view in the current tab', () => {
|
2026-04-19 03:37:33 +02:00
|
|
|
const { onReplaceActiveTab, onEnterNeighborhood } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
|
2026-04-08 09:03:02 +02:00
|
|
|
fireEvent.click(screen.getByText('Facebook Ads Strategy'))
|
|
|
|
|
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[1])
|
2026-04-19 03:37:33 +02:00
|
|
|
expect(onEnterNeighborhood).not.toHaveBeenCalled()
|
2026-04-08 09:03:02 +02:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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', () => {
|
2026-05-23 23:36:22 +02:00
|
|
|
it('adds left padding for macOS traffic lights when the sidebar is collapsed', () => {
|
|
|
|
|
withUserAgent(MAC_USER_AGENT, () => {
|
|
|
|
|
const { container } = renderNoteList({ sidebarCollapsed: true })
|
|
|
|
|
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
|
|
|
|
|
expect(header.style.paddingLeft).toBe('80px')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('does not add macOS traffic-light padding on Windows when the sidebar is collapsed', () => {
|
|
|
|
|
withUserAgent(WINDOWS_USER_AGENT, () => {
|
|
|
|
|
const { container } = renderNoteList({ sidebarCollapsed: true })
|
|
|
|
|
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
|
|
|
|
|
expect(header.style.paddingLeft).toBe('')
|
|
|
|
|
})
|
2026-04-08 09:03:02 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('does not add extra left padding when the sidebar is expanded', () => {
|
2026-05-23 23:36:22 +02:00
|
|
|
withUserAgent(MAC_USER_AGENT, () => {
|
|
|
|
|
const { container } = renderNoteList({ sidebarCollapsed: false })
|
|
|
|
|
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
|
|
|
|
|
expect(header.style.paddingLeft).toBe('')
|
|
|
|
|
})
|
2026-04-08 09:03:02 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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('')
|
|
|
|
|
})
|
|
|
|
|
})
|