feat: distinguish editable from read-only properties in inspector

Split the Properties panel into two visually distinct sections:

1. **Properties** (editable): frontmatter properties with interactive
   hover background, cursor pointer, and click-to-edit. Includes Type
   row, Status, Owner, tags, and all user-defined properties.

2. **Info** (read-only): derived metadata shown below a border
   separator with an "Info" header. Uses muted color (--text-muted)
   with no hover states or click interaction. Includes:
   - Modified date
   - Created date (new)
   - Word count
   - File size (new, formatted as B/KB/MB)

Product decisions:
- Info section uses --text-muted for both labels and values (more
  dimmed than the --muted-foreground used by editable labels)
- Editable rows get rounded hover:bg-muted on the full row
- Type row stays at top of Properties as an identity element
- Added Created date and file Size to give more useful metadata

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-23 15:19:57 +01:00
parent 67b8d1830c
commit 6527db10a2
3 changed files with 199 additions and 8 deletions

View File

@@ -356,4 +356,133 @@ describe('DynamicPropertiesPanel', () => {
fireEvent.click(screen.getByText('Cancel'))
expect(screen.getByText('+ Add property')).toBeInTheDocument()
})
describe('editable vs read-only distinction', () => {
it('renders Info section header', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{}}
/>
)
expect(screen.getByText('Info')).toBeInTheDocument()
})
it('renders Modified and Words in read-only Info section', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry({ modifiedAt: 1700000000 })}
content="---\ntitle: Test\n---\nOne two three"
frontmatter={{}}
/>
)
const readOnlyRows = screen.getAllByTestId('readonly-property')
const labels = readOnlyRows.map(row => row.querySelector('span')?.textContent)
expect(labels).toContain('Modified')
expect(labels).toContain('Words')
})
it('renders Created date in Info section', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry({ createdAt: 1700000000 })}
content=""
frontmatter={{}}
/>
)
const readOnlyRows = screen.getAllByTestId('readonly-property')
const labels = readOnlyRows.map(row => row.querySelector('span')?.textContent)
expect(labels).toContain('Created')
})
it('renders file size in Info section', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry({ fileSize: 4300 })}
content=""
frontmatter={{}}
/>
)
expect(screen.getByText('Size')).toBeInTheDocument()
expect(screen.getByText('4.2 KB')).toBeInTheDocument()
})
it('shows em dash for null timestamps in Info section', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry({ modifiedAt: null, createdAt: null })}
content=""
frontmatter={{}}
/>
)
// Two em dashes for null Modified and Created
const dashes = screen.getAllByText('\u2014')
expect(dashes.length).toBeGreaterThanOrEqual(2)
})
it('editable properties have hover styling via data-testid', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ cadence: 'Weekly', owner: 'Luca' }}
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
/>
)
const editableRows = screen.getAllByTestId('editable-property')
expect(editableRows.length).toBe(2)
// Editable rows have hover:bg-muted class for interactivity
editableRows.forEach(row => {
expect(row.className).toContain('hover:bg-muted')
})
})
it('read-only rows do not have hover styling', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{}}
/>
)
const readOnlyRows = screen.getAllByTestId('readonly-property')
readOnlyRows.forEach(row => {
expect(row.className).not.toContain('hover:bg-muted')
})
})
it('formats file sizes correctly', () => {
// Small file — bytes
const { rerender } = render(
<DynamicPropertiesPanel
entry={makeEntry({ fileSize: 500 })}
content=""
frontmatter={{}}
/>
)
expect(screen.getByText('500 B')).toBeInTheDocument()
// KB range
rerender(
<DynamicPropertiesPanel
entry={makeEntry({ fileSize: 2048 })}
content=""
frontmatter={{}}
/>
)
expect(screen.getByText('2.0 KB')).toBeInTheDocument()
// MB range
rerender(
<DynamicPropertiesPanel
entry={makeEntry({ fileSize: 1048576 })}
content=""
frontmatter={{}}
/>
)
expect(screen.getByText('1.0 MB')).toBeInTheDocument()
})
})
})

View File

@@ -63,6 +63,14 @@ function parseNewValue(rawValue: string): FrontmatterValue {
return items.length === 1 ? items[0] : items
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
const kb = bytes / 1024
if (kb < 1024) return `${kb.toFixed(1)} KB`
const mb = kb / 1024
return `${mb.toFixed(1)} MB`
}
function isStatusKey(key: string): boolean {
return key === 'Status' || key.includes('Status')
}
@@ -165,7 +173,7 @@ function PropertyRow({ propKey, value, editingKey, onStartEdit, onSave, onSaveLi
onUpdate?: (key: string, value: FrontmatterValue) => void; onDelete?: (key: string) => void
}) {
return (
<div className="group/prop flex items-center justify-between">
<div className="group/prop flex items-center justify-between rounded px-1.5 py-0.5 transition-colors hover:bg-muted" data-testid="editable-property">
<span className="font-mono-overline flex shrink-0 items-center gap-1 text-muted-foreground">
{propKey}
{onDelete && (
@@ -191,11 +199,11 @@ function PropertyValueCell({ propKey, value, isEditing, onStartEdit, onSave, onS
return <EditableValue {...editProps} />
}
function StaticRow({ label, value }: { label: string; value: string }) {
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between">
<span className="font-mono-overline shrink-0 text-muted-foreground">{label}</span>
<span className="text-right text-[12px] text-secondary-foreground">{value}</span>
<div className="flex items-center justify-between px-1.5" data-testid="readonly-property">
<span className="font-mono-overline shrink-0" style={{ color: 'var(--text-muted)' }}>{label}</span>
<span className="text-right text-[12px]" style={{ color: 'var(--text-muted)' }}>{value}</span>
</div>
)
}
@@ -256,19 +264,28 @@ export function DynamicPropertiesPanel({
}, [onAddProperty])
return (
<div>
<div className="flex flex-col gap-3">
{/* Editable properties section */}
<div className="flex flex-col gap-2">
<TypeRow isA={entry.isA} onNavigate={onNavigate} />
{propertyEntries.map(([key, value]) => (
<PropertyRow key={key} propKey={key} value={value} editingKey={editingKey} onStartEdit={setEditingKey} onSave={handleSaveValue} onSaveList={handleSaveList} onUpdate={onUpdateProperty} onDelete={onDeleteProperty} />
))}
<StaticRow label="Modified" value={formatDate(entry.modifiedAt)} />
<StaticRow label="Words" value={String(wordCount)} />
</div>
{showAddDialog
? <AddPropertyForm onAdd={handleAdd} onCancel={() => setShowAddDialog(false)} />
: <AddPropertyButton onClick={() => setShowAddDialog(true)} disabled={!onAddProperty} />
}
{/* Read-only Info section */}
<div className="border-t border-border pt-3">
<h4 className="font-mono-overline mb-2 text-muted-foreground">Info</h4>
<div className="flex flex-col gap-1.5">
<InfoRow label="Modified" value={formatDate(entry.modifiedAt)} />
<InfoRow label="Created" value={formatDate(entry.createdAt)} />
<InfoRow label="Words" value={String(wordCount)} />
<InfoRow label="Size" value={formatFileSize(entry.fileSize)} />
</div>
</div>
</div>
)
}

View File

@@ -279,4 +279,49 @@ This is a test note with some words to count.
)
expect(screen.getByText('No revision history')).toBeInTheDocument()
})
it('shows separate Info section with read-only metadata', () => {
render(
<Inspector
{...defaultProps}
entry={mockEntry}
content={mockContent}
/>
)
expect(screen.getByText('Info')).toBeInTheDocument()
expect(screen.getByText('Modified')).toBeInTheDocument()
expect(screen.getByText('Created')).toBeInTheDocument()
expect(screen.getByText('Size')).toBeInTheDocument()
})
it('renders editable properties with interactive styling', () => {
render(
<Inspector
{...defaultProps}
entry={mockEntry}
content={mockContent}
/>
)
const editableRows = screen.getAllByTestId('editable-property')
expect(editableRows.length).toBeGreaterThan(0)
editableRows.forEach(row => {
expect(row.className).toContain('hover:bg-muted')
})
})
it('renders read-only properties with muted non-interactive styling', () => {
render(
<Inspector
{...defaultProps}
entry={mockEntry}
content={mockContent}
/>
)
const readOnlyRows = screen.getAllByTestId('readonly-property')
expect(readOnlyRows.length).toBe(4) // Modified, Created, Words, Size
readOnlyRows.forEach(row => {
expect(row.className).not.toContain('hover:bg-muted')
expect(row.className).not.toContain('cursor-pointer')
})
})
})