Merge pull request #42 from refactoringhq/task/change-note-type
feat: editable type picker in Properties panel
This commit is contained in:
1
design/change-note-type.pen
Normal file
1
design/change-note-type.pen
Normal 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":{}}
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user