diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx
index 40a4b523..950a0a07 100644
--- a/src/components/DynamicPropertiesPanel.tsx
+++ b/src/components/DynamicPropertiesPanel.tsx
@@ -1,4 +1,6 @@
+import { Plus } from '@phosphor-icons/react'
import { useMemo, useCallback, useEffect, useState } from 'react'
+import { Button } from '@/components/ui/button'
import type { VaultEntry } from '../types'
import type { FrontmatterValue } from './Inspector'
import type { ParsedFrontmatter } from '../utils/frontmatter'
@@ -9,7 +11,14 @@ import { TypeSelector } from './TypeSelector'
import { AddPropertyForm } from './AddPropertyForm'
import type { PropertyDisplayMode } from '../utils/propertyTypes'
import { FOCUS_NOTE_ICON_PROPERTY_EVENT } from './noteIconPropertyEvents'
-import { PROPERTY_PANEL_GRID_STYLE, PROPERTY_PANEL_ROW_STYLE } from './propertyPanelLayout'
+import {
+ PROPERTY_PANEL_GRID_STYLE,
+ PROPERTY_PANEL_INTERACTIVE_ROW_CLASS_NAME,
+ PROPERTY_PANEL_LABEL_CLASS_NAME,
+ PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME,
+ PROPERTY_PANEL_PLACEHOLDER_VALUE_CLASS_NAME,
+ PROPERTY_PANEL_ROW_STYLE,
+} from './propertyPanelLayout'
import { humanizePropertyKey } from '../utils/propertyLabels'
import { canonicalSystemMetadataKey, hasSystemMetadataKey } from '../utils/systemMetadata'
@@ -21,8 +30,6 @@ export function containsWikilinks(value: FrontmatterValue): boolean {
}
const PROPERTY_ROW_CLASS_NAME = 'group/prop grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary'
-const PROPERTY_LABEL_CLASS_NAME = 'flex max-w-full min-w-0 items-center gap-1 text-[12px] text-muted-foreground'
-const SUGGESTED_PROPERTY_SLOT_CLASS_NAME = 'grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 rounded border-none bg-transparent px-1.5 text-left outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary cursor-pointer'
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
propKey: string; value: FrontmatterValue; editingKey: string | null
@@ -45,7 +52,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
return (
-
+
{humanizePropertyKey(propKey)}
{onDelete && (
@@ -61,13 +68,27 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disabled: boolean }) {
return (
-
+
+
+
+
+ Add property
+
+
+
)
}
@@ -97,20 +118,29 @@ function SuggestedPropertySlot({ label, displayMode, onAdd }: {
const SuggestedIcon = DISPLAY_MODE_ICONS[displayMode]
return (
-
+ {'\u2014'}
+
)
}
@@ -150,6 +180,46 @@ function useFocusNoteIconProperty({
}, [onAddProperty, setEditingKey, setPendingSuggestedKey])
}
+function useSuggestedPropertyActions({
+ onAddProperty,
+ setEditingKey,
+ setPendingSuggestedKey,
+}: {
+ onAddProperty?: (key: string, value: FrontmatterValue) => void
+ setEditingKey: (key: string | null) => void
+ setPendingSuggestedKey: (key: string | null) => void
+}) {
+ const handleSuggestedAdd = useCallback((key: string) => {
+ if (!onAddProperty) return
+ setPendingSuggestedKey(key)
+ setEditingKey(key)
+ }, [onAddProperty, setEditingKey, setPendingSuggestedKey])
+
+ const handlePendingSuggestedEdit = useCallback((key: string | null) => {
+ setEditingKey(key)
+ if (key === null) setPendingSuggestedKey(null)
+ }, [setEditingKey, setPendingSuggestedKey])
+
+ const handleSaveSuggestedValue = useCallback((key: string, newValue: string) => {
+ setEditingKey(null)
+ setPendingSuggestedKey(null)
+ if (!onAddProperty) {
+ return
+ }
+ const trimmed = newValue.trim()
+ if (!trimmed) {
+ return
+ }
+ onAddProperty(key === 'icon' ? canonicalSystemMetadataKey(key) : key, trimmed)
+ }, [onAddProperty, setEditingKey, setPendingSuggestedKey])
+
+ return {
+ handlePendingSuggestedEdit,
+ handleSaveSuggestedValue,
+ handleSuggestedAdd,
+ }
+}
+
export function DynamicPropertiesPanel({
entry, frontmatter, entries,
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate,
@@ -175,30 +245,15 @@ export function DynamicPropertiesPanel({
() => getMissingSuggestedProperties(Boolean(onAddProperty), existingKeys, pendingSuggestedKey),
[existingKeys, onAddProperty, pendingSuggestedKey],
)
-
- const handleSuggestedAdd = useCallback((key: string) => {
- if (!onAddProperty) return
- setPendingSuggestedKey(key)
- setEditingKey(key)
- }, [onAddProperty, setEditingKey])
-
- const handlePendingSuggestedEdit = useCallback((key: string | null) => {
- setEditingKey(key)
- if (key === null) setPendingSuggestedKey(null)
- }, [setEditingKey])
-
- const handleSaveSuggestedValue = useCallback((key: string, newValue: string) => {
- setEditingKey(null)
- setPendingSuggestedKey(null)
- if (!onAddProperty) {
- return
- }
- const trimmed = newValue.trim()
- if (!trimmed) {
- return
- }
- onAddProperty(key === 'icon' ? canonicalSystemMetadataKey(key) : key, trimmed)
- }, [onAddProperty, setEditingKey])
+ const {
+ handlePendingSuggestedEdit,
+ handleSaveSuggestedValue,
+ handleSuggestedAdd,
+ } = useSuggestedPropertyActions({
+ onAddProperty,
+ setEditingKey,
+ setPendingSuggestedKey,
+ })
useFocusNoteIconProperty({ onAddProperty, setEditingKey, setPendingSuggestedKey })
@@ -244,11 +299,20 @@ export function DynamicPropertiesPanel({
onAdd={() => handleSuggestedAdd(key)}
/>
))}
+ {!showAddDialog && (
+ setShowAddDialog(true)}
+ disabled={!onAddProperty}
+ />
+ )}
- {showAddDialog
- ? setShowAddDialog(false)} vaultStatuses={vaultStatuses} />
- : setShowAddDialog(true)} disabled={!onAddProperty} />
- }
+ {showAddDialog && (
+ setShowAddDialog(false)}
+ vaultStatuses={vaultStatuses}
+ />
+ )}
)
}
diff --git a/src/components/Inspector.test.tsx b/src/components/Inspector.test.tsx
index 23e9b304..2a1eaf93 100644
--- a/src/components/Inspector.test.tsx
+++ b/src/components/Inspector.test.tsx
@@ -1,3 +1,4 @@
+import type { ComponentProps } from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { Inspector } from './Inspector'
@@ -88,6 +89,20 @@ const defaultProps = {
onNavigate: () => {},
}
+type InspectorProps = ComponentProps
+
+function renderInspector(overrides: Partial = {}) {
+ return render()
+}
+
+function renderSelectedInspector(overrides: Partial = {}) {
+ return renderInspector({
+ entry: mockEntry,
+ content: mockContent,
+ ...overrides,
+ })
+}
+
describe('Inspector', () => {
it('renders expanded state with "no note selected"', () => {
render()
@@ -133,7 +148,7 @@ describe('Inspector', () => {
it('shows "Add property" button as disabled placeholder', () => {
render()
- const btn = screen.getByText('Add property')
+ const btn = screen.getByRole('button', { name: 'Add property' })
expect(btn).toBeDisabled()
})
@@ -212,41 +227,22 @@ This is a test note with some words to count.
})
it('hides backlinks section when no notes reference the current note', () => {
- render(
-
- )
+ renderSelectedInspector({ entries: [mockEntry] })
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
})
it('navigates when a backlink is clicked', () => {
const onNavigate = vi.fn()
- render(
-
- )
+ renderSelectedInspector({
+ entries: [mockEntry, referrerEntry],
+ onNavigate,
+ })
fireEvent.click(screen.getByText('Referrer Note'))
expect(onNavigate).toHaveBeenCalledWith('Referrer Note')
})
it('shows git history with commit hashes and messages', () => {
- render(
-
- )
+ renderSelectedInspector({ gitHistory: mockGitHistory })
expect(screen.getByText('History')).toBeInTheDocument()
expect(screen.getByText('a1b2c3d')).toBeInTheDocument()
expect(screen.getByText('e4f5g6h')).toBeInTheDocument()
@@ -255,15 +251,10 @@ This is a test note with some words to count.
it('renders commit entries as clickable buttons', () => {
const onViewCommitDiff = vi.fn()
- render(
-
- )
+ renderSelectedInspector({
+ gitHistory: mockGitHistory,
+ onViewCommitDiff,
+ })
const hashBtn = screen.getByText('a1b2c3d')
const button = hashBtn.closest('button')!
expect(button.tagName).toBe('BUTTON')
@@ -272,25 +263,12 @@ This is a test note with some words to count.
})
it('hides history section when no commits', () => {
- render(
-
- )
+ renderSelectedInspector({ gitHistory: [] })
expect(screen.queryByText('History')).not.toBeInTheDocument()
})
it('shows separate Info section with read-only metadata', () => {
- render(
-
- )
+ renderSelectedInspector()
expect(screen.getByText('Info')).toBeInTheDocument()
expect(screen.getByText('Modified')).toBeInTheDocument()
expect(screen.getByText('Created')).toBeInTheDocument()
@@ -468,31 +446,23 @@ Status: Active
})
it('hides referenced-by section when no entries reference the current note', () => {
- render(
-
- )
+ renderInspector({
+ entry: targetEntry,
+ content: targetContent,
+ entries: [targetEntry],
+ })
expect(screen.queryByText('No references')).not.toBeInTheDocument()
expect(screen.queryByText('Referenced by')).not.toBeInTheDocument()
})
it('navigates when clicking a referenced-by entry', () => {
const onNavigate = vi.fn()
- render(
-
- )
+ renderInspector({
+ entry: targetEntry,
+ content: targetContent,
+ entries: [targetEntry, essayEntry],
+ onNavigate,
+ })
fireEvent.click(screen.getByText('On Writing Well'))
expect(onNavigate).toHaveBeenCalledWith('On Writing Well')
})
diff --git a/src/components/TypeSelector.tsx b/src/components/TypeSelector.tsx
index 1db3db55..bbebe631 100644
--- a/src/components/TypeSelector.tsx
+++ b/src/components/TypeSelector.tsx
@@ -8,7 +8,11 @@ import { cn } from '@/lib/utils'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { getTypeIcon } from './NoteItem'
import { PROPERTY_CHIP_STYLE } from './propertyChipStyles'
-import { PROPERTY_PANEL_ROW_STYLE } from './propertyPanelLayout'
+import {
+ PROPERTY_PANEL_LABEL_CLASS_NAME,
+ PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME,
+ PROPERTY_PANEL_ROW_STYLE,
+} from './propertyPanelLayout'
const TYPE_NONE = '__none__'
const MIN_POPOVER_WIDTH = 220
@@ -94,9 +98,14 @@ function TypeSelectorValue({
function TypeRowLabel() {
return (
-
-
- Type
+
+
+
+
+ Type
)
}
@@ -104,7 +113,10 @@ function TypeRowLabel() {
function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null; customColorKey?: string | null; onNavigate?: (target: string) => void }) {
if (!isA) return null
return (
-
+
{onNavigate ? (
@@ -261,7 +273,11 @@ function EditableTypeSelector({ isA, customColorKey, availableTypes, typeColorKe
}
return (
-
+
diff --git a/src/components/propertyPanelLayout.test.tsx b/src/components/propertyPanelLayout.test.tsx
index b2f12d85..b1445a3e 100644
--- a/src/components/propertyPanelLayout.test.tsx
+++ b/src/components/propertyPanelLayout.test.tsx
@@ -76,6 +76,37 @@ describe('property panel shared grid layout', () => {
})
})
+ it('keeps the add-property row on the shared grid', () => {
+ render(
+
+ )
+
+ const row = screen.getByTestId('add-property-row')
+ expect(row.style.gridTemplateColumns).toBe('subgrid')
+ expect(row.style.gridColumn).toBe('1 / -1')
+ })
+
+ it('uses the same fixed icon slot size for type, suggested, and add-property rows', () => {
+ render(
+
+ )
+
+ expect(screen.getByTestId('type-row-icon-slot')).toHaveClass('size-5')
+ screen.getAllByTestId('suggested-property-icon-slot').forEach((slot) => {
+ expect(slot).toHaveClass('size-5')
+ })
+ expect(screen.getByTestId('add-property-icon-slot')).toHaveClass('size-5')
+ })
+
it('renders plain text values flush with the shared value column', () => {
render(