Add sidebar section groups (PROJECTS, EXPERIMENTS, RESPONSIBILITIES, PROCEDURES)
Each section is collapsible with expand/collapse toggle, shows entity count, and has a + button. Entities are clickable. SidebarSelection type introduced for managing navigation state across filters, section groups, entities, and topics. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,9 +10,13 @@ test('app loads with four-panel layout', async ({ page }) => {
|
||||
await expect(page.locator('.inspector')).toBeVisible()
|
||||
})
|
||||
|
||||
test('sidebar shows navigation items', async ({ page }) => {
|
||||
test('sidebar shows section groups', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(500) // Wait for mock data
|
||||
|
||||
await expect(page.getByText('Laputa')).toBeVisible()
|
||||
await expect(page.getByText('All Notes')).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Laputa' })).toBeVisible()
|
||||
await expect(page.getByText('PROJECTS')).toBeVisible()
|
||||
await expect(page.getByText('EXPERIMENTS')).toBeVisible()
|
||||
await expect(page.getByText('RESPONSIBILITIES')).toBeVisible()
|
||||
await expect(page.getByText('PROCEDURES')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -6,14 +6,17 @@ import { Editor } from './components/Editor'
|
||||
import { Inspector } from './components/Inspector'
|
||||
import { ResizeHandle } from './components/ResizeHandle'
|
||||
import { isTauri, mockInvoke } from './mock-tauri'
|
||||
import type { VaultEntry } from './types'
|
||||
import type { VaultEntry, SidebarSelection } from './types'
|
||||
import './App.css'
|
||||
|
||||
// TODO: Make vault path configurable via settings
|
||||
const TEST_VAULT_PATH = '~/Laputa'
|
||||
|
||||
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
|
||||
function App() {
|
||||
const [entries, setEntries] = useState<VaultEntry[]>([])
|
||||
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
|
||||
const [sidebarWidth, setSidebarWidth] = useState(250)
|
||||
const [noteListWidth, setNoteListWidth] = useState(300)
|
||||
const [inspectorWidth, setInspectorWidth] = useState(280)
|
||||
@@ -56,7 +59,7 @@ function App() {
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="app__sidebar" style={{ width: sidebarWidth }}>
|
||||
<Sidebar />
|
||||
<Sidebar entries={entries} selection={selection} onSelect={setSelection} />
|
||||
</div>
|
||||
<ResizeHandle onResize={handleSidebarResize} />
|
||||
<div className="app__note-list" style={{ width: noteListWidth }}>
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar__header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #2a2a4a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__header h2 {
|
||||
@@ -20,14 +22,97 @@
|
||||
|
||||
.sidebar__nav {
|
||||
padding: 8px 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Section Groups */
|
||||
.sidebar__section {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sidebar__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
margin: 0 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: #888;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sidebar__section-header:hover {
|
||||
background: #2a2a4a;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.sidebar__section-header--active {
|
||||
background: #2a2a4a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.sidebar__collapse-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px 0 0;
|
||||
line-height: 1;
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__section-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar__section-count {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.sidebar__add-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
border-radius: 3px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.sidebar__section-header:hover .sidebar__add-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar__add-btn:hover {
|
||||
color: #ffffff;
|
||||
background: #3a3a5a;
|
||||
}
|
||||
|
||||
/* Section items (entity list) */
|
||||
.sidebar__section-items {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.sidebar__item {
|
||||
padding: 8px 16px;
|
||||
padding: 5px 16px 5px 28px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
margin: 2px 8px;
|
||||
margin: 1px 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar__item:hover {
|
||||
|
||||
@@ -1,18 +1,128 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
|
||||
const mockEntries: VaultEntry[] = [
|
||||
{
|
||||
path: '/vault/project/build-app.md',
|
||||
filename: 'build-app.md',
|
||||
title: 'Build Laputa App',
|
||||
isA: 'Project',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
modifiedAt: 1700000000,
|
||||
fileSize: 1024,
|
||||
},
|
||||
{
|
||||
path: '/vault/responsibility/grow-newsletter.md',
|
||||
filename: 'grow-newsletter.md',
|
||||
title: 'Grow Newsletter',
|
||||
isA: 'Responsibility',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
modifiedAt: 1700000000,
|
||||
fileSize: 512,
|
||||
},
|
||||
{
|
||||
path: '/vault/experiment/stock-screener.md',
|
||||
filename: 'stock-screener.md',
|
||||
title: 'Stock Screener',
|
||||
isA: 'Experiment',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
modifiedAt: 1700000000,
|
||||
fileSize: 256,
|
||||
},
|
||||
{
|
||||
path: '/vault/procedure/weekly-essays.md',
|
||||
filename: 'weekly-essays.md',
|
||||
title: 'Write Weekly Essays',
|
||||
isA: 'Procedure',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
owner: 'Luca',
|
||||
cadence: 'Weekly',
|
||||
modifiedAt: 1700000000,
|
||||
fileSize: 128,
|
||||
},
|
||||
]
|
||||
|
||||
const defaultSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
|
||||
describe('Sidebar', () => {
|
||||
it('renders the app title', () => {
|
||||
render(<Sidebar />)
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('Laputa')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders navigation items', () => {
|
||||
render(<Sidebar />)
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Projects')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tasks')).toBeInTheDocument()
|
||||
expect(screen.getByText('People')).toBeInTheDocument()
|
||||
it('renders section group headers', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('PROJECTS')).toBeInTheDocument()
|
||||
expect(screen.getByText('EXPERIMENTS')).toBeInTheDocument()
|
||||
expect(screen.getByText('RESPONSIBILITIES')).toBeInTheDocument()
|
||||
expect(screen.getByText('PROCEDURES')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows entity names under their section groups', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Grow Newsletter')).toBeInTheDocument()
|
||||
expect(screen.getByText('Stock Screener')).toBeInTheDocument()
|
||||
expect(screen.getByText('Write Weekly Essays')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows correct counts per section', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// Each section has exactly 1 item
|
||||
const counts = screen.getAllByText('1')
|
||||
expect(counts.length).toBe(4)
|
||||
})
|
||||
|
||||
it('collapses and expands sections', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
|
||||
// Collapse PROJECTS section
|
||||
fireEvent.click(screen.getByLabelText('Collapse PROJECTS'))
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
|
||||
// Expand PROJECTS section
|
||||
fireEvent.click(screen.getByLabelText('Expand PROJECTS'))
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect when clicking an entity', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
kind: 'entity',
|
||||
entry: mockEntries[0],
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onSelect when clicking a section header', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByText('PROJECTS'))
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
kind: 'sectionGroup',
|
||||
type: 'Project',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,96 @@
|
||||
import { useState } from 'react'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
import './Sidebar.css'
|
||||
|
||||
export function Sidebar() {
|
||||
interface SidebarProps {
|
||||
entries: VaultEntry[]
|
||||
selection: SidebarSelection
|
||||
onSelect: (selection: SidebarSelection) => void
|
||||
}
|
||||
|
||||
const SECTION_GROUPS = [
|
||||
{ label: 'PROJECTS', type: 'Project' },
|
||||
{ label: 'EXPERIMENTS', type: 'Experiment' },
|
||||
{ label: 'RESPONSIBILITIES', type: 'Responsibility' },
|
||||
{ label: 'PROCEDURES', type: 'Procedure' },
|
||||
] as const
|
||||
|
||||
export function Sidebar({ entries, selection, onSelect }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
|
||||
const toggleSection = (type: string) => {
|
||||
setCollapsed((prev) => ({ ...prev, [type]: !prev[type] }))
|
||||
}
|
||||
|
||||
const isActive = (sel: SidebarSelection): boolean => {
|
||||
if (selection.kind !== sel.kind) return false
|
||||
if (sel.kind === 'filter' && selection.kind === 'filter') return sel.filter === selection.filter
|
||||
if (sel.kind === 'sectionGroup' && selection.kind === 'sectionGroup') return sel.type === selection.type
|
||||
if (sel.kind === 'entity' && selection.kind === 'entity') return sel.entry.path === selection.entry.path
|
||||
if (sel.kind === 'topic' && selection.kind === 'topic') return sel.entry.path === selection.entry.path
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar__header">
|
||||
<h2>Laputa</h2>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar__nav">
|
||||
<div className="sidebar__item sidebar__item--active">All Notes</div>
|
||||
<div className="sidebar__item">Projects</div>
|
||||
<div className="sidebar__item">Tasks</div>
|
||||
<div className="sidebar__item">People</div>
|
||||
{SECTION_GROUPS.map(({ label, type }) => {
|
||||
const items = entries.filter((e) => e.isA === type)
|
||||
const isCollapsed = collapsed[type] ?? false
|
||||
|
||||
return (
|
||||
<div key={type} className="sidebar__section">
|
||||
<div
|
||||
className={`sidebar__section-header${
|
||||
isActive({ kind: 'sectionGroup', type }) ? ' sidebar__section-header--active' : ''
|
||||
}`}
|
||||
onClick={() => onSelect({ kind: 'sectionGroup', type })}
|
||||
>
|
||||
<button
|
||||
className="sidebar__collapse-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleSection(type)
|
||||
}}
|
||||
aria-label={isCollapsed ? `Expand ${label}` : `Collapse ${label}`}
|
||||
>
|
||||
{isCollapsed ? '▸' : '▾'}
|
||||
</button>
|
||||
<span className="sidebar__section-label">{label}</span>
|
||||
<span className="sidebar__section-count">{items.length}</span>
|
||||
<button
|
||||
className="sidebar__add-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// TODO: Wire up create new entity
|
||||
}}
|
||||
aria-label={`Add ${type}`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="sidebar__section-items">
|
||||
{items.map((entry) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
className={`sidebar__item${
|
||||
isActive({ kind: 'entity', entry }) ? ' sidebar__item--active' : ''
|
||||
}`}
|
||||
onClick={() => onSelect({ kind: 'entity', entry })}
|
||||
>
|
||||
{entry.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -12,3 +12,9 @@ export interface VaultEntry {
|
||||
modifiedAt: number | null
|
||||
fileSize: number
|
||||
}
|
||||
|
||||
export type SidebarSelection =
|
||||
| { kind: 'filter'; filter: 'all' | 'people' | 'events' | 'favorites' | 'trash' }
|
||||
| { kind: 'sectionGroup'; type: string }
|
||||
| { kind: 'entity'; entry: VaultEntry }
|
||||
| { kind: 'topic'; entry: VaultEntry }
|
||||
|
||||
Reference in New Issue
Block a user