Add properties panel to inspector (M4 Task 1)

Show selected note metadata: type, status (colored pill), owner,
cadence, modified date, word count (computed from content minus
frontmatter), and placeholder 'Add property' button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-14 20:45:42 +01:00
parent 1a2068562a
commit b379e23c8b
4 changed files with 221 additions and 10 deletions

View File

@@ -159,6 +159,10 @@ function App() {
<Inspector
collapsed={inspectorCollapsed}
onToggle={() => setInspectorCollapsed((c) => !c)}
entry={activeTab?.entry ?? null}
content={activeTab?.content ?? null}
entries={entries}
onNavigate={handleNavigateWikilink}
/>
</div>
</div>

View File

@@ -61,3 +61,53 @@
color: #555;
margin: 0;
}
/* Properties */
.inspector__props {
display: flex;
flex-direction: column;
gap: 8px;
}
.inspector__prop {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
}
.inspector__prop-label {
color: #888;
flex-shrink: 0;
}
.inspector__prop-value {
color: #ccc;
text-align: right;
}
.inspector__status-pill {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
color: #fff;
}
.inspector__add-prop {
margin-top: 12px;
background: none;
border: 1px dashed #444;
color: #666;
padding: 6px 12px;
border-radius: 6px;
cursor: not-allowed;
font-size: 12px;
width: 100%;
}
.inspector__add-prop:hover {
border-color: #555;
color: #888;
}

View File

@@ -1,25 +1,98 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { Inspector } from './Inspector'
import type { VaultEntry } from '../types'
const mockEntry: VaultEntry = {
path: '/vault/project/test.md',
filename: 'test.md',
title: 'Test Project',
isA: 'Project',
aliases: [],
belongsTo: ['[[responsibility/grow-newsletter]]'],
relatedTo: ['[[topic/software-development]]'],
status: 'Active',
owner: 'Luca Rossi',
cadence: null,
modifiedAt: 1707900000,
fileSize: 1024,
}
const mockContent = `---
title: Test Project
is_a: Project
status: Active
---
# Test Project
This is a test note with some words to count.
`
const defaultProps = {
collapsed: false,
onToggle: () => {},
entry: null as VaultEntry | null,
content: null as string | null,
entries: [] as VaultEntry[],
onNavigate: () => {},
}
describe('Inspector', () => {
it('renders expanded state with sections', () => {
render(<Inspector collapsed={false} onToggle={() => {}} />)
it('renders expanded state with "no note selected"', () => {
render(<Inspector {...defaultProps} />)
expect(screen.getByText('Inspector')).toBeInTheDocument()
expect(screen.getByText('Properties')).toBeInTheDocument()
expect(screen.getByText('Relationships')).toBeInTheDocument()
expect(screen.getByText('No note selected')).toBeInTheDocument()
})
it('renders collapsed state without sections', () => {
render(<Inspector collapsed={true} onToggle={() => {}} />)
render(<Inspector {...defaultProps} collapsed={true} />)
expect(screen.queryByText('Inspector')).not.toBeInTheDocument()
expect(screen.queryByText('Properties')).not.toBeInTheDocument()
})
it('calls onToggle when toggle button clicked', () => {
const onToggle = vi.fn()
render(<Inspector collapsed={false} onToggle={onToggle} />)
render(<Inspector {...defaultProps} onToggle={onToggle} />)
fireEvent.click(screen.getByRole('button'))
expect(onToggle).toHaveBeenCalledOnce()
})
it('shows properties when a note is selected', () => {
render(<Inspector {...defaultProps} entry={mockEntry} content={mockContent} />)
expect(screen.getByText('Project')).toBeInTheDocument()
expect(screen.getByText('Active')).toBeInTheDocument()
expect(screen.getByText('Luca Rossi')).toBeInTheDocument()
expect(screen.getByText('Type')).toBeInTheDocument()
expect(screen.getByText('Status')).toBeInTheDocument()
expect(screen.getByText('Owner')).toBeInTheDocument()
expect(screen.getByText('Words')).toBeInTheDocument()
})
it('renders status as a colored pill', () => {
render(<Inspector {...defaultProps} entry={mockEntry} content={mockContent} />)
const pill = screen.getByText('Active')
expect(pill).toHaveClass('inspector__status-pill')
expect(pill).toHaveStyle({ backgroundColor: '#4caf50' })
})
it('computes word count from content minus frontmatter', () => {
render(<Inspector {...defaultProps} entry={mockEntry} content={mockContent} />)
// "# Test Project" + "This is a test note with some words to count." = 13 words
expect(screen.getByText('13')).toBeInTheDocument()
})
it('shows "Add property" button as disabled placeholder', () => {
render(<Inspector {...defaultProps} entry={mockEntry} content={mockContent} />)
const btn = screen.getByText('+ Add property')
expect(btn).toBeDisabled()
})
it('shows cadence when present', () => {
const entryWithCadence = { ...mockEntry, cadence: 'Weekly' }
render(<Inspector {...defaultProps} entry={entryWithCadence} content={mockContent} />)
expect(screen.getByText('Cadence')).toBeInTheDocument()
expect(screen.getByText('Weekly')).toBeInTheDocument()
})
})

View File

@@ -1,11 +1,91 @@
import type { VaultEntry } from '../types'
import './Inspector.css'
interface InspectorProps {
collapsed: boolean
onToggle: () => void
entry: VaultEntry | null
content: string | null
entries: VaultEntry[]
onNavigate: (target: string) => void
}
export function Inspector({ collapsed, onToggle }: InspectorProps) {
const STATUS_COLORS: Record<string, string> = {
Active: '#4caf50',
Done: '#2196f3',
Paused: '#ff9800',
Archived: '#9e9e9e',
Dropped: '#f44336',
}
function formatDate(timestamp: number | null): string {
if (!timestamp) return '—'
const d = new Date(timestamp * 1000)
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
}
function countWords(content: string | null): number {
if (!content) return 0
// Strip YAML frontmatter
const stripped = content.replace(/^---[\s\S]*?---\n?/, '')
const words = stripped.trim().split(/\s+/).filter((w) => w.length > 0)
return words.length
}
function PropertiesPanel({ entry, content }: { entry: VaultEntry; content: string | null }) {
const statusColor = entry.status ? STATUS_COLORS[entry.status] ?? '#888' : undefined
const wordCount = countWords(content)
return (
<div className="inspector__section">
<h4>Properties</h4>
<div className="inspector__props">
{entry.isA && (
<div className="inspector__prop">
<span className="inspector__prop-label">Type</span>
<span className="inspector__prop-value">{entry.isA}</span>
</div>
)}
{entry.status && (
<div className="inspector__prop">
<span className="inspector__prop-label">Status</span>
<span
className="inspector__status-pill"
style={{ backgroundColor: statusColor }}
>
{entry.status}
</span>
</div>
)}
{entry.owner && (
<div className="inspector__prop">
<span className="inspector__prop-label">Owner</span>
<span className="inspector__prop-value">{entry.owner}</span>
</div>
)}
{entry.cadence && (
<div className="inspector__prop">
<span className="inspector__prop-label">Cadence</span>
<span className="inspector__prop-value">{entry.cadence}</span>
</div>
)}
<div className="inspector__prop">
<span className="inspector__prop-label">Modified</span>
<span className="inspector__prop-value">{formatDate(entry.modifiedAt)}</span>
</div>
<div className="inspector__prop">
<span className="inspector__prop-label">Words</span>
<span className="inspector__prop-value">{wordCount}</span>
</div>
</div>
<button className="inspector__add-prop" disabled>
+ Add property
</button>
</div>
)
}
export function Inspector({ collapsed, onToggle, entry, content, entries, onNavigate }: InspectorProps) {
return (
<aside className={`inspector ${collapsed ? 'inspector--collapsed' : ''}`}>
<div className="inspector__header">
@@ -16,10 +96,14 @@ export function Inspector({ collapsed, onToggle }: InspectorProps) {
</div>
{!collapsed && (
<div className="inspector__content">
<div className="inspector__section">
<h4>Properties</h4>
<p className="inspector__empty">No note selected</p>
</div>
{entry ? (
<PropertiesPanel entry={entry} content={content} />
) : (
<div className="inspector__section">
<h4>Properties</h4>
<p className="inspector__empty">No note selected</p>
</div>
)}
<div className="inspector__section">
<h4>Relationships</h4>
<p className="inspector__empty">No relationships</p>