diff --git a/e2e/filtering.spec.ts b/e2e/filtering.spec.ts index aa1fe65e..b80aeea5 100644 --- a/e2e/filtering.spec.ts +++ b/e2e/filtering.spec.ts @@ -54,3 +54,13 @@ test('clicking topic shows entries related to that topic', async ({ page }) => { await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).toBeVisible() await page.screenshot({ path: 'test-results/filter-topic.png' }) }) + +test('search bar filters by title substring', async ({ page }) => { + await page.fill('.note-list__search-input', 'budget') + await page.waitForTimeout(100) + const count = page.locator('.note-list__count') + await expect(count).toHaveText('1') + await expect(page.locator('.note-list__title', { hasText: 'Budget Allocation' })).toBeVisible() + await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).not.toBeVisible() + await page.screenshot({ path: 'test-results/search-budget.png' }) +}) diff --git a/src/components/NoteList.css b/src/components/NoteList.css index 76102c8b..adb10c65 100644 --- a/src/components/NoteList.css +++ b/src/components/NoteList.css @@ -29,6 +29,32 @@ border-radius: 10px; } +/* Search */ +.note-list__search { + padding: 8px 12px; + border-bottom: 1px solid #2a2a4a; +} + +.note-list__search-input { + width: 100%; + padding: 6px 10px; + background: #1e1e3a; + border: 1px solid #2a2a4a; + border-radius: 6px; + color: #e0e0e0; + font-size: 13px; + outline: none; + box-sizing: border-box; +} + +.note-list__search-input::placeholder { + color: #666; +} + +.note-list__search-input:focus { + border-color: #4a4a7a; +} + .note-list__items { flex: 1; } diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx index b23ed01d..6584c057 100644 --- a/src/components/NoteList.test.tsx +++ b/src/components/NoteList.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, fireEvent } from '@testing-library/react' import { describe, it, expect } from 'vitest' import { NoteList } from './NoteList' import type { VaultEntry, SidebarSelection } from '../types' @@ -132,4 +132,32 @@ describe('NoteList', () => { expect(screen.getByText('Build Laputa App')).toBeInTheDocument() expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument() }) + + it('renders a search bar', () => { + render() + expect(screen.getByPlaceholderText('Search notes...')).toBeInTheDocument() + }) + + it('filters by search query (case-insensitive substring)', () => { + render() + 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() + expect(screen.getByText('1')).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.getAllByClassName + ? [] // fallback + : screen.getAllByText(/Oldest|Newest|Middle/) + const titleTexts = titles.map((el) => el.textContent) + expect(titleTexts).toEqual(['Newest', 'Middle', 'Oldest']) + }) }) diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 6e168d0b..0038457e 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import type { VaultEntry, SidebarSelection } from '../types' import './NoteList.css' @@ -50,20 +51,50 @@ function filterEntries(entries: VaultEntry[], selection: SidebarSelection): Vaul } } +function sortByModified(a: VaultEntry, b: VaultEntry): number { + return (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0) +} + export function NoteList({ entries, selection }: NoteListProps) { + const [search, setSearch] = useState('') + const filtered = filterEntries(entries, selection) + // Sort: for entity view, keep pinned first, sort children; otherwise sort all + let sorted: VaultEntry[] + if (selection.kind === 'entity' && filtered.length > 0) { + const [pinned, ...children] = filtered + sorted = [pinned, ...children.sort(sortByModified)] + } else { + sorted = [...filtered].sort(sortByModified) + } + + // Search filter (title substring, case-insensitive) + const query = search.trim().toLowerCase() + const displayed = query + ? sorted.filter((e) => e.title.toLowerCase().includes(query)) + : sorted + return (

Notes

- {filtered.length} + {displayed.length} +
+
+ setSearch(e.target.value)} + />
- {filtered.length === 0 ? ( + {displayed.length === 0 ? (
No notes found
) : ( - filtered.map((entry, i) => ( + displayed.map((entry, i) => (