import { act, fireEvent, screen, waitFor } 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' function makeBookTypeEntries( displayProps: string[] = [], entryOverrides: Parameters[0] = {}, ) { return [ makeTypeDefinition('Book', displayProps), makeEntry({ path: '/vault/book.md', filename: 'book.md', title: 'Book Note', isA: 'Book', createdAt: 1700000000, ...entryOverrides, }), ] } 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('uses breadcrumbs-like button styling for note-list header actions', () => { renderNoteList({ entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High' } }), selection: { kind: 'filter', filter: 'inbox' }, inboxNoteListProperties: null, onUpdateInboxNoteListProperties: () => undefined, }) 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') 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') } }) 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', () => { renderNoteList({ entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }), 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('shows the all-notes customize-columns action and falls back to type-defined chips', () => { renderNoteList({ entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }), selection: allSelection, allNotesNoteListProperties: null, onUpdateAllNotesNoteListProperties: () => undefined, }) 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()) }) it('opens the inbox column picker from the global event and saves new columns', () => { const onUpdateInboxNoteListProperties = vi.fn() renderNoteList({ entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }), 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() 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({ 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({ 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', () => { renderNoteList({ entries: makeBookTypeEntries(['status'], { status: 'Active' }), selection: { kind: 'sectionGroup', type: 'Book' }, }) 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({ entries: makeBookTypeEntries(['Phase'], { properties: { Phase: 'Draft' } }), selection: { kind: 'sectionGroup', type: 'Book' }, }) const chip = screen.getByTestId('property-chip-phase-0') expect(chip).toHaveTextContent('• Draft') expect(chip).toHaveStyle({ backgroundColor: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' }) }) 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() }) it('uses inbox overrides when configured', () => { renderNoteList({ entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }), selection: { kind: 'filter', filter: 'inbox' }, inboxNoteListProperties: ['Owner'], onUpdateInboxNoteListProperties: () => undefined, }) expect(screen.getByText('Luca')).toBeInTheDocument() expect(screen.queryByText('High')).not.toBeInTheDocument() }) 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() }) }) 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('') }) })