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