From 0cf2467fbc879f8e1dd3cbdcb42ac275665e3b2b Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 8 Apr 2026 09:03:02 +0200 Subject: [PATCH] refactor: split note list hotspots --- src/components/NoteItem.test.tsx | 367 +---- src/components/NoteList.behavior.test.tsx | 257 ++++ src/components/NoteList.changes.test.tsx | 236 +++ src/components/NoteList.rendering.test.tsx | 328 ++++ src/components/NoteList.sorting.test.tsx | 295 ++++ src/components/NoteList.test.tsx | 1574 -------------------- src/components/NoteList.tsx | 214 +-- src/components/note-list/noteListHooks.ts | 318 +++- src/test-utils/noteListTestUtils.tsx | 232 +++ src/utils/noteListHelpers.test.ts | 811 ++-------- 10 files changed, 1867 insertions(+), 2765 deletions(-) create mode 100644 src/components/NoteList.behavior.test.tsx create mode 100644 src/components/NoteList.changes.test.tsx create mode 100644 src/components/NoteList.rendering.test.tsx create mode 100644 src/components/NoteList.sorting.test.tsx delete mode 100644 src/components/NoteList.test.tsx create mode 100644 src/test-utils/noteListTestUtils.tsx diff --git a/src/components/NoteItem.test.tsx b/src/components/NoteItem.test.tsx index 3130d445..093e8c0e 100644 --- a/src/components/NoteItem.test.tsx +++ b/src/components/NoteItem.test.tsx @@ -1,307 +1,76 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' import { NoteItem } from './NoteItem' -import type { VaultEntry } from '../types' +import { makeEntry } from '../test-utils/noteListTestUtils' -vi.mock('../mock-tauri', () => ({ isTauri: () => false, mockInvoke: vi.fn() })) - -function makeEntry(overrides: Partial = {}): VaultEntry { - return { - path: '/vault/test.md', filename: 'test.md', title: 'Test Note', - isA: 'Movie', aliases: [], belongsTo: [], relatedTo: [], - status: null, archived: false, - modifiedAt: 1700000000, createdAt: null, fileSize: 100, - snippet: 'A snippet', wordCount: 50, - relationships: {}, icon: null, color: null, order: null, - sidebarLabel: null, template: null, sort: null, view: null, - visible: null, favorite: false, favoriteIndex: null, - outgoingLinks: [], properties: {}, - listPropertiesDisplay: [], - ...overrides, - } -} - -function makeTypeEntry(overrides: Partial = {}): VaultEntry { - return makeEntry({ - path: '/vault/movie.md', filename: 'movie.md', title: 'Movie', - isA: 'Type', listPropertiesDisplay: [], - ...overrides, - }) -} - -const noop = vi.fn() - -describe('NoteItem property chips', () => { - it('renders property chips when type has listPropertiesDisplay', () => { - const entry = makeEntry({ - properties: { rating: 4, genre: 'Drama' }, - }) - const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating', 'genre'] }) - - render( - - ) - - expect(screen.getByTestId('property-chips')).toBeInTheDocument() - expect(screen.getByText('4')).toBeInTheDocument() - expect(screen.getByText('Drama')).toBeInTheDocument() - }) - - it('does not render chips when listPropertiesDisplay is empty', () => { - const entry = makeEntry({ properties: { rating: 4 } }) - const typeEntry = makeTypeEntry({ listPropertiesDisplay: [] }) - - render( - - ) - - expect(screen.queryByTestId('property-chips')).not.toBeInTheDocument() - }) - - it('skips chips for missing properties', () => { - const entry = makeEntry({ properties: { rating: 4 } }) - const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating', 'genre'] }) - - render( - - ) - - expect(screen.getByText('4')).toBeInTheDocument() - expect(screen.queryByText('genre')).not.toBeInTheDocument() - }) - - it('renders relationship values as display labels', () => { - const entry = makeEntry({ - relationships: { Director: ['[[spielberg|Steven Spielberg]]'] }, - }) - const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['Director'] }) - - render( - - ) - - expect(screen.getByText('Steven Spielberg')).toBeInTheDocument() - }) - - it('shows the target note icon as a prefix on relationship chips', () => { - const entry = makeEntry({ - relationships: { Director: ['[[spielberg|Steven Spielberg]]'] }, - }) - const director = makeEntry({ - path: '/vault/spielberg.md', - filename: 'spielberg.md', - title: 'Steven Spielberg', - isA: 'Person', - icon: 'star', - }) - const movieType = makeTypeEntry({ listPropertiesDisplay: ['Director'] }) - const personType = makeTypeEntry({ - path: '/vault/person.md', - filename: 'person.md', - title: 'Person', - icon: 'users', - }) - - render( - - ) - - const chip = screen.getByText('Steven Spielberg').parentElement - expect(chip?.querySelector('svg')).toBeInTheDocument() - expect(chip?.querySelector('svg')).toHaveAttribute('aria-hidden', 'true') - }) - - it('falls back to the target type icon when the target note has no icon', () => { - const entry = makeEntry({ - relationships: { Director: ['[[spielberg|Steven Spielberg]]'] }, - }) - const director = makeEntry({ - path: '/vault/spielberg.md', - filename: 'spielberg.md', - title: 'Steven Spielberg', - isA: 'Person', - icon: null, - }) - const movieType = makeTypeEntry({ listPropertiesDisplay: ['Director'] }) - const personType = makeTypeEntry({ - path: '/vault/person.md', - filename: 'person.md', - title: 'Person', - icon: 'users', - }) - - render( - - ) - - const chip = screen.getByText('Steven Spielberg').parentElement - expect(chip?.querySelector('svg')).toBeInTheDocument() - }) - - it('renders relationship chips without a prefix when neither note nor type has an icon', () => { - const entry = makeEntry({ - relationships: { Director: ['[[spielberg|Steven Spielberg]]'] }, - }) - const director = makeEntry({ - path: '/vault/spielberg.md', - filename: 'spielberg.md', - title: 'Steven Spielberg', - isA: 'Person', - icon: null, - }) - const movieType = makeTypeEntry({ listPropertiesDisplay: ['Director'] }) - const personType = makeTypeEntry({ - path: '/vault/person.md', - filename: 'person.md', - title: 'Person', - icon: null, - }) - - render( - - ) - - const chip = screen.getByText('Steven Spielberg').parentElement - expect(chip?.querySelector('svg')).toBeNull() - expect(chip?.querySelector('img')).toBeNull() - }) - - it('falls back to the target type icon when the target note image icon fails to load', () => { - const entry = makeEntry({ - relationships: { Director: ['[[spielberg|Steven Spielberg]]'] }, - }) - const director = makeEntry({ - path: '/vault/spielberg.md', - filename: 'spielberg.md', - title: 'Steven Spielberg', - isA: 'Person', - icon: 'https://example.com/director.png', - }) - const movieType = makeTypeEntry({ listPropertiesDisplay: ['Director'] }) - const personType = makeTypeEntry({ - path: '/vault/person.md', - filename: 'person.md', - title: 'Person', - icon: 'users', - }) - - render( - - ) - - const chip = screen.getByText('Steven Spielberg').parentElement - const image = chip?.querySelector('img') - expect(image).toBeInTheDocument() - fireEvent.error(image!) - expect(chip?.querySelector('img')).toBeNull() - expect(chip?.querySelector('svg')).toBeInTheDocument() - }) - - it('shows hostname for URL properties', () => { - const entry = makeEntry({ - properties: { url: 'https://www.example.com/page/123' }, - }) - const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['url'] }) - - render( - - ) - - expect(screen.getByText('www.example.com')).toBeInTheDocument() - }) - - it('prefers displayPropsOverride over the type defaults', () => { - const entry = makeEntry({ - properties: { rating: 4, Owner: 'Luca' }, - }) - const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating'] }) - - render( - - ) - - expect(screen.getByText('Luca')).toBeInTheDocument() - expect(screen.queryByText('4')).not.toBeInTheDocument() - }) - - it('does not render chips for binary files', () => { - const entry = makeEntry({ +describe('NoteItem', () => { + it('renders binary files as non-clickable muted rows', () => { + const binaryEntry = makeEntry({ + path: '/vault/photo.png', + filename: 'photo.png', + title: 'photo.png', fileKind: 'binary', - properties: { rating: 4 }, }) - const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating'] }) + const onClickNote = vi.fn() - render( - - ) + render() - expect(screen.queryByTestId('property-chips')).not.toBeInTheDocument() - }) -}) - -describe('NoteItem note icons', () => { - it('renders a Phosphor note icon in the note row', () => { - render( - - ) - - expect(screen.getByTestId('note-title-icon').tagName.toLowerCase()).toBe('svg') - }) - - it('renders an image note icon in the note row', () => { - render( - - ) - - const icon = screen.getByTestId('note-title-icon') - expect(icon.tagName.toLowerCase()).toBe('img') - expect(icon).toHaveAttribute('src', 'https://example.com/favicon.png') + const item = screen.getByTestId('binary-file-item') + expect(item.className).toContain('opacity-50') + expect(item).toHaveAttribute('title', 'Cannot open this file type') + + fireEvent.click(item) + expect(onClickNote).not.toHaveBeenCalled() + }) + + it('renders text files as clickable rows', () => { + const textEntry = makeEntry({ + path: '/vault/config.yml', + filename: 'config.yml', + title: 'config.yml', + fileKind: 'text', + }) + const onClickNote = vi.fn() + + render() + + const item = screen.getByText('config.yml').closest('div')! + fireEvent.click(item) + expect(onClickNote).toHaveBeenCalled() + }) + + it('shows filenames instead of titles when a change status is present', () => { + const entry = makeEntry({ filename: 'my-note.md', title: 'My Note Title' }) + + render() + + expect(screen.getByText('my-note.md')).toBeInTheDocument() + expect(screen.queryByText('My Note Title')).not.toBeInTheDocument() + }) + + it('renders the correct symbol for modified files', () => { + const entry = makeEntry({ filename: 'note.md' }) + + render() + + expect(screen.getByTestId('change-status-icon').textContent).toBe('·') + }) + + it('renders the correct symbol for added files', () => { + const entry = makeEntry({ filename: 'new-note.md' }) + + render() + + expect(screen.getByTestId('change-status-icon').textContent).toBe('+') + }) + + it('renders the regular title when no change status is set', () => { + const entry = makeEntry({ filename: 'note.md', title: 'My Note' }) + + render() + + expect(screen.getByText('My Note')).toBeInTheDocument() + expect(screen.queryByText('note.md')).not.toBeInTheDocument() + expect(screen.queryByTestId('change-status-icon')).not.toBeInTheDocument() }) }) diff --git a/src/components/NoteList.behavior.test.tsx b/src/components/NoteList.behavior.test.tsx new file mode 100644 index 00000000..c7dab17c --- /dev/null +++ b/src/components/NoteList.behavior.test.tsx @@ -0,0 +1,257 @@ +import { fireEvent, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makeEntry, makeIndexedEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils' + +describe('NoteList status indicators', () => { + it('shows a modified indicator for modified notes', () => { + const getNoteStatus = (path: string) => path === mockEntries[0].path ? 'modified' as const : 'clean' as const + renderNoteList({ getNoteStatus }) + + const indicators = screen.getAllByTestId('modified-indicator') + expect(indicators).toHaveLength(1) + const noteRow = indicators[0].closest('[data-testid="modified-indicator"]')!.parentElement!.parentElement! + expect(noteRow.textContent).toContain('Build Laputa App') + }) + + it('does not show indicators when everything is clean', () => { + renderNoteList({ getNoteStatus: () => 'clean' as const }) + expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument() + expect(screen.queryByTestId('new-indicator')).not.toBeInTheDocument() + }) + + it('shows multiple modified indicators when multiple notes are dirty', () => { + const modifiedPaths = new Set([mockEntries[0].path, mockEntries[1].path]) + const getNoteStatus = (path: string) => modifiedPaths.has(path) ? 'modified' as const : 'clean' as const + + renderNoteList({ getNoteStatus }) + expect(screen.getAllByTestId('modified-indicator')).toHaveLength(2) + }) + + it('does not show indicators when getNoteStatus is undefined', () => { + renderNoteList() + expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument() + expect(screen.queryByTestId('new-indicator')).not.toBeInTheDocument() + }) + + it('shows the green new indicator for new notes', () => { + const getNoteStatus = (path: string) => path === mockEntries[0].path ? 'new' as const : 'clean' as const + renderNoteList({ getNoteStatus }) + expect(screen.getAllByTestId('new-indicator')).toHaveLength(1) + expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument() + }) +}) + +describe('NoteList virtualized datasets', () => { + it('renders 9000 entries without crashing', { timeout: 30000 }, () => { + const largeDataset = Array.from({ length: 9000 }, (_, index) => makeIndexedEntry(index)) + const { container } = renderNoteList({ entries: largeDataset }) + expect(container.querySelector('[data-testid="virtuoso-mock"]')).toBeInTheDocument() + }) + + it('renders both ends of a large dataset through the Virtuoso mock', () => { + const largeDataset = Array.from({ length: 500 }, (_, index) => makeIndexedEntry(index)) + renderNoteList({ entries: largeDataset }) + expect(screen.getByText('Note 0')).toBeInTheDocument() + expect(screen.getByText('Note 499')).toBeInTheDocument() + }) + + it('filters large datasets by search query', () => { + const entries = [ + makeIndexedEntry(0, { title: 'Alpha Strategy' }), + ...Array.from({ length: 998 }, (_, index) => makeIndexedEntry(index + 1, { title: `Filler Note ${index + 1}` })), + makeIndexedEntry(999, { title: 'Beta Strategy' }), + ] + + renderNoteList({ entries }) + fireEvent.click(screen.getByTitle('Search notes')) + fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'Strategy' } }) + + expect(screen.getByText('Alpha Strategy')).toBeInTheDocument() + expect(screen.getByText('Beta Strategy')).toBeInTheDocument() + expect(screen.queryByText('Filler Note 1')).not.toBeInTheDocument() + }) + + it('sorts large datasets correctly', () => { + const entries = [ + makeIndexedEntry(0, { title: 'Zebra', modifiedAt: 1000 }), + makeIndexedEntry(1, { title: 'Alpha', modifiedAt: 3000 }), + ...Array.from({ length: 100 }, (_, index) => makeIndexedEntry(index + 2, { title: `Mid ${index}`, modifiedAt: 2000 - index })), + ] + + renderNoteList({ entries }) + expect(screen.getAllByText(/^Alpha$|^Zebra$/)[0].textContent).toBe('Alpha') + }) + + it('filters section groups inside large mixed datasets', () => { + const entries = [ + ...Array.from({ length: 100 }, (_, index) => makeIndexedEntry(index, { isA: 'Project', title: `Project ${index}` })), + ...Array.from({ length: 200 }, (_, index) => makeIndexedEntry(100 + index, { isA: 'Note', title: `Note ${index}` })), + ] + + renderNoteList({ entries, selection: { kind: 'sectionGroup', type: 'Project' } }) + expect(screen.getByText('Project 0')).toBeInTheDocument() + expect(screen.queryByText('Note 0')).not.toBeInTheDocument() + }) + + it('keeps selection highlighting in virtualized lists', () => { + const entries = Array.from({ length: 100 }, (_, index) => makeIndexedEntry(index)) + renderNoteList({ entries, selectedNote: entries[5] }) + expect(screen.getByText('Note 5')).toBeInTheDocument() + }) + + it('keeps click behavior working on virtualized items', () => { + const entries = Array.from({ length: 100 }, (_, index) => makeIndexedEntry(index)) + const { onReplaceActiveTab } = renderNoteList({ entries }) + fireEvent.click(screen.getByText('Note 50')) + expect(onReplaceActiveTab).toHaveBeenCalledWith(entries[50]) + }) +}) + +describe('NoteList multi-select', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function selectTwoNotes(extraProps: Record = {}) { + renderNoteList(extraProps) + fireEvent.click(screen.getByText('Build Laputa App')) + fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true }) + } + + it('selects a range on Shift+Click', () => { + selectTwoNotes() + expect(screen.getAllByTestId('multi-selected-item').length).toBeGreaterThanOrEqual(2) + }) + + it('clears multi-select and opens the note on regular click', () => { + const { onReplaceActiveTab } = renderNoteList() + fireEvent.click(screen.getByText('Build Laputa App')) + fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true }) + fireEvent.click(screen.getByText('Matteo Cellini')) + + expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument() + expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[2]) + }) + + it('clears multi-select and opens a new tab on Cmd+Click', () => { + const { onSelectNote } = renderNoteList() + fireEvent.click(screen.getByText('Build Laputa App')) + fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true }) + fireEvent.click(screen.getByText('Matteo Cellini'), { metaKey: true }) + + expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument() + expect(onSelectNote).toHaveBeenCalledWith(mockEntries[2]) + }) + + it('shows the bulk action bar with the selected count', () => { + selectTwoNotes() + expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument() + expect(screen.getByText('2 selected')).toBeInTheDocument() + }) + + it.each([ + { label: 'archives via button', prop: 'onBulkArchive', trigger: () => fireEvent.click(screen.getByTestId('bulk-archive-btn')) }, + { label: 'deletes via button', prop: 'onBulkDeletePermanently', trigger: () => fireEvent.click(screen.getByTestId('bulk-delete-btn')) }, + { label: 'archives via Cmd+E', prop: 'onBulkArchive', trigger: () => fireEvent.keyDown(window, { key: 'e', metaKey: true }) }, + { label: 'deletes via Cmd+Backspace', prop: 'onBulkDeletePermanently', trigger: () => fireEvent.keyDown(window, { key: 'Backspace', metaKey: true }) }, + { label: 'deletes via Cmd+Delete', prop: 'onBulkDeletePermanently', trigger: () => fireEvent.keyDown(window, { key: 'Delete', metaKey: true }) }, + ])('bulk-select $label and clears the selection', ({ prop, trigger }) => { + const handler = vi.fn() + selectTwoNotes({ [prop]: handler }) + trigger() + expect(handler).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path]) + expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument() + }) + + it('clears the selection from the bulk action bar', () => { + selectTwoNotes() + fireEvent.click(screen.getByTestId('bulk-clear-btn')) + expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument() + expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument() + }) + + it('does not show a bulk action bar when nothing is selected', () => { + renderNoteList() + expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument() + }) +}) + +describe('NoteList filter pills', () => { + const projectEntries = [ + makeEntry({ path: '/p1.md', title: 'Open Project 1', isA: 'Project' }), + makeEntry({ path: '/p2.md', title: 'Open Project 2', isA: 'Project' }), + makeEntry({ path: '/p3.md', title: 'Archived Project', isA: 'Project', archived: true }), + makeEntry({ path: '/n1.md', title: 'Some Note', isA: 'Note' }), + ] + + it('shows filter pills for type sections', () => { + renderNoteList({ entries: projectEntries, selection: { kind: 'sectionGroup', type: 'Project' } }) + expect(screen.getByTestId('filter-pills')).toBeInTheDocument() + expect(screen.getByTestId('filter-pill-open')).toBeInTheDocument() + expect(screen.getByTestId('filter-pill-archived')).toBeInTheDocument() + }) + + it('shows filter pills in all-notes view', () => { + renderNoteList({ entries: projectEntries }) + expect(screen.getByTestId('filter-pills')).toBeInTheDocument() + expect(screen.getByTestId('filter-pill-open')).toBeInTheDocument() + expect(screen.getByTestId('filter-pill-archived')).toBeInTheDocument() + }) + + it('shows the correct all-notes count badges', () => { + renderNoteList({ entries: projectEntries }) + expect(screen.getByTestId('filter-pill-open')).toHaveTextContent('3') + expect(screen.getByTestId('filter-pill-archived')).toHaveTextContent('1') + }) + + it('shows archived notes in all-notes archived mode', () => { + renderNoteList({ entries: projectEntries, noteListFilter: 'archived' }) + expect(screen.getByText('Archived Project')).toBeInTheDocument() + expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument() + expect(screen.queryByText('Some Note')).not.toBeInTheDocument() + }) + + it('shows the correct counts for a type filter', () => { + renderNoteList({ entries: projectEntries, selection: { kind: 'sectionGroup', type: 'Project' } }) + const openPill = screen.getByTestId('filter-pill-open') + const archivedPill = screen.getByTestId('filter-pill-archived') + + expect(openPill).toHaveTextContent('Open') + expect(openPill).toHaveTextContent('2') + expect(archivedPill).toHaveTextContent('Archived') + expect(archivedPill).toHaveTextContent('1') + }) + + it('calls onNoteListFilterChange when a filter pill is clicked', () => { + const onNoteListFilterChange = vi.fn() + renderNoteList({ + entries: projectEntries, + selection: { kind: 'sectionGroup', type: 'Project' }, + onNoteListFilterChange, + }) + + fireEvent.click(screen.getByTestId('filter-pill-archived')) + expect(onNoteListFilterChange).toHaveBeenCalledWith('archived') + }) + + it('shows archived notes when the type filter switches to archived', () => { + renderNoteList({ + entries: projectEntries, + selection: { kind: 'sectionGroup', type: 'Project' }, + noteListFilter: 'archived', + }) + + expect(screen.getByText('Archived Project')).toBeInTheDocument() + expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument() + }) + + it('shows the archived empty state when a section has no archived notes', () => { + renderNoteList({ + entries: projectEntries.filter((entry) => !entry.archived), + selection: { kind: 'sectionGroup', type: 'Project' }, + noteListFilter: 'archived', + }) + + expect(screen.getByText('No archived notes')).toBeInTheDocument() + }) +}) diff --git a/src/components/NoteList.changes.test.tsx b/src/components/NoteList.changes.test.tsx new file mode 100644 index 00000000..8031313c --- /dev/null +++ b/src/components/NoteList.changes.test.tsx @@ -0,0 +1,236 @@ +import { act, fireEvent, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { NoteList } from './NoteList' +import type { SidebarSelection, VaultEntry } from '../types' +import { allSelection, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils' + +const changesSelection: SidebarSelection = { kind: 'filter', filter: 'changes' } +const modifiedFiles = [ + { path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const }, + { path: mockEntries[1].path, relativePath: 'note/facebook-ads-strategy.md', status: 'modified' as const }, +] + +describe('NoteList changes view', () => { + it('shows only modified notes in changes view with filenames', () => { + renderNoteList({ selection: changesSelection, modifiedFiles }) + expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument() + expect(screen.getByText('facebook-ads-strategy.md')).toBeInTheDocument() + expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument() + expect(screen.queryByText('Kickoff Meeting')).not.toBeInTheDocument() + }) + + it('shows the changes header title', () => { + renderNoteList({ selection: changesSelection, modifiedFiles }) + expect(screen.getByText('Changes')).toBeInTheDocument() + }) + + it('shows an empty state when no modified files exist', () => { + renderNoteList({ selection: changesSelection, modifiedFiles: [] }) + expect(screen.getByText('No pending changes')).toBeInTheDocument() + }) + + it('updates the list when modifiedFiles changes', () => { + const { rerender, props } = renderNoteList({ selection: changesSelection, modifiedFiles }) + expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument() + expect(screen.getByText('facebook-ads-strategy.md')).toBeInTheDocument() + + rerender() + expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument() + expect(screen.queryByText('facebook-ads-strategy.md')).not.toBeInTheDocument() + }) + + it('uses modifiedFiles for filtering even when getNoteStatus is also provided', () => { + const getNoteStatus = (path: string) => modifiedFiles.some((file) => file.path === path) ? 'modified' as const : 'clean' as const + renderNoteList({ selection: changesSelection, modifiedFiles, getNoteStatus }) + + expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument() + expect(screen.getByText('facebook-ads-strategy.md')).toBeInTheDocument() + expect(screen.queryByText('matteo-cellini.md')).not.toBeInTheDocument() + }) + + it('matches entries by relative path suffix across machines', () => { + const crossMachineEntries: VaultEntry[] = mockEntries.map((entry) => ({ + ...entry, + path: entry.path.replace('/Users/luca/Laputa', '/Users/other-machine/OtherVault'), + })) + + renderNoteList({ + entries: crossMachineEntries, + selection: changesSelection, + modifiedFiles, + }) + + expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument() + expect(screen.getByText('facebook-ads-strategy.md')).toBeInTheDocument() + expect(screen.queryByText('matteo-cellini.md')).not.toBeInTheDocument() + }) + + it('shows the load error when modifiedFilesError is set', () => { + renderNoteList({ + selection: changesSelection, + modifiedFiles: [], + modifiedFilesError: 'git status failed: not a git repository', + }) + + expect(screen.getByText(/Failed to load changes/)).toBeInTheDocument() + expect(screen.getByText(/git status failed/)).toBeInTheDocument() + }) + + it('shows untracked notes alongside modified notes', () => { + const mixedFiles = [ + { path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const }, + { path: mockEntries[2].path, relativePath: 'person/matteo-cellini.md', status: 'untracked' as const }, + ] + + renderNoteList({ selection: changesSelection, modifiedFiles: mixedFiles }) + expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument() + expect(screen.getByText('matteo-cellini.md')).toBeInTheDocument() + expect(screen.queryByText('facebook-ads-strategy.md')).not.toBeInTheDocument() + }) + + it('shows change-status icons for each modified file', () => { + const mixedFiles = [ + { path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const }, + { path: mockEntries[2].path, relativePath: 'person/matteo-cellini.md', status: 'untracked' as const }, + ] + + renderNoteList({ selection: changesSelection, modifiedFiles: mixedFiles }) + expect(screen.getAllByTestId('change-status-icon')).toHaveLength(2) + }) + + it('shows deleted notes as rows when files are deleted', () => { + const filesWithDeleted = [ + { path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const }, + { path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const }, + { path: '/Users/luca/Laputa/note/also-gone.md', relativePath: 'note/also-gone.md', status: 'deleted' as const }, + ] + + renderNoteList({ selection: changesSelection, modifiedFiles: filesWithDeleted }) + expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument() + expect(screen.getByText('gone.md')).toBeInTheDocument() + expect(screen.getByText('also-gone.md')).toBeInTheDocument() + expect(screen.queryByText(/notes? deleted/)).not.toBeInTheDocument() + }) + + it('renders deleted rows with dimmed strikethrough styling', () => { + renderNoteList({ + selection: changesSelection, + modifiedFiles: [ + { path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const }, + ], + }) + + expect(screen.getByText('gone.md')).toHaveClass('line-through') + expect(screen.getByText('gone.md')).toHaveClass('opacity-70') + }) + + it('does not show a deleted banner in or outside changes view', () => { + renderNoteList({ selection: changesSelection, modifiedFiles }) + expect(screen.queryByText(/notes? deleted/)).not.toBeInTheDocument() + + renderNoteList({ + selection: allSelection, + modifiedFiles: [ + { path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const }, + ], + }) + expect(screen.queryByText(/notes? deleted/)).not.toBeInTheDocument() + }) + + it('shows the discard context menu when onDiscardFile is provided', () => { + renderNoteList({ selection: changesSelection, modifiedFiles, onDiscardFile: vi.fn() }) + const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')! + fireEvent.contextMenu(noteItem) + + expect(screen.getByTestId('changes-context-menu')).toBeInTheDocument() + expect(screen.getByTestId('discard-changes-button')).toBeInTheDocument() + }) + + it('shows the restore action for deleted rows in the context menu', () => { + renderNoteList({ + selection: changesSelection, + modifiedFiles: [ + { path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const }, + ], + onDiscardFile: vi.fn(), + }) + + const noteItem = screen.getByText('gone.md').closest('[class*="border-b"]')! + fireEvent.contextMenu(noteItem) + + expect(screen.getByTestId('changes-context-menu')).toBeInTheDocument() + expect(screen.getByTestId('restore-note-button')).toBeInTheDocument() + }) + + it('does not show a context menu when discard is unavailable', () => { + renderNoteList({ selection: changesSelection, modifiedFiles }) + const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')! + fireEvent.contextMenu(noteItem) + + expect(screen.queryByTestId('changes-context-menu')).not.toBeInTheDocument() + }) + + it('shows the confirmation dialog after clicking discard changes', () => { + renderNoteList({ selection: changesSelection, modifiedFiles, onDiscardFile: vi.fn() }) + const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')! + + fireEvent.contextMenu(noteItem) + fireEvent.click(screen.getByTestId('discard-changes-button')) + + const dialog = screen.getByTestId('discard-confirm-dialog') + expect(dialog).toBeInTheDocument() + expect(dialog.textContent).toContain('Build Laputa App') + }) + + it('opens the restore context-menu action from Shift+F10 on highlighted deleted rows', () => { + renderNoteList({ + selection: changesSelection, + modifiedFiles: [ + { path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const }, + ], + onDiscardFile: vi.fn(), + }) + + const container = screen.getByTestId('note-list-container') + act(() => { + fireEvent.focus(container) + }) + act(() => { + fireEvent.keyDown(container, { key: 'F10', shiftKey: true }) + }) + + expect(screen.getByTestId('changes-context-menu')).toBeInTheDocument() + expect(screen.getByTestId('restore-note-button')).toBeInTheDocument() + }) + + it('calls onDiscardFile with the relative path when discard is confirmed', async () => { + const onDiscardFile = vi.fn().mockResolvedValue(undefined) + renderNoteList({ selection: changesSelection, modifiedFiles, onDiscardFile }) + + const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')! + act(() => { + fireEvent.contextMenu(noteItem) + }) + act(() => { + fireEvent.click(screen.getByTestId('discard-changes-button')) + }) + await act(async () => { + fireEvent.click(screen.getByTestId('discard-confirm-button')) + await Promise.resolve() + }) + + expect(onDiscardFile).toHaveBeenCalledWith('project/26q1-laputa-app.md') + }) + + it('does not call onDiscardFile when cancel is clicked', () => { + const onDiscardFile = vi.fn() + renderNoteList({ selection: changesSelection, modifiedFiles, onDiscardFile }) + + const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')! + fireEvent.contextMenu(noteItem) + fireEvent.click(screen.getByTestId('discard-changes-button')) + fireEvent.click(screen.getByText('Cancel')) + + expect(onDiscardFile).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/NoteList.rendering.test.tsx b/src/components/NoteList.rendering.test.tsx new file mode 100644 index 00000000..43e8f185 --- /dev/null +++ b/src/components/NoteList.rendering.test.tsx @@ -0,0 +1,328 @@ +import { act, fireEvent, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { openNoteListPropertiesPicker } from './note-list/noteListPropertiesEvents' +import { + allSelection, + makeEntry, + makeTypeDefinition, + mockEntries, + renderNoteList, +} from '../test-utils/noteListTestUtils' + +describe('NoteList rendering', () => { + it('shows an empty state when there are no entries', () => { + renderNoteList({ entries: [] }) + expect(screen.getByText('No notes found')).toBeInTheDocument() + }) + + it('renders all entries in the all-notes view', () => { + renderNoteList() + expect(screen.getByText('Build Laputa App')).toBeInTheDocument() + expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() + expect(screen.getByText('Matteo Cellini')).toBeInTheDocument() + }) + + it('filters section groups by type', () => { + renderNoteList({ selection: { kind: 'sectionGroup', type: 'Person' } }) + expect(screen.getByText('Matteo Cellini')).toBeInTheDocument() + expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() + }) + + it('supports event sections', () => { + renderNoteList({ selection: { kind: 'sectionGroup', type: 'Event' } }) + expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument() + expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() + }) + + it('supports project sections', () => { + renderNoteList({ selection: { kind: 'sectionGroup', type: 'Project' } }) + expect(screen.getByText('Build Laputa App')).toBeInTheDocument() + expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument() + }) + + it('passes the selected type when creating a note from a type section', () => { + const { onCreateNote } = renderNoteList({ selection: { kind: 'sectionGroup', type: 'Project' } }) + fireEvent.click(screen.getByTitle('Create new note')) + expect(onCreateNote).toHaveBeenCalledWith('Project') + }) + + it('creates an untyped note from all notes', () => { + const { onCreateNote } = renderNoteList() + fireEvent.click(screen.getByTitle('Create new note')) + expect(onCreateNote).toHaveBeenCalledWith(undefined) + }) + + it('pins the current entity and shows grouped children', () => { + renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } }) + expect(screen.getAllByText('Build Laputa App').length).toBeGreaterThanOrEqual(1) + expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() + expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument() + expect(screen.getByText('Children')).toBeInTheDocument() + expect(screen.getByText('Related to')).toBeInTheDocument() + }) + + it('shows referenced-by groups for topic entities', () => { + renderNoteList({ selection: { kind: 'entity', entry: mockEntries[4] } }) + expect(screen.getByText('Build Laputa App')).toBeInTheDocument() + expect(screen.getByText('Referenced By')).toBeInTheDocument() + }) + + it('toggles the search input from the header action', () => { + renderNoteList() + expect(screen.queryByPlaceholderText('Search notes...')).not.toBeInTheDocument() + fireEvent.click(screen.getByTitle('Search notes')) + expect(screen.getByPlaceholderText('Search notes...')).toBeInTheDocument() + }) + + it('filters by a case-insensitive search query', () => { + renderNoteList() + fireEvent.click(screen.getByTitle('Search notes')) + fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'facebook' } }) + expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() + expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() + }) + + it('sorts entries by last modified descending by default', () => { + renderNoteList({ + entries: [ + { ...mockEntries[0], modifiedAt: 1000, title: 'Oldest' }, + { ...mockEntries[1], modifiedAt: 3000, title: 'Newest', path: '/p2' }, + { ...mockEntries[2], modifiedAt: 2000, title: 'Middle', path: '/p3' }, + ], + }) + + const titles = screen.getAllByText(/Oldest|Newest|Middle/).map((element) => element.textContent) + expect(titles).toEqual(['Newest', 'Middle', 'Oldest']) + }) + + it('hides standalone status badges inside note rows', () => { + renderNoteList() + expect(screen.queryByText('Active')).not.toBeInTheDocument() + }) + + it('shows search and create actions in the header instead of a count badge', () => { + renderNoteList() + expect(screen.getByTitle('Search notes')).toBeInTheDocument() + expect(screen.getByTitle('Create new note')).toBeInTheDocument() + }) + + it('shows backlinks from outgoing links in entity view', () => { + const entriesWithBacklink = mockEntries.map((entry) => + entry.path === mockEntries[2].path ? { ...entry, outgoingLinks: ['Build Laputa App'] } : entry, + ) + + renderNoteList({ + entries: entriesWithBacklink, + selection: { kind: 'entity', entry: mockEntries[0] }, + }) + + expect(screen.getByText('Backlinks')).toBeInTheDocument() + expect(screen.getByText('Matteo Cellini')).toBeInTheDocument() + }) + + it('collapses and expands entity groups', () => { + renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } }) + expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() + + fireEvent.click(screen.getByText('Children')) + expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('Children')) + expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() + }) + + it('shows the entity snippet in the prominent card', () => { + renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } }) + expect(screen.getByText('Build a personal knowledge management app.')).toBeInTheDocument() + }) + + it('shows the inbox customize-columns action and falls back to type-defined chips', () => { + const entries = [ + makeTypeDefinition('Book', ['Priority']), + makeEntry({ + path: '/vault/book.md', + filename: 'book.md', + title: 'Book Note', + isA: 'Book', + properties: { Priority: 'High', Owner: 'Luca' }, + createdAt: 1700000000, + }), + ] + + renderNoteList({ + entries, + selection: { kind: 'filter', filter: 'inbox' }, + inboxNoteListProperties: null, + onUpdateInboxNoteListProperties: () => undefined, + }) + + expect(screen.getByTitle('Customize Inbox columns')).toBeInTheDocument() + expect(screen.getByText('High')).toBeInTheDocument() + expect(screen.queryByText('Luca')).not.toBeInTheDocument() + }) + + it('opens the inbox column picker from the global event and saves new columns', () => { + const onUpdateInboxNoteListProperties = vi.fn() + const entries = [ + makeTypeDefinition('Book', ['Priority']), + makeEntry({ + path: '/vault/book.md', + filename: 'book.md', + title: 'Book Note', + isA: 'Book', + properties: { Priority: 'High', Owner: 'Luca' }, + createdAt: 1700000000, + }), + ] + + renderNoteList({ + entries, + selection: { kind: 'filter', filter: 'inbox' }, + inboxNoteListProperties: null, + onUpdateInboxNoteListProperties, + }) + + act(() => { + openNoteListPropertiesPicker('inbox') + }) + + expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: 'Priority' })).toBeChecked() + + fireEvent.click(screen.getByRole('checkbox', { name: 'Owner' })) + expect(onUpdateInboxNoteListProperties).toHaveBeenCalledWith(['Priority', 'Owner']) + }) + + it('uses inbox overrides when configured', () => { + const entries = [ + makeTypeDefinition('Book', ['Priority']), + makeEntry({ + path: '/vault/book.md', + filename: 'book.md', + title: 'Book Note', + isA: 'Book', + properties: { Priority: 'High', Owner: 'Luca' }, + createdAt: 1700000000, + }), + ] + + renderNoteList({ + entries, + selection: { kind: 'filter', filter: 'inbox' }, + inboxNoteListProperties: ['Owner'], + onUpdateInboxNoteListProperties: () => undefined, + }) + + expect(screen.getByText('Luca')).toBeInTheDocument() + expect(screen.queryByText('High')).not.toBeInTheDocument() + }) +}) + +describe('NoteList click behavior', () => { + it('opens the current tab on a regular click', () => { + const { onReplaceActiveTab, onSelectNote } = renderNoteList() + fireEvent.click(screen.getByText('Build Laputa App')) + expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[0]) + expect(onSelectNote).not.toHaveBeenCalled() + }) + + it('opens a new tab on Cmd+Click', () => { + const { onReplaceActiveTab, onSelectNote } = renderNoteList() + fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true }) + expect(onSelectNote).toHaveBeenCalledWith(mockEntries[0]) + expect(onReplaceActiveTab).not.toHaveBeenCalled() + }) + + it('opens a new tab on Ctrl+Click', () => { + const { onReplaceActiveTab, onSelectNote } = renderNoteList() + fireEvent.click(screen.getByText('Build Laputa App'), { ctrlKey: true }) + expect(onSelectNote).toHaveBeenCalledWith(mockEntries[0]) + expect(onReplaceActiveTab).not.toHaveBeenCalled() + }) + + it('supports Cmd+Click on the entity pinned card', () => { + const { onReplaceActiveTab, onSelectNote } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } }) + const titles = screen.getAllByText('Build Laputa App') + fireEvent.click(titles[titles.length - 1], { metaKey: true }) + expect(onSelectNote).toHaveBeenCalledWith(mockEntries[0]) + expect(onReplaceActiveTab).not.toHaveBeenCalled() + }) + + it('opens the current tab from the entity pinned card on regular click', () => { + const { onReplaceActiveTab, onSelectNote } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } }) + const titles = screen.getAllByText('Build Laputa App') + fireEvent.click(titles[titles.length - 1]) + expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[0]) + expect(onSelectNote).not.toHaveBeenCalled() + }) + + it('opens child notes from entity view in the current tab', () => { + const { onReplaceActiveTab, onSelectNote } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } }) + fireEvent.click(screen.getByText('Facebook Ads Strategy')) + expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[1]) + expect(onSelectNote).not.toHaveBeenCalled() + }) +}) + +describe('NoteList type sections', () => { + const typeEntry = { + ...makeEntry({ + path: '/Users/luca/Laputa/types/project.md', + filename: 'project.md', + title: 'Project', + isA: 'Type', + snippet: 'Defines the Project type.', + modifiedAt: 1700000000, + fileSize: 200, + wordCount: 50, + }), + } + const entriesWithType = [...mockEntries, typeEntry] + + it('does not show a type note pinned card while browsing the section', () => { + renderNoteList({ + entries: entriesWithType, + selection: { kind: 'sectionGroup', type: 'Project' }, + }) + + expect(screen.queryByText('Defines the Project type.')).not.toBeInTheDocument() + expect(screen.getByText('Build Laputa App')).toBeInTheDocument() + }) + + it('renders a clickable type header that opens the type note', () => { + const { onReplaceActiveTab } = renderNoteList({ + entries: entriesWithType, + selection: { kind: 'sectionGroup', type: 'Project' }, + }) + + const headerLink = screen.getByTestId('type-header-link') + expect(headerLink).toHaveTextContent('Project') + fireEvent.click(headerLink) + expect(onReplaceActiveTab).toHaveBeenCalledWith(typeEntry) + }) + + it('does not render a type header outside type sections', () => { + renderNoteList({ entries: entriesWithType, selection: allSelection }) + expect(screen.queryByTestId('type-header-link')).not.toBeInTheDocument() + }) +}) + +describe('NoteList traffic-light padding', () => { + it('adds left padding when the sidebar is collapsed', () => { + const { container } = renderNoteList({ sidebarCollapsed: true }) + const header = container.querySelector('.h-\\[52px\\]') as HTMLElement + expect(header.style.paddingLeft).toBe('80px') + }) + + it('does not add extra left padding when the sidebar is expanded', () => { + const { container } = renderNoteList({ sidebarCollapsed: false }) + const header = container.querySelector('.h-\\[52px\\]') as HTMLElement + expect(header.style.paddingLeft).toBe('') + }) + + it('defaults to no extra padding when sidebarCollapsed is omitted', () => { + const { container } = renderNoteList() + const header = container.querySelector('.h-\\[52px\\]') as HTMLElement + expect(header.style.paddingLeft).toBe('') + }) +}) diff --git a/src/components/NoteList.sorting.test.tsx b/src/components/NoteList.sorting.test.tsx new file mode 100644 index 00000000..05d6b81a --- /dev/null +++ b/src/components/NoteList.sorting.test.tsx @@ -0,0 +1,295 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { fireEvent, screen } from '@testing-library/react' +import { getSortComparator } from '../utils/noteListHelpers' +import { makeEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils' + +describe('getSortComparator', () => { + it('sorts by modified date descending', () => { + const entries = [ + makeEntry({ title: 'A', modifiedAt: 1000 }), + makeEntry({ title: 'B', modifiedAt: 3000 }), + makeEntry({ title: 'C', modifiedAt: 2000 }), + ] + + expect(entries.sort(getSortComparator('modified')).map((entry) => entry.title)).toEqual(['B', 'C', 'A']) + }) + + it('sorts by created date descending', () => { + const entries = [ + makeEntry({ title: 'A', createdAt: 3000, modifiedAt: 1000 }), + makeEntry({ title: 'B', createdAt: 1000, modifiedAt: 3000 }), + makeEntry({ title: 'C', createdAt: 2000, modifiedAt: 2000 }), + ] + + expect(entries.sort(getSortComparator('created')).map((entry) => entry.title)).toEqual(['A', 'C', 'B']) + }) + + it('falls back to modifiedAt when createdAt is null', () => { + const entries = [ + makeEntry({ title: 'A', createdAt: null, modifiedAt: 5000 }), + makeEntry({ title: 'B', createdAt: 2000, modifiedAt: 1000 }), + ] + + expect(entries.sort(getSortComparator('created')).map((entry) => entry.title)).toEqual(['A', 'B']) + }) + + it('sorts by title alphabetically', () => { + const entries = [ + makeEntry({ title: 'Zebra' }), + makeEntry({ title: 'Alpha' }), + makeEntry({ title: 'Middle' }), + ] + + expect(entries.sort(getSortComparator('title')).map((entry) => entry.title)).toEqual(['Alpha', 'Middle', 'Zebra']) + }) + + it('sorts by status with the expected priority', () => { + const entries = [ + makeEntry({ title: 'Done', status: 'Done', modifiedAt: 1000 }), + makeEntry({ title: 'Active', status: 'Active', modifiedAt: 1000 }), + makeEntry({ title: 'NoStatus', status: null, modifiedAt: 1000 }), + makeEntry({ title: 'Paused', status: 'Paused', modifiedAt: 1000 }), + ] + + expect(entries.sort(getSortComparator('status')).map((entry) => entry.title)).toEqual(['Active', 'Paused', 'Done', 'NoStatus']) + }) + + it('uses modifiedAt as the status tiebreaker', () => { + const entries = [ + makeEntry({ title: 'OlderActive', status: 'Active', modifiedAt: 1000 }), + makeEntry({ title: 'NewerActive', status: 'Active', modifiedAt: 3000 }), + ] + + expect(entries.sort(getSortComparator('status')).map((entry) => entry.title)).toEqual(['NewerActive', 'OlderActive']) + }) + + it('supports ascending modified sorting', () => { + const entries = [ + makeEntry({ title: 'A', modifiedAt: 1000 }), + makeEntry({ title: 'B', modifiedAt: 3000 }), + makeEntry({ title: 'C', modifiedAt: 2000 }), + ] + + expect(entries.sort(getSortComparator('modified', 'asc')).map((entry) => entry.title)).toEqual(['A', 'C', 'B']) + }) + + it('supports descending title sorting', () => { + const entries = [ + makeEntry({ title: 'Zebra' }), + makeEntry({ title: 'Alpha' }), + makeEntry({ title: 'Middle' }), + ] + + expect(entries.sort(getSortComparator('title', 'desc')).map((entry) => entry.title)).toEqual(['Zebra', 'Middle', 'Alpha']) + }) + + it('supports ascending created sorting', () => { + const entries = [ + makeEntry({ title: 'A', createdAt: 3000, modifiedAt: 1000 }), + makeEntry({ title: 'B', createdAt: 1000, modifiedAt: 3000 }), + makeEntry({ title: 'C', createdAt: 2000, modifiedAt: 2000 }), + ] + + expect(entries.sort(getSortComparator('created', 'asc')).map((entry) => entry.title)).toEqual(['B', 'C', 'A']) + }) + + it('supports descending status sorting', () => { + const entries = [ + makeEntry({ title: 'Done', status: 'Done', modifiedAt: 1000 }), + makeEntry({ title: 'Active', status: 'Active', modifiedAt: 1000 }), + makeEntry({ title: 'NoStatus', status: null, modifiedAt: 1000 }), + ] + + expect(entries.sort(getSortComparator('status', 'desc')).map((entry) => entry.title)).toEqual(['NoStatus', 'Done', 'Active']) + }) +}) + +describe('NoteList sort controls', () => { + beforeEach(() => { + try { + localStorage.removeItem('laputa-sort-preferences') + } catch { + // ignore storage failures in tests + } + }) + + const zamEntries = [ + makeEntry({ path: '/a.md', title: 'Zebra', modifiedAt: 3000 }), + makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }), + makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }), + ] + + function openListSortMenu(entries = mockEntries) { + renderNoteList({ entries }) + fireEvent.click(screen.getByTestId('sort-button-__list__')) + } + + it('shows the sort button in flat list view', () => { + renderNoteList() + expect(screen.getByTestId('sort-button-__list__')).toBeInTheDocument() + }) + + it('shows a per-group sort button in entity view', () => { + renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } }) + expect(screen.getByTestId('sort-button-Children')).toBeInTheDocument() + }) + + it('opens the sort menu and lists all built-in options', () => { + openListSortMenu() + expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument() + expect(screen.getByTestId('sort-option-modified')).toBeInTheDocument() + expect(screen.getByTestId('sort-option-created')).toBeInTheDocument() + expect(screen.getByTestId('sort-option-title')).toBeInTheDocument() + expect(screen.getByTestId('sort-option-status')).toBeInTheDocument() + }) + + it('changes list order when a different sort option is selected', () => { + openListSortMenu(zamEntries) + + let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((element) => element.textContent) + expect(titles).toEqual(['Zebra', 'Middle', 'Alpha']) + + fireEvent.click(screen.getByTestId('sort-option-title')) + + titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((element) => element.textContent) + expect(titles).toEqual(['Alpha', 'Middle', 'Zebra']) + }) + + it('closes the sort menu after choosing an option', () => { + openListSortMenu() + fireEvent.click(screen.getByTestId('sort-option-title')) + expect(screen.queryByTestId('sort-menu-__list__')).not.toBeInTheDocument() + }) + + it('shows direction arrows for every sort option', () => { + openListSortMenu() + expect(screen.getByTestId('sort-dir-asc-modified')).toBeInTheDocument() + expect(screen.getByTestId('sort-dir-desc-modified')).toBeInTheDocument() + expect(screen.getByTestId('sort-dir-asc-title')).toBeInTheDocument() + expect(screen.getByTestId('sort-dir-desc-title')).toBeInTheDocument() + }) + + it('reverses list order when a direction arrow is chosen', () => { + openListSortMenu(zamEntries) + + let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((element) => element.textContent) + expect(titles).toEqual(['Zebra', 'Middle', 'Alpha']) + + fireEvent.click(screen.getByTestId('sort-dir-asc-modified')) + + titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((element) => element.textContent) + expect(titles).toEqual(['Alpha', 'Middle', 'Zebra']) + }) + + it('persists the chosen direction', () => { + const entries = [ + makeEntry({ path: '/a.md', title: 'Zebra', modifiedAt: 3000 }), + makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }), + ] + + renderNoteList({ entries }) + fireEvent.click(screen.getByTestId('sort-button-__list__')) + fireEvent.click(screen.getByTestId('sort-dir-desc-title')) + + const titles = screen.getAllByText(/Zebra|Alpha/).map((element) => element.textContent) + expect(titles).toEqual(['Zebra', 'Alpha']) + }) + + it('keeps the sort direction icon in sync with the active sort', () => { + renderNoteList() + expect(screen.getByTestId('sort-direction-icon-__list__')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('sort-button-__list__')) + fireEvent.click(screen.getByTestId('sort-option-title')) + + expect(screen.getByTestId('sort-direction-icon-__list__')).toBeInTheDocument() + }) + + it('sorts entity relationship groups independently', () => { + const parent = makeEntry({ + path: '/parent.md', + filename: 'parent.md', + title: 'Parent', + isA: 'Project', + }) + const child1 = makeEntry({ + path: '/child1.md', + filename: 'child1.md', + title: 'Zebra Note', + belongsTo: ['[[parent]]'], + modifiedAt: 3000, + }) + const child2 = makeEntry({ + path: '/child2.md', + filename: 'child2.md', + title: 'Alpha Note', + belongsTo: ['[[parent]]'], + modifiedAt: 1000, + }) + + renderNoteList({ + entries: [parent, child1, child2], + selection: { kind: 'entity', entry: parent }, + }) + + let titles = screen.getAllByText(/Zebra Note|Alpha Note/).map((element) => element.textContent) + expect(titles).toEqual(['Zebra Note', 'Alpha Note']) + + fireEvent.click(screen.getByTestId('sort-button-Children')) + fireEvent.click(screen.getByTestId('sort-option-title')) + + titles = screen.getAllByText(/Zebra Note|Alpha Note/).map((element) => element.textContent) + expect(titles).toEqual(['Alpha Note', 'Zebra Note']) + }) + + it('shows custom properties after a separator in the sort menu', () => { + const entries = [ + makeEntry({ path: '/a.md', title: 'A', properties: { Priority: 'High', Rating: 5 } }), + makeEntry({ path: '/b.md', title: 'B', properties: { Priority: 'Low', Company: 'Acme' } }), + ] + + renderNoteList({ entries }) + fireEvent.click(screen.getByTestId('sort-button-__list__')) + + expect(screen.getByTestId('sort-separator')).toBeInTheDocument() + expect(screen.getByTestId('sort-option-property:Company')).toBeInTheDocument() + expect(screen.getByTestId('sort-option-property:Priority')).toBeInTheDocument() + expect(screen.getByTestId('sort-option-property:Rating')).toBeInTheDocument() + }) + + it('omits the custom-property separator when no properties exist', () => { + renderNoteList() + fireEvent.click(screen.getByTestId('sort-button-__list__')) + expect(screen.queryByTestId('sort-separator')).not.toBeInTheDocument() + }) + + it('sorts entries by a custom property', () => { + const entries = [ + makeEntry({ path: '/a.md', title: 'A', modifiedAt: 3000, properties: { Rating: 3 } }), + makeEntry({ path: '/b.md', title: 'B', modifiedAt: 2000, properties: { Rating: 1 } }), + makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Rating: 5 } }), + ] + + renderNoteList({ entries }) + fireEvent.click(screen.getByTestId('sort-button-__list__')) + fireEvent.click(screen.getByTestId('sort-option-property:Rating')) + + const titles = screen.getAllByText(/^[ABC]$/).map((element) => element.textContent) + expect(titles).toEqual(['B', 'A', 'C']) + }) + + it('pushes entries without a custom property to the end', () => { + const entries = [ + makeEntry({ path: '/a.md', title: 'A', modifiedAt: 3000, properties: { Priority: 'High' } }), + makeEntry({ path: '/b.md', title: 'B', modifiedAt: 2000, properties: {} }), + makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Priority: 'Low' } }), + ] + + renderNoteList({ entries }) + fireEvent.click(screen.getByTestId('sort-button-__list__')) + fireEvent.click(screen.getByTestId('sort-option-property:Priority')) + + const titles = screen.getAllByText(/^[ABC]$/).map((element) => element.textContent) + expect(titles).toEqual(['A', 'C', 'B']) + }) +}) diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx deleted file mode 100644 index 8d64a500..00000000 --- a/src/components/NoteList.test.tsx +++ /dev/null @@ -1,1574 +0,0 @@ -import { render, screen, fireEvent, act } from '@testing-library/react' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { NoteList } from './NoteList' -import { NoteItem } from './NoteItem' -import { getSortComparator, filterEntries, countByFilter, countAllByFilter } from '../utils/noteListHelpers' -import type { NoteListFilter } from '../utils/noteListHelpers' -import type { VaultEntry, SidebarSelection } from '../types' -import { openNoteListPropertiesPicker } from './note-list/noteListPropertiesEvents' - -const allSelection: SidebarSelection = { kind: 'filter', filter: 'all' } -const noopSelect = vi.fn() -const noopReplace = vi.fn() -const noopFilterChange = vi.fn() -const defaultFilterProps = { noteListFilter: 'open' as NoteListFilter, onNoteListFilterChange: noopFilterChange } - -const mockEntries: VaultEntry[] = [ - { - path: '/Users/luca/Laputa/project/26q1-laputa-app.md', - filename: '26q1-laputa-app.md', - title: 'Build Laputa App', - isA: 'Project', - aliases: [], - belongsTo: [], - relatedTo: ['[[topic/software-development]]'], - status: 'Active', - owner: 'Luca', - cadence: null, - archived: false, - modifiedAt: 1700000000, - createdAt: null, - fileSize: 1024, - snippet: 'Build a personal knowledge management app.', - wordCount: 0, - relationships: { - 'Related to': ['[[topic/software-development]]'], - }, - icon: null, - color: null, - order: null, - template: null, sort: null, - outgoingLinks: [], - properties: {}, - }, - { - path: '/Users/luca/Laputa/note/facebook-ads-strategy.md', - filename: 'facebook-ads-strategy.md', - title: 'Facebook Ads Strategy', - isA: 'Note', - aliases: [], - belongsTo: ['[[project/26q1-laputa-app]]'], - relatedTo: ['[[topic/growth]]'], - status: null, - owner: null, - cadence: null, - archived: false, - modifiedAt: 1700000000, - createdAt: null, - fileSize: 847, - snippet: 'Lookalike audiences convert 3x better.', - wordCount: 0, - relationships: { - 'Belongs to': ['[[project/26q1-laputa-app]]'], - 'Related to': ['[[topic/growth]]'], - }, - icon: null, - color: null, - order: null, - template: null, sort: null, - outgoingLinks: [], - properties: {}, - }, - { - path: '/Users/luca/Laputa/person/matteo-cellini.md', - filename: 'matteo-cellini.md', - title: 'Matteo Cellini', - isA: 'Person', - aliases: [], - belongsTo: [], - relatedTo: [], - status: null, - owner: null, - cadence: null, - archived: false, - modifiedAt: 1700000000, - createdAt: null, - fileSize: 320, - snippet: 'Sponsorship manager.', - wordCount: 0, - relationships: {}, - icon: null, - color: null, - order: null, - template: null, sort: null, - outgoingLinks: [], - properties: {}, - }, - { - path: '/Users/luca/Laputa/event/2026-02-14-kickoff.md', - filename: '2026-02-14-kickoff.md', - title: 'Kickoff Meeting', - isA: 'Event', - aliases: [], - belongsTo: [], - relatedTo: [], - status: null, - owner: null, - cadence: null, - archived: false, - modifiedAt: 1700000000, - createdAt: null, - fileSize: 512, - snippet: 'Project kickoff meeting notes.', - wordCount: 0, - relationships: {}, - icon: null, - color: null, - order: null, - template: null, sort: null, - outgoingLinks: [], - properties: {}, - }, - { - path: '/Users/luca/Laputa/topic/software-development.md', - filename: 'software-development.md', - title: 'Software Development', - isA: 'Topic', - aliases: [], - belongsTo: [], - relatedTo: [], - status: null, - owner: null, - cadence: null, - archived: false, - modifiedAt: 1700000000, - createdAt: null, - fileSize: 256, - snippet: 'Frontend, backend, and systems programming.', - wordCount: 0, - relationships: {}, - icon: null, - color: null, - order: null, - template: null, sort: null, - outgoingLinks: [], - properties: {}, - }, -] - -const makeEntry = (overrides: Partial = {}): VaultEntry => ({ - path: '/test.md', filename: 'test.md', title: 'Test', isA: null, - aliases: [], belongsTo: [], relatedTo: [], status: null, - archived: false, - modifiedAt: null, createdAt: null, fileSize: 100, snippet: '', wordCount: 0, - relationships: {}, icon: null, color: null, order: null, sidebarLabel: null, - template: null, sort: null, view: null, visible: null, - outgoingLinks: [], properties: {}, - ...overrides, -}) - -const makeIndexedEntry = (i: number, overrides?: Partial): VaultEntry => - makeEntry({ - path: `/vault/note/note-${i}.md`, - filename: `note-${i}.md`, - title: `Note ${i}`, - isA: 'Note', - modifiedAt: 1700000000 - i * 60, - fileSize: 500, - snippet: `Content of note ${i}`, - ...overrides, - }) - -const makeTypeDefinition = (title: string, displayProps: string[] = []): VaultEntry => - makeEntry({ - path: `/vault/type/${title.toLowerCase()}.md`, - filename: `${title.toLowerCase()}.md`, - title, - isA: 'Type', - listPropertiesDisplay: displayProps, - }) - -describe('NoteList', () => { - it('shows empty state when no entries', () => { - render() - expect(screen.getByText('No notes found')).toBeInTheDocument() - }) - - it('renders all entries with All Notes filter', () => { - render() - expect(screen.getByText('Build Laputa App')).toBeInTheDocument() - expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() - expect(screen.getByText('Matteo Cellini')).toBeInTheDocument() - }) - - it('filters by People (section group)', () => { - render() - expect(screen.getByText('Matteo Cellini')).toBeInTheDocument() - expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() - }) - - it('filters by Events (section group)', () => { - render() - expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument() - expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() - }) - - it('filters by section group type', () => { - render() - expect(screen.getByText('Build Laputa App')).toBeInTheDocument() - expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument() - }) - - it('passes selected type when creating note from type section', () => { - const onCreate = vi.fn() - render() - fireEvent.click(screen.getByTitle('Create new note')) - expect(onCreate).toHaveBeenCalledWith('Project') - }) - - it('passes undefined type when creating note from All Notes', () => { - const onCreate = vi.fn() - render() - fireEvent.click(screen.getByTitle('Create new note')) - expect(onCreate).toHaveBeenCalledWith(undefined) - }) - - it('shows entity pinned at top with grouped children', () => { - render( - - ) - // Entity title appears in header and pinned card - expect(screen.getAllByText('Build Laputa App').length).toBeGreaterThanOrEqual(1) - // Child entry in "Children" group - expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() - // Unrelated entries not shown - expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument() - // Group headers shown - expect(screen.getByText('Children')).toBeInTheDocument() - expect(screen.getByText('Related to')).toBeInTheDocument() - }) - - it('shows entity view with relationship groups for topics', () => { - render( - - ) - // Build Laputa App references this topic via relatedTo — should appear in Referenced By - expect(screen.getByText('Build Laputa App')).toBeInTheDocument() - // Entity view shows group headers - expect(screen.getByText('Referenced By')).toBeInTheDocument() - }) - - it('shows search input when search icon is clicked', () => { - render() - // Search is hidden by default - expect(screen.queryByPlaceholderText('Search notes...')).not.toBeInTheDocument() - // Click search icon to show it - fireEvent.click(screen.getByTitle('Search notes')) - expect(screen.getByPlaceholderText('Search notes...')).toBeInTheDocument() - }) - - it('filters by search query (case-insensitive substring)', () => { - render() - // Open search - fireEvent.click(screen.getByTitle('Search notes')) - const input = screen.getByPlaceholderText('Search notes...') - fireEvent.change(input, { 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', () => { - const entriesWithDifferentDates: VaultEntry[] = [ - { ...mockEntries[0], modifiedAt: 1000, title: 'Oldest' }, - { ...mockEntries[1], modifiedAt: 3000, title: 'Newest', path: '/p2' }, - { ...mockEntries[2], modifiedAt: 2000, title: 'Middle', path: '/p3' }, - ] - render() - const titles = screen.getAllByText(/Oldest|Newest|Middle/) - const titleTexts = titles.map((el) => el.textContent) - expect(titleTexts).toEqual(['Newest', 'Middle', 'Oldest']) - }) - - it('does not render type badge or status on note items', () => { - render() - // Type badges like "Project", "Note" etc. should not appear as separate badge elements - // The word "Project" should only appear in the ALL CAPS pill "PROJECTS 1", not as a standalone badge - expect(screen.queryByText('Active')).not.toBeInTheDocument() - }) - - it('header shows search and plus icons instead of count badge', () => { - render() - expect(screen.getByTitle('Search notes')).toBeInTheDocument() - expect(screen.getByTitle('Create new note')).toBeInTheDocument() - }) - - it('context view shows backlinks from outgoingLinks', () => { - const entriesWithBacklink = mockEntries.map(e => - e.path === mockEntries[2].path ? { ...e, outgoingLinks: ['Build Laputa App'] } : e - ) - render( - - ) - expect(screen.getByText('Backlinks')).toBeInTheDocument() - expect(screen.getByText('Matteo Cellini')).toBeInTheDocument() - }) - - it('context view collapses and expands groups', () => { - render( - - ) - // Children group is expanded by default - expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() - // Click the Children header to collapse - fireEvent.click(screen.getByText('Children')) - // Items should be hidden - expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument() - // Click again to expand - fireEvent.click(screen.getByText('Children')) - expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() - }) - - it('context view shows prominent card with snippet subtitle', () => { - render( - - ) - // Snippet text appears in the prominent card - expect(screen.getByText('Build a personal knowledge management app.')).toBeInTheDocument() - }) - - it('shows the Inbox customize columns button 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, - }), - ] - - render( - , - ) - - 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 the selected 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, - }), - ] - - render( - , - ) - - act(() => { openNoteListPropertiesPicker('inbox') }) - - expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument() - expect(screen.getByRole('checkbox', { name: 'Priority' })).toBeChecked() - - fireEvent.click(screen.getByRole('checkbox', { name: 'Owner' })) - - expect(onUpdateInboxNoteListProperties).toHaveBeenCalledWith(['Priority', 'Owner']) - }) - - it('uses the Inbox override chips 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, - }), - ] - - render( - , - ) - - expect(screen.getByText('Luca')).toBeInTheDocument() - expect(screen.queryByText('High')).not.toBeInTheDocument() - }) -}) - -describe('NoteList click behavior', () => { - beforeEach(() => { - noopSelect.mockClear() - noopReplace.mockClear() - }) - - it('regular click calls onReplaceActiveTab (opens in current tab)', () => { - render() - fireEvent.click(screen.getByText('Build Laputa App')) - expect(noopReplace).toHaveBeenCalledWith(mockEntries[0]) - expect(noopSelect).not.toHaveBeenCalled() - }) - - it('Cmd+Click calls onSelectNote (opens in new tab)', () => { - render() - fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true }) - expect(noopSelect).toHaveBeenCalledWith(mockEntries[0]) - expect(noopReplace).not.toHaveBeenCalled() - }) - - it('Ctrl+Click calls onSelectNote (Windows/Linux)', () => { - render() - fireEvent.click(screen.getByText('Build Laputa App'), { ctrlKey: true }) - expect(noopSelect).toHaveBeenCalledWith(mockEntries[0]) - expect(noopReplace).not.toHaveBeenCalled() - }) - - it('Cmd+Click on entity pinned card calls onSelectNote', () => { - render( - - ) - const titles = screen.getAllByText('Build Laputa App') - fireEvent.click(titles[titles.length - 1], { metaKey: true }) - expect(noopSelect).toHaveBeenCalledWith(mockEntries[0]) - expect(noopReplace).not.toHaveBeenCalled() - }) - - it('regular click on entity pinned card calls onReplaceActiveTab', () => { - render( - - ) - // Title appears in both header and pinned card — use getAllByText and click the pinned card instance - const titles = screen.getAllByText('Build Laputa App') - fireEvent.click(titles[titles.length - 1]) - expect(noopReplace).toHaveBeenCalledWith(mockEntries[0]) - expect(noopSelect).not.toHaveBeenCalled() - }) - - it('click on child note in entity view calls onReplaceActiveTab', () => { - render( - - ) - fireEvent.click(screen.getByText('Facebook Ads Strategy')) - expect(noopReplace).toHaveBeenCalledWith(mockEntries[1]) - expect(noopSelect).not.toHaveBeenCalled() - }) -}) - -describe('getSortComparator', () => { - it('sorts by modified date descending', () => { - const a = makeEntry({ title: 'A', modifiedAt: 1000 }) - const b = makeEntry({ title: 'B', modifiedAt: 3000 }) - const c = makeEntry({ title: 'C', modifiedAt: 2000 }) - const sorted = [a, b, c].sort(getSortComparator('modified')) - expect(sorted.map((e) => e.title)).toEqual(['B', 'C', 'A']) - }) - - it('sorts by created date descending', () => { - const a = makeEntry({ title: 'A', createdAt: 3000, modifiedAt: 1000 }) - const b = makeEntry({ title: 'B', createdAt: 1000, modifiedAt: 3000 }) - const c = makeEntry({ title: 'C', createdAt: 2000, modifiedAt: 2000 }) - const sorted = [a, b, c].sort(getSortComparator('created')) - expect(sorted.map((e) => e.title)).toEqual(['A', 'C', 'B']) - }) - - it('sorts by created date, falling back to modifiedAt when createdAt is null', () => { - const a = makeEntry({ title: 'A', createdAt: null, modifiedAt: 5000 }) - const b = makeEntry({ title: 'B', createdAt: 2000, modifiedAt: 1000 }) - const sorted = [a, b].sort(getSortComparator('created')) - expect(sorted.map((e) => e.title)).toEqual(['A', 'B']) - }) - - it('sorts by title alphabetically', () => { - const a = makeEntry({ title: 'Zebra' }) - const b = makeEntry({ title: 'Alpha' }) - const c = makeEntry({ title: 'Middle' }) - const sorted = [a, b, c].sort(getSortComparator('title')) - expect(sorted.map((e) => e.title)).toEqual(['Alpha', 'Middle', 'Zebra']) - }) - - it('sorts by status (Active > Paused > Done > null)', () => { - const a = makeEntry({ title: 'Done', status: 'Done', modifiedAt: 1000 }) - const b = makeEntry({ title: 'Active', status: 'Active', modifiedAt: 1000 }) - const c = makeEntry({ title: 'NoStatus', status: null, modifiedAt: 1000 }) - const d = makeEntry({ title: 'Paused', status: 'Paused', modifiedAt: 1000 }) - const sorted = [a, b, c, d].sort(getSortComparator('status')) - expect(sorted.map((e) => e.title)).toEqual(['Active', 'Paused', 'Done', 'NoStatus']) - }) - - it('sorts by status with modified date as tiebreaker', () => { - const a = makeEntry({ title: 'OlderActive', status: 'Active', modifiedAt: 1000 }) - const b = makeEntry({ title: 'NewerActive', status: 'Active', modifiedAt: 3000 }) - const sorted = [a, b].sort(getSortComparator('status')) - expect(sorted.map((e) => e.title)).toEqual(['NewerActive', 'OlderActive']) - }) - - it('sorts by modified date ascending when direction is asc', () => { - const a = makeEntry({ title: 'A', modifiedAt: 1000 }) - const b = makeEntry({ title: 'B', modifiedAt: 3000 }) - const c = makeEntry({ title: 'C', modifiedAt: 2000 }) - const sorted = [a, b, c].sort(getSortComparator('modified', 'asc')) - expect(sorted.map((e) => e.title)).toEqual(['A', 'C', 'B']) - }) - - it('sorts by title descending when direction is desc', () => { - const a = makeEntry({ title: 'Zebra' }) - const b = makeEntry({ title: 'Alpha' }) - const c = makeEntry({ title: 'Middle' }) - const sorted = [a, b, c].sort(getSortComparator('title', 'desc')) - expect(sorted.map((e) => e.title)).toEqual(['Zebra', 'Middle', 'Alpha']) - }) - - it('sorts by created date ascending when direction is asc', () => { - const a = makeEntry({ title: 'A', createdAt: 3000, modifiedAt: 1000 }) - const b = makeEntry({ title: 'B', createdAt: 1000, modifiedAt: 3000 }) - const c = makeEntry({ title: 'C', createdAt: 2000, modifiedAt: 2000 }) - const sorted = [a, b, c].sort(getSortComparator('created', 'asc')) - expect(sorted.map((e) => e.title)).toEqual(['B', 'C', 'A']) - }) - - it('sorts by status descending (null first, Done before Active)', () => { - const a = makeEntry({ title: 'Done', status: 'Done', modifiedAt: 1000 }) - const b = makeEntry({ title: 'Active', status: 'Active', modifiedAt: 1000 }) - const c = makeEntry({ title: 'NoStatus', status: null, modifiedAt: 1000 }) - const sorted = [a, b, c].sort(getSortComparator('status', 'desc')) - expect(sorted.map((e) => e.title)).toEqual(['NoStatus', 'Done', 'Active']) - }) -}) - -describe('NoteList sort controls', () => { - beforeEach(() => { - try { localStorage.removeItem('laputa-sort-preferences') } catch { /* noop */ } - }) - - it('shows sort button in note list header for flat view', () => { - render( - - ) - expect(screen.getByTestId('sort-button-__list__')).toBeInTheDocument() - }) - - it('shows sort dropdown per relationship subsection in entity view', () => { - render( - - ) - expect(screen.getByTestId('sort-button-Children')).toBeInTheDocument() - }) - - const renderListAndOpenSort = (entries: VaultEntry[] = mockEntries) => { - render( - - ) - fireEvent.click(screen.getByTestId('sort-button-__list__')) - } - - const zamEntries = [ - makeEntry({ path: '/a.md', title: 'Zebra', modifiedAt: 3000 }), - makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }), - makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }), - ] - - it('opens sort menu on click and shows all options', () => { - renderListAndOpenSort() - expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument() - expect(screen.getByTestId('sort-option-modified')).toBeInTheDocument() - expect(screen.getByTestId('sort-option-created')).toBeInTheDocument() - expect(screen.getByTestId('sort-option-title')).toBeInTheDocument() - expect(screen.getByTestId('sort-option-status')).toBeInTheDocument() - }) - - it('changes sort order when an option is selected', () => { - renderListAndOpenSort(zamEntries) - // Default sort: by modified (Zebra first) — menu is already open - let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent) - expect(titles).toEqual(['Zebra', 'Middle', 'Alpha']) - - // Switch to title sort - fireEvent.click(screen.getByTestId('sort-option-title')) - - // Now should be alphabetical - titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent) - expect(titles).toEqual(['Alpha', 'Middle', 'Zebra']) - }) - - it('closes sort menu after selecting an option', () => { - renderListAndOpenSort() - expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument() - fireEvent.click(screen.getByTestId('sort-option-title')) - expect(screen.queryByTestId('sort-menu-__list__')).not.toBeInTheDocument() - }) - - it('shows direction arrows in sort dropdown menu', () => { - renderListAndOpenSort() - expect(screen.getByTestId('sort-dir-asc-modified')).toBeInTheDocument() - expect(screen.getByTestId('sort-dir-desc-modified')).toBeInTheDocument() - expect(screen.getByTestId('sort-dir-asc-title')).toBeInTheDocument() - expect(screen.getByTestId('sort-dir-desc-title')).toBeInTheDocument() - }) - - it('reverses sort order when clicking direction arrow', () => { - renderListAndOpenSort(zamEntries) - // Default sort: modified descending (Zebra first at 3000) - let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent) - expect(titles).toEqual(['Zebra', 'Middle', 'Alpha']) - - // Click the asc arrow for modified to reverse - fireEvent.click(screen.getByTestId('sort-dir-asc-modified')) - - // Now ascending: Alpha (1000) first - titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent) - expect(titles).toEqual(['Alpha', 'Middle', 'Zebra']) - }) - - it('persists sort direction via saveSortPreferences', () => { - const entries = [ - makeEntry({ path: '/a.md', title: 'Zebra', modifiedAt: 3000 }), - makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }), - ] - render( - - ) - // Select title sort with desc direction - fireEvent.click(screen.getByTestId('sort-button-__list__')) - fireEvent.click(screen.getByTestId('sort-dir-desc-title')) - - // Verify direction took effect: desc title means Z before A - const titles = screen.getAllByText(/Zebra|Alpha/).map((el) => el.textContent) - expect(titles).toEqual(['Zebra', 'Alpha']) - }) - - it('shows direction icon on the sort button that reflects current direction', () => { - render( - - ) - // Default: modified desc → should have ArrowDown icon - expect(screen.getByTestId('sort-direction-icon-__list__')).toBeInTheDocument() - - // Switch to title (default asc) and verify icon changes - fireEvent.click(screen.getByTestId('sort-button-__list__')) - fireEvent.click(screen.getByTestId('sort-option-title')) - // Title default is asc → should show ArrowUp icon - expect(screen.getByTestId('sort-direction-icon-__list__')).toBeInTheDocument() - }) - - it('sorts relationship subsection entries when sort option changed', () => { - // Create an entity with children that have different titles - const parent = makeEntry({ - path: '/parent.md', - filename: 'parent.md', - title: 'Parent', - isA: 'Project', - }) - const child1 = makeEntry({ - path: '/child1.md', - filename: 'child1.md', - title: 'Zebra Note', - belongsTo: ['[[parent]]'], - modifiedAt: 3000, - }) - const child2 = makeEntry({ - path: '/child2.md', - filename: 'child2.md', - title: 'Alpha Note', - belongsTo: ['[[parent]]'], - modifiedAt: 1000, - }) - const entries = [parent, child1, child2] - - render( - - ) - - // Default sort: by modified — Zebra Note (3000) before Alpha Note (1000) - let titles = screen.getAllByText(/Zebra Note|Alpha Note/).map((el) => el.textContent) - expect(titles).toEqual(['Zebra Note', 'Alpha Note']) - - // Switch to title sort - fireEvent.click(screen.getByTestId('sort-button-Children')) - fireEvent.click(screen.getByTestId('sort-option-title')) - - // Now alphabetical: Alpha before Zebra - titles = screen.getAllByText(/Zebra Note|Alpha Note/).map((el) => el.textContent) - expect(titles).toEqual(['Alpha Note', 'Zebra Note']) - }) - - it('shows custom properties with separator in sort dropdown', () => { - const entries = [ - makeEntry({ path: '/a.md', title: 'A', properties: { Priority: 'High', Rating: 5 } }), - makeEntry({ path: '/b.md', title: 'B', properties: { Priority: 'Low', Company: 'Acme' } }), - ] - render( - - ) - fireEvent.click(screen.getByTestId('sort-button-__list__')) - expect(screen.getByTestId('sort-separator')).toBeInTheDocument() - expect(screen.getByTestId('sort-option-property:Company')).toBeInTheDocument() - expect(screen.getByTestId('sort-option-property:Priority')).toBeInTheDocument() - expect(screen.getByTestId('sort-option-property:Rating')).toBeInTheDocument() - }) - - it('omits separator when no custom properties exist', () => { - render( - - ) - fireEvent.click(screen.getByTestId('sort-button-__list__')) - expect(screen.queryByTestId('sort-separator')).not.toBeInTheDocument() - }) - - it('sorts entries by custom property when selected', () => { - const entries = [ - makeEntry({ path: '/a.md', title: 'A', modifiedAt: 3000, properties: { Rating: 3 } }), - makeEntry({ path: '/b.md', title: 'B', modifiedAt: 2000, properties: { Rating: 1 } }), - makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Rating: 5 } }), - ] - render( - - ) - // Default: modified desc → A, B, C - let titles = screen.getAllByText(/^[ABC]$/).map((el) => el.textContent) - expect(titles).toEqual(['A', 'B', 'C']) - - // Switch to Rating sort (asc by default for properties) - fireEvent.click(screen.getByTestId('sort-button-__list__')) - fireEvent.click(screen.getByTestId('sort-option-property:Rating')) - - // Rating asc: B(1), A(3), C(5) - titles = screen.getAllByText(/^[ABC]$/).map((el) => el.textContent) - expect(titles).toEqual(['B', 'A', 'C']) - }) - - it('pushes entries without the property to end when sorting by custom property', () => { - const entries = [ - makeEntry({ path: '/a.md', title: 'A', modifiedAt: 3000, properties: { Priority: 'High' } }), - makeEntry({ path: '/b.md', title: 'B', modifiedAt: 2000, properties: {} }), - makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Priority: 'Low' } }), - ] - render( - - ) - fireEvent.click(screen.getByTestId('sort-button-__list__')) - fireEvent.click(screen.getByTestId('sort-option-property:Priority')) - - // Asc: A(High), C(Low), B(null → end) - const titles = screen.getAllByText(/^[ABC]$/).map((el) => el.textContent) - expect(titles).toEqual(['A', 'C', 'B']) - }) -}) - -describe('filterEntries — entity', () => { - it('entity filter returns empty (entity view uses relationship groups instead)', () => { - const result = filterEntries(mockEntries, { kind: 'entity', entry: mockEntries[4] }) - expect(result).toHaveLength(0) - }) -}) - -describe('NoteList — status indicators', () => { - it('shows modified indicator dot for modified notes', () => { - const getNoteStatus = (path: string) => path === mockEntries[0].path ? 'modified' as const : 'clean' as const - render( - - ) - const indicators = screen.getAllByTestId('modified-indicator') - expect(indicators).toHaveLength(1) - const noteRow = indicators[0].closest('[data-testid="modified-indicator"]')!.parentElement!.parentElement! - expect(noteRow.textContent).toContain('Build Laputa App') - }) - - it('does not show indicator when all notes are clean', () => { - const getNoteStatus = () => 'clean' as const - render( - - ) - expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument() - expect(screen.queryByTestId('new-indicator')).not.toBeInTheDocument() - }) - - it('shows multiple modified indicators for multiple modified files', () => { - const modifiedPaths = new Set([mockEntries[0].path, mockEntries[1].path]) - const getNoteStatus = (path: string) => modifiedPaths.has(path) ? 'modified' as const : 'clean' as const - render( - - ) - expect(screen.getAllByTestId('modified-indicator')).toHaveLength(2) - }) - - it('does not show indicator when getNoteStatus prop is undefined', () => { - render( - - ) - expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument() - expect(screen.queryByTestId('new-indicator')).not.toBeInTheDocument() - }) - - it('shows green new indicator for new notes', () => { - const getNoteStatus = (path: string) => path === mockEntries[0].path ? 'new' as const : 'clean' as const - render( - - ) - expect(screen.getAllByTestId('new-indicator')).toHaveLength(1) - expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument() - }) -}) - -// --- Virtual list performance tests --- - -describe('NoteList — virtual list with large datasets', () => { - it('renders 9000 entries without crashing', { timeout: 30000 }, () => { - const largeDataset = Array.from({ length: 9000 }, (_, i) => makeIndexedEntry(i)) - const { container } = render( - - ) - // Virtuoso mock renders all items; the real component only renders visible ones - expect(container.querySelector('[data-testid="virtuoso-mock"]')).toBeInTheDocument() - }) - - it('renders items from a large dataset via Virtuoso', () => { - const largeDataset = Array.from({ length: 500 }, (_, i) => makeIndexedEntry(i)) - render( - - ) - expect(screen.getByText('Note 0')).toBeInTheDocument() - expect(screen.getByText('Note 499')).toBeInTheDocument() - }) - - it('search filters large dataset correctly', { timeout: 15000 }, () => { - const entries = [ - makeIndexedEntry(0, { title: 'Alpha Strategy' }), - ...Array.from({ length: 998 }, (_, i) => makeIndexedEntry(i + 1, { title: `Filler Note ${i + 1}` })), - makeIndexedEntry(999, { title: 'Beta Strategy' }), - ] - render( - - ) - fireEvent.click(screen.getByTitle('Search notes')) - fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'Strategy' } }) - expect(screen.getByText('Alpha Strategy')).toBeInTheDocument() - expect(screen.getByText('Beta Strategy')).toBeInTheDocument() - expect(screen.queryByText('Filler Note 1')).not.toBeInTheDocument() - }) - - it('sorting works with large dataset', () => { - const entries = [ - makeIndexedEntry(0, { title: 'Zebra', modifiedAt: 1000 }), - makeIndexedEntry(1, { title: 'Alpha', modifiedAt: 3000 }), - ...Array.from({ length: 100 }, (_, i) => makeIndexedEntry(i + 2, { title: `Mid ${i}`, modifiedAt: 2000 - i })), - ] - render( - - ) - // Default sort is modified desc — Alpha (3000) should come first - const firstTitle = screen.getAllByText(/^Alpha$|^Zebra$/)[0] - expect(firstTitle.textContent).toBe('Alpha') - }) - - it('section group filter works with large mixed-type dataset', () => { - const entries = [ - ...Array.from({ length: 100 }, (_, i) => makeIndexedEntry(i, { isA: 'Project', title: `Project ${i}` })), - ...Array.from({ length: 200 }, (_, i) => makeIndexedEntry(100 + i, { isA: 'Note', title: `Note ${i}` })), - ] - render( - - ) - expect(screen.getByText('Project 0')).toBeInTheDocument() - expect(screen.queryByText('Note 0')).not.toBeInTheDocument() - }) - - it('selection highlighting works in virtualized list', () => { - const entries = Array.from({ length: 100 }, (_, i) => makeIndexedEntry(i)) - const selected = entries[5] - render( - - ) - expect(screen.getByText('Note 5')).toBeInTheDocument() - }) - - it('click handler works on virtualized items', () => { - noopReplace.mockClear() - const entries = Array.from({ length: 100 }, (_, i) => makeIndexedEntry(i)) - render( - - ) - fireEvent.click(screen.getByText('Note 50')) - expect(noopReplace).toHaveBeenCalledWith(entries[50]) - }) - - describe('changes filter', () => { - const changesSelection: SidebarSelection = { kind: 'filter', filter: 'changes' } - const modifiedFiles = [ - { path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const }, - { path: mockEntries[1].path, relativePath: 'note/facebook-ads-strategy.md', status: 'modified' as const }, - ] - - it('shows only modified notes in changes view with filenames', () => { - render( - - ) - // Changes view shows filenames instead of titles - expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument() - expect(screen.getByText('facebook-ads-strategy.md')).toBeInTheDocument() - expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument() - expect(screen.queryByText('Kickoff Meeting')).not.toBeInTheDocument() - }) - - it('shows header title "Changes"', () => { - render( - - ) - expect(screen.getByText('Changes')).toBeInTheDocument() - }) - - it('shows empty state when no modified files', () => { - render( - - ) - expect(screen.getByText('No pending changes')).toBeInTheDocument() - }) - - it('updates list when modifiedFiles changes', () => { - const { rerender } = render( - - ) - expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument() - expect(screen.getByText('facebook-ads-strategy.md')).toBeInTheDocument() - - // Simulate one file being committed (removed from modifiedFiles) - const fewerModified = [modifiedFiles[0]] - rerender( - - ) - expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument() - expect(screen.queryByText('facebook-ads-strategy.md')).not.toBeInTheDocument() - }) - - it('shows modified notes when both getNoteStatus and modifiedFiles are provided', () => { - // Regression: App.tsx passes both getNoteStatus and modifiedFiles. - // The changes filter must use modifiedFiles for filtering even when getNoteStatus is present. - const getNoteStatus = (path: string) => modifiedFiles.some((f) => f.path === path) ? 'modified' as const : 'clean' as const - render( - - ) - expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument() - expect(screen.getByText('facebook-ads-strategy.md')).toBeInTheDocument() - expect(screen.queryByText('matteo-cellini.md')).not.toBeInTheDocument() - }) - - it('matches entries by relative path suffix when absolute paths differ (cross-machine)', () => { - // Simulate a cloned vault where cached entries have paths from a different machine - const crossMachineEntries: VaultEntry[] = mockEntries.map((e) => ({ - ...e, - path: e.path.replace('/Users/luca/Laputa', '/Users/other-machine/OtherVault'), - })) - const modifiedFromCurrentMachine = [ - { path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const }, - { path: mockEntries[1].path, relativePath: 'note/facebook-ads-strategy.md', status: 'modified' as const }, - ] - render( - - ) - // Even though absolute paths differ, entries should match via relative path suffix - expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument() - expect(screen.getByText('facebook-ads-strategy.md')).toBeInTheDocument() - expect(screen.queryByText('matteo-cellini.md')).not.toBeInTheDocument() - }) - - it('shows error message when modifiedFilesError is set', () => { - render( - - ) - expect(screen.getByText(/Failed to load changes/)).toBeInTheDocument() - expect(screen.getByText(/git status failed/)).toBeInTheDocument() - }) - - it('shows untracked (new) notes alongside modified notes in changes view', () => { - const mixedFiles = [ - { path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const }, - { path: mockEntries[2].path, relativePath: 'person/matteo-cellini.md', status: 'untracked' as const }, - ] - render( - - ) - expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument() - expect(screen.getByText('matteo-cellini.md')).toBeInTheDocument() - expect(screen.queryByText('facebook-ads-strategy.md')).not.toBeInTheDocument() - }) - - it('shows change status icons for each modified file', () => { - const mixedFiles = [ - { path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const }, - { path: mockEntries[2].path, relativePath: 'person/matteo-cellini.md', status: 'untracked' as const }, - ] - render( - - ) - const icons = screen.getAllByTestId('change-status-icon') - expect(icons).toHaveLength(2) - }) - - it('shows deleted notes as individual rows when files are deleted', () => { - const filesWithDeleted = [ - { path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const }, - { path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const }, - { path: '/Users/luca/Laputa/note/also-gone.md', relativePath: 'note/also-gone.md', status: 'deleted' as const }, - ] - render( - - ) - expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument() - expect(screen.getByText('gone.md')).toBeInTheDocument() - expect(screen.getByText('also-gone.md')).toBeInTheDocument() - expect(screen.queryByText(/notes? deleted/)).not.toBeInTheDocument() - }) - - it('renders deleted rows with dimmed strikethrough styling', () => { - const filesWithOneDeleted = [ - { path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const }, - ] - render( - - ) - expect(screen.getByText('gone.md')).toHaveClass('line-through') - expect(screen.getByText('gone.md')).toHaveClass('opacity-70') - }) - - it('does not show deleted banner when no files are deleted', () => { - render( - - ) - expect(screen.queryByText(/notes? deleted/)).not.toBeInTheDocument() - }) - - it('does not show deleted banner outside changes view', () => { - const filesWithDeleted = [ - { path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const }, - ] - render( - - ) - expect(screen.queryByText(/notes? deleted/)).not.toBeInTheDocument() - }) - - it('shows context menu with "Discard changes" on right-click when onDiscardFile is provided', () => { - const onDiscard = vi.fn() - render( - - ) - const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')! - fireEvent.contextMenu(noteItem) - expect(screen.getByTestId('changes-context-menu')).toBeInTheDocument() - expect(screen.getByTestId('discard-changes-button')).toBeInTheDocument() - }) - - it('shows "Restore note" on deleted rows in the changes context menu', () => { - const onDiscard = vi.fn() - const filesWithDeleted = [ - { path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const }, - ] - render( - - ) - const noteItem = screen.getByText('gone.md').closest('[class*="border-b"]')! - fireEvent.contextMenu(noteItem) - expect(screen.getByTestId('changes-context-menu')).toBeInTheDocument() - expect(screen.getByTestId('restore-note-button')).toBeInTheDocument() - }) - - it('does not show context menu when onDiscardFile is not provided', () => { - render( - - ) - const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')! - fireEvent.contextMenu(noteItem) - expect(screen.queryByTestId('changes-context-menu')).not.toBeInTheDocument() - }) - - it('shows confirmation dialog after clicking "Discard changes"', () => { - const onDiscard = vi.fn() - render( - - ) - const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')! - fireEvent.contextMenu(noteItem) - fireEvent.click(screen.getByTestId('discard-changes-button')) - expect(screen.getByTestId('discard-confirm-dialog')).toBeInTheDocument() - // Dialog mentions the note title - const dialog = screen.getByTestId('discard-confirm-dialog') - expect(dialog.textContent).toContain('Build Laputa App') - }) - - it('opens the restore context menu action from Shift+F10 on a highlighted deleted row', () => { - const onDiscard = vi.fn() - const filesWithDeleted = [ - { path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const }, - ] - render( - - ) - const container = screen.getByTestId('note-list-container') - act(() => { - fireEvent.focus(container) - }) - act(() => { - fireEvent.keyDown(container, { key: 'F10', shiftKey: true }) - }) - expect(screen.getByTestId('changes-context-menu')).toBeInTheDocument() - expect(screen.getByTestId('restore-note-button')).toBeInTheDocument() - }) - - it('calls onDiscardFile with relativePath when discard is confirmed', async () => { - const onDiscard = vi.fn().mockResolvedValue(undefined) - render( - - ) - const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')! - act(() => { - fireEvent.contextMenu(noteItem) - }) - act(() => { - fireEvent.click(screen.getByTestId('discard-changes-button')) - }) - await act(async () => { - fireEvent.click(screen.getByTestId('discard-confirm-button')) - await Promise.resolve() - }) - expect(onDiscard).toHaveBeenCalledWith('project/26q1-laputa-app.md') - }) - - it('does not call onDiscardFile when cancel is clicked', () => { - const onDiscard = vi.fn() - render( - - ) - const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')! - fireEvent.contextMenu(noteItem) - fireEvent.click(screen.getByTestId('discard-changes-button')) - fireEvent.click(screen.getByText('Cancel')) - expect(onDiscard).not.toHaveBeenCalled() - }) - }) -}) - -// --- Multi-select tests --- - -describe('NoteList — multi-select', () => { - beforeEach(() => { - noopSelect.mockClear() - noopReplace.mockClear() - }) - - it('Shift+Click selects a range of notes', () => { - render() - // Regular click to set anchor - fireEvent.click(screen.getByText('Build Laputa App')) - // Shift+Click to select range - fireEvent.click(screen.getByText('Matteo Cellini'), { shiftKey: true }) - // Should select all notes between Build Laputa App and Matteo Cellini (inclusive) - const selected = screen.getAllByTestId('multi-selected-item') - expect(selected.length).toBeGreaterThanOrEqual(2) - }) - - it('regular click clears multi-select and opens note', () => { - render() - // Select range via Shift+click - fireEvent.click(screen.getByText('Build Laputa App')) - fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true }) - expect(screen.getAllByTestId('multi-selected-item').length).toBeGreaterThanOrEqual(1) - // Regular click clears selection and opens note - fireEvent.click(screen.getByText('Matteo Cellini')) - expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument() - expect(noopReplace).toHaveBeenCalledWith(mockEntries[2]) - }) - - it('Cmd+Click clears multi-select and opens in new tab', () => { - render() - // Select range via Shift+click - fireEvent.click(screen.getByText('Build Laputa App')) - fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true }) - expect(screen.getAllByTestId('multi-selected-item').length).toBeGreaterThanOrEqual(1) - // Cmd+click clears selection and opens in new tab - fireEvent.click(screen.getByText('Matteo Cellini'), { metaKey: true }) - expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument() - expect(noopSelect).toHaveBeenCalledWith(mockEntries[2]) - }) - - it('shows bulk action bar with correct count', () => { - render() - fireEvent.click(screen.getByText('Build Laputa App')) - fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true }) - expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument() - expect(screen.getByText('2 selected')).toBeInTheDocument() - }) - - const selectTwoNotes = (extraProps: Record = {}) => { - render() - fireEvent.click(screen.getByText('Build Laputa App')) - fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true }) - } - - it.each([ - { label: 'bulk archive via button', prop: 'onBulkArchive', trigger: () => fireEvent.click(screen.getByTestId('bulk-archive-btn')) }, - { label: 'bulk delete via button', prop: 'onBulkDeletePermanently', trigger: () => fireEvent.click(screen.getByTestId('bulk-delete-btn')) }, - { label: 'Cmd+E archives', prop: 'onBulkArchive', trigger: () => fireEvent.keyDown(window, { key: 'e', metaKey: true }) }, - { label: 'Cmd+Backspace deletes', prop: 'onBulkDeletePermanently', trigger: () => fireEvent.keyDown(window, { key: 'Backspace', metaKey: true }) }, - { label: 'Cmd+Delete deletes', prop: 'onBulkDeletePermanently', trigger: () => fireEvent.keyDown(window, { key: 'Delete', metaKey: true }) }, - ])('$label selected notes and clears selection', ({ prop, trigger }) => { - const handler = vi.fn() - selectTwoNotes({ [prop]: handler }) - trigger() - expect(handler).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path]) - expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument() - }) - - it('clear button on bulk action bar clears selection', () => { - selectTwoNotes() - expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument() - fireEvent.click(screen.getByTestId('bulk-clear-btn')) - expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument() - expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument() - }) - - it('no bulk action bar when nothing is selected', () => { - render() - expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument() - }) -}) - -// --- Type note filtering tests --- - -const typeEntry: VaultEntry = { - path: '/Users/luca/Laputa/types/project.md', - filename: 'project.md', - title: 'Project', - isA: 'Type', - aliases: [], - belongsTo: [], - relatedTo: [], - status: null, - owner: null, - cadence: null, - archived: false, - modifiedAt: 1700000000, - createdAt: null, - fileSize: 200, - snippet: 'Defines the Project type.', - wordCount: 50, - relationships: {}, - icon: null, - color: null, - order: null, - template: null, sort: null, - outgoingLinks: [], - properties: {}, -} - -const entriesWithType = [...mockEntries, typeEntry] - -describe('NoteList — type note filtering', () => { - beforeEach(() => { - noopSelect.mockClear() - noopReplace.mockClear() - }) - - it('does not show type note PinnedCard when browsing a sectionGroup', () => { - render( - - ) - // The type note snippet should NOT be visible (PinnedCard was removed) - expect(screen.queryByText('Defines the Project type.')).not.toBeInTheDocument() - // But the Project instance should still be in the list - expect(screen.getByText('Build Laputa App')).toBeInTheDocument() - }) - - it('shows clickable header title that navigates to type note', () => { - render( - - ) - const headerLink = screen.getByTestId('type-header-link') - expect(headerLink).toBeInTheDocument() - expect(headerLink.textContent).toBe('Project') - expect(headerLink.style.cursor).toBe('pointer') - fireEvent.click(headerLink) - expect(noopReplace).toHaveBeenCalledWith(typeEntry) - }) - - it('header is not clickable when not viewing a type section', () => { - render( - - ) - expect(screen.queryByTestId('type-header-link')).not.toBeInTheDocument() - }) -}) - -describe('NoteList — traffic light padding when sidebar collapsed', () => { - it('adds left padding to header when sidebarCollapsed is true', () => { - const { container } = render( - - ) - const header = container.querySelector('.h-\\[52px\\]') as HTMLElement - expect(header.style.paddingLeft).toBe('80px') - }) - - it('does not add extra left padding when sidebarCollapsed is false', () => { - const { container } = render( - - ) - const header = container.querySelector('.h-\\[52px\\]') as HTMLElement - expect(header.style.paddingLeft).toBe('') - }) - - it('does not add extra left padding when sidebarCollapsed is not provided', () => { - const { container } = render( - - ) - const header = container.querySelector('.h-\\[52px\\]') as HTMLElement - expect(header.style.paddingLeft).toBe('') - }) -}) - -describe('countByFilter', () => { - it('counts open and archived notes per type', () => { - const entries = [ - makeEntry({ path: '/1.md', isA: 'Project' }), - makeEntry({ path: '/2.md', isA: 'Project', archived: true }), - makeEntry({ path: '/3.md', isA: 'Project' }), - makeEntry({ path: '/4.md', isA: 'Note' }), - ] - const counts = countByFilter(entries, 'Project') - expect(counts).toEqual({ open: 2, archived: 1 }) - }) - - it('returns zeros when type has no entries', () => { - expect(countByFilter([], 'Project')).toEqual({ open: 0, archived: 0 }) - }) -}) - -describe('NoteList — filter pills', () => { - const projectEntries = [ - makeEntry({ path: '/p1.md', title: 'Open Project 1', isA: 'Project' }), - makeEntry({ path: '/p2.md', title: 'Open Project 2', isA: 'Project' }), - makeEntry({ path: '/p3.md', title: 'Archived Project', isA: 'Project', archived: true }), - makeEntry({ path: '/n1.md', title: 'Some Note', isA: 'Note' }), - ] - - it('shows filter pills when a type is selected', () => { - render( - - ) - expect(screen.getByTestId('filter-pills')).toBeInTheDocument() - expect(screen.getByTestId('filter-pill-open')).toBeInTheDocument() - expect(screen.getByTestId('filter-pill-archived')).toBeInTheDocument() - }) - - it('shows filter pills in All Notes view', () => { - render( - - ) - expect(screen.getByTestId('filter-pills')).toBeInTheDocument() - expect(screen.getByTestId('filter-pill-open')).toBeInTheDocument() - expect(screen.getByTestId('filter-pill-archived')).toBeInTheDocument() - }) - - it('shows correct All Notes count badges across all types', () => { - render( - - ) - // projectEntries: 2 open Projects + 1 open Note = 3 open, 1 archived - const openPill = screen.getByTestId('filter-pill-open') - const archivedPill = screen.getByTestId('filter-pill-archived') - expect(openPill).toHaveTextContent('3') - expect(archivedPill).toHaveTextContent('1') - }) - - it('shows archived notes in All Notes when filter is archived', () => { - render( - - ) - expect(screen.getByText('Archived Project')).toBeInTheDocument() - expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument() - expect(screen.queryByText('Some Note')).not.toBeInTheDocument() - }) - - it('shows correct count badges for each filter', () => { - render( - - ) - // Open pill should show 2, Archived 1 - const openPill = screen.getByTestId('filter-pill-open') - const archivedPill = screen.getByTestId('filter-pill-archived') - expect(openPill).toHaveTextContent('Open') - expect(openPill).toHaveTextContent('2') - expect(archivedPill).toHaveTextContent('Archived') - expect(archivedPill).toHaveTextContent('1') - }) - - it('calls onNoteListFilterChange when a pill is clicked', () => { - const onFilterChange = vi.fn() - render( - - ) - fireEvent.click(screen.getByTestId('filter-pill-archived')) - expect(onFilterChange).toHaveBeenCalledWith('archived') - }) - - it('shows archived notes when filter is set to archived', () => { - render( - - ) - expect(screen.getByText('Archived Project')).toBeInTheDocument() - expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument() - }) - - it('shows empty state for archived filter when no archived notes exist', () => { - const entriesNoArchived = projectEntries.filter(e => !e.archived) - render( - - ) - expect(screen.getByText('No archived notes')).toBeInTheDocument() - }) -}) - -describe('NoteList — filterEntries with subFilter', () => { - const entries = [ - makeEntry({ path: '/1.md', title: 'Active', isA: 'Project' }), - makeEntry({ path: '/2.md', title: 'Archived', isA: 'Project', archived: true }), - makeEntry({ path: '/4.md', title: 'Other', isA: 'Note' }), - ] - - it('filters sectionGroup by open sub-filter', () => { - const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' }, 'open') - expect(result.map(e => e.title)).toEqual(['Active']) - }) - - it('filters sectionGroup by archived sub-filter', () => { - const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' }, 'archived') - expect(result.map(e => e.title)).toEqual(['Archived']) - }) - - it('without sub-filter, defaults to active only', () => { - const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' }) - expect(result.map(e => e.title)).toEqual(['Active']) - }) - - it('filters all notes by open sub-filter', () => { - const result = filterEntries(entries, { kind: 'filter', filter: 'all' }, 'open') - expect(result.map(e => e.title)).toEqual(['Active', 'Other']) - }) - - it('filters all notes by archived sub-filter', () => { - const result = filterEntries(entries, { kind: 'filter', filter: 'all' }, 'archived') - expect(result.map(e => e.title)).toEqual(['Archived']) - }) -}) - -describe('countAllByFilter', () => { - it('counts all entries by filter status', () => { - const entries = [ - makeEntry({ path: '/1.md', isA: 'Project' }), - makeEntry({ path: '/2.md', isA: 'Note' }), - makeEntry({ path: '/3.md', isA: 'Project', archived: true }), - ] - const counts = countAllByFilter(entries) - expect(counts).toEqual({ open: 2, archived: 1 }) - }) - - it('excludes non-markdown files from counts', () => { - const entries = [ - makeEntry({ path: '/1.md', isA: 'Note', fileKind: 'markdown' }), - makeEntry({ path: '/2.yml', isA: undefined, fileKind: 'text' }), - makeEntry({ path: '/3.png', isA: undefined, fileKind: 'binary' }), - ] - const counts = countAllByFilter(entries) - expect(counts).toEqual({ open: 1, archived: 0 }) - }) -}) - -describe('NoteItem — binary file rendering', () => { - it('renders binary files as non-clickable with muted style', () => { - const binaryEntry = makeEntry({ - path: '/vault/photo.png', filename: 'photo.png', title: 'photo.png', fileKind: 'binary', - }) - const onClick = vi.fn() - render() - - const item = screen.getByTestId('binary-file-item') - expect(item).toBeTruthy() - expect(item.className).toContain('opacity-50') - expect(item).toHaveAttribute('title', 'Cannot open this file type') - - fireEvent.click(item) - expect(onClick).not.toHaveBeenCalled() - }) - - it('renders text files as clickable', () => { - const textEntry = makeEntry({ - path: '/vault/config.yml', filename: 'config.yml', title: 'config.yml', fileKind: 'text', - }) - const onClick = vi.fn() - render() - - const item = screen.getByText('config.yml').closest('div')! - fireEvent.click(item) - expect(onClick).toHaveBeenCalled() - }) -}) - -describe('NoteItem — changeStatus rendering', () => { - it('shows filename instead of title when changeStatus is set', () => { - const entry = makeEntry({ filename: 'my-note.md', title: 'My Note Title' }) - render() - expect(screen.getByText('my-note.md')).toBeInTheDocument() - expect(screen.queryByText('My Note Title')).not.toBeInTheDocument() - }) - - it('shows change status icon with correct symbol for modified', () => { - const entry = makeEntry({ filename: 'note.md' }) - render() - const icon = screen.getByTestId('change-status-icon') - expect(icon.textContent).toBe('·') - }) - - it('shows + symbol for added files', () => { - const entry = makeEntry({ filename: 'new-note.md' }) - render() - const icon = screen.getByTestId('change-status-icon') - expect(icon.textContent).toBe('+') - }) - - it('shows normal title rendering when changeStatus is not set', () => { - const entry = makeEntry({ filename: 'note.md', title: 'My Note' }) - render() - expect(screen.getByText('My Note')).toBeInTheDocument() - expect(screen.queryByText('note.md')).not.toBeInTheDocument() - expect(screen.queryByTestId('change-status-icon')).not.toBeInTheDocument() - }) -}) diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 6153b300..6a647ad9 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -1,19 +1,24 @@ import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react' import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPeriod, ViewFile } from '../types' import type { NoteListFilter } from '../utils/noteListHelpers' -import { countByFilter, countAllByFilter, filterInboxEntries } from '../utils/noteListHelpers' +import { countByFilter, countAllByFilter } from '../utils/noteListHelpers' import { NoteItem } from './NoteItem' -import { prefetchNoteContent } from '../hooks/useTabManagement' import { BulkActionBar } from './BulkActionBar' -import { useMultiSelect } from '../hooks/useMultiSelect' -import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard' import { NoteListHeader } from './note-list/NoteListHeader' import { FilterPills } from './note-list/FilterPills' import { EntityView, ListView } from './note-list/NoteListViews' -import { type DeletedNoteEntry, isDeletedNoteEntry, routeNoteClick, toggleSetMember, resolveHeaderTitle } from './note-list/noteListUtils' +import { type DeletedNoteEntry, resolveHeaderTitle } from './note-list/noteListUtils' import { - useTypeEntryMap, useNoteListData, useNoteListSearch, - useNoteListSort, useMultiSelectKeyboard, useModifiedFilesState, + useChangeStatusResolver, + useListPropertyPicker, + useModifiedFilesState, + useMultiSelectKeyboard, + useNoteListData, + useNoteListInteractions, + useNoteListSearch, + useNoteListSort, + useTypeEntryMap, + useVisibleNotesSync, } from './note-list/noteListHooks' import { Dialog, DialogContent, DialogHeader, DialogTitle, @@ -31,34 +36,6 @@ function useViewFlags(selection: SidebarSelection) { return { isSectionGroup, isFolderView, isInboxView, isAllNotesView, isChangesView, showFilterPills } } -function collectAvailableProperties(entries: VaultEntry[]): string[] { - const keys = new Set() - for (const entry of entries) { - for (const key of Object.keys(entry.properties ?? {})) keys.add(key) - for (const key of Object.keys(entry.relationships ?? {})) keys.add(key) - } - return [...keys].sort((a, b) => a.localeCompare(b)) -} - -function collectTypeAvailableProperties(entries: VaultEntry[], typeName: string): string[] { - return collectAvailableProperties(entries.filter((entry) => entry.isA === typeName)) -} - -function deriveInboxDefaultDisplay(entries: VaultEntry[], typeEntryMap: Record): string[] { - const ordered: string[] = [] - const seen = new Set() - - for (const entry of entries) { - for (const key of typeEntryMap[entry.isA ?? '']?.listPropertiesDisplay ?? []) { - if (seen.has(key)) continue - seen.add(key) - ordered.push(key) - } - } - - return ordered -} - function useBulkActions( multiSelect: ReturnType, onBulkArchive: NoteListProps['onBulkArchive'], @@ -201,151 +178,54 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, onUpdateTypeSort, updateEntry }) const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch() - const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) - const typeEntryMap = useTypeEntryMap(entries) - const inboxEntries = useMemo( - () => isInboxView ? filterInboxEntries(entries, inboxPeriod) : [], - [entries, inboxPeriod, isInboxView], - ) - const typeAvailableProperties = useMemo( - () => typeDocument ? collectTypeAvailableProperties(entries, typeDocument.title) : [], - [entries, typeDocument], - ) - const inboxAvailableProperties = useMemo( - () => collectAvailableProperties(inboxEntries), - [inboxEntries], - ) - const inboxDefaultDisplay = useMemo( - () => deriveInboxDefaultDisplay(inboxEntries, typeEntryMap), - [inboxEntries, typeEntryMap], - ) - const hasCustomInboxProperties = !!(inboxNoteListProperties && inboxNoteListProperties.length > 0) - const inboxDisplayOverride = isInboxView && hasCustomInboxProperties ? inboxNoteListProperties : null - const propertyPicker = useMemo(() => { - if (isInboxView && onUpdateInboxNoteListProperties) { - return { - scope: 'inbox' as const, - availableProperties: inboxAvailableProperties, - currentDisplay: hasCustomInboxProperties ? inboxNoteListProperties ?? [] : inboxDefaultDisplay, - onSave: onUpdateInboxNoteListProperties, - triggerTitle: 'Customize Inbox columns', - } - } - - if (isSectionGroup && typeDocument && onUpdateTypeSort) { - return { - scope: 'type' as const, - availableProperties: typeAvailableProperties, - currentDisplay: typeDocument.listPropertiesDisplay ?? [], - onSave: (value: string[] | null) => onUpdateTypeSort(typeDocument.path, '_list_properties_display', value), - triggerTitle: 'Customize columns', - } - } - - return null - }, [ - hasCustomInboxProperties, - inboxAvailableProperties, - inboxDefaultDisplay, + const { inboxDisplayOverride, propertyPicker } = useListPropertyPicker({ + entries, + selection, + inboxPeriod, + typeDocument, + typeEntryMap, inboxNoteListProperties, - isInboxView, - isSectionGroup, onUpdateInboxNoteListProperties, onUpdateTypeSort, - typeAvailableProperties, - typeDocument, - ]) - const changeStatusMap = useMemo(() => { - if (!isChangesView || !modifiedFiles) return undefined - const map = new Map() - for (const mf of modifiedFiles) { - map.set(mf.path, mf.status) - // Also index by suffix for matching (vault entries may use different path formats) - map.set('/' + mf.relativePath, mf.status) - } - return map - }, [isChangesView, modifiedFiles]) + }) const { isEntityView, isArchivedView, searched, searchedGroups } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, modifiedFiles, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, views }) - // Keep the visible notes ref in sync for keyboard navigation (Cmd+Option+Arrow) - if (visibleNotesRef) { - visibleNotesRef.current = isEntityView - ? searchedGroups.flatMap((g) => g.entries).filter((entry) => !isDeletedNoteEntry(entry)) - : searched.filter((entry) => !isDeletedNoteEntry(entry)) - } + useVisibleNotesSync({ visibleNotesRef, isEntityView, searched, searchedGroups }) const entitySelection = isEntityView && selection.kind === 'entity' ? selection : null - const handleKeyboardOpen = useCallback((entry: VaultEntry) => { - if (isDeletedNoteEntry(entry)) { - onOpenDeletedNote?.(entry) - return - } - onReplaceActiveTab(entry) - }, [onOpenDeletedNote, onReplaceActiveTab]) - const handleKeyboardPrefetch = useCallback((entry: VaultEntry) => { - if (!isDeletedNoteEntry(entry)) prefetchNoteContent(entry.path) - }, []) - const noteListKeyboard = useNoteListKeyboard({ items: searched, selectedNotePath: selectedNote?.path ?? null, onOpen: handleKeyboardOpen, onPrefetch: handleKeyboardPrefetch, enabled: !isEntityView }) - const multiSelect = useMultiSelect(searched, selectedNote?.path ?? null) - useEffect(() => { multiSelect.clear() }, [selection, noteListFilter]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection/filter change - - const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => { - if (isDeletedNoteEntry(entry)) { - routeNoteClick(entry, e, { - onReplace: () => onOpenDeletedNote?.(entry), - onSelect: () => onOpenDeletedNote?.(entry), - multiSelect, - }) - return - } - routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, onOpenInNewWindow, multiSelect }) - if (isChangesView && onAutoTriggerDiff) { - // Small delay to let the tab open before triggering diff - setTimeout(onAutoTriggerDiff, 50) - } - }, [onOpenDeletedNote, onReplaceActiveTab, onSelectNote, onOpenInNewWindow, multiSelect, isChangesView, onAutoTriggerDiff]) + const { handleNoteContextMenu, openContextMenuForEntry, contextMenuNode, dialogNode } = ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles }) + const { + collapsedGroups, + handleClickNote, + handleCreateNote, + handleListKeyDown, + multiSelect, + noteListKeyboard, + toggleGroup, + } = useNoteListInteractions({ + searched, + selectedNotePath: selectedNote?.path ?? null, + selection, + noteListFilter, + isEntityView, + isChangesView, + onReplaceActiveTab, + onSelectNote, + onOpenDeletedNote, + onOpenInNewWindow, + onAutoTriggerDiff, + onDiscardFile, + openContextMenuForEntry, + onCreateNote, + }) + const getChangeStatus = useChangeStatusResolver(isChangesView, modifiedFiles) const { handleBulkArchive, handleBulkDeletePermanently, handleBulkUnarchive, bulkArchiveOrUnarchive } = useBulkActions(multiSelect, onBulkArchive, onBulkDeletePermanently, isArchivedView) useMultiSelectKeyboard(multiSelect, isEntityView, bulkArchiveOrUnarchive, handleBulkDeletePermanently) - const { handleNoteContextMenu, openContextMenuForEntry, contextMenuNode, dialogNode } = ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles }) - - const getChangeStatus = useCallback((path: string) => { - if (!changeStatusMap) return undefined - const direct = changeStatusMap.get(path) - if (direct) return direct - // Try suffix match - for (const [key, status] of changeStatusMap) { - if (path.endsWith(key) || key.endsWith(path.split('/').slice(-1)[0])) return status - } - return undefined - }, [changeStatusMap]) - - const handleListKeyDown = useCallback((e: React.KeyboardEvent) => { - if (isChangesView && onDiscardFile && e.shiftKey && e.key === 'F10' && noteListKeyboard.highlightedPath) { - const entry = searched.find((candidate) => candidate.path === noteListKeyboard.highlightedPath) - if (!entry) return - e.preventDefault() - e.stopPropagation() - const row = document.querySelector(`[data-note-path="${entry.path}"]`) - const rect = row?.getBoundingClientRect() - openContextMenuForEntry(entry, { - x: rect ? rect.left + 24 : 160, - y: rect ? rect.bottom - 8 : 160, - }) - return - } - noteListKeyboard.handleKeyDown(e) - }, [isChangesView, onDiscardFile, noteListKeyboard, searched, openContextMenuForEntry]) - const renderItem = useCallback((entry: VaultEntry) => ( - + ), [entries, selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, getChangeStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath, isChangesView, onDiscardFile, handleNoteContextMenu, inboxDisplayOverride]) - - const handleCreateNote = useCallback(() => { - onCreateNote(selection.kind === 'sectionGroup' ? selection.type : undefined) - }, [onCreateNote, selection]) - const toggleGroup = useCallback((label: string) => { setCollapsedGroups((prev) => toggleSetMember(prev, label)) }, []) const title = resolveHeaderTitle(selection, typeDocument, views) return ( diff --git a/src/components/note-list/noteListHooks.ts b/src/components/note-list/noteListHooks.ts index 1797299e..bd76ff74 100644 --- a/src/components/note-list/noteListHooks.ts +++ b/src/components/note-list/noteListHooks.ts @@ -9,8 +9,13 @@ import { } from '../../utils/noteListHelpers' import type { InboxPeriod } from '../../types' import { buildTypeEntryMap } from '../../utils/typeColors' -import { buildChangesEntries, filterByQuery, filterGroupsByQuery, createNoteStatusResolver, isModifiedEntry } from './noteListUtils' -import type { MultiSelectState } from '../../hooks/useMultiSelect' +import { + buildChangesEntries, filterByQuery, filterGroupsByQuery, createNoteStatusResolver, + isDeletedNoteEntry, isModifiedEntry, routeNoteClick, toggleSetMember, +} from './noteListUtils' +import { useMultiSelect, type MultiSelectState } from '../../hooks/useMultiSelect' +import { useNoteListKeyboard } from '../../hooks/useNoteListKeyboard' +import { prefetchNoteContent } from '../../hooks/useTabManagement' // --- useTypeEntryMap --- @@ -220,3 +225,312 @@ export function useModifiedFilesState(modifiedFiles: ModifiedFile[] | undefined, ) return { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } } + +// --- useChangeStatusResolver --- + +function buildChangeStatusMap(isChangesView: boolean, modifiedFiles?: ModifiedFile[]) { + if (!isChangesView || !modifiedFiles) return undefined + + const map = new Map() + for (const file of modifiedFiles) { + map.set(file.path, file.status) + map.set('/' + file.relativePath, file.status) + } + + return map +} + +function resolveChangeStatus(path: string, changeStatusMap?: Map) { + if (!changeStatusMap) return undefined + + const direct = changeStatusMap.get(path) + if (direct) return direct + + const filename = path.split('/').slice(-1)[0] + for (const [key, status] of changeStatusMap) { + if (path.endsWith(key) || key.endsWith(filename)) return status + } + + return undefined +} + +export function useChangeStatusResolver(isChangesView: boolean, modifiedFiles?: ModifiedFile[]) { + const changeStatusMap = useMemo( + () => buildChangeStatusMap(isChangesView, modifiedFiles), + [isChangesView, modifiedFiles], + ) + + return useCallback( + (path: string) => resolveChangeStatus(path, changeStatusMap), + [changeStatusMap], + ) +} + +// --- useVisibleNotesSync --- + +interface VisibleNotesSyncParams { + visibleNotesRef?: React.MutableRefObject + isEntityView: boolean + searched: VaultEntry[] + searchedGroups: Array<{ entries: VaultEntry[] }> +} + +export function useVisibleNotesSync({ visibleNotesRef, isEntityView, searched, searchedGroups }: VisibleNotesSyncParams) { + useEffect(() => { + if (!visibleNotesRef) return + + visibleNotesRef.current = isEntityView + ? searchedGroups.flatMap((group) => group.entries).filter((entry) => !isDeletedNoteEntry(entry)) + : searched.filter((entry) => !isDeletedNoteEntry(entry)) + }, [visibleNotesRef, isEntityView, searched, searchedGroups]) +} + +// --- useListPropertyPicker --- + +function collectAvailableProperties(entries: VaultEntry[]): string[] { + const keys = new Set() + for (const entry of entries) { + for (const key of Object.keys(entry.properties ?? {})) keys.add(key) + for (const key of Object.keys(entry.relationships ?? {})) keys.add(key) + } + return [...keys].sort((a, b) => a.localeCompare(b)) +} + +function collectTypeAvailableProperties(entries: VaultEntry[], typeName: string): string[] { + return collectAvailableProperties(entries.filter((entry) => entry.isA === typeName)) +} + +function deriveInboxDefaultDisplay(entries: VaultEntry[], typeEntryMap: Record): string[] { + const ordered: string[] = [] + const seen = new Set() + + for (const entry of entries) { + for (const key of typeEntryMap[entry.isA ?? '']?.listPropertiesDisplay ?? []) { + if (seen.has(key)) continue + seen.add(key) + ordered.push(key) + } + } + + return ordered +} + +export interface NoteListPropertyPicker { + scope: 'inbox' | 'type' + availableProperties: string[] + currentDisplay: string[] + onSave: (value: string[] | null) => void + triggerTitle: string +} + +interface UseListPropertyPickerParams { + entries: VaultEntry[] + selection: SidebarSelection + inboxPeriod: InboxPeriod + typeDocument: VaultEntry | null + typeEntryMap: Record + inboxNoteListProperties?: string[] | null + onUpdateInboxNoteListProperties?: (value: string[] | null) => void + onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void +} + +export function useListPropertyPicker({ + entries, + selection, + inboxPeriod, + typeDocument, + typeEntryMap, + inboxNoteListProperties, + onUpdateInboxNoteListProperties, + onUpdateTypeSort, +}: UseListPropertyPickerParams) { + const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox' + const isSectionGroup = selection.kind === 'sectionGroup' + + const inboxEntries = useMemo( + () => isInboxView ? filterInboxEntries(entries, inboxPeriod) : [], + [entries, inboxPeriod, isInboxView], + ) + const typeAvailableProperties = useMemo( + () => typeDocument ? collectTypeAvailableProperties(entries, typeDocument.title) : [], + [entries, typeDocument], + ) + const inboxAvailableProperties = useMemo( + () => collectAvailableProperties(inboxEntries), + [inboxEntries], + ) + const inboxDefaultDisplay = useMemo( + () => deriveInboxDefaultDisplay(inboxEntries, typeEntryMap), + [inboxEntries, typeEntryMap], + ) + const hasCustomInboxProperties = !!(inboxNoteListProperties && inboxNoteListProperties.length > 0) + const inboxDisplayOverride = isInboxView && hasCustomInboxProperties ? inboxNoteListProperties : null + + const propertyPicker = useMemo(() => { + if (isInboxView && onUpdateInboxNoteListProperties) { + return { + scope: 'inbox', + availableProperties: inboxAvailableProperties, + currentDisplay: hasCustomInboxProperties ? inboxNoteListProperties ?? [] : inboxDefaultDisplay, + onSave: onUpdateInboxNoteListProperties, + triggerTitle: 'Customize Inbox columns', + } + } + + if (isSectionGroup && typeDocument && onUpdateTypeSort) { + return { + scope: 'type', + availableProperties: typeAvailableProperties, + currentDisplay: typeDocument.listPropertiesDisplay ?? [], + onSave: (value: string[] | null) => onUpdateTypeSort(typeDocument.path, '_list_properties_display', value), + triggerTitle: 'Customize columns', + } + } + + return null + }, [ + hasCustomInboxProperties, + inboxAvailableProperties, + inboxDefaultDisplay, + inboxNoteListProperties, + isInboxView, + isSectionGroup, + onUpdateInboxNoteListProperties, + onUpdateTypeSort, + typeAvailableProperties, + typeDocument, + ]) + + return { inboxDisplayOverride, propertyPicker } +} + +// --- useNoteListInteractions --- + +interface UseNoteListInteractionsParams { + searched: VaultEntry[] + selectedNotePath: string | null + selection: SidebarSelection + noteListFilter: NoteListFilter + isEntityView: boolean + isChangesView: boolean + onReplaceActiveTab: (entry: VaultEntry) => void + onSelectNote: (entry: VaultEntry) => void + onOpenDeletedNote?: (entry: VaultEntry) => void + onOpenInNewWindow?: (entry: VaultEntry) => void + onAutoTriggerDiff?: () => void + onDiscardFile?: (relativePath: string) => Promise + openContextMenuForEntry: (entry: VaultEntry, point: { x: number; y: number }) => void + onCreateNote: (type?: string) => void +} + +export function useNoteListInteractions({ + searched, + selectedNotePath, + selection, + noteListFilter, + isEntityView, + isChangesView, + onReplaceActiveTab, + onSelectNote, + onOpenDeletedNote, + onOpenInNewWindow, + onAutoTriggerDiff, + onDiscardFile, + openContextMenuForEntry, + onCreateNote, +}: UseNoteListInteractionsParams) { + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) + + const handleKeyboardOpen = useCallback((entry: VaultEntry) => { + if (isDeletedNoteEntry(entry)) { + onOpenDeletedNote?.(entry) + return + } + onReplaceActiveTab(entry) + }, [onOpenDeletedNote, onReplaceActiveTab]) + + const handleKeyboardPrefetch = useCallback((entry: VaultEntry) => { + if (!isDeletedNoteEntry(entry)) prefetchNoteContent(entry.path) + }, []) + + const noteListKeyboard = useNoteListKeyboard({ + items: searched, + selectedNotePath, + onOpen: handleKeyboardOpen, + onPrefetch: handleKeyboardPrefetch, + enabled: !isEntityView, + }) + const multiSelect = useMultiSelect(searched, selectedNotePath) + + useEffect(() => { + multiSelect.clear() + }, [noteListFilter, selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear only when selection/filter changes + + const handleClickNote = useCallback((entry: VaultEntry, event: React.MouseEvent) => { + if (isDeletedNoteEntry(entry)) { + routeNoteClick(entry, event, { + onReplace: () => onOpenDeletedNote?.(entry), + onSelect: () => onOpenDeletedNote?.(entry), + multiSelect, + }) + return + } + + routeNoteClick(entry, event, { + onReplace: onReplaceActiveTab, + onSelect: onSelectNote, + onOpenInNewWindow, + multiSelect, + }) + + if (isChangesView && onAutoTriggerDiff) { + setTimeout(onAutoTriggerDiff, 50) + } + }, [ + isChangesView, + multiSelect, + onAutoTriggerDiff, + onOpenDeletedNote, + onOpenInNewWindow, + onReplaceActiveTab, + onSelectNote, + ]) + + const handleListKeyDown = useCallback((event: React.KeyboardEvent) => { + if (isChangesView && onDiscardFile && event.shiftKey && event.key === 'F10' && noteListKeyboard.highlightedPath) { + const entry = searched.find((candidate) => candidate.path === noteListKeyboard.highlightedPath) + if (!entry) return + + event.preventDefault() + event.stopPropagation() + + const row = document.querySelector(`[data-note-path="${entry.path}"]`) + const rect = row?.getBoundingClientRect() + openContextMenuForEntry(entry, { + x: rect ? rect.left + 24 : 160, + y: rect ? rect.bottom - 8 : 160, + }) + return + } + + noteListKeyboard.handleKeyDown(event) + }, [isChangesView, noteListKeyboard, onDiscardFile, openContextMenuForEntry, searched]) + + const handleCreateNote = useCallback(() => { + onCreateNote(selection.kind === 'sectionGroup' ? selection.type : undefined) + }, [onCreateNote, selection]) + + const toggleGroup = useCallback((label: string) => { + setCollapsedGroups((prev) => toggleSetMember(prev, label)) + }, []) + + return { + collapsedGroups, + handleClickNote, + handleCreateNote, + handleListKeyDown, + multiSelect, + noteListKeyboard, + toggleGroup, + } +} diff --git a/src/test-utils/noteListTestUtils.tsx b/src/test-utils/noteListTestUtils.tsx new file mode 100644 index 00000000..e0bbd7ba --- /dev/null +++ b/src/test-utils/noteListTestUtils.tsx @@ -0,0 +1,232 @@ +import type { ComponentProps } from 'react' +import { render } from '@testing-library/react' +import { vi } from 'vitest' +import { NoteList } from '../components/NoteList' +import type { NoteListFilter } from '../utils/noteListHelpers' +import type { VaultEntry, SidebarSelection } from '../types' + +type NoteListProps = ComponentProps + +export const allSelection: SidebarSelection = { kind: 'filter', filter: 'all' } + +export const mockEntries: VaultEntry[] = [ + { + path: '/Users/luca/Laputa/project/26q1-laputa-app.md', + filename: '26q1-laputa-app.md', + title: 'Build Laputa App', + isA: 'Project', + aliases: [], + belongsTo: [], + relatedTo: ['[[topic/software-development]]'], + status: 'Active', + owner: 'Luca', + cadence: null, + archived: false, + modifiedAt: 1700000000, + createdAt: null, + fileSize: 1024, + snippet: 'Build a personal knowledge management app.', + wordCount: 0, + relationships: { + 'Related to': ['[[topic/software-development]]'], + }, + icon: null, + color: null, + order: null, + template: null, + sort: null, + outgoingLinks: [], + properties: {}, + }, + { + path: '/Users/luca/Laputa/note/facebook-ads-strategy.md', + filename: 'facebook-ads-strategy.md', + title: 'Facebook Ads Strategy', + isA: 'Note', + aliases: [], + belongsTo: ['[[project/26q1-laputa-app]]'], + relatedTo: ['[[topic/growth]]'], + status: null, + owner: null, + cadence: null, + archived: false, + modifiedAt: 1700000000, + createdAt: null, + fileSize: 847, + snippet: 'Lookalike audiences convert 3x better.', + wordCount: 0, + relationships: { + 'Belongs to': ['[[project/26q1-laputa-app]]'], + 'Related to': ['[[topic/growth]]'], + }, + icon: null, + color: null, + order: null, + template: null, + sort: null, + outgoingLinks: [], + properties: {}, + }, + { + path: '/Users/luca/Laputa/person/matteo-cellini.md', + filename: 'matteo-cellini.md', + title: 'Matteo Cellini', + isA: 'Person', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + owner: null, + cadence: null, + archived: false, + modifiedAt: 1700000000, + createdAt: null, + fileSize: 320, + snippet: 'Sponsorship manager.', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + template: null, + sort: null, + outgoingLinks: [], + properties: {}, + }, + { + path: '/Users/luca/Laputa/event/2026-02-14-kickoff.md', + filename: '2026-02-14-kickoff.md', + title: 'Kickoff Meeting', + isA: 'Event', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + owner: null, + cadence: null, + archived: false, + modifiedAt: 1700000000, + createdAt: null, + fileSize: 512, + snippet: 'Project kickoff meeting notes.', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + template: null, + sort: null, + outgoingLinks: [], + properties: {}, + }, + { + path: '/Users/luca/Laputa/topic/software-development.md', + filename: 'software-development.md', + title: 'Software Development', + isA: 'Topic', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + owner: null, + cadence: null, + archived: false, + modifiedAt: 1700000000, + createdAt: null, + fileSize: 256, + snippet: 'Frontend, backend, and systems programming.', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + template: null, + sort: null, + outgoingLinks: [], + properties: {}, + }, +] + +export const makeEntry = (overrides: Partial = {}): VaultEntry => ({ + path: '/test.md', + filename: 'test.md', + title: 'Test', + isA: null, + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + archived: false, + modifiedAt: null, + createdAt: null, + fileSize: 100, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, + sort: null, + view: null, + visible: null, + outgoingLinks: [], + properties: {}, + ...overrides, +}) + +export const makeIndexedEntry = (index: number, overrides?: Partial): VaultEntry => + makeEntry({ + path: `/vault/note/note-${index}.md`, + filename: `note-${index}.md`, + title: `Note ${index}`, + isA: 'Note', + modifiedAt: 1700000000 - index * 60, + fileSize: 500, + snippet: `Content of note ${index}`, + ...overrides, + }) + +export const makeTypeDefinition = (title: string, displayProps: string[] = []): VaultEntry => + makeEntry({ + path: `/vault/type/${title.toLowerCase()}.md`, + filename: `${title.toLowerCase()}.md`, + title, + isA: 'Type', + listPropertiesDisplay: displayProps, + }) + +export function createNoteListSpies() { + return { + onSelectNote: vi.fn(), + onReplaceActiveTab: vi.fn(), + onCreateNote: vi.fn(), + onNoteListFilterChange: vi.fn(), + } +} + +export function buildNoteListProps(overrides: Partial = {}) { + const spies = createNoteListSpies() + const props: NoteListProps = { + entries: mockEntries, + selection: allSelection, + selectedNote: null, + noteListFilter: 'open' as NoteListFilter, + onNoteListFilterChange: spies.onNoteListFilterChange, + onSelectNote: spies.onSelectNote, + onReplaceActiveTab: spies.onReplaceActiveTab, + onCreateNote: spies.onCreateNote, + ...overrides, + } + + return { props, ...spies } +} + +export function renderNoteList(overrides: Partial = {}) { + const built = buildNoteListProps(overrides) + return { + ...render(), + ...built, + } +} diff --git a/src/utils/noteListHelpers.test.ts b/src/utils/noteListHelpers.test.ts index b9a765dd..b367d705 100644 --- a/src/utils/noteListHelpers.test.ts +++ b/src/utils/noteListHelpers.test.ts @@ -1,739 +1,104 @@ -import { describe, it, expect, vi, afterEach } from 'vitest' -import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups, getSortComparator, extractSortableProperties, getSortOptionLabel, getDefaultDirection, parseSortConfig, serializeSortConfig, isInboxEntry, filterInboxEntries, filterEntries } from './noteListHelpers' -import type { VaultEntry } from '../types' +import { describe, expect, it } from 'vitest' +import { countAllByFilter, countByFilter, filterEntries } from './noteListHelpers' +import { allSelection, makeEntry, mockEntries } from '../test-utils/noteListTestUtils' -function makeEntry(overrides: Partial = {}): VaultEntry { - return { - path: '/vault/note/test.md', filename: 'test.md', title: 'Test', - isA: 'Note', aliases: [], belongsTo: [], relatedTo: [], - status: null, organized: false, archived: false, - modifiedAt: null, createdAt: null, fileSize: 0, - snippet: '', wordCount: 0, relationships: {}, - icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [], - ...overrides, - } -} - -describe('formatSubtitle', () => { - afterEach(() => { vi.restoreAllMocks() }) - - it('shows date and word count when both available', () => { - const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 342 }) - const result = formatSubtitle(entry) - expect(result).toContain('342 words') - expect(result).toContain('\u00b7') +describe('filterEntries', () => { + it('returns empty for entity selections because entity view uses grouped relationships', () => { + const result = filterEntries(mockEntries, { kind: 'entity', entry: mockEntries[4] }) + expect(result).toHaveLength(0) }) - it('shows "Empty" when word count is 0', () => { - const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 0 }) - const result = formatSubtitle(entry) - expect(result).toContain('Empty') - expect(result).not.toContain('words') - }) - - it('shows only word count when no date available', () => { - const entry = makeEntry({ wordCount: 100 }) - expect(formatSubtitle(entry)).toBe('100 words') - }) - - it('shows only "Empty" when no date and no content', () => { - const entry = makeEntry() - expect(formatSubtitle(entry)).toBe('Empty') - }) - - it('falls back to createdAt when modifiedAt is null', () => { - const entry = makeEntry({ createdAt: 1700000000, wordCount: 50 }) - const result = formatSubtitle(entry) - expect(result).toContain('50 words') - expect(result).toContain('\u00b7') - }) - - it('includes link count when outgoingLinks is non-empty', () => { - const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 200, outgoingLinks: ['a', 'b', 'c'] }) - const result = formatSubtitle(entry) - expect(result).toContain('3 links') - }) - - it('uses singular "link" when exactly one', () => { - const entry = makeEntry({ wordCount: 100, outgoingLinks: ['one'] }) - expect(formatSubtitle(entry)).toContain('1 link') - expect(formatSubtitle(entry)).not.toContain('1 links') - }) - - it('omits link count when outgoingLinks is empty', () => { - const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 50, outgoingLinks: [] }) - expect(formatSubtitle(entry)).not.toContain('link') - }) - - it('formats word count with locale separators for large numbers', () => { - const entry = makeEntry({ wordCount: 1240 }) - const result = formatSubtitle(entry) - expect(result).toMatch(/1,?240 words/) - }) -}) - -describe('formatSearchSubtitle', () => { - afterEach(() => { vi.restoreAllMocks() }) - - it('shows modified date, created date, word count, and links', () => { - const now = Math.floor(Date.now() / 1000) - const entry = makeEntry({ - modifiedAt: now - 3600, - createdAt: now - 86400 * 30, - wordCount: 520, - outgoingLinks: ['a', 'b', 'c', 'd', 'e'], - }) - const result = formatSearchSubtitle(entry) - expect(result).toContain('1h ago') - expect(result).toContain('Created') - expect(result).toContain('520 words') - expect(result).toContain('5 links') - }) - - it('omits created date when same as modified', () => { - const now = Math.floor(Date.now() / 1000) - const entry = makeEntry({ modifiedAt: now, createdAt: now, wordCount: 100 }) - const result = formatSearchSubtitle(entry) - expect(result).not.toContain('Created') - }) - - it('omits created date when createdAt is null', () => { - const now = Math.floor(Date.now() / 1000) - const entry = makeEntry({ modifiedAt: now, createdAt: null, wordCount: 100 }) - const result = formatSearchSubtitle(entry) - expect(result).not.toContain('Created') - }) - - it('shows "Empty" for zero word count', () => { - const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 0 }) - expect(formatSearchSubtitle(entry)).toContain('Empty') - }) - - it('omits link count when no outgoing links', () => { - const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 50, outgoingLinks: [] }) - expect(formatSearchSubtitle(entry)).not.toContain('link') - }) - - it('falls back to createdAt when modifiedAt is null', () => { - const entry = makeEntry({ createdAt: 1700000000, wordCount: 200, outgoingLinks: ['a'] }) - const result = formatSearchSubtitle(entry) - expect(result).toContain('200 words') - expect(result).toContain('1 link') - expect(result).not.toContain('Created') - }) -}) - -describe('relativeDate', () => { - it('returns empty string for null', () => { - expect(relativeDate(null)).toBe('') - }) - - it('returns "just now" for recent timestamps', () => { - const now = Math.floor(Date.now() / 1000) - expect(relativeDate(now)).toBe('just now') - }) - - it('returns minutes ago for timestamps within an hour', () => { - const fiveMinAgo = Math.floor(Date.now() / 1000) - 300 - expect(relativeDate(fiveMinAgo)).toBe('5m ago') - }) - - it('returns hours ago for timestamps within a day', () => { - const twoHoursAgo = Math.floor(Date.now() / 1000) - 7200 - expect(relativeDate(twoHoursAgo)).toBe('2h ago') - }) - - it('returns days ago for timestamps within a week', () => { - const threeDaysAgo = Math.floor(Date.now() / 1000) - 86400 * 3 - expect(relativeDate(threeDaysAgo)).toBe('3d ago') - }) - - it('returns formatted date for older timestamps', () => { - // Use a fixed timestamp: Nov 14, 2023 - expect(relativeDate(1700000000)).toMatch(/Nov 14/) - }) -}) - -// --- buildRelationshipGroups tests --- - -function makeVault(overrides: Partial[]): VaultEntry[] { - return overrides.map((o, i) => makeEntry({ - path: `/Laputa/note/entry-${i}.md`, - filename: `entry-${i}.md`, - title: `Entry ${i}`, - modifiedAt: 1700000000 - i * 100, - ...o, - })) -} - -describe('buildRelationshipGroups', () => { - it('shows direct relationship properties from entity.relationships', () => { - const building = makeEntry({ path: '/Laputa/responsibility/building.md', filename: 'building.md', title: 'Building' }) - const entity = makeEntry({ - path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha', - relationships: { 'Belongs to': ['[[responsibility/building]]'] }, - }) - const groups = buildRelationshipGroups(entity, [entity, building]) - const labels = groups.map((g) => g.label) - expect(labels).toContain('Belongs to') - expect(groups.find((g) => g.label === 'Belongs to')!.entries[0].title).toBe('Building') - }) - - it('shows all direct relationships even when entries also appear as Children', () => { - // The entity has "Notes" pointing at note1 and note2. - // Those notes also have belongsTo pointing back at the entity. - // Previously, Children consumed them via the seen set, suppressing "Notes". - const note1 = makeEntry({ path: '/Laputa/note/note1.md', filename: 'note1.md', title: 'Note 1', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 }) - const note2 = makeEntry({ path: '/Laputa/note/note2.md', filename: 'note2.md', title: 'Note 2', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 }) - const entity = makeEntry({ - path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha', - relationships: { Notes: ['[[note/note1]]', '[[note/note2]]'] }, - }) - const groups = buildRelationshipGroups(entity, [entity, note1, note2]) - const labels = groups.map((g) => g.label) - expect(labels).toContain('Notes') - expect(groups.find((g) => g.label === 'Notes')!.entries).toHaveLength(2) - }) - - it('shows all 5+ direct relationship properties', () => { - const entries = makeVault([ - { path: '/Laputa/area/eng.md', filename: 'eng.md', title: 'Engineering' }, - { path: '/Laputa/person/alice.md', filename: 'alice.md', title: 'Alice' }, - { path: '/Laputa/note/n1.md', filename: 'n1.md', title: 'Note 1' }, - { path: '/Laputa/note/n2.md', filename: 'n2.md', title: 'Note 2' }, - { path: '/Laputa/topic/rust.md', filename: 'rust.md', title: 'Rust' }, - { path: '/Laputa/project/sibling.md', filename: 'sibling.md', title: 'Sibling' }, - ]) - const entity = makeEntry({ - path: '/Laputa/project/big.md', filename: 'big.md', title: 'Big Project', - relationships: { - 'Belongs to': ['[[area/eng]]'], - Notes: ['[[note/n1]]', '[[note/n2]]'], - Owner: ['[[person/alice]]'], - 'Related to': ['[[project/sibling]]'], - Topics: ['[[topic/rust]]'], - }, - }) - const groups = buildRelationshipGroups(entity, [entity, ...entries]) - const labels = groups.map((g) => g.label) - expect(labels).toContain('Belongs to') - expect(labels).toContain('Notes') - expect(labels).toContain('Owner') - expect(labels).toContain('Related to') - expect(labels).toContain('Topics') - }) - - it('shows Children group for reverse belongsTo entries not covered by direct rels', () => { - const child = makeEntry({ path: '/Laputa/note/child.md', filename: 'child.md', title: 'Child', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 }) - const entity = makeEntry({ - path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha', - relationships: {}, - }) - const groups = buildRelationshipGroups(entity, [entity, child]) - const labels = groups.map((g) => g.label) - expect(labels).toContain('Children') - expect(groups.find((g) => g.label === 'Children')!.entries[0].title).toBe('Child') - }) - - it('excludes Type key from relationship groups', () => { - const entity = makeEntry({ - path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha', - relationships: { Type: ['[[project]]'] }, - }) - const groups = buildRelationshipGroups(entity, [entity]) - const labels = groups.map((g) => g.label) - expect(labels).not.toContain('Type') - }) - - it('returns empty groups for entity with no relationships', () => { - const entity = makeEntry({ path: '/Laputa/note/solo.md', filename: 'solo.md', title: 'Solo', relationships: {} }) - const groups = buildRelationshipGroups(entity, [entity]) - expect(groups).toHaveLength(0) - }) - - it('shows single-item and multi-item relationship properties', () => { - const alice = makeEntry({ path: '/Laputa/person/alice.md', filename: 'alice.md', title: 'Alice' }) - const n1 = makeEntry({ path: '/Laputa/note/n1.md', filename: 'n1.md', title: 'Note 1' }) - const n2 = makeEntry({ path: '/Laputa/note/n2.md', filename: 'n2.md', title: 'Note 2' }) - const entity = makeEntry({ - path: '/Laputa/project/x.md', filename: 'x.md', title: 'X', - relationships: { - Owner: ['[[person/alice]]'], - Notes: ['[[note/n1]]', '[[note/n2]]'], - }, - }) - const groups = buildRelationshipGroups(entity, [entity, alice, n1, n2]) - expect(groups.find((g) => g.label === 'Owner')!.entries).toHaveLength(1) - expect(groups.find((g) => g.label === 'Notes')!.entries).toHaveLength(2) - }) - - it('shows Instances group for Type entities', () => { - const instance1 = makeEntry({ path: '/Laputa/project/a.md', filename: 'a.md', title: 'Project A', isA: 'Project', modifiedAt: 1700000000 }) - const instance2 = makeEntry({ path: '/Laputa/project/b.md', filename: 'b.md', title: 'Project B', isA: 'Project', modifiedAt: 1700000000 }) - const typeEntity = makeEntry({ - path: '/Laputa/project.md', filename: 'project.md', title: 'Project', - isA: 'Type', relationships: {}, - }) - const groups = buildRelationshipGroups(typeEntity, [typeEntity, instance1, instance2]) - const labels = groups.map((g) => g.label) - expect(labels).toContain('Instances') - expect(groups.find((g) => g.label === 'Instances')!.entries).toHaveLength(2) - }) - - it('direct relationships are sorted alphabetically', () => { - const a = makeEntry({ path: '/Laputa/note/a.md', filename: 'a.md', title: 'A' }) - const b = makeEntry({ path: '/Laputa/note/b.md', filename: 'b.md', title: 'B' }) - const c = makeEntry({ path: '/Laputa/note/c.md', filename: 'c.md', title: 'C' }) - const entity = makeEntry({ - path: '/Laputa/project/x.md', filename: 'x.md', title: 'X', - relationships: { - Zebra: ['[[note/c]]'], - Alpha: ['[[note/a]]'], - Middle: ['[[note/b]]'], - }, - }) - const groups = buildRelationshipGroups(entity, [entity, a, b, c]) - const directLabels = groups.map((g) => g.label) - expect(directLabels.indexOf('Alpha')).toBeLessThan(directLabels.indexOf('Middle')) - expect(directLabels.indexOf('Middle')).toBeLessThan(directLabels.indexOf('Zebra')) - }) - - it('Referenced By shows entries whose relatedTo matches the entity', () => { - const referer = makeEntry({ - path: '/Laputa/project/ref.md', filename: 'ref.md', title: 'Referer', - relatedTo: ['[[project/alpha]]'], modifiedAt: 1700000000, - }) - const entity = makeEntry({ - path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha', - relationships: {}, - }) - const groups = buildRelationshipGroups(entity, [entity, referer]) - expect(groups.find((g) => g.label === 'Referenced By')!.entries[0].title).toBe('Referer') - }) - - it('Backlinks shows entries that mention the entity via outgoingLinks', () => { - const linker = makeEntry({ - path: '/Laputa/note/linker.md', filename: 'linker.md', title: 'Linker', modifiedAt: 1700000000, - outgoingLinks: ['Alpha'], - }) - const entity = makeEntry({ - path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha', - relationships: {}, - }) - const groups = buildRelationshipGroups(entity, [entity, linker]) - expect(groups.find((g) => g.label === 'Backlinks')!.entries[0].title).toBe('Linker') - }) - - it('resolves all entries in a large Notes relationship (regression: No Code)', () => { - // Simulates the No Code topic note with 32 Notes, 2 Referred by Data, 1 Belongs to - const noteRefs = [ - '8020', 'airdev-build-hub', 'airdev-leader', 'budibase', 'bullet-launch', - 'canvas', 'chameleon', 'felt', 'flutterflow', 'framer-ai', - 'jumpstart', 'mailparser', 'make', 'michele-sampieri', 'n8n-a', - 'n8n-ai', 'nocodey', 'outseta', 'lemon-squeezy', 'retool', - 'rise-no-code', 'scene', 'scrapingbee', 'softr', 'superblocks', - 'superwall', 'tails', 'supabase', 'varun-anand', 'xano', - 'directus', 'framer-design', - ] - const noteEntries = noteRefs.map((slug, i) => makeEntry({ - path: `/Laputa/${slug}.md`, filename: `${slug}.md`, title: `Title ${slug}`, - modifiedAt: 1700000000 - i * 100, - })) - const engineering = makeEntry({ - path: '/Laputa/engineering.md', filename: 'engineering.md', title: 'Engineering', - modifiedAt: 1700000000, - }) - const entity = makeEntry({ - path: '/Laputa/no-code.md', filename: 'no-code.md', title: 'No Code', - isA: 'Topic', - relationships: { - 'Belongs to': ['[[engineering|Engineering]]'], - Notes: noteRefs.map((slug) => `[[${slug}|Title ${slug}]]`), - 'Referred by Data': ['[[michele-sampieri|Michele Sampieri]]', '[[varun-anand|Varun Anand]]'], - }, - }) - const allEntries = [entity, engineering, ...noteEntries] - const groups = buildRelationshipGroups(entity, allEntries) - - const belongsGroup = groups.find((g) => g.label === 'Belongs to') - expect(belongsGroup).toBeDefined() - expect(belongsGroup!.entries).toHaveLength(1) - - const notesGroup = groups.find((g) => g.label === 'Notes') - expect(notesGroup).toBeDefined() - expect(notesGroup!.entries).toHaveLength(32) - - // michele-sampieri and varun-anand already consumed by Notes → Referred by Data has 0 new - const referredGroup = groups.find((g) => g.label === 'Referred by Data') - expect(referredGroup).toBeUndefined() - }) - - it('resolves refs by title when filename differs from wikilink target', () => { - // Wikilink [[Airdev]] but filename is airdev-tool.md, title is "Airdev" - const airdev = makeEntry({ - path: '/vault/airdev-tool.md', filename: 'airdev-tool.md', title: 'Airdev', - }) - const budibase = makeEntry({ - path: '/vault/budibase-app.md', filename: 'budibase-app.md', title: 'Budibase', - aliases: ['Budi'], - }) - const entity = makeEntry({ - path: '/vault/no-code.md', filename: 'no-code.md', title: 'No Code', - relationships: { Notes: ['[[Airdev]]', '[[Budi]]'] }, - }) - const groups = buildRelationshipGroups(entity, [entity, airdev, budibase]) - const notesGroup = groups.find((g) => g.label === 'Notes') - expect(notesGroup).toBeDefined() - expect(notesGroup!.entries).toHaveLength(2) - expect(notesGroup!.entries.map(e => e.title).sort()).toEqual(['Airdev', 'Budibase']) - }) - - it('resolves Children via title match when belongsTo target differs from filename', () => { - // Child's belongsTo uses [[No Code]] but entity filename is no-code-topic.md - const child = makeEntry({ - path: '/vault/tool.md', filename: 'tool.md', title: 'Tool', - belongsTo: ['[[No Code]]'], modifiedAt: 1700000000, - }) - const entity = makeEntry({ - path: '/vault/no-code-topic.md', filename: 'no-code-topic.md', title: 'No Code', - relationships: {}, - }) - const groups = buildRelationshipGroups(entity, [entity, child]) - expect(groups.find((g) => g.label === 'Children')!.entries).toHaveLength(1) - }) -}) - -describe('getSortComparator — custom properties', () => { - it('sorts by string property alphabetically', () => { - const a = makeEntry({ title: 'A', properties: { Priority: 'High' } }) - const b = makeEntry({ title: 'B', properties: { Priority: 'Low' } }) - const c = makeEntry({ title: 'C', properties: { Priority: 'Medium' } }) - const sorted = [a, b, c].sort(getSortComparator('property:Priority')) - expect(sorted.map((e) => e.title)).toEqual(['A', 'B', 'C']) - }) - - it('sorts by numeric property', () => { - const a = makeEntry({ title: 'A', properties: { Rating: 3 } }) - const b = makeEntry({ title: 'B', properties: { Rating: 5 } }) - const c = makeEntry({ title: 'C', properties: { Rating: 1 } }) - const sorted = [a, b, c].sort(getSortComparator('property:Rating')) - expect(sorted.map((e) => e.title)).toEqual(['C', 'A', 'B']) - }) - - it('sorts by date property chronologically', () => { - const a = makeEntry({ title: 'A', properties: { 'Due date': '2026-06-15' } }) - const b = makeEntry({ title: 'B', properties: { 'Due date': '2026-01-01' } }) - const c = makeEntry({ title: 'C', properties: { 'Due date': '2026-03-10' } }) - const sorted = [a, b, c].sort(getSortComparator('property:Due date')) - expect(sorted.map((e) => e.title)).toEqual(['B', 'C', 'A']) - }) - - it('pushes null values to end regardless of direction', () => { - const a = makeEntry({ title: 'A', properties: { Priority: 'High' } }) - const b = makeEntry({ title: 'B', properties: {} }) - const c = makeEntry({ title: 'C', properties: { Priority: 'Low' } }) - const ascSorted = [a, b, c].sort(getSortComparator('property:Priority', 'asc')) - expect(ascSorted.map((e) => e.title)).toEqual(['A', 'C', 'B']) - const descSorted = [a, b, c].sort(getSortComparator('property:Priority', 'desc')) - expect(descSorted.map((e) => e.title)).toEqual(['C', 'A', 'B']) - }) - - it('sorts descending when direction is desc', () => { - const a = makeEntry({ title: 'A', properties: { Rating: 3 } }) - const b = makeEntry({ title: 'B', properties: { Rating: 5 } }) - const c = makeEntry({ title: 'C', properties: { Rating: 1 } }) - const sorted = [a, b, c].sort(getSortComparator('property:Rating', 'desc')) - expect(sorted.map((e) => e.title)).toEqual(['B', 'A', 'C']) - }) - - it('handles entries with no properties field gracefully', () => { - const a = makeEntry({ title: 'A', properties: { Priority: 'High' } }) - const b = makeEntry({ title: 'B', properties: {} }) - const sorted = [a, b].sort(getSortComparator('property:Priority')) - expect(sorted.map((e) => e.title)).toEqual(['A', 'B']) - }) - - it('handles boolean property sorting', () => { - const a = makeEntry({ title: 'A', properties: { Reviewed: true } }) - const b = makeEntry({ title: 'B', properties: { Reviewed: false } }) - const sorted = [a, b].sort(getSortComparator('property:Reviewed')) - expect(sorted.map((e) => e.title)).toEqual(['B', 'A']) - }) -}) - -describe('extractSortableProperties', () => { - it('returns union of all property keys across entries', () => { + it('filters section groups by open sub-filter', () => { const entries = [ - makeEntry({ properties: { Priority: 'High', Rating: 5 } }), - makeEntry({ properties: { Priority: 'Low', Company: 'Acme' } }), + makeEntry({ path: '/1.md', title: 'Active', isA: 'Project' }), + makeEntry({ path: '/2.md', title: 'Archived', isA: 'Project', archived: true }), + makeEntry({ path: '/4.md', title: 'Other', isA: 'Note' }), ] - expect(extractSortableProperties(entries)).toEqual(['Company', 'Priority', 'Rating']) + + const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' }, 'open') + expect(result.map((entry) => entry.title)).toEqual(['Active']) }) - it('returns empty array for entries without properties', () => { - const entries = [makeEntry(), makeEntry()] - expect(extractSortableProperties(entries)).toEqual([]) - }) - - it('returns empty array for empty entry list', () => { - expect(extractSortableProperties([])).toEqual([]) - }) - - it('deduplicates property keys', () => { + it('filters section groups by archived sub-filter', () => { const entries = [ - makeEntry({ properties: { Priority: 'High' } }), - makeEntry({ properties: { Priority: 'Low' } }), + makeEntry({ path: '/1.md', title: 'Active', isA: 'Project' }), + makeEntry({ path: '/2.md', title: 'Archived', isA: 'Project', archived: true }), + makeEntry({ path: '/4.md', title: 'Other', isA: 'Note' }), ] - expect(extractSortableProperties(entries)).toEqual(['Priority']) - }) -}) -describe('getSortOptionLabel', () => { - it('returns label for built-in options', () => { - expect(getSortOptionLabel('modified')).toBe('Modified') - expect(getSortOptionLabel('title')).toBe('Title') + const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' }, 'archived') + expect(result.map((entry) => entry.title)).toEqual(['Archived']) }) - it('returns property key for custom properties', () => { - expect(getSortOptionLabel('property:Priority')).toBe('Priority') - expect(getSortOptionLabel('property:Due date')).toBe('Due date') - }) -}) - -describe('getDefaultDirection', () => { - it('returns desc for time-based sorts', () => { - expect(getDefaultDirection('modified')).toBe('desc') - expect(getDefaultDirection('created')).toBe('desc') - }) - - it('returns asc for other sorts', () => { - expect(getDefaultDirection('title')).toBe('asc') - expect(getDefaultDirection('status')).toBe('asc') - expect(getDefaultDirection('property:Priority')).toBe('asc') - }) -}) - -describe('serializeSortConfig', () => { - it('serializes a built-in sort config', () => { - expect(serializeSortConfig({ option: 'modified', direction: 'desc' })).toBe('modified:desc') - expect(serializeSortConfig({ option: 'title', direction: 'asc' })).toBe('title:asc') - }) - - it('serializes a custom property sort config', () => { - expect(serializeSortConfig({ option: 'property:Priority', direction: 'asc' })).toBe('property:Priority:asc') - }) -}) - -describe('parseSortConfig', () => { - it('parses a built-in sort config', () => { - expect(parseSortConfig('modified:desc')).toEqual({ option: 'modified', direction: 'desc' }) - expect(parseSortConfig('title:asc')).toEqual({ option: 'title', direction: 'asc' }) - }) - - it('parses a custom property sort config with colon in option', () => { - expect(parseSortConfig('property:Priority:asc')).toEqual({ option: 'property:Priority', direction: 'asc' }) - }) - - it('returns null for null/undefined input', () => { - expect(parseSortConfig(null)).toBeNull() - expect(parseSortConfig(undefined)).toBeNull() - }) - - it('returns null for empty string', () => { - expect(parseSortConfig('')).toBeNull() - }) - - it('returns null for invalid direction', () => { - expect(parseSortConfig('modified:up')).toBeNull() - }) - - it('returns null for string without colon', () => { - expect(parseSortConfig('modified')).toBeNull() - }) - - it('roundtrips correctly', () => { - const configs = [ - { option: 'modified' as const, direction: 'desc' as const }, - { option: 'title' as const, direction: 'asc' as const }, - { option: 'property:Due date' as const, direction: 'desc' as const }, - ] - for (const config of configs) { - expect(parseSortConfig(serializeSortConfig(config))).toEqual(config) - } - }) -}) - -// --- Inbox --- - -describe('isInboxEntry', () => { - it('returns true for a note that is not organized', () => { - const note = makeEntry({ organized: false }) - expect(isInboxEntry(note)).toBe(true) - }) - - it('returns false for an organized note', () => { - const note = makeEntry({ organized: true }) - expect(isInboxEntry(note)).toBe(false) - }) - - it('returns false for an archived note', () => { - const note = makeEntry({ archived: true }) - expect(isInboxEntry(note)).toBe(false) - }) - - it('excludes Type entries from inbox', () => { - const note = makeEntry({ isA: 'Type' }) - expect(isInboxEntry(note)).toBe(false) - }) - - it('returns true for a note without _organized field (defaults to false)', () => { - const note = makeEntry({}) - expect(isInboxEntry(note)).toBe(true) - }) -}) - -describe('filterInboxEntries', () => { - const now = Math.floor(Date.now() / 1000) - const DAY = 86400 - - const allEntries = [ - makeEntry({ path: '/vault/a.md', filename: 'a.md', title: 'A', createdAt: now - 2 * DAY }), - makeEntry({ path: '/vault/b.md', filename: 'b.md', title: 'B', createdAt: now - 15 * DAY }), - makeEntry({ path: '/vault/c.md', filename: 'c.md', title: 'C', createdAt: now - 60 * DAY }), - makeEntry({ path: '/vault/d.md', filename: 'd.md', title: 'D', createdAt: now - 120 * DAY }), - makeEntry({ path: '/vault/org.md', filename: 'org.md', title: 'Organized', createdAt: now - 1 * DAY, organized: true }), - ] - - it('filters by "week" period (last 7 days)', () => { - const result = filterInboxEntries(allEntries, 'week') - expect(result.map(e => e.title)).toEqual(['A']) - }) - - it('filters by "month" period (last 30 days)', () => { - const result = filterInboxEntries(allEntries, 'month') - expect(result.map(e => e.title)).toEqual(['A', 'B']) - }) - - it('filters by "quarter" period (last 90 days)', () => { - const result = filterInboxEntries(allEntries, 'quarter') - expect(result.map(e => e.title)).toEqual(['A', 'B', 'C']) - }) - - it('filters by "all" period', () => { - const result = filterInboxEntries(allEntries, 'all') - expect(result.map(e => e.title)).toEqual(['A', 'B', 'C', 'D']) - }) - - it('sorts by createdAt descending', () => { - const result = filterInboxEntries(allEntries, 'all') - for (let i = 1; i < result.length; i++) { - expect((result[i - 1].createdAt ?? 0)).toBeGreaterThanOrEqual((result[i].createdAt ?? 0)) - } - }) - - it('excludes organized notes', () => { - const result = filterInboxEntries(allEntries, 'all') - expect(result.find(e => e.title === 'Organized')).toBeUndefined() - }) - - it('returns empty array when all notes are organized', () => { - const organized = [ - makeEntry({ path: '/vault/x.md', title: 'X', organized: true }), - makeEntry({ path: '/vault/y.md', title: 'Y', organized: true }), - ] - const result = filterInboxEntries(organized, 'all') - expect(result).toEqual([]) - }) -}) - -describe('filterEntries — folder selection', () => { - const entries = [ - makeEntry({ path: '/vault/projects/laputa/note1.md', title: 'Note 1' }), - makeEntry({ path: '/vault/projects/laputa/note2.md', title: 'Note 2' }), - makeEntry({ path: '/vault/projects/portfolio/site.md', title: 'Site' }), - makeEntry({ path: '/vault/areas/health.md', title: 'Health' }), - makeEntry({ path: '/vault/root-note.md', title: 'Root' }), - ] - - it('filters entries by folder path', () => { - const result = filterEntries(entries, { kind: 'folder', path: 'projects/laputa' }) - expect(result.map(e => e.title)).toEqual(['Note 1', 'Note 2']) - }) - - it('does not include notes from sibling folders', () => { - const result = filterEntries(entries, { kind: 'folder', path: 'projects/laputa' }) - expect(result.find(e => e.title === 'Site')).toBeUndefined() - }) - - it('filters recursively — includes notes from subfolders', () => { - const result = filterEntries(entries, { kind: 'folder', path: 'projects' }) - expect(result.map(e => e.title)).toEqual(['Note 1', 'Note 2', 'Site']) - }) - - it('filters direct children', () => { - const result = filterEntries(entries, { kind: 'folder', path: 'areas' }) - expect(result.map(e => e.title)).toEqual(['Health']) - }) - - it('returns empty for root when entries are in subfolders', () => { - const result = filterEntries(entries, { kind: 'folder', path: 'nonexistent' }) - expect(result).toEqual([]) - }) - - it('excludes archived entries by default', () => { - const withArchived = [ - ...entries, - makeEntry({ path: '/vault/projects/laputa/archived.md', title: 'Archived', archived: true }), - ] - const result = filterEntries(withArchived, { kind: 'folder', path: 'projects/laputa' }) - expect(result.find(e => e.title === 'Archived')).toBeUndefined() - }) -}) - -describe('filterEntries — fileKind filtering', () => { - const entries = [ - makeEntry({ path: '/vault/note.md', title: 'Note', fileKind: 'markdown' }), - makeEntry({ path: '/vault/config.yml', title: 'config.yml', fileKind: 'text' }), - makeEntry({ path: '/vault/photo.png', title: 'photo.png', fileKind: 'binary' }), - makeEntry({ path: '/vault/projects/readme.md', title: 'README', fileKind: 'markdown' }), - makeEntry({ path: '/vault/projects/data.json', title: 'data.json', fileKind: 'text' }), - makeEntry({ path: '/vault/projects/image.jpg', title: 'image.jpg', fileKind: 'binary' }), - ] - - it('all-notes filter only shows markdown files', () => { - const result = filterEntries(entries, { kind: 'filter', filter: 'all' }) - expect(result.map(e => e.title)).toEqual(['Note', 'README']) - }) - - it('folder view shows all file kinds including binary', () => { - const result = filterEntries(entries, { kind: 'folder', path: 'projects' }) - expect(result.map(e => e.title)).toEqual(['README', 'data.json', 'image.jpg']) - }) - - it('sectionGroup filter only shows markdown files', () => { - const typed = entries.map(e => ({ ...e, isA: 'Note' })) - const result = filterEntries(typed, { kind: 'sectionGroup', type: 'Note' }) - expect(result.map(e => e.title)).toEqual(['Note', 'README']) - }) - - it('entries without fileKind are treated as markdown', () => { - const legacy = [ - makeEntry({ path: '/vault/old.md', title: 'Old' }), - ] - const result = filterEntries(legacy, { kind: 'filter', filter: 'all' }) - expect(result.map(e => e.title)).toEqual(['Old']) - }) -}) - -describe('filterInboxEntries — excludes non-markdown files', () => { - it('only shows markdown files in inbox', () => { - const now = Math.floor(Date.now() / 1000) + it('defaults section groups to active notes when no sub-filter is provided', () => { const entries = [ - makeEntry({ path: '/vault/note.md', title: 'Note', fileKind: 'markdown', createdAt: now }), - makeEntry({ path: '/vault/config.yml', title: 'config.yml', fileKind: 'text', createdAt: now }), - makeEntry({ path: '/vault/photo.png', title: 'photo.png', fileKind: 'binary', createdAt: now }), + makeEntry({ path: '/1.md', title: 'Active', isA: 'Project' }), + makeEntry({ path: '/2.md', title: 'Archived', isA: 'Project', archived: true }), + makeEntry({ path: '/4.md', title: 'Other', isA: 'Note' }), ] - const result = filterInboxEntries(entries, 'all') - expect(result.map(e => e.title)).toEqual(['Note']) + + const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' }) + expect(result.map((entry) => entry.title)).toEqual(['Active']) + }) + + it('filters all notes by open sub-filter', () => { + const entries = [ + makeEntry({ path: '/1.md', title: 'Active', isA: 'Project' }), + makeEntry({ path: '/2.md', title: 'Archived', isA: 'Project', archived: true }), + makeEntry({ path: '/4.md', title: 'Other', isA: 'Note' }), + ] + + const result = filterEntries(entries, allSelection, 'open') + expect(result.map((entry) => entry.title)).toEqual(['Active', 'Other']) + }) + + it('filters all notes by archived sub-filter', () => { + const entries = [ + makeEntry({ path: '/1.md', title: 'Active', isA: 'Project' }), + makeEntry({ path: '/2.md', title: 'Archived', isA: 'Project', archived: true }), + makeEntry({ path: '/4.md', title: 'Other', isA: 'Note' }), + ] + + const result = filterEntries(entries, allSelection, 'archived') + expect(result.map((entry) => entry.title)).toEqual(['Archived']) + }) +}) + +describe('countByFilter', () => { + it('counts open and archived entries for a type', () => { + const entries = [ + makeEntry({ path: '/1.md', isA: 'Project' }), + makeEntry({ path: '/2.md', isA: 'Project', archived: true }), + makeEntry({ path: '/3.md', isA: 'Project' }), + makeEntry({ path: '/4.md', isA: 'Note' }), + ] + + expect(countByFilter(entries, 'Project')).toEqual({ open: 2, archived: 1 }) + }) + + it('returns zeros when a type has no matching entries', () => { + expect(countByFilter([], 'Project')).toEqual({ open: 0, archived: 0 }) + }) +}) + +describe('countAllByFilter', () => { + it('counts all entries by archive status', () => { + const entries = [ + makeEntry({ path: '/1.md', isA: 'Project' }), + makeEntry({ path: '/2.md', isA: 'Note' }), + makeEntry({ path: '/3.md', isA: 'Project', archived: true }), + ] + + expect(countAllByFilter(entries)).toEqual({ open: 2, archived: 1 }) + }) + + it('excludes non-markdown files from counts', () => { + const entries = [ + makeEntry({ path: '/1.md', isA: 'Note', fileKind: 'markdown' }), + makeEntry({ path: '/2.yml', isA: undefined, fileKind: 'text' }), + makeEntry({ path: '/3.png', isA: undefined, fileKind: 'binary' }), + ] + + expect(countAllByFilter(entries)).toEqual({ open: 1, archived: 0 }) }) })