Merge pull request #42 from refactoringhq/task/change-note-type

feat: editable type picker in Properties panel
This commit is contained in:
Luca Rossi
2026-02-24 15:00:50 +01:00
committed by GitHub
4 changed files with 150 additions and 4 deletions

View File

@@ -0,0 +1 @@
{"children":[{"type":"frame","id":"typePicker1","x":0,"y":0,"name":"Type Picker — Dropdown Open","clip":true,"width":280,"height":300,"fill":"#ffffff","layout":"vertical","padding":[16,16],"gap":12,"children":[{"type":"text","id":"heading1","text":"Properties Panel — Type Selector","fontSize":14,"fontWeight":600,"fill":"#1a1a1a"},{"type":"frame","id":"typeRow1","name":"Type Row","width":"fill_container","height":32,"alignItems":"center","justifyContent":"space-between","children":[{"type":"text","id":"typeLabel1","text":"TYPE","fontSize":10,"fontWeight":600,"letterSpacing":1.2,"fill":"#8b8b8b","fontFamily":"IBM Plex Mono"},{"type":"frame","id":"typeTrigger1","name":"Select Trigger","height":26,"padding":[2,8],"gap":4,"alignItems":"center","fill":"#e8f0fe","cornerRadius":6,"children":[{"type":"text","id":"typeValue1","text":"Note","fontSize":12,"fontWeight":500,"fill":"#4285f4"},{"type":"text","id":"chevron1","text":"▾","fontSize":10,"fill":"#4285f4"}]}]},{"type":"frame","id":"dropdown1","name":"Dropdown Menu (Open)","width":160,"fill":"#ffffff","cornerRadius":8,"padding":[4,4],"gap":2,"stroke":{"align":"outside","thickness":1,"fill":"#e0e0e0"},"children":[{"type":"frame","id":"optNone","name":"Option: None","width":"fill_container","height":28,"padding":[0,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"text","id":"optNoneText","text":"None","fontSize":13,"fill":"#8b8b8b"}]},{"type":"frame","id":"separator1","name":"Separator","width":"fill_container","height":1,"fill":"#e0e0e0"},{"type":"frame","id":"optProject","name":"Option: Project","width":"fill_container","height":28,"padding":[0,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"text","id":"optProjectText","text":"Project","fontSize":13,"fill":"#1a1a1a"}]},{"type":"frame","id":"optPerson","name":"Option: Person","width":"fill_container","height":28,"padding":[0,8],"alignItems":"center","cornerRadius":4,"fill":"#f0f0f0","children":[{"type":"text","id":"optPersonText","text":"Person","fontSize":13,"fill":"#1a1a1a"}]},{"type":"frame","id":"optTopic","name":"Option: Topic","width":"fill_container","height":28,"padding":[0,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"text","id":"optTopicText","text":"Topic","fontSize":13,"fill":"#1a1a1a"}]},{"type":"frame","id":"optExperiment","name":"Option: Experiment","width":"fill_container","height":28,"padding":[0,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"text","id":"optExpText","text":"Experiment","fontSize":13,"fill":"#1a1a1a"}]}]}]}],"variables":{}}

View File

@@ -1,8 +1,17 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { DynamicPropertiesPanel, containsWikilinks } from './DynamicPropertiesPanel'
import type { VaultEntry } from '../types'
// Radix Select needs ResizeObserver and pointer/scroll APIs in JSDOM
beforeAll(() => {
global.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} }
Element.prototype.scrollIntoView = vi.fn()
Element.prototype.hasPointerCapture = () => false
Element.prototype.setPointerCapture = vi.fn()
Element.prototype.releasePointerCapture = vi.fn()
})
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note/test.md',
filename: 'test.md',
@@ -218,7 +227,7 @@ describe('DynamicPropertiesPanel', () => {
expect(onAddProperty).toHaveBeenCalledWith('priority', 'high')
})
it('handles navigating to type via click', () => {
it('handles navigating to type via click in read-only mode', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry({ isA: 'Project' })}
@@ -231,6 +240,88 @@ describe('DynamicPropertiesPanel', () => {
expect(onNavigate).toHaveBeenCalledWith('type/project')
})
describe('TypeSelector', () => {
const typeEntries = [
makeEntry({ path: '/vault/type/project.md', title: 'Project', isA: 'Type' }),
makeEntry({ path: '/vault/type/person.md', title: 'Person', isA: 'Type' }),
makeEntry({ path: '/vault/type/topic.md', title: 'Topic', isA: 'Type' }),
]
it('renders as dropdown when editable', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()} content="" frontmatter={{}}
entries={typeEntries} onUpdateProperty={onUpdateProperty}
/>
)
expect(screen.getByTestId('type-selector')).toBeInTheDocument()
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
it('shows available types in dropdown', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()} content="" frontmatter={{}}
entries={typeEntries} onUpdateProperty={onUpdateProperty}
/>
)
fireEvent.pointerDown(screen.getByRole('combobox'), { button: 0, pointerType: 'mouse' })
expect(screen.getByRole('option', { name: 'None' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'Person' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'Project' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'Topic' })).toBeInTheDocument()
})
function openAndSelect(optionName: string) {
fireEvent.pointerDown(screen.getByRole('combobox'), { button: 0, pointerType: 'mouse' })
fireEvent.click(screen.getByRole('option', { name: optionName }))
}
it('calls onUpdateProperty when type selected', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()} content="" frontmatter={{}}
entries={typeEntries} onUpdateProperty={onUpdateProperty}
/>
)
openAndSelect('Project')
expect(onUpdateProperty).toHaveBeenCalledWith('type', 'Project')
})
it('clears type when None selected', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry({ isA: 'Project' })} content="" frontmatter={{}}
entries={typeEntries} onUpdateProperty={onUpdateProperty}
/>
)
openAndSelect('None')
expect(onUpdateProperty).toHaveBeenCalledWith('type', null)
})
it('shows current type even when not in available types', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry({ isA: 'CustomType' })} content="" frontmatter={{}}
entries={typeEntries} onUpdateProperty={onUpdateProperty}
/>
)
fireEvent.pointerDown(screen.getByRole('combobox'), { button: 0, pointerType: 'mouse' })
expect(screen.getByRole('option', { name: 'CustomType' })).toBeInTheDocument()
})
it('shows None placeholder when entry has no type', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry({ isA: null })} content="" frontmatter={{}}
entries={typeEntries} onUpdateProperty={onUpdateProperty}
/>
)
expect(screen.getByTestId('type-selector')).toBeInTheDocument()
expect(screen.getByText('None')).toBeInTheDocument()
})
})
it('renders modified date', () => {
render(
<DynamicPropertiesPanel

View File

@@ -5,6 +5,7 @@ import type { ParsedFrontmatter } from '../utils/frontmatter'
import { EditableValue, TagPillList, UrlValue } from './EditableValue'
import { isUrlValue } from '../utils/url'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { countWords } from '../utils/wikilinks'
@@ -144,7 +145,9 @@ function AddPropertyForm({ onAdd, onCancel }: { onAdd: (key: string, value: stri
)
}
function TypeRow({ isA, onNavigate }: { isA?: string | null; onNavigate?: (target: string) => void }) {
const TYPE_NONE = '__none__'
function ReadOnlyType({ isA, onNavigate }: { isA?: string | null; onNavigate?: (target: string) => void }) {
if (!isA) return null
return (
<div className="flex items-center justify-between">
@@ -162,6 +165,47 @@ function TypeRow({ isA, onNavigate }: { isA?: string | null; onNavigate?: (targe
)
}
function TypeSelector({ isA, availableTypes, onUpdateProperty, onNavigate }: {
isA?: string | null; availableTypes: string[]
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onNavigate?: (target: string) => void
}) {
if (!onUpdateProperty) return <ReadOnlyType isA={isA} onNavigate={onNavigate} />
const currentValue = isA || TYPE_NONE
const options = isA && !availableTypes.includes(isA)
? [...availableTypes, isA].sort((a, b) => a.localeCompare(b))
: availableTypes
return (
<div className="flex items-center justify-between" data-testid="type-selector">
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
<Select value={currentValue} onValueChange={v => onUpdateProperty('type', v === TYPE_NONE ? null : v)}>
<SelectTrigger
size="sm"
className="h-auto min-h-0 gap-1 border-none px-2 py-0.5 shadow-none"
style={isA ? {
background: getTypeLightColor(isA),
color: getTypeColor(isA),
fontSize: 12,
fontWeight: 500,
borderRadius: 6,
} : { fontSize: 12, borderRadius: 6 }}
>
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
<SelectItem value={TYPE_NONE}>None</SelectItem>
<SelectSeparator />
{options.map(type => (
<SelectItem key={type} value={type}>{type}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
function PropertyRow({ propKey, value, editingKey, onStartEdit, onSave, onSaveList, onUpdate, onDelete }: {
propKey: string; value: FrontmatterValue; editingKey: string | null
onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void
@@ -219,6 +263,7 @@ export function DynamicPropertiesPanel({
entry,
content,
frontmatter,
entries,
onUpdateProperty,
onDeleteProperty,
onAddProperty,
@@ -227,6 +272,7 @@ export function DynamicPropertiesPanel({
entry: VaultEntry
content: string | null
frontmatter: ParsedFrontmatter
entries?: VaultEntry[]
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
@@ -237,6 +283,13 @@ export function DynamicPropertiesPanel({
const wordCount = countWords(content ?? '')
const availableTypes = useMemo(() =>
(entries ?? [])
.filter(e => e.isA === 'Type')
.map(e => e.title)
.sort((a, b) => a.localeCompare(b))
, [entries])
const propertyEntries = useMemo(() => {
return Object.entries(frontmatter)
.filter(([key, value]) => !SKIP_KEYS.has(key) && !RELATIONSHIP_KEYS.has(key) && !containsWikilinks(value))
@@ -264,7 +317,7 @@ export function DynamicPropertiesPanel({
<div className="flex flex-col gap-3">
{/* Editable properties section */}
<div className="flex flex-col gap-2">
<TypeRow isA={entry.isA} onNavigate={onNavigate} />
<TypeSelector isA={entry.isA} availableTypes={availableTypes} onUpdateProperty={onUpdateProperty} 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} />
))}

View File

@@ -141,6 +141,7 @@ export function Inspector({
<>
<DynamicPropertiesPanel
entry={entry} content={content} frontmatter={frontmatter}
entries={entries}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
onAddProperty={onAddProperty ? handleAddProperty : undefined}