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:
lucaronin
2026-02-14 19:35:10 +01:00
parent 463caf02ba
commit 3871cb5ba1
6 changed files with 310 additions and 22 deletions

View File

@@ -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()
})

View File

@@ -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 }}>

View File

@@ -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 {

View File

@@ -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',
})
})
})

View File

@@ -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>
)

View File

@@ -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 }