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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user