Files
tolaria/src/components/NoteList.rendering.test.tsx

578 lines
22 KiB
TypeScript
Raw Normal View History

import { act, fireEvent, screen, waitFor } from '@testing-library/react'
2026-04-08 09:03:02 +02:00
import { describe, expect, it, vi } from 'vitest'
import { openNoteListPropertiesPicker } from './note-list/noteListPropertiesEvents'
import {
allSelection,
makeEntry,
makeTypeDefinition,
mockEntries,
renderNoteList,
} from '../test-utils/noteListTestUtils'
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
function searchNoteList(query: string) {
const searchInput = screen.queryByPlaceholderText('Search notes...')
if (!searchInput) fireEvent.click(screen.getByTitle('Search notes'))
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: query } })
}
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,
})
}
function expectOnlySearchMatch(title: string, matchingQuery: string, hiddenQuery: string) {
searchNoteList(matchingQuery)
expect(screen.getByText(title)).toBeInTheDocument()
searchNoteList(hiddenQuery)
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)
})
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()
2026-04-17 01:46:04 +02:00
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-17 01:46:04 +02:00
it('filters by snippet text when the title does not match', () => {
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.' }),
],
})
searchNoteList('nebula-only')
expect(screen.getByText('Beta Note')).toBeInTheDocument()
expect(screen.queryByText('Alpha Note')).not.toBeInTheDocument()
})
it('filters by visible property values and ignores hidden properties', () => {
renderBookNoteList({
entryOverrides: {
title: 'Property Search Note',
properties: { Priority: 'Boarding Window', Owner: 'Hidden Owner Value' },
},
allNotesNoteListProperties: null,
})
expectOnlySearchMatch('Property Search Note', 'boarding window', 'hidden owner value')
})
it('uses the active all-notes columns when filtering by visible property values', () => {
renderBookNoteList({
entryOverrides: {
title: 'Override Search Note',
properties: { Priority: 'Hidden Priority', Owner: 'Visible Owner Value' },
},
allNotesNoteListProperties: ['Owner'],
})
expectOnlySearchMatch('Override Search Note', 'visible owner value', 'hidden priority')
})
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-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()
})
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', () => {
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()
})
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' } },
allNotesNoteListProperties: null,
})
expect(screen.getByTitle('Customize All Notes columns')).toBeInTheDocument()
expect(screen.getByText('High')).toBeInTheDocument()
expect(screen.queryByText('Luca')).not.toBeInTheDocument()
})
it('opens the all-notes column picker as a searchable combobox and saves new columns', async () => {
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()
expect(screen.getByTestId('list-properties-popover')).toHaveClass('overflow-hidden')
expect(screen.getByTestId('list-properties-scroll-area')).toBeInTheDocument()
expect(screen.getByTestId('list-properties-scroll-area')).toHaveClass('overflow-y-auto')
expect(screen.getByRole('checkbox', { name: 'Priority' })).toBeChecked()
expect(screen.getByRole('checkbox', { name: 'Owner' })).toBeInTheDocument()
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()
fireEvent.click(screen.getByRole('checkbox', { name: 'Owner' }))
expect(onUpdateAllNotesNoteListProperties).toHaveBeenCalledWith(['Priority', 'Owner'])
fireEvent.keyDown(combobox, { key: 'Escape' })
await waitFor(() => expect(screen.queryByTestId('list-properties-popover')).not.toBeInTheDocument())
})
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()
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'])
})
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' }),
selection: { kind: 'sectionGroup', type: 'Book' },
onUpdateTypeSort: () => undefined,
})
act(() => {
openNoteListPropertiesPicker('type')
})
expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument()
expect(screen.getByRole('combobox', { name: 'Search note-list properties' })).toBeInTheDocument()
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' } }),
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-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', () => {
renderNoteList({
2026-04-10 18:31:51 +02:00
entries: makeBookTypeEntries(['Phase'], { properties: { Phase: 'Draft' } }),
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-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
it('Cmd+clicks relationship chips through the note list without triggering the row click', () => {
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,
})
const { onReplaceActiveTab, onSelectNote } = renderNoteList({
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()
expect(onSelectNote).not.toHaveBeenCalled()
fireEvent.click(chip, { metaKey: true })
expect(onSelectNote).toHaveBeenCalledWith(projectEntry)
expect(onReplaceActiveTab).not.toHaveBeenCalled()
})
2026-04-08 09:03:02 +02:00
})
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('')
})
})