406 lines
14 KiB
TypeScript
406 lines
14 KiB
TypeScript
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('shows status in the type column picker when at least one note has it set', () => {
|
|
const entries = [
|
|
makeTypeDefinition('Book'),
|
|
makeEntry({
|
|
path: '/vault/book.md',
|
|
filename: 'book.md',
|
|
title: 'Book Note',
|
|
isA: 'Book',
|
|
status: 'Active',
|
|
createdAt: 1700000000,
|
|
}),
|
|
]
|
|
|
|
renderNoteList({
|
|
entries,
|
|
selection: { kind: 'sectionGroup', type: 'Book' },
|
|
onUpdateTypeSort: () => undefined,
|
|
})
|
|
|
|
act(() => {
|
|
openNoteListPropertiesPicker('type')
|
|
})
|
|
|
|
expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument()
|
|
expect(screen.getByRole('checkbox', { name: 'status' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('keeps blank statuses out of the type column picker', () => {
|
|
const entries = [
|
|
makeTypeDefinition('Book'),
|
|
makeEntry({
|
|
path: '/vault/book.md',
|
|
filename: 'book.md',
|
|
title: 'Book Note',
|
|
isA: 'Book',
|
|
status: '',
|
|
properties: { Owner: 'Luca' },
|
|
createdAt: 1700000000,
|
|
}),
|
|
]
|
|
|
|
renderNoteList({
|
|
entries,
|
|
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', () => {
|
|
const entries = [
|
|
makeTypeDefinition('Book', ['status']),
|
|
makeEntry({
|
|
path: '/vault/book.md',
|
|
filename: 'book.md',
|
|
title: 'Book Note',
|
|
isA: 'Book',
|
|
status: 'Active',
|
|
createdAt: 1700000000,
|
|
}),
|
|
]
|
|
|
|
renderNoteList({
|
|
entries,
|
|
selection: { kind: 'sectionGroup', type: 'Book' },
|
|
})
|
|
|
|
expect(screen.getByTestId('property-chips')).toHaveTextContent('Active')
|
|
})
|
|
|
|
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('')
|
|
})
|
|
})
|