Merge pull request #22 from refactoringhq/task/click-opens-new-tab
feat: click opens note in current tab, Cmd+Click opens new tab
This commit is contained in:
@@ -46,11 +46,11 @@ function TrashDateLine({ entry }: { entry: VaultEntry }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function NoteItem({ entry, isSelected, typeEntryMap, onSelectNote }: {
|
||||
export function NoteItem({ entry, isSelected, typeEntryMap, onClickNote }: {
|
||||
entry: VaultEntry
|
||||
isSelected: boolean
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
|
||||
}) {
|
||||
const te = typeEntryMap[entry.isA ?? '']
|
||||
const typeColor = getTypeColor(entry.isA ?? 'Note', te?.color)
|
||||
@@ -68,7 +68,7 @@ export function NoteItem({ entry, isSelected, typeEntryMap, onSelectNote }: {
|
||||
padding: isSelected ? '14px 16px 14px 13px' : '14px 16px',
|
||||
...(isSelected && { borderLeftColor: typeColor, backgroundColor: typeLightColor }),
|
||||
}}
|
||||
onClick={() => onSelectNote(entry)}
|
||||
onClick={(e: React.MouseEvent) => onClickNote(entry, e)}
|
||||
>
|
||||
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
|
||||
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { VaultEntry, SidebarSelection } from '../types'
|
||||
|
||||
const allSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
const noopSelect = vi.fn()
|
||||
const noopReplace = vi.fn()
|
||||
|
||||
const mockEntries: VaultEntry[] = [
|
||||
{
|
||||
@@ -132,38 +133,38 @@ const mockEntries: VaultEntry[] = [
|
||||
|
||||
describe('NoteList', () => {
|
||||
it('shows empty state when no entries', () => {
|
||||
render(<NoteList entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('No notes found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all entries with All Notes filter', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
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(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Person' }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Person' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by Events (section group)', () => {
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Event' }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Event' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by section group type', () => {
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows entity pinned at top with grouped children', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Entity title appears in header and pinned card
|
||||
expect(screen.getAllByText('Build Laputa App').length).toBeGreaterThanOrEqual(1)
|
||||
@@ -178,7 +179,7 @@ describe('NoteList', () => {
|
||||
|
||||
it('filters by topic (relatedTo references)', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'topic', entry: mockEntries[4] }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'topic', entry: mockEntries[4] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Build Laputa App has relatedTo: [[topic/software-development]]
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
@@ -186,7 +187,7 @@ describe('NoteList', () => {
|
||||
})
|
||||
|
||||
it('shows search input when search icon is clicked', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
// Search is hidden by default
|
||||
expect(screen.queryByPlaceholderText('Search notes...')).not.toBeInTheDocument()
|
||||
// Click search icon to show it
|
||||
@@ -195,7 +196,7 @@ describe('NoteList', () => {
|
||||
})
|
||||
|
||||
it('filters by search query (case-insensitive substring)', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
// Open search
|
||||
fireEvent.click(screen.getByTitle('Search notes'))
|
||||
const input = screen.getByPlaceholderText('Search notes...')
|
||||
@@ -210,21 +211,21 @@ describe('NoteList', () => {
|
||||
{ ...mockEntries[1], modifiedAt: 3000, title: 'Newest', path: '/p2' },
|
||||
{ ...mockEntries[2], modifiedAt: 2000, title: 'Middle', path: '/p3' },
|
||||
]
|
||||
render(<NoteList entries={entriesWithDifferentDates} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={entriesWithDifferentDates} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
// 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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByTitle('Search notes')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('Create new note')).toBeInTheDocument()
|
||||
})
|
||||
@@ -234,7 +235,7 @@ describe('NoteList', () => {
|
||||
[mockEntries[2].path]: 'Met with [[project/26q1-laputa-app]] team.',
|
||||
}
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} allContent={allContent} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={allContent} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Backlinks')).toBeInTheDocument()
|
||||
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
||||
@@ -242,7 +243,7 @@ describe('NoteList', () => {
|
||||
|
||||
it('context view collapses and expands groups', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Children group is expanded by default
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
@@ -257,13 +258,68 @@ describe('NoteList', () => {
|
||||
|
||||
it('context view shows prominent card with entity snippet', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Snippet appears in the prominent card
|
||||
expect(screen.getByText('Build a personal knowledge management app.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NoteList click behavior', () => {
|
||||
beforeEach(() => {
|
||||
noopSelect.mockClear()
|
||||
noopReplace.mockClear()
|
||||
})
|
||||
|
||||
it('regular click calls onReplaceActiveTab (opens in current tab)', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(noopReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Ctrl+Click calls onSelectNote (opens in new tab, Windows/Linux)', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
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(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByText('Build a personal knowledge management app.'), { metaKey: true })
|
||||
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(noopReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('regular click on entity pinned card calls onReplaceActiveTab', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByText('Build a personal knowledge management app.'))
|
||||
expect(noopReplace).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(noopSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('click on child note in entity view calls onReplaceActiveTab', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'))
|
||||
expect(noopReplace).toHaveBeenCalledWith(mockEntries[1])
|
||||
expect(noopSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortComparator', () => {
|
||||
const makeEntry = (overrides: Partial<VaultEntry>): VaultEntry => ({
|
||||
path: '/test.md',
|
||||
@@ -402,21 +458,21 @@ describe('NoteList sort controls', () => {
|
||||
|
||||
it('shows sort button in note list header for flat view', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('sort-button-__list__')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows sort dropdown per relationship subsection in entity view', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('sort-button-Children')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens sort menu on click and shows all options', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument()
|
||||
@@ -433,7 +489,7 @@ describe('NoteList sort controls', () => {
|
||||
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Default sort: by modified (Zebra first)
|
||||
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
|
||||
@@ -450,7 +506,7 @@ describe('NoteList sort controls', () => {
|
||||
|
||||
it('closes sort menu after selecting an option', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument()
|
||||
@@ -460,7 +516,7 @@ describe('NoteList sort controls', () => {
|
||||
|
||||
it('shows direction arrows in sort dropdown menu', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
// Each option should have asc and desc direction buttons
|
||||
@@ -477,7 +533,7 @@ describe('NoteList sort controls', () => {
|
||||
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Default sort: modified descending (Zebra first at 3000)
|
||||
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
|
||||
@@ -498,7 +554,7 @@ describe('NoteList sort controls', () => {
|
||||
makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Select title sort with desc direction
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
@@ -511,7 +567,7 @@ describe('NoteList sort controls', () => {
|
||||
|
||||
it('shows direction icon on the sort button that reflects current direction', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Default: modified desc → should have ArrowDown icon
|
||||
expect(screen.getByTestId('sort-direction-icon-__list__')).toBeInTheDocument()
|
||||
@@ -548,7 +604,7 @@ describe('NoteList sort controls', () => {
|
||||
const entries = [parent, child1, child2]
|
||||
|
||||
render(
|
||||
<NoteList entries={entries} selection={{ kind: 'entity', entry: parent }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={{ kind: 'entity', entry: parent }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
|
||||
// Default sort: by modified — Zebra Note (3000) before Alpha Note (1000)
|
||||
@@ -664,31 +720,31 @@ describe('NoteList — trash view', () => {
|
||||
const trashSelection: SidebarSelection = { kind: 'filter', filter: 'trash' }
|
||||
|
||||
it('shows "Trash" header when trash filter is active', () => {
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Trash')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows only trashed entries in trash view', () => {
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Old Draft Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Deprecated API Notes')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows TRASHED badge on trashed entries', () => {
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
const badges = screen.getAllByText('TRASHED')
|
||||
expect(badges.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('shows 30-day warning banner when expired notes exist', () => {
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Notes in trash for 30+ days will be permanently deleted')).toBeInTheDocument()
|
||||
expect(screen.getByText(/1 note is past the 30-day retention period/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Trash is empty" when no trashed entries', () => {
|
||||
render(<NoteList entries={mockEntries} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Trash is empty')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,13 +22,14 @@ interface NoteListProps {
|
||||
allContent: Record<string, string>
|
||||
modifiedFiles?: ModifiedFile[]
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
onReplaceActiveTab: (entry: VaultEntry) => void
|
||||
onCreateNote: () => void
|
||||
}
|
||||
|
||||
function PinnedCard({ entry, typeEntryMap, onSelectNote, showDate }: {
|
||||
function PinnedCard({ entry, typeEntryMap, onClickNote, showDate }: {
|
||||
entry: VaultEntry
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
|
||||
showDate?: boolean
|
||||
}) {
|
||||
const te = typeEntryMap[entry.isA ?? '']
|
||||
@@ -36,7 +37,7 @@ function PinnedCard({ entry, typeEntryMap, onSelectNote, showDate }: {
|
||||
const bgColor = getTypeLightColor(entry.isA ?? '', te?.color)
|
||||
const Icon = getTypeIcon(entry.isA, te?.icon)
|
||||
return (
|
||||
<div className="relative cursor-pointer border-b border-[var(--border)]" style={{ backgroundColor: bgColor, padding: '14px 16px' }} onClick={() => onSelectNote(entry)}>
|
||||
<div className="relative cursor-pointer border-b border-[var(--border)]" style={{ backgroundColor: bgColor, padding: '14px 16px' }} onClick={(e: React.MouseEvent) => onClickNote(entry, e)}>
|
||||
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||
<Icon width={16} height={16} className="absolute right-3 top-3.5" style={{ color }} data-testid="type-icon" />
|
||||
<div className="pr-6 text-[14px] font-bold" style={{ color }}>{entry.title}</div>
|
||||
@@ -112,16 +113,16 @@ function useTypeEntryMap(entries: VaultEntry[]) {
|
||||
|
||||
// --- View sub-components ---
|
||||
|
||||
function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggleGroup, onSortChange, renderItem, typeEntryMap, onSelectNote }: {
|
||||
function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggleGroup, onSortChange, renderItem, typeEntryMap, onClickNote }: {
|
||||
entity: VaultEntry; groups: RelationshipGroup[]; query: string
|
||||
collapsedGroups: Set<string>; sortPrefs: Record<string, SortConfig>
|
||||
onToggleGroup: (label: string) => void; onSortChange: (label: string, opt: SortOption, dir: SortDirection) => void
|
||||
renderItem: (entry: VaultEntry) => React.ReactNode
|
||||
typeEntryMap: Record<string, VaultEntry>; onSelectNote: (entry: VaultEntry) => void
|
||||
typeEntryMap: Record<string, VaultEntry>; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<PinnedCard entry={entity} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} showDate />
|
||||
<PinnedCard entry={entity} typeEntryMap={typeEntryMap} onClickNote={onClickNote} showDate />
|
||||
{groups.length === 0
|
||||
? <EmptyMessage text={query ? 'No matching items' : 'No related items'} />
|
||||
: groups.map((group) => (
|
||||
@@ -132,16 +133,16 @@ function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggl
|
||||
)
|
||||
}
|
||||
|
||||
function ListView({ typeDocument, isTrashView, expiredTrashCount, searched, query, renderItem, typeEntryMap, onSelectNote }: {
|
||||
function ListView({ typeDocument, isTrashView, expiredTrashCount, searched, query, renderItem, typeEntryMap, onClickNote }: {
|
||||
typeDocument: VaultEntry | null; isTrashView: boolean; expiredTrashCount: number
|
||||
searched: VaultEntry[]; query: string
|
||||
renderItem: (entry: VaultEntry) => React.ReactNode
|
||||
typeEntryMap: Record<string, VaultEntry>; onSelectNote: (entry: VaultEntry) => void
|
||||
typeEntryMap: Record<string, VaultEntry>; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
|
||||
}) {
|
||||
const emptyText = isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found')
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
{typeDocument && <PinnedCard entry={typeDocument} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />}
|
||||
{typeDocument && <PinnedCard entry={typeDocument} typeEntryMap={typeEntryMap} onClickNote={onClickNote} />}
|
||||
<TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
|
||||
{searched.length === 0
|
||||
? <EmptyMessage text={emptyText} />
|
||||
@@ -167,6 +168,16 @@ function countExpiredTrash(entries: VaultEntry[]): number {
|
||||
return entries.filter((e) => e.trashedAt && (now - e.trashedAt) >= 86400 * 30).length
|
||||
}
|
||||
|
||||
// --- Click routing ---
|
||||
|
||||
function routeNoteClick(
|
||||
entry: VaultEntry, e: React.MouseEvent,
|
||||
onSelectNote: (entry: VaultEntry) => void,
|
||||
onReplaceActiveTab: (entry: VaultEntry) => void,
|
||||
) {
|
||||
if (e.metaKey || e.ctrlKey) { onSelectNote(entry) } else { onReplaceActiveTab(entry) }
|
||||
}
|
||||
|
||||
// --- Data hooks ---
|
||||
|
||||
interface NoteListDataParams {
|
||||
@@ -205,7 +216,7 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list
|
||||
|
||||
// --- Main component ---
|
||||
|
||||
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, onSelectNote, onCreateNote }: NoteListProps) {
|
||||
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, onSelectNote, onReplaceActiveTab, onCreateNote }: NoteListProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [searchVisible, setSearchVisible] = useState(false)
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
@@ -226,9 +237,13 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
const listDirection = listConfig.direction
|
||||
const { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedFiles })
|
||||
|
||||
const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
|
||||
routeNoteClick(entry, e, onSelectNote, onReplaceActiveTab)
|
||||
}, [onSelectNote, onReplaceActiveTab])
|
||||
|
||||
const renderItem = useCallback((entry: VaultEntry) => (
|
||||
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />
|
||||
), [selectedNote?.path, onSelectNote, typeEntryMap])
|
||||
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
|
||||
), [selectedNote?.path, handleClickNote, typeEntryMap])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
|
||||
@@ -253,9 +268,9 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
|
||||
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
|
||||
{isEntityView && selection.kind === 'entity' ? (
|
||||
<EntityView entity={selection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />
|
||||
<EntityView entity={selection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
|
||||
) : (
|
||||
<ListView typeDocument={typeDocument} isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />
|
||||
<ListView typeDocument={typeDocument} isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user