Tests cover: - Customize button renders - Popover opens with toggles for all sections - Hiding a section removes it and its items from sidebar - Re-showing a section restores it - Persistence to/from localStorage - Hidden sections don't affect top nav (All Notes, Favorites) - Popover closes on outside click Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
478 lines
16 KiB
TypeScript
478 lines
16 KiB
TypeScript
import { render, screen, fireEvent } from '@testing-library/react'
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { Sidebar } from './Sidebar'
|
|
import type { VaultEntry, SidebarSelection } from '../types'
|
|
|
|
// Mock localStorage for section visibility tests
|
|
const localStorageMock = (() => {
|
|
let store: Record<string, string> = {}
|
|
return {
|
|
getItem: (key: string) => store[key] ?? null,
|
|
setItem: (key: string, value: string) => { store[key] = value },
|
|
removeItem: (key: string) => { delete store[key] },
|
|
clear: () => { store = {} },
|
|
}
|
|
})()
|
|
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
|
|
|
|
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,
|
|
createdAt: null,
|
|
fileSize: 1024,
|
|
snippet: '',
|
|
relationships: {},
|
|
},
|
|
{
|
|
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,
|
|
createdAt: null,
|
|
fileSize: 512,
|
|
snippet: '',
|
|
relationships: {},
|
|
},
|
|
{
|
|
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,
|
|
createdAt: null,
|
|
fileSize: 256,
|
|
snippet: '',
|
|
relationships: {},
|
|
},
|
|
{
|
|
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,
|
|
createdAt: null,
|
|
fileSize: 128,
|
|
snippet: '',
|
|
relationships: {},
|
|
},
|
|
{
|
|
path: '/vault/topic/software-development.md',
|
|
filename: 'software-development.md',
|
|
title: 'Software Development',
|
|
isA: 'Topic',
|
|
aliases: ['Dev', 'Coding'],
|
|
belongsTo: [],
|
|
relatedTo: [],
|
|
status: null,
|
|
owner: null,
|
|
cadence: null,
|
|
modifiedAt: 1700000000,
|
|
createdAt: null,
|
|
fileSize: 256,
|
|
snippet: '',
|
|
relationships: {},
|
|
},
|
|
{
|
|
path: '/vault/topic/trading.md',
|
|
filename: 'trading.md',
|
|
title: 'Trading',
|
|
isA: 'Topic',
|
|
aliases: ['Algorithmic Trading'],
|
|
belongsTo: [],
|
|
relatedTo: [],
|
|
status: null,
|
|
owner: null,
|
|
cadence: null,
|
|
modifiedAt: 1700000000,
|
|
createdAt: null,
|
|
fileSize: 180,
|
|
snippet: '',
|
|
relationships: {},
|
|
},
|
|
{
|
|
path: '/vault/person/alice.md',
|
|
filename: 'alice.md',
|
|
title: 'Alice',
|
|
isA: 'Person',
|
|
aliases: [],
|
|
belongsTo: [],
|
|
relatedTo: [],
|
|
status: null,
|
|
owner: null,
|
|
cadence: null,
|
|
modifiedAt: 1700000000,
|
|
createdAt: null,
|
|
fileSize: 100,
|
|
snippet: '',
|
|
relationships: {},
|
|
},
|
|
{
|
|
path: '/vault/event/kickoff.md',
|
|
filename: 'kickoff.md',
|
|
title: 'Kickoff Meeting',
|
|
isA: 'Event',
|
|
aliases: [],
|
|
belongsTo: [],
|
|
relatedTo: [],
|
|
status: null,
|
|
owner: null,
|
|
cadence: null,
|
|
modifiedAt: 1700000000,
|
|
createdAt: null,
|
|
fileSize: 200,
|
|
snippet: '',
|
|
relationships: {},
|
|
},
|
|
]
|
|
|
|
const defaultSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
|
|
|
|
describe('Sidebar', () => {
|
|
it('renders top nav items (All Notes and Favorites)', () => {
|
|
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} />)
|
|
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
|
expect(screen.getByText('Favorites')).toBeInTheDocument()
|
|
})
|
|
|
|
it('renders section group headers with new labels', () => {
|
|
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()
|
|
expect(screen.getByText('People')).toBeInTheDocument()
|
|
expect(screen.getByText('Events')).toBeInTheDocument()
|
|
expect(screen.getByText('Topics')).toBeInTheDocument()
|
|
expect(screen.getByText('Types')).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 People and Events as section groups', () => {
|
|
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
|
expect(screen.getByText('Alice')).toBeInTheDocument()
|
|
expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument()
|
|
})
|
|
|
|
it('collapses and expands sections', () => {
|
|
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
|
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByLabelText('Collapse Projects'))
|
|
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
|
|
|
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',
|
|
})
|
|
})
|
|
|
|
it('calls onSelect with sectionGroup for People', () => {
|
|
const onSelect = vi.fn()
|
|
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)
|
|
fireEvent.click(screen.getByText('People'))
|
|
expect(onSelect).toHaveBeenCalledWith({
|
|
kind: 'sectionGroup',
|
|
type: 'Person',
|
|
})
|
|
})
|
|
|
|
it('renders Topics section with topic entries', () => {
|
|
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
|
expect(screen.getByText('Topics')).toBeInTheDocument()
|
|
expect(screen.getByText('Software Development')).toBeInTheDocument()
|
|
expect(screen.getByText('Trading')).toBeInTheDocument()
|
|
})
|
|
|
|
it('calls onSelect with topic kind when clicking a topic', () => {
|
|
const onSelect = vi.fn()
|
|
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)
|
|
fireEvent.click(screen.getByText('Software Development'))
|
|
expect(onSelect).toHaveBeenCalledWith({
|
|
kind: 'topic',
|
|
entry: mockEntries[4],
|
|
})
|
|
})
|
|
|
|
it('renders + buttons for each section group when onCreateType is provided', () => {
|
|
const onCreateType = vi.fn()
|
|
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onCreateType={onCreateType} />)
|
|
const createButtons = screen.getAllByTitle(/^New /)
|
|
expect(createButtons.length).toBe(8) // Projects, Experiments, Responsibilities, Procedures, People, Events, Topics, Types
|
|
})
|
|
|
|
it('calls onCreateType with correct type when + button is clicked', () => {
|
|
const onCreateType = vi.fn()
|
|
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onCreateType={onCreateType} />)
|
|
fireEvent.click(screen.getByTitle('New Project'))
|
|
expect(onCreateType).toHaveBeenCalledWith('Project')
|
|
})
|
|
|
|
it('does not render + buttons when onCreateType is not provided', () => {
|
|
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
|
expect(screen.queryByTitle('New Project')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('renders commit button even when no modified files', () => {
|
|
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} onCommitPush={() => {}} />)
|
|
expect(screen.getByText('Commit & Push')).toBeInTheDocument()
|
|
})
|
|
|
|
it('shows badge on commit button when modified files exist', () => {
|
|
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={3} onCommitPush={() => {}} />)
|
|
expect(screen.getByText('3')).toBeInTheDocument()
|
|
})
|
|
|
|
describe('dynamic custom type sections', () => {
|
|
const entriesWithCustomTypes: VaultEntry[] = [
|
|
...mockEntries,
|
|
{
|
|
path: '/vault/type/recipe.md',
|
|
filename: 'recipe.md',
|
|
title: 'Recipe',
|
|
isA: 'Type',
|
|
aliases: [],
|
|
belongsTo: [],
|
|
relatedTo: [],
|
|
status: null,
|
|
owner: null,
|
|
cadence: null,
|
|
modifiedAt: 1700000000,
|
|
createdAt: null,
|
|
fileSize: 200,
|
|
snippet: '',
|
|
relationships: {},
|
|
},
|
|
{
|
|
path: '/vault/type/book.md',
|
|
filename: 'book.md',
|
|
title: 'Book',
|
|
isA: 'Type',
|
|
aliases: [],
|
|
belongsTo: [],
|
|
relatedTo: [],
|
|
status: null,
|
|
owner: null,
|
|
cadence: null,
|
|
modifiedAt: 1700000000,
|
|
createdAt: null,
|
|
fileSize: 200,
|
|
snippet: '',
|
|
relationships: {},
|
|
},
|
|
{
|
|
path: '/vault/recipe/pasta.md',
|
|
filename: 'pasta.md',
|
|
title: 'Pasta Carbonara',
|
|
isA: 'Recipe',
|
|
aliases: [],
|
|
belongsTo: [],
|
|
relatedTo: [],
|
|
status: null,
|
|
owner: null,
|
|
cadence: null,
|
|
modifiedAt: 1700000000,
|
|
createdAt: null,
|
|
fileSize: 300,
|
|
snippet: '',
|
|
relationships: {},
|
|
},
|
|
]
|
|
|
|
it('renders custom type sections derived from Type entries', () => {
|
|
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} onCreateType={() => {}} />)
|
|
expect(screen.getByText('Books')).toBeInTheDocument()
|
|
expect(screen.getByText('Recipes')).toBeInTheDocument()
|
|
})
|
|
|
|
it('shows instances of custom types under their section', () => {
|
|
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} onCreateType={() => {}} />)
|
|
expect(screen.getByText('Pasta Carbonara')).toBeInTheDocument()
|
|
})
|
|
|
|
it('renders + button on custom type sections for creating instances', () => {
|
|
const onCreateType = vi.fn()
|
|
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} onCreateType={onCreateType} />)
|
|
fireEvent.click(screen.getByTitle('New Recipe'))
|
|
expect(onCreateType).toHaveBeenCalledWith('Recipe')
|
|
})
|
|
|
|
it('calls onCreateNewType when + is clicked on Types section', () => {
|
|
const onCreateNewType = vi.fn()
|
|
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} onCreateNewType={onCreateNewType} />)
|
|
fireEvent.click(screen.getByTitle('New Type'))
|
|
expect(onCreateNewType).toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not show built-in types as custom sections', () => {
|
|
const projectTypeEntry: VaultEntry = {
|
|
path: '/vault/type/project.md',
|
|
filename: 'project.md',
|
|
title: 'Project',
|
|
isA: 'Type',
|
|
aliases: [],
|
|
belongsTo: [],
|
|
relatedTo: [],
|
|
status: null,
|
|
owner: null,
|
|
cadence: null,
|
|
modifiedAt: 1700000000,
|
|
createdAt: null,
|
|
fileSize: 200,
|
|
snippet: '',
|
|
relationships: {},
|
|
}
|
|
render(<Sidebar entries={[...mockEntries, projectTypeEntry]} selection={defaultSelection} onSelect={() => {}} />)
|
|
// "Projects" should appear once (the built-in section), not twice
|
|
const projectLabels = screen.getAllByText('Projects')
|
|
expect(projectLabels.length).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe('customize section visibility', () => {
|
|
beforeEach(() => {
|
|
localStorageMock.clear()
|
|
})
|
|
|
|
it('renders a "Customize sections" button', () => {
|
|
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
|
expect(screen.getByTitle('Customize sections')).toBeInTheDocument()
|
|
})
|
|
|
|
it('opens popover with toggle for each section when clicking customize button', () => {
|
|
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
|
fireEvent.click(screen.getByTitle('Customize sections'))
|
|
expect(screen.getByText('Show in sidebar')).toBeInTheDocument()
|
|
expect(screen.getByLabelText('Toggle Projects')).toBeInTheDocument()
|
|
expect(screen.getByLabelText('Toggle People')).toBeInTheDocument()
|
|
expect(screen.getByLabelText('Toggle Topics')).toBeInTheDocument()
|
|
})
|
|
|
|
it('hides a section when its toggle is clicked off', () => {
|
|
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
|
// People section should be visible initially
|
|
expect(screen.getByText('Alice')).toBeInTheDocument()
|
|
|
|
// Open customize popover and toggle People off
|
|
fireEvent.click(screen.getByTitle('Customize sections'))
|
|
fireEvent.click(screen.getByLabelText('Toggle People'))
|
|
|
|
// People section and its items should be gone
|
|
expect(screen.queryByText('Alice')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('re-shows a section when its toggle is clicked on again', () => {
|
|
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
|
|
|
// Hide People
|
|
fireEvent.click(screen.getByTitle('Customize sections'))
|
|
fireEvent.click(screen.getByLabelText('Toggle People'))
|
|
expect(screen.queryByText('Alice')).not.toBeInTheDocument()
|
|
|
|
// Show People again
|
|
fireEvent.click(screen.getByLabelText('Toggle People'))
|
|
expect(screen.getByText('Alice')).toBeInTheDocument()
|
|
})
|
|
|
|
it('persists hidden sections in localStorage', () => {
|
|
const { unmount } = render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
|
fireEvent.click(screen.getByTitle('Customize sections'))
|
|
fireEvent.click(screen.getByLabelText('Toggle Events'))
|
|
unmount()
|
|
|
|
// Verify localStorage was updated
|
|
const stored = JSON.parse(localStorage.getItem('laputa-hidden-sections') || '[]')
|
|
expect(stored).toContain('Event')
|
|
})
|
|
|
|
it('restores hidden sections from localStorage on mount', () => {
|
|
localStorage.setItem('laputa-hidden-sections', JSON.stringify(['Person', 'Event']))
|
|
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
|
|
|
// People and Events sections should be hidden
|
|
expect(screen.queryByText('Alice')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('Kickoff Meeting')).not.toBeInTheDocument()
|
|
|
|
// Other sections should still be visible
|
|
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
|
expect(screen.getByText('Software Development')).toBeInTheDocument()
|
|
})
|
|
|
|
it('does not affect All Notes or other sidebar filters when sections are hidden', () => {
|
|
localStorage.setItem('laputa-hidden-sections', JSON.stringify(['Project', 'Person']))
|
|
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
|
|
|
// Top nav items still present
|
|
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
|
expect(screen.getByText('Favorites')).toBeInTheDocument()
|
|
})
|
|
|
|
it('closes popover when clicking outside', () => {
|
|
render(
|
|
<div>
|
|
<div data-testid="outside">outside</div>
|
|
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />
|
|
</div>
|
|
)
|
|
fireEvent.click(screen.getByTitle('Customize sections'))
|
|
expect(screen.getByText('Show in sidebar')).toBeInTheDocument()
|
|
|
|
fireEvent.mouseDown(screen.getByTestId('outside'))
|
|
expect(screen.queryByText('Show in sidebar')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|